From 5fe43d8c1c30d37864ceeb81bd03225abb6fac81 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 7 May 2026 18:19:43 +0800 Subject: [PATCH 001/217] =?UTF-8?q?refactor(ui):=20PromptInput=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能实现 - 新增App组件,支持会话管理、消息展示及状态显示 - 实现PromptInput组件,支持文本输入、多技能选择、图片粘贴及快捷命令 - 优化MessageView组件样式,调整颜色配色使界面更美观 - 提供会话列表视图和欢迎界面切换功能 - 支持用户终止会话、重启和退出命令 - 提供流式进度和多进程状态反馈 - 实现技能选择下拉框和斜杠命令菜单的键盘导航 - 完善多种快捷键操作支持,提升终端交互体验 - 提供会话退出摘要文本生成功能 - 代码结构拆分合理,类型定义完善,利于后续维护和扩展 --- src/ui/App.tsx | 23 +- src/ui/MessageView.tsx | 4 +- src/ui/PromptInput.tsx | 368 ++---------------------------- src/ui/exitSummary.ts | 8 +- src/ui/index.ts | 2 +- src/ui/prompt/cursor.ts | 203 ++++++++++++++++ src/ui/prompt/index.ts | 4 + src/ui/prompt/useTerminalInput.ts | 139 +++++++++++ 8 files changed, 389 insertions(+), 362 deletions(-) create mode 100644 src/ui/prompt/cursor.ts create mode 100644 src/ui/prompt/index.ts create mode 100644 src/ui/prompt/useTerminalInput.ts diff --git a/src/ui/App.tsx b/src/ui/App.tsx index a848553..e551583 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Box, Static, Text, useApp, useStdout } from "ink"; +import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; import chalk from "chalk"; import * as fs from "fs"; import * as os from "os"; @@ -43,6 +43,7 @@ type AppProps = { export function App({ projectRoot, version = "", onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); + const { columns } = useWindowSize(); const [view, setView] = useState("chat"); const [busy, setBusy] = useState(false); const [skills, setSkills] = useState([]); @@ -116,6 +117,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R } } + const writeRef = useRef(write); + writeRef.current = write; const handlePrompt = useCallback( async (submission: PromptSubmission) => { if (submission.command === "exit") { @@ -141,7 +144,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R if (onRestart) { onRestart(); } else { - write("\u001B[2J\u001B[3J\u001B[H"); + writeRef.current("\u001B[2J\u001B[3J\u001B[H"); sessionManager.setActiveSessionId(null); setMessages([]); setStatusLine(""); @@ -199,7 +202,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setRunningProcesses(null); } }, - [exit, onRestart, sessionManager, write] + [exit, onRestart, sessionManager] ); const handleInterrupt = useCallback(() => { @@ -221,7 +224,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R [sessionManager] ); - const screenWidth = stdout?.columns ?? 80; + const screenWidth = useMemo(()=> columns ?? stdout?.columns ?? 80, [columns, stdout]); const promptHistory = useMemo(() => { return messages .filter((message) => message.role === "user" && typeof message.content === "string") @@ -236,9 +239,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const shouldShowQuestionPrompt = Boolean( pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId) ); - const loadingText = busy - ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) - : null; + const loadingText = useMemo( + () => busy + ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) + : null, + [busy, streamProgress, runningProcesses] + ); const welcomeSettings = useMemo(() => resolveCurrentSettings(), []); const welcomeItem: SessionMessage = useMemo(() => ({ id: "__welcome__", @@ -277,7 +283,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R }, [pendingQuestion]); return ( - + {(item) => { if (item.id === "__welcome__") { @@ -325,6 +331,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R /> ) : isExiting ? null : ( - {`> ${text}`} + {`> ${text}`} {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} + {` 📎 ${message.contentParams.length} image attachment(s)`} ) : null} ); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 2cf0c74..5ae1554 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useApp, useStdin, useStdout } from "ink"; +import React, {useEffect, useMemo, useState} from "react"; +import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; import { EMPTY_BUFFER, @@ -31,6 +31,14 @@ import { import { readClipboardImage } from "./clipboard"; import type { SkillInfo } from "../session"; +// Re-exported from prompt modules for backward compatibility +export { useTerminalInput, parseTerminalInput } from "./prompt"; +export type { InputKey } from "./prompt"; + +import { useTerminalInput, parseTerminalInput } from "./prompt"; +import type { InputKey } from "./prompt"; +import { usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./prompt/cursor"; + export type PromptSubmission = { text: string; imageUrls: string[]; @@ -40,6 +48,7 @@ export type PromptSubmission = { type Props = { skills: SkillInfo[]; + screenWidth: number; promptHistory: string[]; busy: boolean; loadingText?: string | null; @@ -48,43 +57,11 @@ type Props = { onInterrupt: () => void; }; -const BACKSPACE_BYTES = new Set(["", ""]); -const FORWARD_DELETE_SEQUENCES = new Set(["[3~", ""]); -const HOME_SEQUENCES = new Set(["", "[1~", "[7~", "OH"]); -const END_SEQUENCES = new Set(["", "[4~", "[8~", "OF"]); -const SHIFT_RETURN_SEQUENCES = new Set(["\r", ""]); -const META_RETURN_SEQUENCES = new Set(["", ""]); -const CTRL_LEFT_SEQUENCES = new Set(["", ""]); -const CTRL_RIGHT_SEQUENCES = new Set(["", ""]); -const META_LEFT_SEQUENCES = new Set(["", "", "b"]); -const META_RIGHT_SEQUENCES = new Set(["", "", "f"]); -const TERMINAL_FOCUS_IN = ""; -const TERMINAL_FOCUS_OUT = ""; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -export type InputKey = { - upArrow: boolean; - downArrow: boolean; - leftArrow: boolean; - rightArrow: boolean; - home: boolean; - end: boolean; - pageDown: boolean; - pageUp: boolean; - return: boolean; - escape: boolean; - ctrl: boolean; - shift: boolean; - tab: boolean; - backspace: boolean; - delete: boolean; - meta: boolean; - focusIn: boolean; - focusOut: boolean; -}; - -export function PromptInput({ +export const PromptInput = React.memo(function PromptInput({ skills, + screenWidth, promptHistory, busy, loadingText, @@ -94,7 +71,6 @@ export function PromptInput({ }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); - const screenWidth = Math.max(20, stdout?.columns ?? 80); const [buffer, setBuffer] = useState(EMPTY_BUFFER); const [imageUrls, setImageUrls] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); @@ -107,13 +83,13 @@ export function PromptInput({ const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); const [spinnerIndex, setSpinnerIndex] = useState(0); - const lastCtrlDAt = useRef(0); + const lastCtrlDAt = React.useRef(0); - const slashItems = useMemo(() => buildSlashCommands(skills), [skills]); + const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); const slashMenu = showSkillsDropdown ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : []; const showMenu = slashMenu.length > 0; - const promptHistoryKey = useMemo(() => promptHistory.join("\0"), [promptHistory]); + const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); const promptPrefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; const footerText = statusMessage ? statusMessage @@ -122,7 +98,7 @@ export function PromptInput({ ? loadingText : "esc to interrupt · ctrl+c to cancel input" : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; - const cursorPlacement = useMemo( + const cursorPlacement = React.useMemo( () => getPromptCursorPlacement(buffer, screenWidth, promptPrefix, footerText), [buffer, footerText, promptPrefix, screenWidth] ); @@ -422,7 +398,7 @@ export function PromptInput({ return; } - if (input.startsWith("")) { + if (input.startsWith("\u001B")) { // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. return; } @@ -552,7 +528,7 @@ export function PromptInput({ setBuffer((state) => removeCurrentSlashToken(state)); } - const divider = "─".repeat(screenWidth); + const divider = useMemo(() => "─".repeat(screenWidth), [screenWidth]); const visibleSkillStart = Math.min( Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8) @@ -560,7 +536,7 @@ export function PromptInput({ const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); return ( - + {imageUrls.length > 0 ? ( {formatImageAttachmentStatus(imageUrls.length)} @@ -624,7 +600,7 @@ export function PromptInput({ ); -} +}); export const IMAGE_ATTACHMENT_CLEAR_HINT = "ctrl+x clear images"; @@ -679,207 +655,6 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick void), - callback?: (error?: Error | null) => void -) => boolean; - -function usePromptTerminalCursor( - stdout: NodeJS.WriteStream | undefined, - placement: CursorPlacement, - isActive: boolean -): void { - const directWriteRef = useRef<((data: string) => void) | null>(null); - const activePlacementRef = useRef(null); - - useLayoutEffect(() => { - if (!stdout?.isTTY) { - return; - } - - const stream = stdout as NodeJS.WriteStream & { write: WriteFn }; - const originalWrite = stream.write; - const directWrite = (data: string) => { - originalWrite.call(stdout, data); - }; - const restorePromptCursor = () => { - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; - } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - }; - const patchedWrite: WriteFn = (...args) => { - restorePromptCursor(); - return originalWrite.apply(stdout, args); - }; - - directWriteRef.current = directWrite; - stream.write = patchedWrite; - - return () => { - restorePromptCursor(); - stream.write = originalWrite; - directWriteRef.current = null; - }; - }, [stdout]); - - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } - - const directWrite = directWriteRef.current; - if (!directWrite) { - return; - } - - directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); - activePlacementRef.current = placement; - - return () => { - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; - } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - }; - }, [isActive, placement.column, placement.rowsUp, stdout]); -} - -function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } - - stdout.write(enableTerminalFocusReporting()); - return () => { - stdout.write(disableTerminalFocusReporting()); - }; - }, [isActive, stdout]); -} - -export function getPromptCursorPlacement( - state: PromptBufferState, - screenWidth: number, - promptPrefix: string, - footerText: string -): CursorPlacement { - const width = Math.max(1, screenWidth); - const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); - const beforeCursor = state.text.slice(0, cursor); - const at = state.text[cursor]; - const displayText = beforeCursor + (typeof at === "undefined" || at === "\n" ? " " : at) + - (at === "\n" ? "\n" : "") + (typeof at === "undefined" ? "" : state.text.slice(cursor + 1)); - - const cursorPosition = measureTextPosition(beforeCursor, width, textWidth(promptPrefix)); - const promptRows = measureTextRows(displayText, width, textWidth(promptPrefix)); - const footerRows = 1 + measureTextRows(footerText, width, 0); - - return { - rowsUp: (promptRows - 1 - cursorPosition.row) + footerRows + 1, - column: cursorPosition.column - }; -} - -function measureTextRows(text: string, width: number, initialColumn: number): number { - return measureTextPosition(text, width, initialColumn).row + 1; -} - -function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } { - let row = 0; - let column = Math.min(initialColumn, width - 1); - - for (const char of Array.from(text)) { - if (char === "\n") { - row++; - column = Math.min(initialColumn, width - 1); - continue; - } - - const charColumns = textWidth(char); - if (column + charColumns > width) { - row++; - column = Math.min(initialColumn, width - 1); - } - column += charColumns; - if (column >= width) { - row++; - column = Math.min(initialColumn, width - 1); - } - } - - return { row, column }; -} - -function textWidth(value: string): number { - let width = 0; - for (const char of Array.from(value.normalize())) { - width += characterWidth(char); - } - return width; -} - -function characterWidth(char: string): number { - const codePoint = char.codePointAt(0) ?? 0; - if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) { - return 0; - } - if (codePoint >= 0x300 && codePoint <= 0x36f) { - return 0; - } - if ( - (codePoint >= 0x1100 && codePoint <= 0x115f) || - (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || - (codePoint >= 0xac00 && codePoint <= 0xd7a3) || - (codePoint >= 0xf900 && codePoint <= 0xfaff) || - (codePoint >= 0xfe10 && codePoint <= 0xfe19) || - (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || - (codePoint >= 0xff00 && codePoint <= 0xff60) || - (codePoint >= 0xffe0 && codePoint <= 0xffe6) - ) { - return 2; - } - return 1; -} - -function cursorUp(rows: number): string { - return rows > 0 ? `\u001B[${rows}A` : ""; -} - -function cursorDown(rows: number): string { - return rows > 0 ? `\u001B[${rows}B` : ""; -} - -function cursorForward(columns: number): string { - return columns > 0 ? `\u001B[${columns}C` : ""; -} - -function showCursor(): string { - return "\u001B[?25h"; -} - -function hideCursor(): string { - return "\u001B[?25l"; -} - -function enableTerminalFocusReporting(): string { - return "\u001B[?1004h"; -} - -function disableTerminalFocusReporting(): string { - return "\u001B[?1004l"; -} - export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); @@ -898,104 +673,3 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool } return before + chalk.inverse(at) + after; } - -export function useTerminalInput( - inputHandler: (input: string, key: InputKey) => void, - options: { isActive?: boolean } = {} -): void { - const { stdin, setRawMode } = useStdin(); - const isActive = options.isActive ?? true; - - useEffect(() => { - if (!isActive) { - return; - } - setRawMode(true); - return () => { - setRawMode(false); - }; - }, [isActive, setRawMode]); - - useEffect(() => { - if (!isActive) { - return; - } - const handleData = (data: Buffer | string) => { - const { input, key } = parseTerminalInput(data); - inputHandler(input, key); - }; - - stdin?.on("data", handleData); - return () => { - stdin?.off("data", handleData); - }; - }, [isActive, stdin, inputHandler]); -} - -export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { - const raw = String(data); - let input = raw; - const key: InputKey = { - upArrow: raw === "\u001B[A", - downArrow: raw === "\u001B[B", - leftArrow: raw === "\u001B[D" || CTRL_LEFT_SEQUENCES.has(raw) || META_LEFT_SEQUENCES.has(raw), - rightArrow: raw === "\u001B[C" || CTRL_RIGHT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw), - home: HOME_SEQUENCES.has(raw), - end: END_SEQUENCES.has(raw), - pageDown: raw === "\u001B[6~", - pageUp: raw === "\u001B[5~", - return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), - escape: raw === "\u001B", - ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw), - shift: SHIFT_RETURN_SEQUENCES.has(raw), - tab: raw === "\t" || raw === "\u001B[Z", - backspace: BACKSPACE_BYTES.has(raw), - delete: FORWARD_DELETE_SEQUENCES.has(raw), - meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), - focusIn: raw === TERMINAL_FOCUS_IN, - focusOut: raw === TERMINAL_FOCUS_OUT - }; - - if (input <= "\u001A" && !key.return) { - input = String.fromCharCode(input.charCodeAt(0) + "a".charCodeAt(0) - 1); - key.ctrl = true; - } - - const isKnownEscapeSequence = - key.upArrow || - key.downArrow || - key.leftArrow || - key.rightArrow || - key.home || - key.end || - key.pageDown || - key.pageUp || - key.tab || - key.delete || - key.return || - key.ctrl || - key.meta || - key.focusIn || - key.focusOut; - - if (raw.startsWith("\u001B")) { - input = raw.slice(1); - key.meta = key.meta || !isKnownEscapeSequence; - } - - const isLatinUppercase = input >= "A" && input <= "Z"; - const isCyrillicUppercase = input >= "А" && input <= "Я"; - if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) { - key.shift = true; - } - - if (key.tab && input === "[Z") { - key.shift = true; - } - - if (key.tab || key.backspace || key.delete) { - input = ""; - } - - return { input, key }; -} diff --git a/src/ui/exitSummary.ts b/src/ui/exitSummary.ts index 58c23d3..5cc1e6f 100644 --- a/src/ui/exitSummary.ts +++ b/src/ui/exitSummary.ts @@ -82,7 +82,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const rows: string[] = [ "", - ` ${header}`, + `${header}`, "", ]; @@ -105,8 +105,8 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { padLeft("Input Tokens", colInput) + padLeft("Output Tokens", colOutput) + padLeft("Cached Tokens", colCached); - rows.push(` ${chalk.bold(headerRow)}`); - rows.push(` ${divider}`); + rows.push(chalk.bold(headerRow)); + rows.push(divider); const reqsStr = String(assistantCount).padStart(colReqs); const inputStr = formatNumber(usage.promptTokens).padStart(colInput); @@ -118,7 +118,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { padRight(chalk.yellow(inputStr), colInput) + padRight(chalk.yellow(outputStr), colOutput) + padRight(chalk.yellow(cachedStr), colCached); - rows.push(` ${dataRow}`); + rows.push(dataRow); rows.push(""); } diff --git a/src/ui/index.ts b/src/ui/index.ts index 0cab22b..b290950 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -11,13 +11,13 @@ export { toggleSkillSelection, removeCurrentSlashToken, isClearImageAttachmentsShortcut, - getPromptCursorPlacement, renderBufferWithCursor, useTerminalInput, parseTerminalInput, type PromptSubmission, type InputKey } from "./PromptInput"; +export { getPromptCursorPlacement } from "./prompt/cursor"; export { SessionList, formatSessionTitle } from "./SessionList"; export { ThemedGradient } from "./ThemedGradient"; export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts new file mode 100644 index 0000000..b3bedf7 --- /dev/null +++ b/src/ui/prompt/cursor.ts @@ -0,0 +1,203 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; +import type { PromptBufferState } from "../promptBuffer"; + +type CursorPlacement = { + rowsUp: number; + column: number; +}; + +type WriteFn = ( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void +) => boolean; + +function cursorUp(rows: number): string { + return rows > 0 ? `\u001B[${rows}A` : ""; +} + +function cursorDown(rows: number): string { + return rows > 0 ? `\u001B[${rows}B` : ""; +} + +function cursorForward(columns: number): string { + return columns > 0 ? `\u001B[${columns}C` : ""; +} + +function showCursor(): string { + return "\u001B[?25h"; +} + +function hideCursor(): string { + return "\u001B[?25l"; +} + +function enableTerminalFocusReporting(): string { + return "\u001B[?1004h"; +} + +function disableTerminalFocusReporting(): string { + return "\u001B[?1004l"; +} + +export function getPromptCursorPlacement( + state: PromptBufferState, + screenWidth: number, + promptPrefix: string, + footerText: string +): CursorPlacement { + const width = Math.max(1, screenWidth); + const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); + const beforeCursor = state.text.slice(0, cursor); + const at = state.text[cursor]; + const displayText = beforeCursor + (typeof at === "undefined" || at === "\n" ? " " : at) + + (at === "\n" ? "\n" : "") + (typeof at === "undefined" ? "" : state.text.slice(cursor + 1)); + + const cursorPosition = measureTextPosition(beforeCursor, width, textWidth(promptPrefix)); + const promptRows = measureTextRows(displayText, width, textWidth(promptPrefix)); + const footerRows = 1 + measureTextRows(footerText, width, 0); + + return { + rowsUp: (promptRows - 1 - cursorPosition.row) + footerRows + 1, + column: cursorPosition.column + }; +} + +function measureTextRows(text: string, width: number, initialColumn: number): number { + return measureTextPosition(text, width, initialColumn).row + 1; +} + +function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } { + let row = 0; + let column = Math.min(initialColumn, width - 1); + + for (const char of Array.from(text)) { + if (char === "\n") { + row++; + column = Math.min(initialColumn, width - 1); + continue; + } + + const charColumns = textWidth(char); + if (column + charColumns > width) { + row++; + column = Math.min(initialColumn, width - 1); + } + column += charColumns; + if (column >= width) { + row++; + column = Math.min(initialColumn, width - 1); + } + } + + return { row, column }; +} + +function textWidth(value: string): number { + let width = 0; + for (const char of Array.from(value.normalize())) { + width += characterWidth(char); + } + return width; +} + +function characterWidth(char: string): number { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) { + return 0; + } + if (codePoint >= 0x300 && codePoint <= 0x36f) { + return 0; + } + if ( + (codePoint >= 0x1100 && codePoint <= 0x115f) || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) + ) { + return 2; + } + return 1; +} + +export function usePromptTerminalCursor( + stdout: NodeJS.WriteStream | undefined, + placement: CursorPlacement, + isActive: boolean +): void { + const directWriteRef = useRef<((data: string) => void) | null>(null); + const activePlacementRef = useRef(null); + + useLayoutEffect(() => { + if (!stdout?.isTTY) { + return; + } + + const stream = stdout as NodeJS.WriteStream & { write: WriteFn }; + const originalWrite = stream.write; + const directWrite = (data: string) => { + originalWrite.call(stdout, data); + }; + const restorePromptCursor = () => { + const activePlacement = activePlacementRef.current; + if (!activePlacement) { + return; + } + directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); + activePlacementRef.current = null; + }; + const patchedWrite: WriteFn = (...args) => { + restorePromptCursor(); + return originalWrite.apply(stdout, args); + }; + + directWriteRef.current = directWrite; + stream.write = patchedWrite; + + return () => { + restorePromptCursor(); + stream.write = originalWrite; + directWriteRef.current = null; + }; + }, [stdout]); + + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + const directWrite = directWriteRef.current; + if (!directWrite) { + return; + } + + directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); + activePlacementRef.current = placement; + + return () => { + const activePlacement = activePlacementRef.current; + if (!activePlacement) { + return; + } + directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); + activePlacementRef.current = null; + }; + }, [isActive, placement.column, placement.rowsUp, stdout]); +} + +export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableTerminalFocusReporting()); + return () => { + stdout.write(disableTerminalFocusReporting()); + }; + }, [isActive, stdout]); +} diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts new file mode 100644 index 0000000..f51f6e7 --- /dev/null +++ b/src/ui/prompt/index.ts @@ -0,0 +1,4 @@ +export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; +export type { InputKey } from "./useTerminalInput"; + +export { usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./cursor"; diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts new file mode 100644 index 0000000..12e28f3 --- /dev/null +++ b/src/ui/prompt/useTerminalInput.ts @@ -0,0 +1,139 @@ +import { useEffect, useRef } from "react"; +import { useStdin } from "ink"; + +export type InputKey = { + upArrow: boolean; + downArrow: boolean; + leftArrow: boolean; + rightArrow: boolean; + home: boolean; + end: boolean; + pageDown: boolean; + pageUp: boolean; + return: boolean; + escape: boolean; + ctrl: boolean; + shift: boolean; + tab: boolean; + backspace: boolean; + delete: boolean; + meta: boolean; + focusIn: boolean; + focusOut: boolean; +}; + +const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); +const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]); +const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]); +const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]); +const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u"]); +const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]); +const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]); +const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]); +const META_LEFT_SEQUENCES = new Set(["\u001B[1;3D", "\u001B[3D", "\u001Bb"]); +const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); +const TERMINAL_FOCUS_IN = "\u001B[I"; +const TERMINAL_FOCUS_OUT = "\u001B[O"; + +export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { + const raw = String(data); + let input = raw; + const key: InputKey = { + upArrow: raw === "\u001B[A", + downArrow: raw === "\u001B[B", + leftArrow: raw === "\u001B[D" || CTRL_LEFT_SEQUENCES.has(raw) || META_LEFT_SEQUENCES.has(raw), + rightArrow: raw === "\u001B[C" || CTRL_RIGHT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw), + home: HOME_SEQUENCES.has(raw), + end: END_SEQUENCES.has(raw), + pageDown: raw === "\u001B[6~", + pageUp: raw === "\u001B[5~", + return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), + escape: raw === "\u001B", + ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw), + shift: SHIFT_RETURN_SEQUENCES.has(raw), + tab: raw === "\t" || raw === "\u001B[Z", + backspace: BACKSPACE_BYTES.has(raw), + delete: FORWARD_DELETE_SEQUENCES.has(raw), + meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), + focusIn: raw === TERMINAL_FOCUS_IN, + focusOut: raw === TERMINAL_FOCUS_OUT + }; + + if (input <= "\u001A" && !key.return) { + input = String.fromCharCode(input.charCodeAt(0) + "a".charCodeAt(0) - 1); + key.ctrl = true; + } + + const isKnownEscapeSequence = + key.upArrow || + key.downArrow || + key.leftArrow || + key.rightArrow || + key.home || + key.end || + key.pageDown || + key.pageUp || + key.tab || + key.delete || + key.return || + key.ctrl || + key.meta || + key.focusIn || + key.focusOut; + + if (raw.startsWith("\u001B")) { + input = raw.slice(1); + key.meta = key.meta || !isKnownEscapeSequence; + } + + const isLatinUppercase = input >= "A" && input <= "Z"; + const isCyrillicUppercase = input >= "А" && input <= "Я"; + if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) { + key.shift = true; + } + + if (key.tab && input === "[Z") { + key.shift = true; + } + + if (key.tab || key.backspace || key.delete) { + input = ""; + } + + return { input, key }; +} + +export function useTerminalInput( + inputHandler: (input: string, key: InputKey) => void, + options: { isActive?: boolean } = {} +): void { + const { stdin, setRawMode } = useStdin(); + const isActive = options.isActive ?? true; + const handlerRef = useRef(inputHandler); + handlerRef.current = inputHandler; + + useEffect(() => { + if (!isActive) { + return; + } + setRawMode(true); + return () => { + setRawMode(false); + }; + }, [isActive, setRawMode]); + + useEffect(() => { + if (!isActive) { + return; + } + const handleData = (data: Buffer | string) => { + const { input, key } = parseTerminalInput(data); + handlerRef.current(input, key); + }; + + stdin?.on("data", handleData); + return () => { + stdin?.off("data", handleData); + }; + }, [isActive, stdin]); +} From 009f702567bd439ea058477073a7c221ddb82ed8 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 7 May 2026 22:16:23 +0800 Subject: [PATCH 002/217] =?UTF-8?q?fix(ui):=20PromptInput=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E6=96=B0=E5=A2=9E=20placeholder=20=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现用户提示输入组件,增加输入占位符和提示符动画 - 增加终端光标位置计算和光标显示控制 --- src/tests/promptInputKeys.test.ts | 10 +++--- src/ui/App.tsx | 15 +++++++-- src/ui/PromptInput.tsx | 53 +++++++++++++++++++------------ src/ui/prompt/cursor.ts | 6 ++-- 4 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 8180ece..bda2673 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -118,23 +118,23 @@ test("renderBufferWithCursor draws the simulated cursor when focused", () => { }); test("getPromptCursorPlacement targets the prompt row above divider and footer", () => { - const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, "> ", "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); }); test("getPromptCursorPlacement targets the reserved row after a trailing newline", () => { - const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, "> ", "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 2 }); }); test("getPromptCursorPlacement accounts for CJK character width", () => { - const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, "> ", "Enter send"); + const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, "Enter send"); assert.equal(placement.column, 6); }); test("getPromptCursorPlacement accounts for multiline buffer rows", () => { - const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, "> ", "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); - const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, "> ", "Enter send"); + const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, "Enter send"); assert.deepEqual(middle, { rowsUp: 4, column: 4 }); }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e551583..5159348 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -209,6 +209,11 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R sessionManager.interruptActiveSession(); }, [sessionManager]); + const handleSubmit = useCallback( + (submission: PromptSubmission) => { void handlePrompt(submission); }, + [handlePrompt] + ); + const handleSelectSession = useCallback( async (sessionId: string) => { sessionManager.setActiveSessionId(sessionId); @@ -224,7 +229,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R [sessionManager] ); - const screenWidth = useMemo(()=> columns ?? stdout?.columns ?? 80, [columns, stdout]); + const [stableColumns, setStableColumns] = useState(columns); + useEffect(() => { + const timer = setTimeout(() => setStableColumns(columns), 100); + return () => clearTimeout(timer); + }, [columns]); + const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]); const promptHistory = useMemo(() => { return messages .filter((message) => message.role === "user" && typeof message.content === "string") @@ -336,8 +346,9 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R promptHistory={promptHistory} busy={busy} loadingText={loadingText} - onSubmit={(submission) => void handlePrompt(submission)} + onSubmit={handleSubmit} onInterrupt={handleInterrupt} + placeholder='Type your message...' /> )} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 5ae1554..2454ee9 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -53,11 +53,31 @@ type Props = { busy: boolean; loadingText?: string | null; disabled?: boolean; + placeholder?: string; onSubmit: (submission: PromptSubmission) => void; onInterrupt: () => void; }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const PROMPT_PREFIX_WIDTH = 2; + +const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { + const [spinnerIndex, setSpinnerIndex] = useState(0); + + useEffect(() => { + if (!busy) { + setSpinnerIndex(0); + return; + } + const timer = setInterval(() => { + setSpinnerIndex((index) => (index + 1) % SPINNER_FRAMES.length); + }, 80); + return () => clearInterval(timer); + }, [busy]); + + const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; + return {prefix}; +}); export const PromptInput = React.memo(function PromptInput({ skills, @@ -66,6 +86,7 @@ export const PromptInput = React.memo(function PromptInput({ busy, loadingText, disabled, + placeholder, onSubmit, onInterrupt }: Props): React.ReactElement { @@ -82,7 +103,6 @@ export const PromptInput = React.memo(function PromptInput({ const [historyCursor, setHistoryCursor] = useState(-1); const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); - const [spinnerIndex, setSpinnerIndex] = useState(0); const lastCtrlDAt = React.useRef(0); const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); @@ -90,7 +110,6 @@ export const PromptInput = React.memo(function PromptInput({ const slashMenu = showSkillsDropdown ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : []; const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); - const promptPrefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; const footerText = statusMessage ? statusMessage : busy @@ -99,24 +118,13 @@ export const PromptInput = React.memo(function PromptInput({ : "esc to interrupt · ctrl+c to cancel input" : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; const cursorPlacement = React.useMemo( - () => getPromptCursorPlacement(buffer, screenWidth, promptPrefix, footerText), - [buffer, footerText, promptPrefix, screenWidth] + () => getPromptCursorPlacement(buffer, screenWidth, PROMPT_PREFIX_WIDTH, footerText), + [buffer, footerText, screenWidth] ); useTerminalFocusReporting(stdout, !disabled); usePromptTerminalCursor(stdout, cursorPlacement, !disabled); - useEffect(() => { - if (!busy) { - setSpinnerIndex(0); - return; - } - const timer = setInterval(() => { - setSpinnerIndex((index) => (index + 1) % SPINNER_FRAMES.length); - }, 80); - return () => clearInterval(timer); - }, [busy]); - useEffect(() => { if (!showMenu) { setMenuIndex(0); @@ -589,12 +597,12 @@ export const PromptInput = React.memo(function PromptInput({ {slashMenu.length > 8 ? … {slashMenu.length - 8} more : null} ) : null} - {divider} + {divider} - {promptPrefix} - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus)} + + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} - {divider} + {divider} {footerText} @@ -655,12 +663,17 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick Date: Fri, 8 May 2026 09:24:54 +0800 Subject: [PATCH 003/217] =?UTF-8?q?feat(ui):=20=E5=A2=9E=E5=8A=A0=E6=A0=B9?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E8=BF=90=E8=A1=8C=E8=AD=A6=E5=91=8A=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在App组件中添加rootDirectoryWarning警告信息,提示用户避免在根目录运行 - 使用fs.realpathSync和os.homedir进行路径比较,捕获文件系统错误 - 将rootDirectoryWarning通过props传递给WelcomeScreen组件 - 在WelcomeScreen中显示警告框,突出展示根目录运行风险提示 - 保持UI布局整洁,新增警告信息自适应欢迎界面宽度显示 --- src/ui/App.tsx | 14 ++++++++++++++ src/ui/WelcomeScreen.tsx | 32 +++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5159348..271b877 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -59,6 +59,19 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [showWelcome, setShowWelcome] = useState(true); const [, setNowTick] = useState(0); + const rootDirectoryWarning = useMemo(() => { + try { + const workspaceRealPath = fs.realpathSync(projectRoot); + const rootRealPath = fs.realpathSync(os.homedir()); + if (workspaceRealPath === rootRealPath) { + return 'Warning: You are running DeepCode CLI in the root directory. Your entire folder structure will be used for context. It is strongly recommended to run in a project-specific directory.'; + } + return null; + } catch { + return 'Could not verify the current directory due to a file system error.'; + } + }, [projectRoot]); + const messagesRef = useRef([]); messagesRef.current = messages; @@ -305,6 +318,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R skills={skills} version={version} width={screenWidth} + rootDirectoryWarning={rootDirectoryWarning} /> ); } diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index f2f2121..0478b10 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -1,7 +1,7 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useState} from "react"; import { Box, Text } from "ink"; -import * as os from "os"; -import * as path from "path"; +import * as os from "node:os"; +import path from 'node:path'; import type { SkillInfo } from "../session"; import type { ResolvedDeepcodingSettings } from "../settings"; import { @@ -17,6 +17,7 @@ type WelcomeScreenProps = { skills: SkillInfo[]; version: string; width: number; + rootDirectoryWarning: string | null; }; const TITLE_PANEL_WIDTH = 70; @@ -36,7 +37,8 @@ export function WelcomeScreen({ settings, skills, version, - width + width, + rootDirectoryWarning }: WelcomeScreenProps): React.ReactElement { const tips = useMemo(() => buildWelcomeTips(skills), [skills]); const [tipIndex] = useState(() => randomTipIndex(tips.length)); @@ -88,13 +90,21 @@ export function WelcomeScreen({ - {tip ? ( - - - Tips: {tip.label} - {tip.description} - - - ) : null} + + {tip ? ( + + + Tips: {tip.label} - {tip.description} + + + ) : null} + + {rootDirectoryWarning ? ( + + {rootDirectoryWarning} + + ) : null} + ); } From d743d20f8303c777cda3537bb712856a4f848074 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 09:45:47 +0800 Subject: [PATCH 004/217] =?UTF-8?q?feat(ui):=20=E5=A2=9E=E5=8A=A0=E6=A0=B9?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E8=BF=90=E8=A1=8C=E8=AD=A6=E5=91=8A=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 暂时没有安全机制,所以移除了警告信息 --- src/ui/WelcomeScreen.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 0478b10..6c5c117 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -98,12 +98,6 @@ export function WelcomeScreen({ ) : null} - - {rootDirectoryWarning ? ( - - {rootDirectoryWarning} - - ) : null} ); From ee0d0965784ad7bb157ee712906873190c22fa4f Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 11:57:09 +0800 Subject: [PATCH 005/217] =?UTF-8?q?refactor(SessionList):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E7=AA=97=E5=8F=A3=E5=A4=A7=E5=B0=8F=E5=92=8C?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 useWindowSize 以适配终端尺寸变化 - 基于窗口高度动态计算可见会话条目数 - 实现滚动偏移保障选中项始终可见 - 增加分页键(PageUp/PageDown)、Home、End 操作支持快速导航 - 限定选中索引在有效范围内避免越界错误 - 改进 UI 布局,添加边框和滚动提示信息 - 优化会话渲染逻辑,支持动态渲染当前可见会话列表 - 美化选中与非选中会话条目的显示样式 - 维护旧版显示逻辑以供替代使用 --- src/ui/SessionList.tsx | 118 +++++++++++++++++++++++++++++++++------ src/ui/WelcomeScreen.tsx | 16 ++---- 2 files changed, 106 insertions(+), 28 deletions(-) diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index f5c3117..17043d3 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,6 +1,6 @@ -import React, { useState } from "react"; -import { Box, Text, useInput } from "ink"; -import type { SessionEntry } from "../session"; +import React, {useState, useMemo} from "react"; +import {Box, Text, useInput, useWindowSize} from "ink"; +import type {SessionEntry} from "../session"; type Props = { sessions: SessionEntry[]; @@ -8,14 +8,45 @@ type Props = { onCancel: () => void; }; -export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { +export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactElement { const [index, setIndex] = useState(0); + const {columns, rows} = useWindowSize(); + + // 根据终端高度动态计算可见的会话数量 + const maxVisibleSessions = useMemo(() => { + // 减去边框、标题、页脚、滚动指示器等占用的空间 + // 外层容器 height=rows-1,外边框2 + header1 + 内边框2 + footer1 + 滚动指示器1 = 8 + const reservedLines = 8; + const linesPerSession = 3; // height=2 + marginBottom=1 + const availableLines = Math.max(0, rows - reservedLines); + return Math.max(1, Math.floor(availableLines / linesPerSession)); + }, [rows]); + + // 确保index在有效范围内 + const safeIndex = useMemo(() => { + if (sessions.length === 0) return 0; + return Math.max(0, Math.min(index, sessions.length - 1)); + }, [index, sessions.length]); + + // 计算滚动偏移量,确保选中的项目始终可见 + const scrollOffset = useMemo(() => { + if (safeIndex < maxVisibleSessions) return 0; + return safeIndex - maxVisibleSessions + 1; + }, [safeIndex, maxVisibleSessions]); + + // 获取当前可见的会话列表 + const visibleSessions = useMemo(() => { + return sessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); + }, [sessions, scrollOffset, maxVisibleSessions]); useInput((input, key) => { if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { onCancel(); return; } + if (sessions.length === 0) { + return; + } if (key.upArrow) { setIndex((i) => Math.max(0, i - 1)); return; @@ -24,8 +55,24 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac setIndex((i) => Math.min(sessions.length - 1, i + 1)); return; } + if (key.pageUp) { + setIndex((i) => Math.max(0, i - maxVisibleSessions)); + return; + } + if (key.pageDown) { + setIndex((i) => Math.min(sessions.length - 1, i + maxVisibleSessions)); + return; + } + if (key.home) { + setIndex(0); + return; + } + if (key.end) { + setIndex(sessions.length - 1); + return; + } if (key.return) { - const session = sessions[index]; + const session = sessions[safeIndex]; if (session) { onSelect(session.id); } @@ -42,19 +89,54 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac } return ( - - Resume a session - {sessions.slice(0, 30).map((session, i) => ( - - {i === index ? "› " : " "} - {formatTimestamp(session.updateTime)} - {formatSessionTitle(session.summary || "Untitled")} - ({session.status}) - - ))} - {sessions.length > 30 ? … {sessions.length - 30} older sessions hidden. : null} - - ↑/↓ to navigate · Enter to select · Esc to cancel + + + {/* Header row */} + Resume a session ({sessions.length} total) + {/* Session list */} + + {visibleSessions.map((session, i) => { + const actualIndex = scrollOffset + i; + return ( + + + + {actualIndex === safeIndex ? "› " : " "} + + + + + + {formatSessionTitle(session.summary || "Untitled")} + + ({session.status}) + + + {formatTimestamp(session.updateTime)} + + + + ); + })} + {(scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length) ? ( + + {scrollOffset > 0 ? … {scrollOffset} newer sessions above. : null} + {scrollOffset + maxVisibleSessions < sessions.length ? … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below. : null} + + ) : null} + + {/* Footer */} + + ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel + ); diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 6c5c117..fab6e9e 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -1,15 +1,11 @@ -import React, { useMemo, useState} from "react"; -import { Box, Text } from "ink"; +import React, {useMemo, useState} from "react"; +import {Box, Text} from "ink"; import * as os from "node:os"; import path from 'node:path'; -import type { SkillInfo } from "../session"; -import type { ResolvedDeepcodingSettings } from "../settings"; -import { - BUILTIN_SLASH_COMMANDS, - buildSlashCommands, - formatSlashCommandDescription -} from "./slashCommands"; -import { ThemedGradient } from "./ThemedGradient"; +import type {SkillInfo} from "../session"; +import type {ResolvedDeepcodingSettings} from "../settings"; +import {buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription} from "./slashCommands"; +import {ThemedGradient} from "./ThemedGradient"; type WelcomeScreenProps = { projectRoot: string; From d2302acb00b6897a420d021ddbce8966a080ecf9 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 12:00:21 +0800 Subject: [PATCH 006/217] =?UTF-8?q?refactor(SessionList):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E7=AA=97=E5=8F=A3=E5=A4=A7=E5=B0=8F=E5=92=8C?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 useWindowSize 以适配终端尺寸变化 - 基于窗口高度动态计算可见会话条目数 - 实现滚动偏移保障选中项始终可见 - 增加分页键(PageUp/PageDown)、Home、End 操作支持快速导航 - 限定选中索引在有效范围内避免越界错误 - 改进 UI 布局,添加边框和滚动提示信息 - 优化会话渲染逻辑,支持动态渲染当前可见会话列表 - 美化选中与非选中会话条目的显示样式 - 维护旧版显示逻辑以供替代使用 --- src/ui/SessionList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 17043d3..8f75788 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -99,7 +99,10 @@ export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactE > {/* Header row */} - Resume a session ({sessions.length} total) + + Resume a session + ({sessions.length} total) + {/* Session list */} From d034c43e6019307b870362891ab22b442d6ca096 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 15:48:15 +0800 Subject: [PATCH 007/217] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E5=85=89=E6=A0=87=E5=92=8C=E4=BC=9A=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=AB=98=E5=BA=A6=E9=99=90=E5=88=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePromptTerminalCursor 添加卸载状态判断,避免卸载时光标渲染问题 - PromptInput 新增输入状态管理与快捷键支持,增强图片粘贴和命令处理 - SessionList 限制会话列表最大高度为30行,防止终端溢出显示异常 - 修正光标钩子 layoutKey 依赖,确保光标位置动态更新正确 --- src/ui/PromptInput.tsx | 4 +++- src/ui/SessionList.tsx | 4 ++-- src/ui/prompt/cursor.ts | 13 ++++++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 2454ee9..f6ce96b 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -123,7 +123,8 @@ export const PromptInput = React.memo(function PromptInput({ ); useTerminalFocusReporting(stdout, !disabled); - usePromptTerminalCursor(stdout, cursorPlacement, !disabled); + const layoutKey = showSkillsDropdown || showMenu; + usePromptTerminalCursor(stdout, cursorPlacement, !disabled, layoutKey); useEffect(() => { if (!showMenu) { @@ -597,6 +598,7 @@ export const PromptInput = React.memo(function PromptInput({ {slashMenu.length > 8 ? … {slashMenu.length - 8} more : null} ) : null} + {/* Input */} {divider} diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 8f75788..abf4f93 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -18,7 +18,7 @@ export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactE // 外层容器 height=rows-1,外边框2 + header1 + 内边框2 + footer1 + 滚动指示器1 = 8 const reservedLines = 8; const linesPerSession = 3; // height=2 + marginBottom=1 - const availableLines = Math.max(0, rows - reservedLines); + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); return Math.max(1, Math.floor(availableLines / linesPerSession)); }, [rows]); @@ -92,7 +92,7 @@ export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactE void) | null>(null); const activePlacementRef = useRef(null); + const unmountingRef = useRef(false); useLayoutEffect(() => { if (!stdout?.isTTY) { @@ -143,6 +145,9 @@ export function usePromptTerminalCursor( originalWrite.call(stdout, data); }; const restorePromptCursor = () => { + if (unmountingRef.current) { + return; + } const activePlacement = activePlacementRef.current; if (!activePlacement) { return; @@ -170,6 +175,7 @@ export function usePromptTerminalCursor( return; } + unmountingRef.current = false; const directWrite = directWriteRef.current; if (!directWrite) { return; @@ -179,6 +185,7 @@ export function usePromptTerminalCursor( activePlacementRef.current = placement; return () => { + unmountingRef.current = true; const activePlacement = activePlacementRef.current; if (!activePlacement) { return; @@ -186,7 +193,7 @@ export function usePromptTerminalCursor( directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); activePlacementRef.current = null; }; - }, [isActive, placement.column, placement.rowsUp, stdout]); + }, [isActive, placement.column, placement.rowsUp, stdout, layoutKey]); } export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { From f6174dfeee294851d8a212f19935d3d181ac4549 Mon Sep 17 00:00:00 2001 From: DongWei <139533063+reddishJade@users.noreply.github.com> Date: Fri, 8 May 2026 16:54:17 +0800 Subject: [PATCH 008/217] fix: resolve Git Bash from all Windows git candidates --- src/tools/shell-utils.ts | 49 ++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/tools/shell-utils.ts b/src/tools/shell-utils.ts index ecb8a42..6be71f9 100644 --- a/src/tools/shell-utils.ts +++ b/src/tools/shell-utils.ts @@ -26,8 +26,7 @@ export function findGitBashPath(): string { return cachedGitBashPath; } - const gitPath = findWindowsExecutable("git"); - if (gitPath) { + for (const gitPath of findAllWindowsExecutableCandidates("git")) { const bashPath = pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"); if (fs.existsSync(bashPath)) { cachedGitBashPath = bashPath; @@ -154,14 +153,8 @@ export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { return env; } -function findWindowsExecutable(executable: string): string | null { - if (executable === "git") { - for (const location of WINDOWS_GIT_LOCATIONS) { - if (fs.existsSync(location)) { - return location; - } - } - } +function findAllWindowsExecutableCandidates(executable: string): string[] { + const extraCandidates = executable === "git" ? WINDOWS_GIT_LOCATIONS : []; try { const output = execFileSync("where.exe", [executable], { @@ -169,19 +162,31 @@ function findWindowsExecutable(executable: string): string | null { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); - const candidates = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - const cwd = process.cwd().toLowerCase(); - for (const candidate of candidates) { - const normalized = path.resolve(candidate).toLowerCase(); - const candidateDir = path.dirname(normalized).toLowerCase(); - if (candidateDir === cwd || normalized.startsWith(`${cwd}${path.sep}`)) { - continue; - } - return candidate; - } + return filterWindowsExecutableCandidates([ + ...output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean), + ...extraCandidates + ]); } catch { - // Fall through to a not-found result. + return filterWindowsExecutableCandidates(extraCandidates); + } +} + +function filterWindowsExecutableCandidates(candidates: string[]): string[] { + const cwd = process.cwd().toLowerCase(); + const seen = new Set(); + const results: string[] = []; + + for (const candidate of candidates) { + const normalized = path.resolve(candidate).toLowerCase(); + const candidateDir = path.dirname(normalized).toLowerCase(); + if (candidateDir === cwd || normalized.startsWith(`${cwd}${path.sep}`)) { + continue; + } + if (!seen.has(normalized) && fs.existsSync(candidate)) { + seen.add(normalized); + results.push(candidate); + } } - return null; + return results; } From 79b67b61dbfc156d8b6250f1dfd7360b929544df Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 17:22:04 +0800 Subject: [PATCH 009/217] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E5=85=89=E6=A0=87=E7=AE=A1=E7=90=86=E5=92=8C?= =?UTF-8?q?=E5=89=AA=E8=B4=B4=E6=9D=BF=E5=9B=BE=E5=83=8F=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增异步读取剪贴板图像函数,避免阻塞事件循环 - 改进终端光标位置管理,添加延迟重定位以处理特殊布局变化 - 移除无用的 layoutKey 参数,简化 usePromptTerminalCursor 使用 - 优化光标显示和隐藏逻辑,增强光标状态跟踪 - 修改 PromptInput 组件去除多余参数传递,保持接口简洁 --- src/ui/PromptInput.tsx | 3 +-- src/ui/clipboard.ts | 14 ++++++++++++++ src/ui/prompt/cursor.ts | 20 +++++++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index f6ce96b..dab35e2 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -123,8 +123,7 @@ export const PromptInput = React.memo(function PromptInput({ ); useTerminalFocusReporting(stdout, !disabled); - const layoutKey = showSkillsDropdown || showMenu; - usePromptTerminalCursor(stdout, cursorPlacement, !disabled, layoutKey); + usePromptTerminalCursor(stdout, cursorPlacement, !disabled); useEffect(() => { if (!showMenu) { diff --git a/src/ui/clipboard.ts b/src/ui/clipboard.ts index 72ecd59..3ea65b7 100644 --- a/src/ui/clipboard.ts +++ b/src/ui/clipboard.ts @@ -142,3 +142,17 @@ export function readClipboardImage(): ClipboardImage | null { return null; } + +export async function readClipboardImageAsync(): Promise { + return new Promise((resolve) => { + // 使用 setImmediate 确保不会阻塞事件循环 + setImmediate(() => { + try { + const result = readClipboardImage(); + resolve(result); + } catch { + resolve(null); + } + }); + }); +} diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index bedec80..44e4c57 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -127,11 +127,11 @@ function characterWidth(char: string): number { export function usePromptTerminalCursor( stdout: NodeJS.WriteStream | undefined, placement: CursorPlacement, - isActive: boolean, - layoutKey?: boolean + isActive: boolean ): void { const directWriteRef = useRef<((data: string) => void) | null>(null); const activePlacementRef = useRef(null); + const lastPlacementRef = useRef(null); const unmountingRef = useRef(false); useLayoutEffect(() => { @@ -154,6 +154,19 @@ export function usePromptTerminalCursor( } directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); activePlacementRef.current = null; + // Schedule a deferred re-position in case the layout effect does not + // re-run (e.g. a dropdown closed without changing the buffer). + Promise.resolve().then(() => { + if (unmountingRef.current || activePlacementRef.current) { + return; + } + const latest = directWriteRef.current; + const p = lastPlacementRef.current; + if (latest && p) { + latest(showCursor() + cursorUp(p.rowsUp) + "\r" + cursorForward(p.column)); + activePlacementRef.current = p; + } + }); }; const patchedWrite: WriteFn = (...args) => { restorePromptCursor(); @@ -183,6 +196,7 @@ export function usePromptTerminalCursor( directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); activePlacementRef.current = placement; + lastPlacementRef.current = placement; return () => { unmountingRef.current = true; @@ -193,7 +207,7 @@ export function usePromptTerminalCursor( directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); activePlacementRef.current = null; }; - }, [isActive, placement.column, placement.rowsUp, stdout, layoutKey]); + }, [isActive, placement.column, placement.rowsUp, stdout]); } export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { From 3a45679bb43b81230678d465caeb7443cae9244a Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 19:46:20 +0800 Subject: [PATCH 010/217] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E5=85=89=E6=A0=87=E7=AE=A1=E7=90=86=E5=92=8C?= =?UTF-8?q?=E5=89=AA=E8=B4=B4=E6=9D=BF=E5=9B=BE=E5=83=8F=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增异步读取剪贴板图像函数,避免阻塞事件循环 - 改进终端光标位置管理,添加延迟重定位以处理特殊布局变化 - 移除无用的 layoutKey 参数,简化 usePromptTerminalCursor 使用 - 优化光标显示和隐藏逻辑,增强光标状态跟踪 - 修改 PromptInput 组件去除多余参数传递,保持接口简洁 --- src/ui/App.tsx | 4 ++-- src/ui/MessageView.tsx | 14 ++++++++++++-- src/ui/PromptInput.tsx | 20 ++++++++++++-------- src/ui/SessionList.tsx | 16 ++++++++-------- src/ui/ThemedGradient.tsx | 2 +- src/ui/WelcomeScreen.tsx | 6 ++++++ src/ui/clipboard.ts | 2 +- src/ui/exitSummary.ts | 12 ++++++++---- 8 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6403f5d..2976516 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -57,7 +57,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [dismissedQuestionIds, setDismissedQuestionIds] = useState>(() => new Set()); const [isExiting, setIsExiting] = useState(false); const [showWelcome, setShowWelcome] = useState(true); - const [, setNowTick] = useState(0); + const [nowTick, setNowTick] = useState(0); const rootDirectoryWarning = useMemo(() => { try { @@ -266,7 +266,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R () => busy ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) : null, - [busy, streamProgress, runningProcesses] + [busy, streamProgress, runningProcesses, nowTick] ); const welcomeSettings = useMemo(() => resolveCurrentSettings(), []); const welcomeItem: SessionMessage = useMemo(() => ({ diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index 7e142a0..c9160bb 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -8,7 +8,7 @@ type Props = { collapsed?: boolean; }; -export function MessageView({ message }: Props): React.ReactElement | null { +export function MessageView({ message, collapsed }: Props): React.ReactElement | null { if (!message.visible) { return null; } @@ -31,9 +31,19 @@ export function MessageView({ message }: Props): React.ReactElement | null { if (isThinking) { const summary = buildThinkingSummary(content, message.messageParams); + if (collapsed !== false) { + return ( + + + + ); + } return ( - + + + {content ? {renderMarkdown(content)} : null} + ); } diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index dab35e2..640e160 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -28,7 +28,7 @@ import { formatSlashCommandDescription, formatSlashCommandLabel } from "./slashCommands"; -import { readClipboardImage } from "./clipboard"; +import { readClipboardImageAsync } from "./clipboard"; import type { SkillInfo } from "../session"; // Re-exported from prompt modules for backward compatibility @@ -243,13 +243,17 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.ctrl && (input === "v" || input === "V")) { - const image = readClipboardImage(); - if (image) { - setImageUrls((prev) => [...prev, image.dataUrl]); - setStatusMessage("Attached image from clipboard"); - } else { - setStatusMessage("No image found in clipboard"); - } + setStatusMessage("Reading clipboard..."); + readClipboardImageAsync().then((image) => { + if (image) { + setImageUrls((prev) => [...prev, image.dataUrl]); + setStatusMessage("Attached image from clipboard"); + } else { + setStatusMessage("No image found in clipboard"); + } + }).catch(() => { + setStatusMessage("Failed to read clipboard"); + }); return; } diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index abf4f93..ed1f22f 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -12,29 +12,29 @@ export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactE const [index, setIndex] = useState(0); const {columns, rows} = useWindowSize(); - // 根据终端高度动态计算可见的会话数量 + // Dynamically calculate the number of visible sessions based on terminal height const maxVisibleSessions = useMemo(() => { - // 减去边框、标题、页脚、滚动指示器等占用的空间 - // 外层容器 height=rows-1,外边框2 + header1 + 内边框2 + footer1 + 滚动指示器1 = 8 + // Subtract space used by borders, header, footer, scroll indicator, etc. + // Outer container height=rows-1, outer border 2 + header 1 + inner border 2 + footer 1 + scroll indicator 1 = 8 const reservedLines = 8; const linesPerSession = 3; // height=2 + marginBottom=1 const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); return Math.max(1, Math.floor(availableLines / linesPerSession)); }, [rows]); - // 确保index在有效范围内 + // Ensure index stays within valid range const safeIndex = useMemo(() => { if (sessions.length === 0) return 0; return Math.max(0, Math.min(index, sessions.length - 1)); }, [index, sessions.length]); - // 计算滚动偏移量,确保选中的项目始终可见 + // Calculate scroll offset to keep the selected item visible const scrollOffset = useMemo(() => { if (safeIndex < maxVisibleSessions) return 0; return safeIndex - maxVisibleSessions + 1; }, [safeIndex, maxVisibleSessions]); - // 获取当前可见的会话列表 + // Get the currently visible session list const visibleSessions = useMemo(() => { return sessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); }, [sessions, scrollOffset, maxVisibleSessions]); @@ -91,8 +91,8 @@ export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactE return ( = ({ children, ...props }) => { - const gradient = ['#229ac3e6', '#229ac3e6']; //先用纯色 + const gradient = ['#229ac3e6', '#229ac3e6']; // Use solid color for now if (gradient && gradient.length >= 2) { return ( diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index fab6e9e..48be4b0 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -86,6 +86,12 @@ export function WelcomeScreen({ + {rootDirectoryWarning ? ( + + {rootDirectoryWarning} + + ) : null} + {tip ? ( diff --git a/src/ui/clipboard.ts b/src/ui/clipboard.ts index 3ea65b7..127ac96 100644 --- a/src/ui/clipboard.ts +++ b/src/ui/clipboard.ts @@ -145,7 +145,7 @@ export function readClipboardImage(): ClipboardImage | null { export async function readClipboardImageAsync(): Promise { return new Promise((resolve) => { - // 使用 setImmediate 确保不会阻塞事件循环 + // Use setImmediate to avoid blocking the event loop setImmediate(() => { try { const result = readClipboardImage(); diff --git a/src/ui/exitSummary.ts b/src/ui/exitSummary.ts index 5cc1e6f..3e13d5f 100644 --- a/src/ui/exitSummary.ts +++ b/src/ui/exitSummary.ts @@ -8,15 +8,19 @@ type ExitSummaryInput = { model?: string; }; +const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g; + +function visibleLength(text: string): number { + return text.replace(ANSI_RE, "").length; +} + function padRight(text: string, width: number): string { - const visible = text.replace(/\u001b\[[0-9;]*m/g, ""); - const padding = Math.max(0, width - visible.length); + const padding = Math.max(0, width - visibleLength(text)); return text + " ".repeat(padding); } function padLeft(text: string, width: number): string { - const visible = text.replace(/\u001b\[[0-9;]*m/g, ""); - const padding = Math.max(0, width - visible.length); + const padding = Math.max(0, width - visibleLength(text)); return " ".repeat(padding) + text; } From d304354e768b0a8e1d900a5af33f0554b11906ae Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 19:57:18 +0800 Subject: [PATCH 011/217] =?UTF-8?q?fix(ui):=20=E7=A7=BB=E9=99=A4=20rootDir?= =?UTF-8?q?ectoryWarning=20=E6=8F=90=E7=A4=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/WelcomeScreen.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 48be4b0..cd0281e 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -13,7 +13,7 @@ type WelcomeScreenProps = { skills: SkillInfo[]; version: string; width: number; - rootDirectoryWarning: string | null; + rootDirectoryWarning?: string | null; }; const TITLE_PANEL_WIDTH = 70; @@ -34,7 +34,6 @@ export function WelcomeScreen({ skills, version, width, - rootDirectoryWarning }: WelcomeScreenProps): React.ReactElement { const tips = useMemo(() => buildWelcomeTips(skills), [skills]); const [tipIndex] = useState(() => randomTipIndex(tips.length)); @@ -86,12 +85,6 @@ export function WelcomeScreen({ - {rootDirectoryWarning ? ( - - {rootDirectoryWarning} - - ) : null} - {tip ? ( From b669a540a1b67838d08189058d0a582fbebe1a95 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 8 May 2026 20:20:56 +0800 Subject: [PATCH 012/217] feat: update version to 0.1.15 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index efa38cc..c72ca99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.14", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.14", + "version": "0.1.15", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index 5dc2a70..a938ef5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.14", + "version": "0.1.15", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From 4be64b17eae5f78b66e74753cb23d36304773b0a Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 20:24:14 +0800 Subject: [PATCH 013/217] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=89=AA=E8=B4=B4=E6=9D=BF=E5=9B=BE=E5=83=8F=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E5=92=8C=E6=B8=B8=E6=A0=87=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改readClipboardImageAsync函数,改用reject处理异常,避免误捕获错误 - 在游标相关代码中清空lastPlacementRef,防止卸载后遗留状态 - 更新SessionList组件中文本颜色,使界面元素色彩更加醒目 --- src/ui/SessionList.tsx | 2 +- src/ui/clipboard.ts | 6 +++--- src/ui/prompt/cursor.ts | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index ed1f22f..64ef77a 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -100,7 +100,7 @@ export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactE {/* Header row */} - Resume a session + Resume a session ({sessions.length} total) {/* Session list */} diff --git a/src/ui/clipboard.ts b/src/ui/clipboard.ts index 127ac96..e66b9b4 100644 --- a/src/ui/clipboard.ts +++ b/src/ui/clipboard.ts @@ -144,14 +144,14 @@ export function readClipboardImage(): ClipboardImage | null { } export async function readClipboardImageAsync(): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { // Use setImmediate to avoid blocking the event loop setImmediate(() => { try { const result = readClipboardImage(); resolve(result); - } catch { - resolve(null); + } catch (error) { + reject(error); } }); }); diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 44e4c57..d795888 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -200,6 +200,7 @@ export function usePromptTerminalCursor( return () => { unmountingRef.current = true; + lastPlacementRef.current = null; const activePlacement = activePlacementRef.current; if (!activePlacement) { return; From c53108dd330177422cf3e667df69fcac9cc6fa19 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 8 May 2026 21:11:34 +0800 Subject: [PATCH 014/217] =?UTF-8?q?fix(ui):=20=E5=AE=8C=E5=85=A8=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20rootDirectoryWarning=20=E7=9B=B8=E5=85=B3=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/App.tsx | 14 -------------- src/ui/WelcomeScreen.tsx | 1 - 2 files changed, 15 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 2976516..d4dde6b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -59,19 +59,6 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [showWelcome, setShowWelcome] = useState(true); const [nowTick, setNowTick] = useState(0); - const rootDirectoryWarning = useMemo(() => { - try { - const workspaceRealPath = fs.realpathSync(projectRoot); - const rootRealPath = fs.realpathSync(os.homedir()); - if (workspaceRealPath === rootRealPath) { - return 'Warning: You are running DeepCode CLI in the root directory. Your entire folder structure will be used for context. It is strongly recommended to run in a project-specific directory.'; - } - return null; - } catch { - return 'Could not verify the current directory due to a file system error.'; - } - }, [projectRoot]); - const messagesRef = useRef([]); messagesRef.current = messages; @@ -318,7 +305,6 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R skills={skills} version={version} width={screenWidth} - rootDirectoryWarning={rootDirectoryWarning} /> ); } diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index cd0281e..8407f54 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -13,7 +13,6 @@ type WelcomeScreenProps = { skills: SkillInfo[]; version: string; width: number; - rootDirectoryWarning?: string | null; }; const TITLE_PANEL_WIDTH = 70; From 4fd1ab9a8f9c8ef6305873c53e6d9c2705a046dd Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 10:51:16 +0800 Subject: [PATCH 015/217] =?UTF-8?q?fix(ui):=20resume=20=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现主App组件,支持多会话切换和消息流处理 - 新增消息视图MessageView改进,优化用户及助手消息展示样式 - 添加会话列表SessionList组件,支持会话选择和状态更新 --- src/ui/App.tsx | 27 ++++++++++++++++++++------- src/ui/MessageView.tsx | 33 +++++++++++++++++++-------------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d4dde6b..1fba48b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -57,6 +57,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [dismissedQuestionIds, setDismissedQuestionIds] = useState>(() => new Set()); const [isExiting, setIsExiting] = useState(false); const [showWelcome, setShowWelcome] = useState(true); + const [welcomeNonce, setWelcomeNonce] = useState(0); const [nowTick, setNowTick] = useState(0); const messagesRef = useRef([]); @@ -153,6 +154,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setActiveStatus(null); setDismissedQuestionIds(new Set()); setShowWelcome(true); + setWelcomeNonce((n) => n + 1); await refreshSkills(); refreshSessionsList(); } @@ -216,14 +218,25 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R 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); - setMessages(loadVisibleMessages(sessionManager, sessionId)); + // 先清空让 的 index 重置为 0 + setMessages([]); + setShowWelcome(false); + setWelcomeNonce((n) => n + 1); + setView("chat"); + // 再加载新消息,此时 index 已为 0,会渲染全部 items + setTimeout(() => { + setMessages(loadVisibleMessages(sessionManager, sessionId)); + setShowWelcome(true); + }, 0); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session) : ""); setRunningProcesses(session?.processes ?? null); setActiveStatus(session?.status ?? null); - setShowWelcome(false); - setView("chat"); await refreshSkills(sessionId); }, [sessionManager] @@ -257,7 +270,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R ); const welcomeSettings = useMemo(() => resolveCurrentSettings(), []); const welcomeItem: SessionMessage = useMemo(() => ({ - id: "__welcome__", + id: `__welcome__${welcomeNonce}`, sessionId: "", role: "system", content: "", @@ -267,7 +280,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R visible: true, createTime: "", updateTime: "" - }), []); + }), [welcomeNonce]); const staticItems = useMemo(() => { if (showWelcome && view === "chat") { return [welcomeItem, ...messages]; @@ -296,10 +309,10 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R {(item) => { - if (item.id === "__welcome__") { + if (item.id.startsWith("__welcome__")) { return ( - {`> ${text}`} - {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} - ) : null} + + + {`>`} + + {text} + {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( + {` 📎 ${message.contentParams.length} image attachment(s)`} + ) : null} + + ); } @@ -39,9 +44,9 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | ); } return ( - + - + {content ? {renderMarkdown(content)} : null} @@ -49,9 +54,9 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | } return ( - - Assistant - + + + {content ? {renderMarkdown(content)} : null} @@ -62,7 +67,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | const summary = buildToolSummary(message); const diffLines = getToolDiffPreviewLines(summary); return ( - + + ⚡ Loaded skill: {message.meta.skill.name} ); } if (message.meta?.isSummary) { return ( - + (conversation summary inserted) ); From 79173ad9867e2ac62899ecb471069e39c4313917 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 9 May 2026 12:14:45 +0800 Subject: [PATCH 016/217] fix: Update the project skill loading path to support the .agents directory while maintaining compatibility with the legacy .deepcode directory. --- README.md | 2 +- src/cli.tsx | 3 ++- src/session.ts | 8 ++++++-- src/tests/session.test.ts | 40 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e9a6f90..ea5dcde 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ npm install -g @vegamo/deepcode-cli Deep Code CLI 支持 agent skills,允许您扩展助手的能力: - **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 -- **Project-level Skills**:从 `./.deepcode/skills/` 目录中加载项目专属 skills。 +- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 ### **为 DeepSeek 优化** - 专门为 DeepSeek 模型性能调优。 diff --git a/src/cli.tsx b/src/cli.tsx index a546a61..86c50ea 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -25,7 +25,8 @@ if (args.includes("--help") || args.includes("-h")) { "Configuration:", " ~/.deepcode/settings.json API key, model, base URL", " ~/.agents/skills/*/SKILL.md User-level skills", - " ./.deepcode/skills/*/SKILL.md Project-level skills", + " ./.agents/skills/*/SKILL.md Project-level skills", + " ./.deepcode/skills/*/SKILL.md Legacy project-level skills", "", "Inside the TUI:", " enter Send the prompt", diff --git a/src/session.ts b/src/session.ts index 9a85c39..cf86d6b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -556,7 +556,8 @@ The candidate skills are as follows:\n\n`; async listSkills(sessionId?: string): Promise { const homeDir = os.homedir(); const agentsRoot = path.join(homeDir, ".agents", "skills"); - const projectSkillsRoot = path.join(this.projectRoot, ".deepcode", "skills"); + const legacyProjectSkillsRoot = path.join(this.projectRoot, ".deepcode", "skills"); + const projectAgentsSkillsRoot = path.join(this.projectRoot, ".agents", "skills"); const skillsByName = new Map(); const collectSkills = (root: string, displayRoot: string): SkillInfo[] => { @@ -596,7 +597,10 @@ The candidate skills are as follows:\n\n`; for (const skill of collectSkills(agentsRoot, "~/.agents/skills")) { skillsByName.set(skill.name, skill); } - for (const skill of collectSkills(projectSkillsRoot, "./.deepcode/skills")) { + for (const skill of collectSkills(legacyProjectSkillsRoot, "./.deepcode/skills")) { + skillsByName.set(skill.name, skill); + } + for (const skill of collectSkills(projectAgentsSkillsRoot, "./.agents/skills")) { skillsByName.set(skill.name, skill); } diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 0ec4a1e..8ad3cf5 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -289,6 +289,46 @@ test("SessionManager marks skills loaded from existing session messages", async assert.equal(loadedSkill?.isLoaded, true); }); +test("SessionManager lists project skills from .agents with legacy .deepcode compatibility", async () => { + const workspace = createTempDir("deepcode-project-skills-workspace-"); + const home = createTempDir("deepcode-project-skills-home-"); + process.env.HOME = home; + + const userSkillDir = path.join(home, ".agents", "skills", "shared"); + fs.mkdirSync(userSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(userSkillDir, "SKILL.md"), + "---\nname: shared\ndescription: User-level skill\n---\n# Shared\n", + "utf8" + ); + + const legacyProjectSkillDir = path.join(workspace, ".deepcode", "skills", "legacy"); + fs.mkdirSync(legacyProjectSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(legacyProjectSkillDir, "SKILL.md"), + "---\nname: legacy\ndescription: Legacy project skill\n---\n# Legacy\n", + "utf8" + ); + + const projectAgentsSkillDir = path.join(workspace, ".agents", "skills", "shared"); + fs.mkdirSync(projectAgentsSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(projectAgentsSkillDir, "SKILL.md"), + "---\nname: shared\ndescription: Project .agents skill\n---\n# Shared\n", + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-project-skills"); + const skills = await manager.listSkills(); + const legacySkill = skills.find((skill) => skill.name === "legacy"); + const sharedSkill = skills.find((skill) => skill.name === "shared"); + + assert.equal(legacySkill?.path, "./.deepcode/skills/legacy/SKILL.md"); + assert.equal(legacySkill?.description, "Legacy project skill"); + assert.equal(sharedSkill?.path, "./.agents/skills/shared/SKILL.md"); + assert.equal(sharedSkill?.description, "Project .agents skill"); +}); + test("createSession reports a new prompt with the machineId token", async () => { const workspace = createTempDir("deepcode-session-workspace-"); const home = createTempDir("deepcode-session-home-"); From deae89f09f3c65202300ce6f02d6304943dafaff Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 9 May 2026 12:39:29 +0800 Subject: [PATCH 017/217] Fix prompt cursor wrapping --- src/tests/promptInputKeys.test.ts | 6 ++++++ src/ui/PromptInput.tsx | 21 ++++++++++----------- src/ui/prompt/cursor.ts | 13 +++++++++++++ src/ui/prompt/index.ts | 2 +- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index bda2673..870c1d3 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -117,6 +117,12 @@ test("renderBufferWithCursor draws the simulated cursor when focused", () => { assert.equal(stripAnsi(renderBufferWithCursor({ text: "\n", cursor: 1 }, true)), "\n "); }); +test("renderBufferWithCursor styles exactly one simulated cursor", () => { + assert.equal((renderBufferWithCursor({ text: "", cursor: 0 }, true).match(ANSI_RE) ?? []).length, 2); + assert.equal((renderBufferWithCursor({ text: "hello", cursor: 1 }, true).match(ANSI_RE) ?? []).length, 2); + assert.equal((renderBufferWithCursor({ text: "hello\nworld", cursor: 6 }, true).match(ANSI_RE) ?? []).length, 2); +}); + test("getPromptCursorPlacement targets the prompt row above divider and footer", () => { const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 640e160..98029b8 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -37,7 +37,7 @@ export type { InputKey } from "./prompt"; import { useTerminalInput, parseTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; -import { usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./prompt/cursor"; +import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt/cursor"; export type PromptSubmission = { text: string; @@ -117,13 +117,8 @@ export const PromptInput = React.memo(function PromptInput({ ? loadingText : "esc to interrupt · ctrl+c to cancel input" : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; - const cursorPlacement = React.useMemo( - () => getPromptCursorPlacement(buffer, screenWidth, PROMPT_PREFIX_WIDTH, footerText), - [buffer, footerText, screenWidth] - ); - useTerminalFocusReporting(stdout, !disabled); - usePromptTerminalCursor(stdout, cursorPlacement, !disabled); + useHiddenTerminalCursor(stdout, !disabled); useEffect(() => { if (!showMenu) { @@ -676,7 +671,7 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool const after = text.slice(cursor + 1); if (text.length === 0 && placeholder) { - return chalk.dim(" " +placeholder); + return chalk.dim(` ${placeholder}`); } if (!isFocused) { @@ -684,10 +679,14 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool } if (typeof at === "undefined") { - return before + chalk.inverse(" "); + return before + renderCursorCell(" "); } if (at === "\n") { - return before + chalk.inverse(" ") + "\n" + after; + return before + renderCursorCell(" ") + "\n" + after; } - return before + chalk.inverse(at) + after; + return before + renderCursorCell(at) + after; +} + +function renderCursorCell(value: string): string { + return `\u001B[7m${value}\u001B[27m`; } diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index d795888..c24e1f6 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -211,6 +211,19 @@ export function usePromptTerminalCursor( }, [isActive, placement.column, placement.rowsUp, stdout]); } +export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(hideCursor()); + return () => { + stdout.write(showCursor()); + }; + }, [isActive, stdout]); +} + export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { useLayoutEffect(() => { if (!isActive || !stdout?.isTTY) { diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index f51f6e7..d56c7dc 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -1,4 +1,4 @@ export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; export type { InputKey } from "./useTerminalInput"; -export { usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./cursor"; +export { useHiddenTerminalCursor, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./cursor"; From 71a8096e44c6c2a08eb36843cbb44a4fe309fbeb Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 9 May 2026 13:00:30 +0800 Subject: [PATCH 018/217] feat: align README_en.md --- README_cn.md | 2 +- README_en.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README_cn.md b/README_cn.md index e9a6f90..ea5dcde 100644 --- a/README_cn.md +++ b/README_cn.md @@ -36,7 +36,7 @@ npm install -g @vegamo/deepcode-cli Deep Code CLI 支持 agent skills,允许您扩展助手的能力: - **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 -- **Project-level Skills**:从 `./.deepcode/skills/` 目录中加载项目专属 skills。 +- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 ### **为 DeepSeek 优化** - 专门为 DeepSeek 模型性能调优。 diff --git a/README_en.md b/README_en.md index 5a380eb..dc49ae0 100644 --- a/README_en.md +++ b/README_en.md @@ -36,7 +36,7 @@ The configuration file is shared with the [Deep Code VSCode extension](https://g Deep Code CLI supports agent skills that allow you to extend the assistant's capabilities: - **User-level Skills**: discovered and activated from `~/.agents/skills/`. -- **Project-level Skills**: loaded from `./.deepcode/skills/` for project-specific workflows. +- **Project-level Skills**: loaded from `./.agents/skills/` for project-specific workflows, with legacy `./.deepcode/skills/` compatibility. ### **Optimized for DeepSeek** - Specifically tuned for DeepSeek model performance. From 4014451f06a33a4c4e183d18fc2e121bc56ec9a0 Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 9 May 2026 13:01:05 +0800 Subject: [PATCH 019/217] Document prompt cursor ANSI rendering --- src/ui/PromptInput.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 98029b8..cb5eef7 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -687,6 +687,8 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool return before + renderCursorCell(at) + after; } +// Use explicit ANSI instead of chalk.inverse so cursor rendering stays enabled +// in non-TTY environments such as tests, where Chalk may strip styling. function renderCursorCell(value: string): string { return `\u001B[7m${value}\u001B[27m`; } From 56cc986bed63c89f5363af88d41c94866a30d782 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 9 May 2026 13:08:38 +0800 Subject: [PATCH 020/217] feat: update version to 0.1.16 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c72ca99..8305db2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.15", + "version": "0.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.15", + "version": "0.1.16", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index a938ef5..ba0c8e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.15", + "version": "0.1.16", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From 97bb31f038be84f8852a523ab3971fc113106469 Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 09:28:06 +0800 Subject: [PATCH 021/217] =?UTF-8?q?feat(ui):=20=E5=AE=9E=E7=8E=B0=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E6=8F=90=E7=A4=BA=E8=BE=93=E5=85=A5=E5=92=8C=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=8F=9C=E5=8D=95=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PromptInput 组件,支持文本输入、多技能选择和图像粘贴 - 实现键盘快捷键处理,包括历史记录导航和光标控制 - 新增 SlashCommandMenu 组件,支持命令列表展示及高亮 - 支持命令行式斜杠命令自动补全与选择逻辑 - 添加提示和状态信息显示,提升用户交互体验 - 处理粘贴板图像读取与附加图像管理功能 - 优化终端焦点管理和光标位置计算逻辑 - 提供选中技能的增加、切换和显示功能 - 实现输入缓冲区文本渲染及光标位置高亮 # Conflicts: # src/ui/PromptInput.tsx --- src/ui/PromptInput.tsx | 33 ++++++----------- src/ui/SlashCommandMenu.tsx | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 src/ui/SlashCommandMenu.tsx diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index cb5eef7..f9f0814 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -25,8 +25,6 @@ import { buildSlashCommands, filterSlashCommands, findExactSlashCommand, - formatSlashCommandDescription, - formatSlashCommandLabel } from "./slashCommands"; import { readClipboardImageAsync } from "./clipboard"; import type { SkillInfo } from "../session"; @@ -35,9 +33,9 @@ import type { SkillInfo } from "../session"; export { useTerminalInput, parseTerminalInput } from "./prompt"; export type { InputKey } from "./prompt"; -import { useTerminalInput, parseTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt/cursor"; +import SlashCommandMenu from "./SlashCommandMenu"; export type PromptSubmission = { text: string; @@ -556,6 +554,13 @@ export const PromptInput = React.memo(function PromptInput({ (use /skills to edit) ) : null} + {/* Input */} + {divider} + + + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} + + {divider} {showSkillsDropdown ? ( Select Skills @@ -581,28 +586,10 @@ export const PromptInput = React.memo(function PromptInput({ {visibleSkillStart + visibleSkills.length < skills.length ? ( … {skills.length - visibleSkillStart - visibleSkills.length} more ) : null} - space toggle · enter toggle · esc close + space toggle · enter toggle · esc to close ) : null} - {showMenu ? ( - - {slashMenu.slice(0, 8).map((item, idx) => ( - - {idx === menuIndex ? "› " : " "} - {formatSlashCommandLabel(item)} - {formatSlashCommandDescription(item.description)} - - ))} - {slashMenu.length > 8 ? … {slashMenu.length - 8} more : null} - - ) : null} - {/* Input */} - {divider} - - - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} - - {divider} + {footerText} diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx new file mode 100644 index 0000000..0df7e52 --- /dev/null +++ b/src/ui/SlashCommandMenu.tsx @@ -0,0 +1,70 @@ +import {formatSlashCommandDescription, formatSlashCommandLabel, SlashCommandItem} from "./slashCommands"; +import React from "react"; +import {Box, Text} from "ink"; + +type SlashCommandMenuProps = { + items: SlashCommandItem[]; + activeIndex: number; + width: number; + maxVisible?: number; +}; + +const SlashCommandMenu = React.memo(function SlashCommandMenu({ + items, + activeIndex, + maxVisible = 6, + width + }: SlashCommandMenuProps): React.ReactElement | null { + if (items.length === 0) { + return null; + } + + // 计算可见窗口起始位置,确保 activeIndex 始终在可见区域内 + const visibleStart = Math.min( + Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), + Math.max(0, items.length - maxVisible) + ); + const visibleItems = items.slice(visibleStart, visibleStart + maxVisible); + + // 计算标签列最佳宽度:包含前缀"› "或" "(2字符),不超过容器一半(扣除gap) + const labelColumnWidth = React.useMemo(() => { + const longestLabel = Math.max(...items.map((s) => s.label.length)); + const contentWidth = longestLabel + 2; // +2 for prefix "› " or " " + const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 + return Math.min(contentWidth, maxAllowed); + }, [items, width]); + + return ( + + {visibleStart > 0 ? ( + + ) : null} + {visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + return ( + + + + {actualIndex === activeIndex ? "› " : " "} + {formatSlashCommandLabel(item)} + + + + + {formatSlashCommandDescription(item.description)} + + + + ); + })} + + {visibleStart + visibleItems.length < items.length ? ( + + ) : null} + ({activeIndex + 1}/{items.length}) ↑↓ to navigate · Enter to select + + + ); +}); + +export default SlashCommandMenu; \ No newline at end of file From 8dac4287f0ca9e3ce40e2c8e704b99896dbf343f Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 14:34:14 +0800 Subject: [PATCH 022/217] =?UTF-8?q?style(ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=8F=9C=E5=8D=95=E5=8F=8A=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E7=9A=84=E9=AB=98=E4=BA=AE=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E8=A1=A8=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 把 PromptInput 中非忙碌状态的提示文字颜色从绿色改为更柔和的蓝色 (#229ac3) - 将 SlashCommandMenu 中选中项的文字颜色由预设的 cyanBright 改为统一的蓝色 (#229ac3) - 在 PromptInput 中添加对 showMenu 的条件判断,控制底部文字的显示避免 UI 冗余 - 统一并优化了命令菜单及提示输入的颜色风格,提高界面视觉一致性 --- src/ui/PromptInput.tsx | 6 +++--- src/ui/SlashCommandMenu.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index f9f0814..d9937eb 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -74,7 +74,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ @@ -590,9 +590,9 @@ export const PromptInput = React.memo(function PromptInput({ ) : null} - + {!showMenu && {footerText} - + } ); }); diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index 0df7e52..d5cae8a 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -44,13 +44,13 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ return ( - + {actualIndex === activeIndex ? "› " : " "} {formatSlashCommandLabel(item)} - + {formatSlashCommandDescription(item.description)} From cbb6b24b638346f9b9c55da8c94ef8b88e15a2a5 Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 16:45:05 +0800 Subject: [PATCH 023/217] =?UTF-8?q?feat(prompt):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=85=89=E6=A0=87=E5=AE=9A=E4=BD=8D=E5=92=8C=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将光标定位函数参数从 footerText 修改为 belowRows,更灵活表示下方行数占用 - 修正渲染光标时隐藏和显示终端光标,防止光标闪烁和位置错误 - 在 PromptInput 组件中动态计算命令菜单行数,根据菜单展开调整光标位置 - 添加 BufferWithCursor 组件替代旧的光标渲染逻辑,更好支持焦点状态和占位符 - 新增 SlashCommandMenu 组件,实现带滚动箭头和导航提示的命令菜单显示 - 调整 MessageView 和 PromptInput 样式,优化边距和边框显示 - 增加大量单元测试,覆盖输入解析和光标定位等核心功能 --- src/tests/promptInputKeys.test.ts | 10 ++--- src/ui/MessageView.tsx | 4 +- src/ui/PromptInput.tsx | 32 +++++++++++++--- src/ui/SlashCommandMenu.tsx | 4 +- src/ui/prompt/cursor.ts | 62 +++++++++++-------------------- 5 files changed, 56 insertions(+), 56 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 870c1d3..83ba1aa 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -124,23 +124,23 @@ test("renderBufferWithCursor styles exactly one simulated cursor", () => { }); test("getPromptCursorPlacement targets the prompt row above divider and footer", () => { - const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, 2); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); }); test("getPromptCursorPlacement targets the reserved row after a trailing newline", () => { - const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, 2); assert.deepEqual(placement, { rowsUp: 3, column: 2 }); }); test("getPromptCursorPlacement accounts for CJK character width", () => { - const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, "Enter send"); + const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, 2); assert.equal(placement.column, 6); }); test("getPromptCursorPlacement accounts for multiline buffer rows", () => { - const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, 2); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); - const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, "Enter send"); + const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, 2); assert.deepEqual(middle, { rowsUp: 4, column: 4 }); }); diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index 9ec54ef..cc2a1df 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -38,13 +38,13 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | const summary = buildThinkingSummary(content, message.messageParams); if (collapsed !== false) { return ( - + ); } return ( - + {content ? {renderMarkdown(content)} : null} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index d9937eb..178c0bb 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -34,7 +34,7 @@ export { useTerminalInput, parseTerminalInput } from "./prompt"; export type { InputKey } from "./prompt"; import type { InputKey } from "./prompt"; -import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt/cursor"; +import { useTerminalInput, useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt/cursor"; import SlashCommandMenu from "./SlashCommandMenu"; export type PromptSubmission = { @@ -533,7 +533,6 @@ export const PromptInput = React.memo(function PromptInput({ setBuffer((state) => removeCurrentSlashToken(state)); } - const divider = useMemo(() => "─".repeat(screenWidth), [screenWidth]); const visibleSkillStart = Math.min( Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8) @@ -555,12 +554,15 @@ export const PromptInput = React.memo(function PromptInput({ ) : null} {/* Input */} - {divider} - + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} - {divider} {showSkillsDropdown ? ( Select Skills @@ -650,6 +652,26 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick{` ${placeholder}`}; + } + + if (!isFocused) { + return {text.endsWith("\n") ? `${text} ` : text}; + } + + // Render text normally. The terminal hardware cursor (positioned by + // usePromptTerminalCursor) provides the blinking cursor feedback. + return {text}; +} + export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean, placeholder?: string): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index d5cae8a..c47b8df 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -50,9 +50,7 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ - - {formatSlashCommandDescription(item.description)} - + {formatSlashCommandDescription(item.description)} ); diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index c24e1f6..47974a4 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -44,7 +44,7 @@ export function getPromptCursorPlacement( state: PromptBufferState, screenWidth: number, prefixWidth: number, - footerText: string + belowRows: number ): CursorPlacement { const width = Math.max(1, screenWidth); const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); @@ -55,15 +55,14 @@ export function getPromptCursorPlacement( const cursorPosition = measureTextPosition(beforeCursor, width, prefixWidth); const promptRows = measureTextRows(displayText, width, prefixWidth); - const footerRows = 1 + measureTextRows(footerText, width, 0); return { - rowsUp: (promptRows - 1 - cursorPosition.row) + footerRows + 1, + rowsUp: (promptRows - 1 - cursorPosition.row) + belowRows + 1, column: cursorPosition.column }; } -function measureTextRows(text: string, width: number, initialColumn: number): number { +export function measureTextRows(text: string, width: number, initialColumn: number): number { return measureTextPosition(text, width, initialColumn).row + 1; } @@ -131,11 +130,12 @@ export function usePromptTerminalCursor( ): void { const directWriteRef = useRef<((data: string) => void) | null>(null); const activePlacementRef = useRef(null); - const lastPlacementRef = useRef(null); const unmountingRef = useRef(false); + // Patch stdout.write to hide the cursor before every write. This prevents + // the terminal cursor from appearing mid-render at the wrong position. useLayoutEffect(() => { - if (!stdout?.isTTY) { + if (!isActive || !stdout?.isTTY) { return; } @@ -144,45 +144,27 @@ export function usePromptTerminalCursor( const directWrite = (data: string) => { originalWrite.call(stdout, data); }; - const restorePromptCursor = () => { - if (unmountingRef.current) { - return; - } - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; - } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - // Schedule a deferred re-position in case the layout effect does not - // re-run (e.g. a dropdown closed without changing the buffer). - Promise.resolve().then(() => { - if (unmountingRef.current || activePlacementRef.current) { - return; - } - const latest = directWriteRef.current; - const p = lastPlacementRef.current; - if (latest && p) { - latest(showCursor() + cursorUp(p.rowsUp) + "\r" + cursorForward(p.column)); - activePlacementRef.current = p; - } - }); - }; + + directWriteRef.current = directWrite; + const patchedWrite: WriteFn = (...args) => { - restorePromptCursor(); + // Before each write, move the cursor back to the bottom and hide it. + if (!unmountingRef.current && activePlacementRef.current) { + directWrite("\r" + cursorDown(activePlacementRef.current.rowsUp) + hideCursor()); + activePlacementRef.current = null; + } return originalWrite.apply(stdout, args); }; - directWriteRef.current = directWrite; stream.write = patchedWrite; - return () => { - restorePromptCursor(); + unmountingRef.current = true; stream.write = originalWrite; directWriteRef.current = null; }; - }, [stdout]); + }, [isActive, stdout]); + // Show and position the terminal cursor after each render. useLayoutEffect(() => { if (!isActive || !stdout?.isTTY) { return; @@ -196,19 +178,17 @@ export function usePromptTerminalCursor( directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); activePlacementRef.current = placement; - lastPlacementRef.current = placement; return () => { unmountingRef.current = true; - lastPlacementRef.current = null; - const activePlacement = activePlacementRef.current; - if (!activePlacement) { + const p = activePlacementRef.current; + if (!p) { return; } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); + directWrite("\r" + cursorDown(p.rowsUp) + hideCursor()); activePlacementRef.current = null; }; - }, [isActive, placement.column, placement.rowsUp, stdout]); + }, [isActive, placement.rowsUp, placement.column, stdout]); } export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { From c7675115b0965bf987d3446e99a99849990a8ff8 Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 19:44:07 +0800 Subject: [PATCH 024/217] =?UTF-8?q?feat(prompt):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=85=89=E6=A0=87=E5=AE=9A=E4=BD=8D=E5=92=8C=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将光标定位函数参数从 footerText 修改为 belowRows,更灵活表示下方行数占用 - 修正渲染光标时隐藏和显示终端光标,防止光标闪烁和位置错误 - 在 PromptInput 组件中动态计算命令菜单行数,根据菜单展开调整光标位置 - 添加 BufferWithCursor 组件替代旧的光标渲染逻辑,更好支持焦点状态和占位符 - 新增 SlashCommandMenu 组件,实现带滚动箭头和导航提示的命令菜单显示 - 调整 MessageView 和 PromptInput 样式,优化边距和边框显示 - 增加大量单元测试,覆盖输入解析和光标定位等核心功能 --- src/ui/PromptInput.tsx | 56 +++++++++++++++++++++++++++++++++++------ src/ui/prompt/cursor.ts | 19 +++++--------- src/ui/prompt/index.ts | 2 +- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 178c0bb..9916de6 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -34,7 +34,7 @@ export { useTerminalInput, parseTerminalInput } from "./prompt"; export type { InputKey } from "./prompt"; import type { InputKey } from "./prompt"; -import { useTerminalInput, useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt/cursor"; +import { useTerminalInput, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, measureTextRows } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; export type PromptSubmission = { @@ -115,8 +115,33 @@ export const PromptInput = React.memo(function PromptInput({ ? loadingText : "esc to interrupt · ctrl+c to cancel input" : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; + + // Compute where the terminal hardware cursor should be placed. + // The terminal cursor is the ONLY cursor — BufferWithCursor renders + // plain text without any inverse styling, relying on the terminal's + // native blinking cursor for visual feedback. + const cursorPlacement = React.useMemo(() => { + const menuRows = showMenu + ? (() => { + const maxVisible = 6; + const visibleStart = Math.min( + Math.max(0, menuIndex - Math.floor((maxVisible - 1) / 2)), + Math.max(0, slashMenu.length - maxVisible) + ); + const visibleCount = Math.min(slashMenu.length, maxVisible); + const hasTopArrow = visibleStart > 0 ? 1 : 0; + const hasBottomArrow = visibleStart + visibleCount < slashMenu.length ? 1 : 0; + return hasTopArrow + visibleCount + hasBottomArrow + 1 + 1; + })() + : 0; + const belowRows = showMenu + ? 1 + menuRows + : 1 + measureTextRows(footerText, screenWidth, 0); + return getPromptCursorPlacement(buffer, screenWidth, PROMPT_PREFIX_WIDTH, belowRows); + }, [buffer, footerText, screenWidth, showMenu, slashMenu.length, menuIndex]); + useTerminalFocusReporting(stdout, !disabled); - useHiddenTerminalCursor(stdout, !disabled); + usePromptTerminalCursor(stdout, cursorPlacement, !disabled); useEffect(() => { if (!showMenu) { @@ -561,7 +586,7 @@ export const PromptInput = React.memo(function PromptInput({ borderRight={false} borderDimColor> - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} + {showSkillsDropdown ? ( @@ -652,6 +677,15 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick{text.endsWith("\n") ? `${text} ` : text}; } - // Render text normally. The terminal hardware cursor (positioned by - // usePromptTerminalCursor) provides the blinking cursor feedback. + // Render plain text only. The terminal hardware cursor provides the + // blinking indicator at the correct position. return {text}; } +/** + * Render the input buffer as a plain string with an ANSI inverse cell at the + * cursor position. Kept for external use (e.g. tests) where Ink JSX is not + * available. Prefer BufferWithCursor inside Ink render trees. + */ export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean, placeholder?: string): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); @@ -696,8 +735,11 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool return before + renderCursorCell(at) + after; } -// Use explicit ANSI instead of chalk.inverse so cursor rendering stays enabled -// in non-TTY environments such as tests, where Chalk may strip styling. +/** + * Wrap a character with ANSI inverse codes (CSI 7 m ... CSI 27 m). + * Explicit ANSI is used instead of chalk.inverse so the cursor stays visible + * in non-TTY environments (e.g. tests) where Chalk strips styling. + */ function renderCursorCell(value: string): string { return `\u001B[7m${value}\u001B[27m`; } diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 47974a4..eb45b56 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,6 +40,12 @@ function disableTerminalFocusReporting(): string { return "\u001B[?1004l"; } +/** + * Calculate where the terminal hardware cursor should be placed relative to + * the Ink-rendered inverse cursor cell. The returned {@code rowsUp} and + * {@code column} values are used by {@link usePromptTerminalCursor} to + * position the terminal's blinking cursor on top of the simulated inverse cell. + */ export function getPromptCursorPlacement( state: PromptBufferState, screenWidth: number, @@ -191,19 +197,6 @@ export function usePromptTerminalCursor( }, [isActive, placement.rowsUp, placement.column, stdout]); } -export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } - - stdout.write(hideCursor()); - return () => { - stdout.write(showCursor()); - }; - }, [isActive, stdout]); -} - export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { useLayoutEffect(() => { if (!isActive || !stdout?.isTTY) { diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index d56c7dc..09ebba3 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -1,4 +1,4 @@ export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; export type { InputKey } from "./useTerminalInput"; -export { useHiddenTerminalCursor, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./cursor"; +export { usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, measureTextRows } from "./cursor"; From 4c015a3332cf367026456192decf0596baf245a8 Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 19:56:37 +0800 Subject: [PATCH 025/217] =?UTF-8?q?feat(terminal):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E7=B2=98=E8=B4=B4=E8=BE=93=E5=85=A5=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持终端的括号粘贴模式,准确识别粘贴开始和结束标记 - 在括号粘贴过程中累积文本,确保一次性处理完整粘贴内容 - 标准化行结束符,将 Windows 和旧 macOS 换行统一为 \n - 修改通用输入处理,保持多行内容格式不变,提升用户粘贴体验 - 在输入事件中新增 paste 标志,区分普通输入与粘贴输入 --- src/ui/PromptInput.tsx | 4 ++- src/ui/prompt/useTerminalInput.ts | 42 ++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 9916de6..a963c25 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -434,7 +434,9 @@ export const PromptInput = React.memo(function PromptInput({ } if (input && !key.ctrl && !key.meta) { - const sanitized = input.replace(/\r/g, ""); + // Normalize line endings from paste: \r\n (Windows) → \n, \r (old macOS/Enter) → \n. + // This preserves multi-line formatting when the user pastes content. + const sanitized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); updateBuffer((s) => insertText(s, sanitized)); } }, { isActive: !disabled }); diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index 12e28f3..3939c6d 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -20,6 +20,8 @@ export type InputKey = { meta: boolean; focusIn: boolean; focusOut: boolean; + /** The input was received as part of a bracketed paste (sent by the terminal). */ + paste: boolean; }; const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); @@ -34,6 +36,9 @@ const META_LEFT_SEQUENCES = new Set(["\u001B[1;3D", "\u001B[3D", "\u001Bb"]); const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); const TERMINAL_FOCUS_IN = "\u001B[I"; const TERMINAL_FOCUS_OUT = "\u001B[O"; +/** Bracketed paste mode markers: start and end delimiters sent by terminals. */ +const BRACKETED_PASTE_START = "\u001B[200~"; +const BRACKETED_PASTE_END = "\u001B[201~"; export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { const raw = String(data); @@ -56,7 +61,8 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: delete: FORWARD_DELETE_SEQUENCES.has(raw), meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), focusIn: raw === TERMINAL_FOCUS_IN, - focusOut: raw === TERMINAL_FOCUS_OUT + focusOut: raw === TERMINAL_FOCUS_OUT, + paste: false }; if (input <= "\u001A" && !key.return) { @@ -111,6 +117,9 @@ export function useTerminalInput( const isActive = options.isActive ?? true; const handlerRef = useRef(inputHandler); handlerRef.current = inputHandler; + // Accumulates text between bracketed paste start and end markers. + // Non-null means a paste is in progress. + const pasteRef = useRef(null); useEffect(() => { if (!isActive) { @@ -127,6 +136,37 @@ export function useTerminalInput( return; } const handleData = (data: Buffer | string) => { + const raw = String(data); + + // Bracketed paste mode: the terminal wraps pasted content in + // \u001B[200~ (start) and \u001B[201~ (end). Accumulate everything + // between them and deliver as a single input chunk with + // normalized line endings. + if (raw === "\u001B[200~") { + pasteRef.current = ""; + return; + } + if (raw === "\u001B[201~") { + const pasted = pasteRef.current; + pasteRef.current = null; + if (pasted != null && pasted.length > 0) { + // Normalize any line-ending variant to \n. + const normalized = pasted.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + handlerRef.current(normalized, { + upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, + home: false, end: false, pageDown: false, pageUp: false, + return: false, escape: false, ctrl: false, shift: false, + tab: false, backspace: false, delete: false, meta: false, + focusIn: false, focusOut: false, paste: true + }); + } + return; + } + if (typeof pasteRef.current === "string") { + pasteRef.current += raw; + return; + } + const { input, key } = parseTerminalInput(data); handlerRef.current(input, key); }; From 6f276249176805058f8fd59f3ede16d4d72f6a56 Mon Sep 17 00:00:00 2001 From: td <2826079730@qq.com> Date: Sat, 9 May 2026 20:38:38 +0800 Subject: [PATCH 026/217] feat: add /init command and AGENTS.md generation functionality --- src/cli.tsx | 1 + src/init.ts | 30 +++++++++++++++ src/prompt.ts | 20 ++++++++++ src/session.ts | 19 ++++++---- src/tests/slashCommands.test.ts | 9 ++++- src/tools/executor.ts | 2 + src/tools/init-handler.ts | 66 +++++++++++++++++++++++++++++++++ src/ui/App.tsx | 22 +++++++---- src/ui/PromptInput.tsx | 10 ++++- src/ui/slashCommands.ts | 8 +++- 10 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 src/init.ts create mode 100644 src/tools/init-handler.ts diff --git a/src/cli.tsx b/src/cli.tsx index a546a61..4a1d690 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -39,6 +39,7 @@ if (args.includes("--help") || args.includes("-h")) { " / Open the skills/commands menu", " /new Start a fresh conversation", " /resume Pick a previous conversation to continue", + " /init Create AGENTS.md in the current project", " /exit Quit", " ctrl+d twice Quit" ].join("\n") + "\n" diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..22d831b --- /dev/null +++ b/src/init.ts @@ -0,0 +1,30 @@ +export const INIT_AGENT_GUIDE_PROMPT = `Generate a project agent guide and save it to AGENTS.md in the current project root. + +Your goal is to inspect this repository, understand its actual structure and conventions, then produce a clear, concise, well-structured Markdown document that helps future agents contribute effectively. + +Important workflow: +- First inspect the repository layout and metadata so the guide reflects the actual project rather than generic defaults. +- Use available tools such as bash and read to examine files like package manifests, README files, source directories, tests, and recent git history. +- When the guide is ready, call the Init tool with the complete Markdown content. The Init tool writes the file to AGENTS.md in the current project root. + +Document requirements: +- Title the document "Repository Guidelines". +- Use Markdown headings (#, ##, etc.) for structure. +- Keep the document concise. 200-400 words is optimal. +- Keep explanations short, direct, and specific to this repository. +- Provide examples where helpful, such as commands, directory paths, and naming patterns. +- Maintain a professional, instructional tone. + +Recommended sections: +- Project Structure & Module Organization: Outline the project structure, including where source code, tests, docs, and assets are located. +- Build, Test, and Development Commands: List key commands for building, testing, and running locally, and briefly explain each. +- Coding Style & Naming Conventions: Specify indentation, language-specific style preferences, naming patterns, and formatting or linting tools used. +- Testing Guidelines: Identify test frameworks, coverage expectations, test naming conventions, and how to run tests. +- Commit & Pull Request Guidelines: Summarize commit message conventions from git history and outline PR requirements. +- Optional sections if relevant: Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. + +Do not stop after drafting the content in chat. Save the final Markdown by calling the Init tool.`; + +export function buildInitPrompt(): string { + return INIT_AGENT_GUIDE_PROMPT; +} diff --git a/src/prompt.ts b/src/prompt.ts index 5c11305..21614bb 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -598,6 +598,26 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, }, }, + { + type: "function", + function: { + name: "Init", + description: + "Save the completed project agent guide to AGENTS.md in the current project root. Use this after inspecting the repository and drafting the final Markdown content.", + parameters: { + type: "object", + properties: { + content: { + type: "string", + description: + "Complete Markdown content for AGENTS.md. The document should be titled \"Repository Guidelines\".", + }, + }, + required: ["content"], + additionalProperties: false, + }, + }, + }, ]; tools.push({ diff --git a/src/session.ts b/src/session.ts index 9a85c39..2537676 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1449,26 +1449,29 @@ ${skillMd} } private loadAgentInstructions(): string | null { - const candidatePaths = [ - path.join(this.projectRoot, "AGENTS.md"), - path.join(os.homedir(), ".deepcode", "AGENTS.md") + const candidateFiles = [ + { label: "Project AGENTS.md", filePath: path.join(this.projectRoot, "AGENTS.md") }, + { label: "User AGENTS.md", filePath: path.join(os.homedir(), ".deepcode", "AGENTS.md") } ]; + const loadedInstructions: string[] = []; - for (const candidatePath of candidatePaths) { + for (const candidate of candidateFiles) { try { - if (!fs.existsSync(candidatePath)) { + if (!fs.existsSync(candidate.filePath)) { continue; } - const content = fs.readFileSync(candidatePath, "utf8").trim(); + const content = fs.readFileSync(candidate.filePath, "utf8").trim(); if (content) { - return content; + loadedInstructions.push( + `# ${candidate.label}\n\n${content}` + ); } } catch { continue; } } - return null; + return loadedInstructions.length > 0 ? loadedInstructions.join("\n\n---\n\n") : null; } private buildSystemMessage( diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 8396c9e..9516d5d 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", "new", "resume", "exit"]); + assert.deepEqual(builtinNames, ["skills", "new", "resume", "init", "exit"]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -58,6 +58,13 @@ test("findExactSlashCommand returns built-in /skills", () => { assert.equal(item?.kind, "skills"); }); +test("findExactSlashCommand returns built-in /init", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/init"); + assert.ok(item); + assert.equal(item?.kind, "init"); +}); + test("findExactSlashCommand returns the matching skill", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/code-review"); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 3b16a4b..e2115c7 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -3,6 +3,7 @@ import type { ReasoningEffort } from "../settings"; import { handleAskUserQuestionTool } from "./ask-user-question-handler"; import { handleBashTool } from "./bash-handler"; import { handleEditTool } from "./edit-handler"; +import { handleInitTool } from "./init-handler"; import { handleReadTool } from "./read-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; @@ -113,6 +114,7 @@ export class ToolExecutor { this.toolHandlers.set("read", handleReadTool); this.toolHandlers.set("write", handleWriteTool); this.toolHandlers.set("edit", handleEditTool); + this.toolHandlers.set("Init", handleInitTool); this.toolHandlers.set("AskUserQuestion", handleAskUserQuestionTool); this.toolHandlers.set("WebSearch", handleWebSearchTool); } diff --git a/src/tools/init-handler.ts b/src/tools/init-handler.ts new file mode 100644 index 0000000..9532804 --- /dev/null +++ b/src/tools/init-handler.ts @@ -0,0 +1,66 @@ +import * as fs from "fs"; +import * as path from "path"; +import { z } from "zod"; +import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { buildDiffPreview, ensureParentDirectory, normalizeContent, readTextFileWithMetadata, writeTextFile } from "./file-utils"; +import { executeValidatedTool } from "./runtime"; + +const initSchema = z.strictObject({ + content: z.string().min(1, "content is required.") +}); + +export async function handleInitTool( + args: Record, + context: ToolExecutionContext +): Promise { + return executeValidatedTool( + "Init", + initSchema, + args, + context, + async (input) => { + const filePath = path.join(context.projectRoot, "AGENTS.md"); + const normalizedContent = normalizeContent(input.content); + const existingMetadata = fs.existsSync(filePath) + ? readTextFileWithMetadata(filePath) + : null; + + try { + ensureParentDirectory(filePath); + const lineEndings = + existingMetadata?.lineEndings ?? + (input.content.includes("\r\n") ? "CRLF" : "LF"); + const encoding = existingMetadata?.encoding ?? "utf8"; + const diffPreview = buildDiffPreview( + filePath, + existingMetadata?.content ?? null, + normalizedContent + ); + const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings); + + return { + ok: true, + name: "Init", + output: existingMetadata + ? "Updated AGENTS.md." + : "Created AGENTS.md.", + metadata: { + file_path: filePath, + type: existingMetadata ? "update" : "create", + bytes, + encoding, + line_endings: lineEndings, + diff_preview: diffPreview + } + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + name: "Init", + error: message + }; + } + } + ); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d313119..9cdf53a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import OpenAI from "openai"; +import { buildInitPrompt } from "../init"; import { SessionManager, type LlmStreamProgress, @@ -162,24 +163,31 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return; } + const isInitCommand = submission.command === "init"; + if (isInitCommand) { + setShowWelcome(false); + } + const prompt: UserPromptContent = { - text: submission.text, - imageUrls: submission.imageUrls, - skills: submission.selectedSkills && submission.selectedSkills.length > 0 + text: isInitCommand ? buildInitPrompt() : submission.text, + imageUrls: isInitCommand ? [] : submission.imageUrls, + skills: !isInitCommand && submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined }; - const trimmedText = (submission.text ?? "").trim(); - const selectedSkillNames = submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; + const trimmedText = (isInitCommand ? "/init" : submission.text ?? "").trim(); + const selectedSkillNames = isInitCommand + ? [] + : submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; const userDisplayContent = trimmedText || (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") - || (submission.imageUrls.length > 0 ? "[Image]" : ""); + || (!isInitCommand && submission.imageUrls.length > 0 ? "[Image]" : ""); if (userDisplayContent) { setMessages((prev) => [ ...prev, - buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length) + buildSyntheticUserMessage(userDisplayContent, isInitCommand ? 0 : submission.imageUrls.length) ]); } diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 2cf0c74..7424160 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -35,7 +35,7 @@ export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; - command?: "new" | "resume" | "exit"; + command?: "new" | "resume" | "init" | "exit"; }; type Props = { @@ -502,6 +502,14 @@ export function PromptInput({ setShowSkillsDropdown(false); return; } + if (item.kind === "init") { + onSubmit({ text: "/init", imageUrls: [], command: "init" }); + setBuffer(EMPTY_BUFFER); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + return; + } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index d6709f5..34f6053 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,6 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "new" | "resume" | "exit"; +export type SlashCommandKind = "skill" | "skills" | "new" | "resume" | "exit" | "init"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -29,6 +29,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/resume", description: "Pick a previous conversation to continue" }, + { + kind: "init", + name: "init", + label: "/init", + description: "Create AGENTS.md for this project" + }, { kind: "exit", name: "exit", From babde42f5cebf140afc4c9f23fe5505964c49b9d Mon Sep 17 00:00:00 2001 From: td <2826079730@qq.com> Date: Sat, 9 May 2026 20:39:13 +0800 Subject: [PATCH 027/217] fix: update build script to use chmodSync for setting executable permissions --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a938ef5..150f24e 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ }, "scripts": { "typecheck": "tsc -p ./ --noEmit", - "bundle": "esbuild ./src/cli.tsx --bundle --platform=node --format=esm --target=node18 --outfile=dist/cli.js --banner:js='#!/usr/bin/env node' --jsx=automatic --jsx-import-source=react --packages=external --log-override:empty-import-meta=silent", - "build": "npm run typecheck && npm run bundle && chmod +x dist/cli.js", + "bundle": "esbuild ./src/cli.tsx --bundle --platform=node --format=esm --target=node18 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" --jsx=automatic --jsx-import-source=react --packages=external --log-override:empty-import-meta=silent", + "build": "npm run typecheck && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", "test": "tsx --test src/tests/*.test.ts", "test:single": "tsx --test", "prepack": "npm run build" From 1f3c3e0c6abf6b87c405cd7acaf3fd10568eba1a Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 21:25:59 +0800 Subject: [PATCH 028/217] =?UTF-8?q?style(ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=A7=86=E5=9B=BE=E7=9A=84=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在消息视图组件中给部分 Box 组件添加左边距 - 增加 Loaded skill 文本和 conversation summary 插入文本的左侧间距 - 改善消息内容的整体排版和视觉层次感 --- src/ui/MessageView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index cc2a1df..d05e30c 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -67,7 +67,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | const summary = buildToolSummary(message); const diffLines = getToolDiffPreviewLines(summary); return ( - + + ⚡ Loaded skill: {message.meta.skill.name} ); } if (message.meta?.isSummary) { return ( - + (conversation summary inserted) ); From 02071f1fad4f7c4e76b6a24030d03dd1aada754e Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 21:55:48 +0800 Subject: [PATCH 029/217] =?UTF-8?q?style(ui):=20=E6=9B=B4=E6=94=B9?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=88=97=E8=A1=A8=E9=A1=B9=E7=9B=AE=E7=AC=A6?= =?UTF-8?q?=E5=8F=B7=E6=A0=B7=E5=BC=8F=E4=B8=BA=E6=98=9F=E5=BD=A2=E7=AC=A6?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将消息视图中的项目符号从“•”更改为“✧” - 更新文本组件的颜色保持不变 - 优化界面视觉效果,提高标识度 --- src/ui/MessageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index d05e30c..77d1ee9 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -111,7 +111,7 @@ function StatusLine({ return ( {[ - , + , " ", {name}, params ? {` ${params}`} : null From 200adb478c5cbc332fac8d6e0d97f97b916bca0a Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 9 May 2026 22:52:15 +0800 Subject: [PATCH 030/217] feat: enhance cursor rendering in prompt input --- src/tests/promptInputKeys.test.ts | 2 ++ src/ui/PromptInput.tsx | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 870c1d3..0bdde2d 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -111,6 +111,7 @@ test("renderBufferWithCursor hides the simulated cursor when unfocused", () => { test("renderBufferWithCursor draws the simulated cursor when focused", () => { assert.equal(stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true)), " "); + assert.equal(stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything")), " Ask anything"); assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello", cursor: 5 }, true)), "hello "); assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello", cursor: 1 }, true)), "hello"); assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello\n", cursor: 6 }, true)), "hello\n "); @@ -119,6 +120,7 @@ test("renderBufferWithCursor draws the simulated cursor when focused", () => { test("renderBufferWithCursor styles exactly one simulated cursor", () => { assert.equal((renderBufferWithCursor({ text: "", cursor: 0 }, true).match(ANSI_RE) ?? []).length, 2); + assert.ok(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything").includes("\u001B[7m \u001B[27m")); assert.equal((renderBufferWithCursor({ text: "hello", cursor: 1 }, true).match(ANSI_RE) ?? []).length, 2); assert.equal((renderBufferWithCursor({ text: "hello\nworld", cursor: 6 }, true).match(ANSI_RE) ?? []).length, 2); }); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index cb5eef7..df175e3 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -671,7 +671,10 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool const after = text.slice(cursor + 1); if (text.length === 0 && placeholder) { - return chalk.dim(` ${placeholder}`); + if (!isFocused) { + return chalk.dim(` ${placeholder}`); + } + return renderCursorCell(" ") + chalk.dim(` ${placeholder}`); } if (!isFocused) { From 674d4623ced13a131522f4970a8b8c100a0c8577 Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 9 May 2026 23:11:41 +0800 Subject: [PATCH 031/217] =?UTF-8?q?refactor(prompt):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E5=85=89=E6=A0=87=E6=B8=B2=E6=9F=93=E5=92=8C?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改进光标位置恢复逻辑,避免布局稳定时光标丢失 - 更新测试用例,覆盖光标渲染和光标位置计算改动 --- src/tests/promptInputKeys.test.ts | 14 ++-- src/ui/PromptInput.tsx | 111 +++++++----------------------- src/ui/prompt/cursor.ts | 83 ++++++++++++++-------- src/ui/prompt/index.ts | 2 +- src/ui/prompt/useTerminalInput.ts | 44 +----------- 5 files changed, 91 insertions(+), 163 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 83ba1aa..9a99009 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -111,6 +111,7 @@ test("renderBufferWithCursor hides the simulated cursor when unfocused", () => { test("renderBufferWithCursor draws the simulated cursor when focused", () => { assert.equal(stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true)), " "); + assert.equal(stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything")), " Ask anything"); assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello", cursor: 5 }, true)), "hello "); assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello", cursor: 1 }, true)), "hello"); assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello\n", cursor: 6 }, true)), "hello\n "); @@ -119,28 +120,29 @@ test("renderBufferWithCursor draws the simulated cursor when focused", () => { test("renderBufferWithCursor styles exactly one simulated cursor", () => { assert.equal((renderBufferWithCursor({ text: "", cursor: 0 }, true).match(ANSI_RE) ?? []).length, 2); + assert.ok(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything").includes("\u001B[7m \u001B[27m")); assert.equal((renderBufferWithCursor({ text: "hello", cursor: 1 }, true).match(ANSI_RE) ?? []).length, 2); assert.equal((renderBufferWithCursor({ text: "hello\nworld", cursor: 6 }, true).match(ANSI_RE) ?? []).length, 2); }); test("getPromptCursorPlacement targets the prompt row above divider and footer", () => { - const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, 2); + const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); }); test("getPromptCursorPlacement targets the reserved row after a trailing newline", () => { - const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, 2); + const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 2 }); }); test("getPromptCursorPlacement accounts for CJK character width", () => { - const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, 2); + const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, "Enter send"); assert.equal(placement.column, 6); }); test("getPromptCursorPlacement accounts for multiline buffer rows", () => { - const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, 2); + const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); - const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, 2); + const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, "Enter send"); assert.deepEqual(middle, { rowsUp: 4, column: 4 }); -}); +}); \ No newline at end of file diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index a963c25..487e46e 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from "react"; +import React, {useEffect, useState} from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; import { @@ -33,8 +33,9 @@ import type { SkillInfo } from "../session"; export { useTerminalInput, parseTerminalInput } from "./prompt"; export type { InputKey } from "./prompt"; +import { useTerminalInput, parseTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; -import { useTerminalInput, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, measureTextRows } from "./prompt"; +import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; export type PromptSubmission = { @@ -57,7 +58,6 @@ type Props = { }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -const PROMPT_PREFIX_WIDTH = 2; const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { const [spinnerIndex, setSpinnerIndex] = useState(0); @@ -74,20 +74,20 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ - skills, - screenWidth, - promptHistory, - busy, - loadingText, - disabled, - placeholder, - onSubmit, - onInterrupt -}: Props): React.ReactElement { + skills, + screenWidth, + promptHistory, + busy, + loadingText, + disabled, + placeholder, + onSubmit, + onInterrupt + }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); const [buffer, setBuffer] = useState(EMPTY_BUFFER); @@ -115,33 +115,8 @@ export const PromptInput = React.memo(function PromptInput({ ? loadingText : "esc to interrupt · ctrl+c to cancel input" : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; - - // Compute where the terminal hardware cursor should be placed. - // The terminal cursor is the ONLY cursor — BufferWithCursor renders - // plain text without any inverse styling, relying on the terminal's - // native blinking cursor for visual feedback. - const cursorPlacement = React.useMemo(() => { - const menuRows = showMenu - ? (() => { - const maxVisible = 6; - const visibleStart = Math.min( - Math.max(0, menuIndex - Math.floor((maxVisible - 1) / 2)), - Math.max(0, slashMenu.length - maxVisible) - ); - const visibleCount = Math.min(slashMenu.length, maxVisible); - const hasTopArrow = visibleStart > 0 ? 1 : 0; - const hasBottomArrow = visibleStart + visibleCount < slashMenu.length ? 1 : 0; - return hasTopArrow + visibleCount + hasBottomArrow + 1 + 1; - })() - : 0; - const belowRows = showMenu - ? 1 + menuRows - : 1 + measureTextRows(footerText, screenWidth, 0); - return getPromptCursorPlacement(buffer, screenWidth, PROMPT_PREFIX_WIDTH, belowRows); - }, [buffer, footerText, screenWidth, showMenu, slashMenu.length, menuIndex]); - useTerminalFocusReporting(stdout, !disabled); - usePromptTerminalCursor(stdout, cursorPlacement, !disabled); + useHiddenTerminalCursor(stdout, !disabled); useEffect(() => { if (!showMenu) { @@ -434,9 +409,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (input && !key.ctrl && !key.meta) { - // Normalize line endings from paste: \r\n (Windows) → \n, \r (old macOS/Enter) → \n. - // This preserves multi-line formatting when the user pastes content. - const sanitized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const sanitized = input.replace(/\r/g, ""); updateBuffer((s) => insertText(s, sanitized)); } }, { isActive: !disabled }); @@ -473,7 +446,7 @@ export const PromptInput = React.memo(function PromptInput({ } const text = promptHistory[nextCursor] ?? ""; - setBuffer({ text, cursor: direction < 0 ? 0 : text.length }); + setBuffer({ text, cursor: text.length }); setHistoryCursor(nextCursor); } @@ -588,7 +561,7 @@ export const PromptInput = React.memo(function PromptInput({ borderRight={false} borderDimColor> - + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} {showSkillsDropdown ? ( @@ -679,40 +652,6 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick{` ${placeholder}`}; - } - - if (!isFocused) { - return {text.endsWith("\n") ? `${text} ` : text}; - } - - // Render plain text only. The terminal hardware cursor provides the - // blinking indicator at the correct position. - return {text}; -} - -/** - * Render the input buffer as a plain string with an ANSI inverse cell at the - * cursor position. Kept for external use (e.g. tests) where Ink JSX is not - * available. Prefer BufferWithCursor inside Ink render trees. - */ export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean, placeholder?: string): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); @@ -721,7 +660,10 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool const after = text.slice(cursor + 1); if (text.length === 0 && placeholder) { - return chalk.dim(` ${placeholder}`); + if (!isFocused) { + return chalk.dim(` ${placeholder}`); + } + return renderCursorCell(" ") + chalk.dim(` ${placeholder}`); } if (!isFocused) { @@ -737,11 +679,8 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool return before + renderCursorCell(at) + after; } -/** - * Wrap a character with ANSI inverse codes (CSI 7 m ... CSI 27 m). - * Explicit ANSI is used instead of chalk.inverse so the cursor stays visible - * in non-TTY environments (e.g. tests) where Chalk strips styling. - */ +// Use explicit ANSI instead of chalk.inverse so cursor rendering stays enabled +// in non-TTY environments such as tests, where Chalk may strip styling. function renderCursorCell(value: string): string { return `\u001B[7m${value}\u001B[27m`; -} +} \ No newline at end of file diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index eb45b56..19f5cb9 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,17 +40,11 @@ function disableTerminalFocusReporting(): string { return "\u001B[?1004l"; } -/** - * Calculate where the terminal hardware cursor should be placed relative to - * the Ink-rendered inverse cursor cell. The returned {@code rowsUp} and - * {@code column} values are used by {@link usePromptTerminalCursor} to - * position the terminal's blinking cursor on top of the simulated inverse cell. - */ export function getPromptCursorPlacement( state: PromptBufferState, screenWidth: number, prefixWidth: number, - belowRows: number + footerText: string ): CursorPlacement { const width = Math.max(1, screenWidth); const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); @@ -61,14 +55,15 @@ export function getPromptCursorPlacement( const cursorPosition = measureTextPosition(beforeCursor, width, prefixWidth); const promptRows = measureTextRows(displayText, width, prefixWidth); + const footerRows = 1 + measureTextRows(footerText, width, 0); return { - rowsUp: (promptRows - 1 - cursorPosition.row) + belowRows + 1, + rowsUp: (promptRows - 1 - cursorPosition.row) + footerRows + 1, column: cursorPosition.column }; } -export function measureTextRows(text: string, width: number, initialColumn: number): number { +function measureTextRows(text: string, width: number, initialColumn: number): number { return measureTextPosition(text, width, initialColumn).row + 1; } @@ -136,12 +131,11 @@ export function usePromptTerminalCursor( ): void { const directWriteRef = useRef<((data: string) => void) | null>(null); const activePlacementRef = useRef(null); + const lastPlacementRef = useRef(null); const unmountingRef = useRef(false); - // Patch stdout.write to hide the cursor before every write. This prevents - // the terminal cursor from appearing mid-render at the wrong position. useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { + if (!stdout?.isTTY) { return; } @@ -150,27 +144,45 @@ export function usePromptTerminalCursor( const directWrite = (data: string) => { originalWrite.call(stdout, data); }; - - directWriteRef.current = directWrite; - - const patchedWrite: WriteFn = (...args) => { - // Before each write, move the cursor back to the bottom and hide it. - if (!unmountingRef.current && activePlacementRef.current) { - directWrite("\r" + cursorDown(activePlacementRef.current.rowsUp) + hideCursor()); - activePlacementRef.current = null; + const restorePromptCursor = () => { + if (unmountingRef.current) { + return; } + const activePlacement = activePlacementRef.current; + if (!activePlacement) { + return; + } + directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); + activePlacementRef.current = null; + // Schedule a deferred re-position in case the layout effect does not + // re-run (e.g. a dropdown closed without changing the buffer). + Promise.resolve().then(() => { + if (unmountingRef.current || activePlacementRef.current) { + return; + } + const latest = directWriteRef.current; + const p = lastPlacementRef.current; + if (latest && p) { + latest(showCursor() + cursorUp(p.rowsUp) + "\r" + cursorForward(p.column)); + activePlacementRef.current = p; + } + }); + }; + const patchedWrite: WriteFn = (...args) => { + restorePromptCursor(); return originalWrite.apply(stdout, args); }; + directWriteRef.current = directWrite; stream.write = patchedWrite; + return () => { - unmountingRef.current = true; + restorePromptCursor(); stream.write = originalWrite; directWriteRef.current = null; }; - }, [isActive, stdout]); + }, [stdout]); - // Show and position the terminal cursor after each render. useLayoutEffect(() => { if (!isActive || !stdout?.isTTY) { return; @@ -184,17 +196,32 @@ export function usePromptTerminalCursor( directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); activePlacementRef.current = placement; + lastPlacementRef.current = placement; return () => { unmountingRef.current = true; - const p = activePlacementRef.current; - if (!p) { + lastPlacementRef.current = null; + const activePlacement = activePlacementRef.current; + if (!activePlacement) { return; } - directWrite("\r" + cursorDown(p.rowsUp) + hideCursor()); + directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); activePlacementRef.current = null; }; - }, [isActive, placement.rowsUp, placement.column, stdout]); + }, [isActive, placement.column, placement.rowsUp, stdout]); +} + +export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(hideCursor()); + return () => { + stdout.write(showCursor()); + }; + }, [isActive, stdout]); } export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { @@ -208,4 +235,4 @@ export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined stdout.write(disableTerminalFocusReporting()); }; }, [isActive, stdout]); -} +} \ No newline at end of file diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index 09ebba3..1542ef8 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -1,4 +1,4 @@ export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; export type { InputKey } from "./useTerminalInput"; -export { usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, measureTextRows } from "./cursor"; +export { useHiddenTerminalCursor, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./cursor"; \ No newline at end of file diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index 3939c6d..8f7942e 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -20,8 +20,6 @@ export type InputKey = { meta: boolean; focusIn: boolean; focusOut: boolean; - /** The input was received as part of a bracketed paste (sent by the terminal). */ - paste: boolean; }; const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); @@ -36,9 +34,6 @@ const META_LEFT_SEQUENCES = new Set(["\u001B[1;3D", "\u001B[3D", "\u001Bb"]); const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); const TERMINAL_FOCUS_IN = "\u001B[I"; const TERMINAL_FOCUS_OUT = "\u001B[O"; -/** Bracketed paste mode markers: start and end delimiters sent by terminals. */ -const BRACKETED_PASTE_START = "\u001B[200~"; -const BRACKETED_PASTE_END = "\u001B[201~"; export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { const raw = String(data); @@ -61,8 +56,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: delete: FORWARD_DELETE_SEQUENCES.has(raw), meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), focusIn: raw === TERMINAL_FOCUS_IN, - focusOut: raw === TERMINAL_FOCUS_OUT, - paste: false + focusOut: raw === TERMINAL_FOCUS_OUT }; if (input <= "\u001A" && !key.return) { @@ -117,9 +111,6 @@ export function useTerminalInput( const isActive = options.isActive ?? true; const handlerRef = useRef(inputHandler); handlerRef.current = inputHandler; - // Accumulates text between bracketed paste start and end markers. - // Non-null means a paste is in progress. - const pasteRef = useRef(null); useEffect(() => { if (!isActive) { @@ -136,37 +127,6 @@ export function useTerminalInput( return; } const handleData = (data: Buffer | string) => { - const raw = String(data); - - // Bracketed paste mode: the terminal wraps pasted content in - // \u001B[200~ (start) and \u001B[201~ (end). Accumulate everything - // between them and deliver as a single input chunk with - // normalized line endings. - if (raw === "\u001B[200~") { - pasteRef.current = ""; - return; - } - if (raw === "\u001B[201~") { - const pasted = pasteRef.current; - pasteRef.current = null; - if (pasted != null && pasted.length > 0) { - // Normalize any line-ending variant to \n. - const normalized = pasted.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - handlerRef.current(normalized, { - upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, - home: false, end: false, pageDown: false, pageUp: false, - return: false, escape: false, ctrl: false, shift: false, - tab: false, backspace: false, delete: false, meta: false, - focusIn: false, focusOut: false, paste: true - }); - } - return; - } - if (typeof pasteRef.current === "string") { - pasteRef.current += raw; - return; - } - const { input, key } = parseTerminalInput(data); handlerRef.current(input, key); }; @@ -176,4 +136,4 @@ export function useTerminalInput( stdin?.off("data", handleData); }; }, [isActive, stdin]); -} +} \ No newline at end of file From da946654de3e97b3d177a06f1b83333972c0d85c Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sun, 10 May 2026 11:57:00 +0800 Subject: [PATCH 032/217] feat: update version to 0.1.17 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8305db2..1c96a5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.16", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.16", + "version": "0.1.17", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index ba0c8e4..d18d186 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.16", + "version": "0.1.17", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From b9c4819fb4a423ba8a7e8526fac564d5d366fdb8 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sun, 10 May 2026 19:15:40 +0800 Subject: [PATCH 033/217] feat: keep only the changes in package.json, revert all other changes to the main branch version. --- src/cli.tsx | 1 - src/init.ts | 30 --------------- src/prompt.ts | 20 ---------- src/session.ts | 19 ++++------ src/tests/slashCommands.test.ts | 9 +---- src/tools/executor.ts | 2 - src/tools/init-handler.ts | 66 --------------------------------- src/ui/App.tsx | 22 ++++------- src/ui/PromptInput.tsx | 10 +---- src/ui/slashCommands.ts | 8 +--- 10 files changed, 18 insertions(+), 169 deletions(-) delete mode 100644 src/init.ts delete mode 100644 src/tools/init-handler.ts diff --git a/src/cli.tsx b/src/cli.tsx index f29327c..86c50ea 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -40,7 +40,6 @@ if (args.includes("--help") || args.includes("-h")) { " / Open the skills/commands menu", " /new Start a fresh conversation", " /resume Pick a previous conversation to continue", - " /init Create AGENTS.md in the current project", " /exit Quit", " ctrl+d twice Quit" ].join("\n") + "\n" diff --git a/src/init.ts b/src/init.ts deleted file mode 100644 index 22d831b..0000000 --- a/src/init.ts +++ /dev/null @@ -1,30 +0,0 @@ -export const INIT_AGENT_GUIDE_PROMPT = `Generate a project agent guide and save it to AGENTS.md in the current project root. - -Your goal is to inspect this repository, understand its actual structure and conventions, then produce a clear, concise, well-structured Markdown document that helps future agents contribute effectively. - -Important workflow: -- First inspect the repository layout and metadata so the guide reflects the actual project rather than generic defaults. -- Use available tools such as bash and read to examine files like package manifests, README files, source directories, tests, and recent git history. -- When the guide is ready, call the Init tool with the complete Markdown content. The Init tool writes the file to AGENTS.md in the current project root. - -Document requirements: -- Title the document "Repository Guidelines". -- Use Markdown headings (#, ##, etc.) for structure. -- Keep the document concise. 200-400 words is optimal. -- Keep explanations short, direct, and specific to this repository. -- Provide examples where helpful, such as commands, directory paths, and naming patterns. -- Maintain a professional, instructional tone. - -Recommended sections: -- Project Structure & Module Organization: Outline the project structure, including where source code, tests, docs, and assets are located. -- Build, Test, and Development Commands: List key commands for building, testing, and running locally, and briefly explain each. -- Coding Style & Naming Conventions: Specify indentation, language-specific style preferences, naming patterns, and formatting or linting tools used. -- Testing Guidelines: Identify test frameworks, coverage expectations, test naming conventions, and how to run tests. -- Commit & Pull Request Guidelines: Summarize commit message conventions from git history and outline PR requirements. -- Optional sections if relevant: Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. - -Do not stop after drafting the content in chat. Save the final Markdown by calling the Init tool.`; - -export function buildInitPrompt(): string { - return INIT_AGENT_GUIDE_PROMPT; -} diff --git a/src/prompt.ts b/src/prompt.ts index 21614bb..5c11305 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -598,26 +598,6 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, }, }, - { - type: "function", - function: { - name: "Init", - description: - "Save the completed project agent guide to AGENTS.md in the current project root. Use this after inspecting the repository and drafting the final Markdown content.", - parameters: { - type: "object", - properties: { - content: { - type: "string", - description: - "Complete Markdown content for AGENTS.md. The document should be titled \"Repository Guidelines\".", - }, - }, - required: ["content"], - additionalProperties: false, - }, - }, - }, ]; tools.push({ diff --git a/src/session.ts b/src/session.ts index 4362631..cf86d6b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1453,29 +1453,26 @@ ${skillMd} } private loadAgentInstructions(): string | null { - const candidateFiles = [ - { label: "Project AGENTS.md", filePath: path.join(this.projectRoot, "AGENTS.md") }, - { label: "User AGENTS.md", filePath: path.join(os.homedir(), ".deepcode", "AGENTS.md") } + const candidatePaths = [ + path.join(this.projectRoot, "AGENTS.md"), + path.join(os.homedir(), ".deepcode", "AGENTS.md") ]; - const loadedInstructions: string[] = []; - for (const candidate of candidateFiles) { + for (const candidatePath of candidatePaths) { try { - if (!fs.existsSync(candidate.filePath)) { + if (!fs.existsSync(candidatePath)) { continue; } - const content = fs.readFileSync(candidate.filePath, "utf8").trim(); + const content = fs.readFileSync(candidatePath, "utf8").trim(); if (content) { - loadedInstructions.push( - `# ${candidate.label}\n\n${content}` - ); + return content; } } catch { continue; } } - return loadedInstructions.length > 0 ? loadedInstructions.join("\n\n---\n\n") : null; + return null; } private buildSystemMessage( diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 9516d5d..8396c9e 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", "new", "resume", "init", "exit"]); + assert.deepEqual(builtinNames, ["skills", "new", "resume", "exit"]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -58,13 +58,6 @@ test("findExactSlashCommand returns built-in /skills", () => { assert.equal(item?.kind, "skills"); }); -test("findExactSlashCommand returns built-in /init", () => { - const items = buildSlashCommands(skills); - const item = findExactSlashCommand(items, "/init"); - assert.ok(item); - assert.equal(item?.kind, "init"); -}); - test("findExactSlashCommand returns the matching skill", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/code-review"); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index e2115c7..3b16a4b 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -3,7 +3,6 @@ import type { ReasoningEffort } from "../settings"; import { handleAskUserQuestionTool } from "./ask-user-question-handler"; import { handleBashTool } from "./bash-handler"; import { handleEditTool } from "./edit-handler"; -import { handleInitTool } from "./init-handler"; import { handleReadTool } from "./read-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; @@ -114,7 +113,6 @@ export class ToolExecutor { this.toolHandlers.set("read", handleReadTool); this.toolHandlers.set("write", handleWriteTool); this.toolHandlers.set("edit", handleEditTool); - this.toolHandlers.set("Init", handleInitTool); this.toolHandlers.set("AskUserQuestion", handleAskUserQuestionTool); this.toolHandlers.set("WebSearch", handleWebSearchTool); } diff --git a/src/tools/init-handler.ts b/src/tools/init-handler.ts deleted file mode 100644 index 9532804..0000000 --- a/src/tools/init-handler.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import { z } from "zod"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { buildDiffPreview, ensureParentDirectory, normalizeContent, readTextFileWithMetadata, writeTextFile } from "./file-utils"; -import { executeValidatedTool } from "./runtime"; - -const initSchema = z.strictObject({ - content: z.string().min(1, "content is required.") -}); - -export async function handleInitTool( - args: Record, - context: ToolExecutionContext -): Promise { - return executeValidatedTool( - "Init", - initSchema, - args, - context, - async (input) => { - const filePath = path.join(context.projectRoot, "AGENTS.md"); - const normalizedContent = normalizeContent(input.content); - const existingMetadata = fs.existsSync(filePath) - ? readTextFileWithMetadata(filePath) - : null; - - try { - ensureParentDirectory(filePath); - const lineEndings = - existingMetadata?.lineEndings ?? - (input.content.includes("\r\n") ? "CRLF" : "LF"); - const encoding = existingMetadata?.encoding ?? "utf8"; - const diffPreview = buildDiffPreview( - filePath, - existingMetadata?.content ?? null, - normalizedContent - ); - const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings); - - return { - ok: true, - name: "Init", - output: existingMetadata - ? "Updated AGENTS.md." - : "Created AGENTS.md.", - metadata: { - file_path: filePath, - type: existingMetadata ? "update" : "create", - bytes, - encoding, - line_endings: lineEndings, - diff_preview: diffPreview - } - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - name: "Init", - error: message - }; - } - } - ); -} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 40b7d55..1fba48b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -5,7 +5,6 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import OpenAI from "openai"; -import { buildInitPrompt } from "../init"; import { SessionManager, type LlmStreamProgress, @@ -168,31 +167,24 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return; } - const isInitCommand = submission.command === "init"; - if (isInitCommand) { - setShowWelcome(false); - } - const prompt: UserPromptContent = { - text: isInitCommand ? buildInitPrompt() : submission.text, - imageUrls: isInitCommand ? [] : submission.imageUrls, - skills: !isInitCommand && submission.selectedSkills && submission.selectedSkills.length > 0 + text: submission.text, + imageUrls: submission.imageUrls, + skills: submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined }; - const trimmedText = (isInitCommand ? "/init" : submission.text ?? "").trim(); - const selectedSkillNames = isInitCommand - ? [] - : submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; + const trimmedText = (submission.text ?? "").trim(); + const selectedSkillNames = submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; const userDisplayContent = trimmedText || (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") - || (!isInitCommand && submission.imageUrls.length > 0 ? "[Image]" : ""); + || (submission.imageUrls.length > 0 ? "[Image]" : ""); if (userDisplayContent) { setMessages((prev) => [ ...prev, - buildSyntheticUserMessage(userDisplayContent, isInitCommand ? 0 : submission.imageUrls.length) + buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length) ]); } diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index de8661d..487e46e 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -42,7 +42,7 @@ export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; - command?: "new" | "resume" | "init" | "exit"; + command?: "new" | "resume" | "exit"; }; type Props = { @@ -483,14 +483,6 @@ export const PromptInput = React.memo(function PromptInput({ setShowSkillsDropdown(false); return; } - if (item.kind === "init") { - onSubmit({ text: "/init", imageUrls: [], command: "init" }); - setBuffer(EMPTY_BUFFER); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); - return; - } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index 34f6053..d6709f5 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,6 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "new" | "resume" | "exit" | "init"; +export type SlashCommandKind = "skill" | "skills" | "new" | "resume" | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -29,12 +29,6 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/resume", description: "Pick a previous conversation to continue" }, - { - kind: "init", - name: "init", - label: "/init", - description: "Create AGENTS.md for this project" - }, { kind: "exit", name: "exit", From c0464a979da31c928526e271906ea60534d5c01e Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sun, 10 May 2026 19:36:29 +0800 Subject: [PATCH 034/217] feat: add prompt_for_init_command.md (Originally from: https://github.com/openai/codex Licensed under Apache License 2.0, Copyright 2025 OpenAI) --- docs/prompts/prompt_for_init_command.md | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/prompts/prompt_for_init_command.md diff --git a/docs/prompts/prompt_for_init_command.md b/docs/prompts/prompt_for_init_command.md new file mode 100644 index 0000000..b8fd388 --- /dev/null +++ b/docs/prompts/prompt_for_init_command.md @@ -0,0 +1,40 @@ +Generate a file named AGENTS.md that serves as a contributor guide for this repository. +Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. +Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. + +Document Requirements + +- Title the document "Repository Guidelines". +- Use Markdown headings (#, ##, etc.) for structure. +- Keep the document concise. 200-400 words is optimal. +- Keep explanations short, direct, and specific to this repository. +- Provide examples where helpful (commands, directory paths, naming patterns). +- Maintain a professional, instructional tone. + +Recommended Sections + +Project Structure & Module Organization + +- Outline the project structure, including where the source code, tests, and assets are located. + +Build, Test, and Development Commands + +- List key commands for building, testing, and running locally (e.g., npm test, make build). +- Briefly explain what each command does. + +Coding Style & Naming Conventions + +- Specify indentation rules, language-specific style preferences, and naming patterns. +- Include any formatting or linting tools used. + +Testing Guidelines + +- Identify testing frameworks and coverage requirements. +- State test naming conventions and how to run tests. + +Commit & Pull Request Guidelines + +- Summarize commit message conventions found in the project’s Git history. +- Outline pull request requirements (descriptions, linked issues, screenshots, etc.). + +(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. From ee3050714f476660355c8d745a3c01ae2624e4db Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sun, 10 May 2026 20:09:33 +0800 Subject: [PATCH 035/217] feat: add /init prompt handling and update AGENTS.md generation logic --- ...or_init_command.md => init_command.md.ejs} | 6 +- package-lock.json | 13 +++ package.json | 2 + src/session.ts | 85 ++++++++++++++++--- src/tests/session.test.ts | 68 +++++++++++++++ 5 files changed, 160 insertions(+), 14 deletions(-) rename docs/prompts/{prompt_for_init_command.md => init_command.md.ejs} (87%) diff --git a/docs/prompts/prompt_for_init_command.md b/docs/prompts/init_command.md.ejs similarity index 87% rename from docs/prompts/prompt_for_init_command.md rename to docs/prompts/init_command.md.ejs index b8fd388..ec2f2f6 100644 --- a/docs/prompts/prompt_for_init_command.md +++ b/docs/prompts/init_command.md.ejs @@ -1,4 +1,8 @@ -Generate a file named AGENTS.md that serves as a contributor guide for this repository. +<% if (agentsMdFile == null) { %> +Generate a file named ./AGENTS.md that serves as a contributor guide for this repository. +<% } else { %> +Update <%= agentsMdFile %> that serves as a contributor guide for this repository. +<% } %> Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. diff --git a/package-lock.json b/package-lock.json index 1c96a5a..800eecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "chalk": "^5.6.2", + "ejs": "^5.0.2", "gradient-string": "^3.0.0", "gray-matter": "^4.0.3", "ignore": "^7.0.5", @@ -666,6 +667,18 @@ "devOptional": true, "license": "MIT" }, + "node_modules/ejs": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", + "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", diff --git a/package.json b/package.json index c811faf..b519f6b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "files": [ "dist/cli.js", "docs/tools/**", + "docs/prompts/**", "README.md", "LICENSE" ], @@ -32,6 +33,7 @@ }, "dependencies": { "chalk": "^5.6.2", + "ejs": "^5.0.2", "gradient-string": "^3.0.0", "gray-matter": "^4.0.3", "ignore": "^7.0.5", diff --git a/src/session.ts b/src/session.ts index cf86d6b..aa83894 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,6 +2,8 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; +import { createRequire } from "module"; +import { fileURLToPath } from "url"; import matter from "gray-matter"; import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; import { launchNotifyScript } from "./notify"; @@ -16,6 +18,10 @@ const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; +const require = createRequire(import.meta.url); +const ejs = require("ejs") as { + render: (template: string, data?: Record) => string; +}; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -70,6 +76,15 @@ function accumulateUsage(current: unknown | null, next: unknown | null | undefin return addUsageValue(current, next); } +function getExtensionRoot(): string { + if (typeof __dirname !== "undefined") { + return path.resolve(__dirname, ".."); + } + + const currentFilePath = fileURLToPath(import.meta.url); + return path.resolve(path.dirname(currentFilePath), ".."); +} + function getTotalTokens(usage: unknown | null | undefined): number { if (!isUsageRecord(usage)) { return 0; @@ -769,6 +784,7 @@ The candidate skills are as follows:\n\n`; this.reportNewPrompt(); const signal = controller?.signal; this.throwIfAborted(signal); + this.applyInitCommandPrompt(userPrompt); if (userPrompt.text) { const skills = await this.listSkills(); @@ -861,6 +877,7 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); + this.applyInitCommandPrompt(userPrompt); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, @@ -1452,29 +1469,71 @@ ${skillMd} }; } - private loadAgentInstructions(): string | null { + private applyInitCommandPrompt(userPrompt: UserPromptContent): void { + if (userPrompt.text !== "/init") { + return; + } + userPrompt.text = this.renderInitCommandPrompt(); + } + + private renderInitCommandPrompt(): string { + const templatePath = path.join(getExtensionRoot(), "docs", "prompts", "init_command.md.ejs"); + const template = fs.readFileSync(templatePath, "utf8"); + return ejs.render(template, { + agentsMdFile: this.getEffectiveProjectAgentsMdFile() + }); + } + + private getEffectiveProjectAgentsMdFile(): string | null { + return this.loadProjectAgentInstructions()?.displayPath ?? null; + } + + private loadProjectAgentInstructions(): { content: string; displayPath: string } | null { const candidatePaths = [ - path.join(this.projectRoot, "AGENTS.md"), - path.join(os.homedir(), ".deepcode", "AGENTS.md") + { + absolutePath: path.join(this.projectRoot, ".deepcode", "AGENTS.md"), + displayPath: "./.deepcode/AGENTS.md" + }, + { + absolutePath: path.join(this.projectRoot, "AGENTS.md"), + displayPath: "./AGENTS.md" + } ]; for (const candidatePath of candidatePaths) { - try { - if (!fs.existsSync(candidatePath)) { - continue; - } - const content = fs.readFileSync(candidatePath, "utf8").trim(); - if (content) { - return content; - } - } catch { - continue; + const content = this.readNonEmptyFile(candidatePath.absolutePath); + if (content) { + return { + content, + displayPath: candidatePath.displayPath + }; } } return null; } + private readNonEmptyFile(filePath: string): string | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + const content = fs.readFileSync(filePath, "utf8").trim(); + return content || null; + } catch { + return null; + } + } + + private loadAgentInstructions(): string | null { + const projectInstructions = this.loadProjectAgentInstructions(); + if (projectInstructions) { + return projectInstructions.content; + } + + return this.readNonEmptyFile(path.join(os.homedir(), ".deepcode", "AGENTS.md")); + } + private buildSystemMessage( sessionId: string, content: string, diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 8ad3cf5..767610c 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -329,6 +329,74 @@ test("SessionManager lists project skills from .agents with legacy .deepcode com assert.equal(sharedSkill?.description, "Project .agents skill"); }); +test("createSession expands /init with the active .deepcode project AGENTS path", async () => { + const workspace = createTempDir("deepcode-init-deepcode-workspace-"); + const home = createTempDir("deepcode-init-deepcode-home-"); + process.env.HOME = home; + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); + fs.writeFileSync(path.join(workspace, ".deepcode", "AGENTS.md"), "deepcode project instructions", "utf8"); + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-deepcode"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "/init" }); + const messages = manager.listSessionMessages(sessionId); + const userMessage = messages.find((message) => message.role === "user"); + const systemContents = messages + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.match(userMessage?.content ?? "", /Update \.\/\.deepcode\/AGENTS\.md/); + assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); + assert.ok(systemContents.includes("deepcode project instructions")); + assert.ok(!systemContents.includes("root project instructions")); +}); + +test("replySession expands /init with the active root project AGENTS path", async () => { + const workspace = createTempDir("deepcode-init-root-workspace-"); + const home = createTempDir("deepcode-init-root-home-"); + process.env.HOME = home; + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-root"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + await manager.replySession(sessionId, { text: "/init" }); + const userMessages = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "user"); + const replyMessage = userMessages[userMessages.length - 1]; + + assert.match(replyMessage?.content ?? "", /Update \.\/AGENTS\.md/); +}); + +test("createSession expands /init as generate when no project AGENTS file is effective", async () => { + const workspace = createTempDir("deepcode-init-generate-workspace-"); + const home = createTempDir("deepcode-init-generate-home-"); + process.env.HOME = home; + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.mkdirSync(path.join(home, ".deepcode"), { recursive: true }); + fs.writeFileSync(path.join(home, ".deepcode", "AGENTS.md"), "user instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-generate"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "/init" }); + const userMessage = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "user"); + + assert.match(userMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); + assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); +}); + test("createSession reports a new prompt with the machineId token", async () => { const workspace = createTempDir("deepcode-session-workspace-"); const home = createTempDir("deepcode-session-home-"); From 3cb57817ce0180a35d8b4437e8900a30cea9432f Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sun, 10 May 2026 20:36:33 +0800 Subject: [PATCH 036/217] feat: add /init command to initialize AGENTS.md with instructions for LLM --- src/cli.tsx | 1 + src/tests/slashCommands.test.ts | 10 +++++++++- src/ui/PromptInput.tsx | 10 +++++++++- src/ui/slashCommands.ts | 8 +++++++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index 86c50ea..88f401d 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -39,6 +39,7 @@ if (args.includes("--help") || args.includes("-h")) { " esc Interrupt the current model turn", " / Open the skills/commands menu", " /new Start a fresh conversation", + " /init Initialize an AGENTS.md file with instructions for LLM", " /resume Pick a previous conversation to continue", " /exit Quit", " ctrl+d twice Quit" diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 8396c9e..7af6053 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", "new", "resume", "exit"]); + assert.deepEqual(builtinNames, ["skills", "new", "init", "resume", "exit"]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -51,6 +51,14 @@ test("findExactSlashCommand returns built-in /new", () => { assert.equal(item?.kind, "new"); }); +test("findExactSlashCommand returns built-in /init", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/init"); + assert.ok(item); + assert.equal(item?.kind, "init"); + assert.equal(item?.description, "Initialize an AGENTS.md file with instructions for LLM"); +}); + test("findExactSlashCommand returns built-in /skills", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/skills"); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 487e46e..0c68d38 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -475,6 +475,14 @@ export const PromptInput = React.memo(function PromptInput({ setShowSkillsDropdown(false); return; } + if (item.kind === "init") { + onSubmit({ text: "/init", imageUrls: [] }); + setBuffer(EMPTY_BUFFER); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + return; + } if (item.kind === "resume") { onSubmit({ text: "", imageUrls: [], command: "resume" }); setBuffer(EMPTY_BUFFER); @@ -683,4 +691,4 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool // in non-TTY environments such as tests, where Chalk may strip styling. function renderCursorCell(value: string): string { return `\u001B[7m${value}\u001B[27m`; -} \ No newline at end of file +} diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index d6709f5..ca330ef 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,6 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "new" | "resume" | "exit"; +export type SlashCommandKind = "skill" | "skills" | "new" | "init" | "resume" | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -23,6 +23,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/new", description: "Start a fresh conversation" }, + { + kind: "init", + name: "init", + label: "/init", + description: "Initialize an AGENTS.md file with instructions for LLM" + }, { kind: "resume", name: "resume", From b16fc190858d8fc12972a9250d18e6772d1a6e21 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sun, 10 May 2026 20:37:20 +0800 Subject: [PATCH 037/217] feat: update version to 0.1.18 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 800eecf..fc2042d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.17", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.17", + "version": "0.1.18", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index b519f6b..7a35493 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.17", + "version": "0.1.18", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From 1629b1fa8b3859d8a1369a3108012ca820f27baa Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 11 May 2026 07:51:09 +0800 Subject: [PATCH 038/217] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96App?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=9A=84hook=E5=92=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将refreshSessionsList和refreshSkills两个函数改为useCallback,避免不必要的重新创建 - 调整代码格式,统一加逗号和缩进,提高代码可读性 - 优化useEffect依赖,确保正确响应依赖变化 - 简化部分三元表达式和数组操作,提升代码简洁性 - 调整PromptInput中的jsx格式,统一引号和缩进样式 - 修复代码中多处格式和逗号缺失的问题 - 统一引入和导出语句的逗号,符合代码风格规范 --- .prettierignore | 5 + .prettierrc | 8 + eslint.config.mjs | 54 + package-lock.json | 2358 ++++++++++++++++++++++-- package.json | 15 +- src/AsciiArt.ts | 8 + src/cli.tsx | 16 +- src/debug-logger.ts | 4 +- src/error-logger.ts | 25 +- src/notify.ts | 9 +- src/openai-thinking.ts | 2 +- src/prompt.ts | 55 +- src/settings.ts | 10 +- src/tests/askUserQuestion.test.ts | 120 +- src/tests/clipboard.test.ts | 25 +- src/tests/debug-logger.test.ts | 6 +- src/tests/exitSummary.test.ts | 27 +- src/tests/loadingText.test.ts | 33 +- src/tests/markdown.test.ts | 1 - src/tests/messageView.test.ts | 21 +- src/tests/openai-thinking.test.ts | 44 +- src/tests/promptBuffer.test.ts | 2 +- src/tests/promptInputKeys.test.ts | 4 +- src/tests/session.test.ts | 330 ++-- src/tests/settings-and-notify.test.ts | 38 +- src/tests/shell-utils.test.ts | 12 +- src/tests/slashCommands.test.ts | 6 +- src/tests/thinkingState.test.ts | 16 +- src/tests/tool-handlers.test.ts | 92 +- src/tests/updateCheck.test.ts | 2 +- src/tests/web-search-handler.test.ts | 56 +- src/tests/welcomeScreen.test.ts | 2 +- src/tools/ask-user-question-handler.ts | 54 +- src/tools/bash-handler.ts | 26 +- src/tools/edit-handler.ts | 130 +- src/tools/executor.ts | 22 +- src/tools/file-utils.ts | 15 +- src/tools/read-handler.ts | 125 +- src/tools/runtime.ts | 41 +- src/tools/shell-utils.ts | 26 +- src/tools/state.ts | 16 +- src/tools/web-search-handler.ts | 64 +- src/tools/write-handler.ts | 59 +- src/ui/App.tsx | 132 +- src/ui/AskUserQuestionPrompt.tsx | 67 +- src/ui/MessageView.tsx | 53 +- src/ui/PromptInput.tsx | 524 +++--- src/ui/SessionList.tsx | 56 +- src/ui/SlashCommandMenu.tsx | 54 +- src/ui/ThemedGradient.tsx | 13 +- src/ui/UpdatePrompt.tsx | 15 +- src/ui/WelcomeScreen.tsx | 42 +- src/ui/askUserQuestion.ts | 31 +- src/ui/clipboard.ts | 4 +- src/ui/exitSummary.ts | 17 +- src/ui/index.ts | 8 +- src/ui/markdown.ts | 4 +- src/ui/prompt/cursor.ts | 15 +- src/ui/prompt/index.ts | 7 +- src/ui/prompt/useTerminalInput.ts | 4 +- src/ui/promptBuffer.ts | 4 +- src/ui/slashCommands.ts | 24 +- src/updateCheck.ts | 24 +- 63 files changed, 3515 insertions(+), 1567 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 eslint.config.mjs create mode 100644 src/AsciiArt.ts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..84dd052 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tgz +*.log +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0b297e4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 120, + "endOfLine": "lf" +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..50e4149 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,54 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import prettierConfig from "eslint-config-prettier"; + +export default tseslint.config( + // Base recommended rules from ESLint + js.configs.recommended, + // TypeScript recommended rules + ...tseslint.configs.recommended, + // Custom project rules + { + rules: { + // CLI project allows console + "no-console": "off", + // Allow dynamic require for package.json (cli.tsx) + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-require-imports": "off", + // Allow control regex for ANSI stripping (markdown.test.ts) + "no-control-regex": "off", + // Enforce consistent type imports + "@typescript-eslint/consistent-type-imports": "warn", + // Unused vars: allow _-prefixed parameters + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + }, + }, + // React hooks rules + { + plugins: { + "react-hooks": reactHooks, + }, + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + }, + }, + // Test files: relaxed rules + { + files: ["src/tests/**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + }, + }, + // Prettier config: disable conflicting ESLint rules, MUST be last + prettierConfig, +); diff --git a/package-lock.json b/package-lock.json index fc2042d..b4f261e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,11 +24,17 @@ "deepcode": "dist/cli.js" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "esbuild": "^0.28.0", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "prettier": "^3.8.3", "tsx": "^4.21.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.2" }, "engines": { "node": ">=18.17.0" @@ -47,6 +53,246 @@ "node": ">=18" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -489,159 +735,884 @@ "node": ">=18" } }, - "node_modules/@types/gradient-string": { - "version": "1.1.6", - "resolved": "https://registry.npmmirror.com/@types/gradient-string/-/gradient-string-1.1.6.tgz", - "integrity": "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "license": "MIT", "dependencies": { - "@types/tinycolor2": "*" + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@types/tinycolor2": { - "version": "1.4.6", - "resolved": "https://registry.npmmirror.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", - "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", - "license": "MIT" + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "license": "MIT", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "environment": "^1.0.0" + "@eslint/core": "^0.17.0" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://opencollective.com/eslint" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 4" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://eslint.org/donate" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/cli-boxes": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", - "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", - "license": "MIT", - "engines": { - "node": ">=18.20 <19 || >=20.10" + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "restore-cursor": "^4.0.0" + "@humanfs/types": "^0.15.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18.18.0" } }, - "node_modules/cli-truncate": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-6.0.0.tgz", - "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", - "license": "MIT", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18.18.0" } }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/code-excerpt/-/code-excerpt-4.0.0.tgz", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gradient-string": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/@types/gradient-string/-/gradient-string-1.1.6.tgz", + "integrity": "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==", + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmmirror.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/code-excerpt/-/code-excerpt-4.0.0.tgz", "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", "license": "MIT", "dependencies": { @@ -651,6 +1622,40 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -660,6 +1665,21 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -667,6 +1687,31 @@ "devOptional": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ejs": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", @@ -676,80 +1721,297 @@ "ejs": "bin/cli.js" }, "engines": { - "node": ">=0.12.18" + "node": ">=0.12.18" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "node": ">= 4" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esprima": { @@ -765,6 +2027,52 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -777,6 +2085,96 @@ "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -792,6 +2190,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -817,6 +2225,32 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gradient-string": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/gradient-string/-/gradient-string-3.0.0.tgz", @@ -845,6 +2279,33 @@ "node": ">=6.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", @@ -854,6 +2315,33 @@ "node": ">= 4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-5.0.0.tgz", @@ -944,6 +2432,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -959,6 +2457,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ci": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/is-in-ci/-/is-in-ci-2.0.0.tgz", @@ -974,6 +2485,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "3.14.2", "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", @@ -987,6 +2512,63 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", @@ -996,6 +2578,53 @@ "node": ">=0.10.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -1005,6 +2634,40 @@ "node": ">=6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", @@ -1041,6 +2704,69 @@ } } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/patch-console/-/patch-console-2.0.0.tgz", @@ -1050,6 +2776,82 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", @@ -1074,6 +2876,16 @@ "react": "^19.2.0" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1119,6 +2931,39 @@ "node": ">=4" } }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1199,6 +3044,32 @@ "node": ">=0.10.0" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -1229,6 +3100,23 @@ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinygradient": { "version": "1.1.5", "resolved": "https://registry.npmmirror.com/tinygradient/-/tinygradient-1.1.5.tgz", @@ -1239,6 +3127,19 @@ "tinycolor2": "^1.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz", @@ -1743,6 +3644,19 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "5.6.0", "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-5.6.0.tgz", @@ -1772,6 +3686,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", @@ -1779,6 +3717,63 @@ "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/widest-line": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/widest-line/-/widest-line-6.0.0.tgz", @@ -1794,6 +3789,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "10.0.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", @@ -1832,6 +3837,26 @@ } } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmmirror.com/yoga-layout/-/yoga-layout-3.2.1.tgz", @@ -1846,6 +3871,19 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 7a35493..7e5f993 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,12 @@ "scripts": { "typecheck": "tsc -p ./ --noEmit", "bundle": "esbuild ./src/cli.tsx --bundle --platform=node --format=esm --target=node18 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" --jsx=automatic --jsx-import-source=react --packages=external --log-override:empty-import-meta=silent", - "build": "npm run typecheck && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write 'src/**/*.{ts,tsx}'", + "format:check": "prettier --check 'src/**/*.{ts,tsx}'", + "check": "npm run typecheck && npm run lint && npm run format:check", + "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", "test": "tsx --test src/tests/*.test.ts", "test:single": "tsx --test", "prepack": "npm run build" @@ -44,10 +49,16 @@ "zod": "^4.4.3" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "esbuild": "^0.28.0", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "prettier": "^3.8.3", "tsx": "^4.21.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.2" } } diff --git a/src/AsciiArt.ts b/src/AsciiArt.ts new file mode 100644 index 0000000..0a28273 --- /dev/null +++ b/src/AsciiArt.ts @@ -0,0 +1,8 @@ +export const AsciiLogo = [ + "██████╗ ███████╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗", + "██╔══██╗██╔════╝██╔════╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝", + "██║ ██║█████╗ █████╗ ██████╔╝ ██║ ██║ ██║██║ ██║█████╗", + "██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ██║ ██║ ██║██║ ██║██╔══╝", + "██████╔╝███████╗███████╗██║ ╚██████╗╚██████╔╝██████╔╝███████╗", + "╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝", +].join("\n"); diff --git a/src/cli.tsx b/src/cli.tsx index 88f401d..f04125f 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -42,7 +42,7 @@ if (args.includes("--help") || args.includes("-h")) { " /init Initialize an AGENTS.md file with instructions for LLM", " /resume Pick a previous conversation to continue", " /exit Quit", - " ctrl+d twice Quit" + " ctrl+d twice Quit", ].join("\n") + "\n" ); process.exit(0); @@ -52,10 +52,7 @@ const projectRoot = process.cwd(); configureWindowsShell(); if (!process.stdin.isTTY) { - process.stderr.write( - "deepcode requires an interactive terminal (TTY). " + - "Re-run from a real terminal session.\n" - ); + process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); process.exit(1); } @@ -68,11 +65,7 @@ async function main(): Promise { function startApp(): void { const inkInstance = render( - restartRef.current?.()} - />, + restartRef.current?.()} />, { exitOnCtrlC: false } ); @@ -109,11 +102,10 @@ function configureWindowsShell(): void { function readPackageInfo(): PackageInfo { try { - // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require("../package.json") as { name?: unknown; version?: unknown }; return { name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli", - version: typeof pkg.version === "string" ? pkg.version : "" + version: typeof pkg.version === "string" ? pkg.version : "", }; } catch { return { name: "@vegamo/deepcode-cli", version: "" }; diff --git a/src/debug-logger.ts b/src/debug-logger.ts index c309df0..124049e 100644 --- a/src/debug-logger.ts +++ b/src/debug-logger.ts @@ -42,12 +42,12 @@ export function normalizeDebugError(error: unknown): { name: string; message: st return { name: error.name, message: error.message, - stack: error.stack + stack: error.stack, }; } return { name: "UnknownError", - message: String(error) + message: String(error), }; } diff --git a/src/error-logger.ts b/src/error-logger.ts index 9b53e98..52d469f 100644 --- a/src/error-logger.ts +++ b/src/error-logger.ts @@ -19,15 +19,9 @@ function maskSensitive(text: string): string { return ( text // Mask Bearer tokens in Authorization headers - .replace( - /(Authorization:\s*Bearer\s+)[^\s\r\n]+/gi, - "$1***MASKED***" - ) + .replace(/(Authorization:\s*Bearer\s+)[^\s\r\n]+/gi, "$1***MASKED***") // Mask "apiKey" or "api_key" values in JSON-like strings - .replace( - /((?:api[Kk]ey|api_key|secret)\s*[:=]\s*"?)[^",}\s]+/gi, - "$1***MASKED***" - ) + .replace(/((?:api[Kk]ey|api_key|secret)\s*[:=]\s*"?)[^",}\s]+/gi, "$1***MASKED***") ); } @@ -50,9 +44,7 @@ function truncateContent(value: string): string { * is a string. Every other field is kept exactly as-is so the logged request * mirrors the original API payload (no fields added or removed). */ -function sanitizeRequestPayload( - request: Record -): Record { +function sanitizeRequestPayload(request: Record): Record { function walk(value: unknown): unknown { if (!value || typeof value !== "object") { return value; @@ -118,10 +110,7 @@ export function logApiError(entry: ApiErrorLogEntry): void { }; if (entry.response !== undefined) { - logLine.response = - typeof entry.response === "string" - ? maskSensitive(entry.response) - : entry.response; + logLine.response = typeof entry.response === "string" ? maskSensitive(entry.response) : entry.response; } const newLine = JSON.stringify(logLine) + "\n"; @@ -132,11 +121,7 @@ export function logApiError(entry: ApiErrorLogEntry): void { const raw = fs.readFileSync(ERROR_LOG_PATH, "utf8"); const lines = raw.split("\n").filter((line) => line.trim().length > 0); if (lines.length > MAX_ENTRIES) { - fs.writeFileSync( - ERROR_LOG_PATH, - lines.slice(-MAX_ENTRIES).join("\n") + "\n", - "utf8" - ); + fs.writeFileSync(ERROR_LOG_PATH, lines.slice(-MAX_ENTRIES).join("\n") + "\n", "utf8"); } } catch { // Silently ignore logging failures to avoid disrupting the main flow diff --git a/src/notify.ts b/src/notify.ts index 8c27583..2fdc9fa 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -16,13 +16,10 @@ export function formatDurationSeconds(durationMs: number): string { return String(Math.floor(safeMs / 1000)); } -export function buildNotifyEnv( - durationMs: number, - baseEnv: NodeJS.ProcessEnv = process.env -): NodeJS.ProcessEnv { +export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...baseEnv, - DURATION: formatDurationSeconds(durationMs) + DURATION: formatDurationSeconds(durationMs), }; } @@ -41,7 +38,7 @@ export function launchNotifyScript( cwd: workingDirectory, detached: process.platform !== "win32", env: buildNotifyEnv(durationMs), - stdio: "ignore" as const + stdio: "ignore" as const, }; try { diff --git a/src/openai-thinking.ts b/src/openai-thinking.ts index 8beb6da..0726152 100644 --- a/src/openai-thinking.ts +++ b/src/openai-thinking.ts @@ -20,6 +20,6 @@ export function buildThinkingRequestOptions( return { thinking, - ...(thinkingEnabled ? { extra_body: { reasoning_effort: reasoningEffort } } : {}) + ...(thinkingEnabled ? { extra_body: { reasoning_effort: reasoningEffort } } : {}), }; } diff --git a/src/prompt.ts b/src/prompt.ts index 5c11305..e1def61 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -251,7 +251,7 @@ type PromptToolOptions = { webSearchEnabled?: boolean; }; -function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): string { +function readToolDocs(extensionRoot: string, _options: PromptToolOptions = {}): string { const toolsDir = path.join(extensionRoot, "docs", "tools"); if (!fs.existsSync(toolsDir)) { return ""; @@ -276,9 +276,7 @@ function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): s export function getSystemPrompt(projectRoot: string, options: PromptToolOptions = {}): string { const toolDocs = readToolDocs(getExtensionRoot(), options); - const basePrompt = toolDocs - ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` - : SYSTEM_PROMPT_BASE; + const basePrompt = toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; return `${basePrompt}\n\n${getRuntimeContext(projectRoot)}`; } @@ -291,7 +289,7 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { content: message.content, contentParams: message.contentParams, messageParams: message.messageParams, - createTime: message.createTime + createTime: message.createTime, }) ) .join("\n"); @@ -313,9 +311,9 @@ function getRuntimeContext(projectRoot: string): string { ...runtimeVersions, "command installed": { "ast-grep": checkToolInstalled("ast-grep"), - "ripgrep": checkToolInstalled("rg"), - "jq": checkToolInstalled("jq") - } + ripgrep: checkToolInstalled("rg"), + jq: checkToolInstalled("jq"), + }, }; return `# Local Workspace Environment\n\n\`\`\`json ${JSON.stringify(env, null, 2)} @@ -329,7 +327,7 @@ function checkToolInstalled(tool: string): boolean { execFileSync(bashPath, ["-lc", `command -v ${shellSingleQuote(tool)}`], { encoding: "utf8", stdio: "ignore", - windowsHide: true + windowsHide: true, }); return true; } @@ -373,7 +371,7 @@ function getCommandVersion(command: string, args: string[]): string | null { if (process.platform === "win32") { return execFileSync(findGitBashPath(), ["-lc", `${commandText} 2>&1`], { encoding: "utf8", - windowsHide: true + windowsHide: true, }).trim(); } return execSync(`${commandText} 2>&1`, { encoding: "utf8" }).trim(); @@ -387,7 +385,7 @@ function getUnameInfo(): string { if (process.platform === "win32") { return execFileSync(findGitBashPath(), ["-lc", "uname -a"], { encoding: "utf8", - windowsHide: true + windowsHide: true, }).trim(); } return execSync("uname -a", { encoding: "utf8" }).trim(); @@ -421,7 +419,7 @@ export type ToolDefinition = { }; }; -export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { +export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { const tools: ToolDefinition[] = [ { type: "function", @@ -457,8 +455,7 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { properties: { questions: { type: "array", - description: - "Questions to present to the user. Usually only one question is needed at a time.", + description: "Questions to present to the user. Usually only one question is needed at a time.", items: { type: "object", properties: { @@ -468,20 +465,17 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, multiSelect: { type: "boolean", - description: - "Whether the user may choose multiple options.", + description: "Whether the user may choose multiple options.", }, options: { type: "array", - description: - "A list of predefined options for the user to choose from.", + description: "A list of predefined options for the user to choose from.", items: { type: "object", properties: { label: { type: "string", - description: - "The display text for the option.", + description: "The display text for the option.", }, description: { type: "string", @@ -506,8 +500,7 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { type: "function", function: { name: "read", - description: - "Read files from the filesystem (text, images, PDFs, notebooks).", + description: "Read files from the filesystem (text, images, PDFs, notebooks).", parameters: { type: "object", properties: { @@ -525,8 +518,7 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, pages: { type: "string", - description: - 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', + description: 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', }, }, required: ["file_path"], @@ -538,8 +530,7 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { type: "function", function: { name: "write", - description: - "Create files or overwrite them with a complete string payload. Prefer edit for existing files.", + description: "Create files or overwrite them with a complete string payload. Prefer edit for existing files.", parameters: { type: "object", properties: { @@ -571,7 +562,8 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, snippet_id: { type: "string", - description: "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", + description: + "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", }, old_string: { type: "string", @@ -583,14 +575,12 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, replace_all: { type: "boolean", - description: - "Replace all occurences of old_string (default false)", + description: "Replace all occurences of old_string (default false)", default: false, }, expected_occurrences: { type: "number", - description: - "Expected number of matches, especially useful as a safety check with replace_all", + description: "Expected number of matches, especially useful as a safety check with replace_all", }, }, required: ["old_string", "new_string"], @@ -610,7 +600,8 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { properties: { query: { type: "string", - description: "A search query phrased as a clear, specific natural language question or statement that includes key context.", + description: + "A search query phrased as a clear, specific natural language question or statement that includes key context.", }, }, required: ["query"], diff --git a/src/settings.ts b/src/settings.ts index c3a5399..ffbadf0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -33,10 +33,7 @@ function resolveReasoningEffort(value: unknown): ReasoningEffort { return value === "high" || value === "max" ? value : "max"; } -function resolveThinkingEnabled( - settings: DeepcodingSettings | null | undefined, - model: string -): boolean { +function resolveThinkingEnabled(settings: DeepcodingSettings | null | undefined, model: string): boolean { if (typeof settings?.thinkingEnabled === "boolean") { return settings.thinkingEnabled; } @@ -56,8 +53,7 @@ export function resolveSettings( const env = settings?.env ?? {}; const model = env.MODEL?.trim() || defaults.model; const notify = typeof settings?.notify === "string" ? settings.notify.trim() : ""; - const webSearchTool = - typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : ""; + const webSearchTool = typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : ""; return { apiKey: env.API_KEY?.trim(), @@ -67,6 +63,6 @@ export function resolveSettings( reasoningEffort: resolveReasoningEffort(settings?.reasoningEffort), debugLogEnabled: settings?.debugLogEnabled === true, notify: notify || undefined, - webSearchTool: webSearchTool || undefined + webSearchTool: webSearchTool || undefined, }; } diff --git a/src/tests/askUserQuestion.test.ts b/src/tests/askUserQuestion.test.ts index 2a668da..f754351 100644 --- a/src/tests/askUserQuestion.test.ts +++ b/src/tests/askUserQuestion.test.ts @@ -1,10 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - findPendingAskUserQuestion, - formatAskUserQuestionAnswers, - formatAskUserQuestionDecline -} from "../ui"; +import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, formatAskUserQuestionDecline } from "../ui"; import type { SessionMessage } from "../session"; function message(content: unknown): SessionMessage { @@ -19,31 +15,31 @@ function message(content: unknown): SessionMessage { compacted: false, visible: true, createTime: now, - updateTime: now + updateTime: now, }; } test("findPendingAskUserQuestion returns latest pending AskUserQuestion tool message", () => { - const pending = findPendingAskUserQuestion([ - message({ ok: true, name: "read" }), - message({ - ok: true, - name: "AskUserQuestion", - awaitUserResponse: true, - metadata: { - kind: "ask_user_question", - questions: [ - { - question: "Which package manager should we use?", - options: [ - { label: "npm", description: "Use package-lock.json." }, - { label: "yarn" } - ] - } - ] - } - }) - ], "waiting_for_user"); + const pending = findPendingAskUserQuestion( + [ + message({ ok: true, name: "read" }), + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [ + { + question: "Which package manager should we use?", + options: [{ label: "npm", description: "Use package-lock.json." }, { label: "yarn" }], + }, + ], + }, + }), + ], + "waiting_for_user" + ); assert.equal(pending?.messageId, "tool-message"); assert.equal(pending?.questions[0]?.question, "Which package manager should we use?"); @@ -51,26 +47,29 @@ test("findPendingAskUserQuestion returns latest pending AskUserQuestion tool mes }); test("findPendingAskUserQuestion preserves multiple pending questions in order", () => { - const pending = findPendingAskUserQuestion([ - message({ - ok: true, - name: "AskUserQuestion", - awaitUserResponse: true, - metadata: { - kind: "ask_user_question", - questions: [ - { - question: "Use default description?", - options: [{ label: "Yes" }, { label: "Custom" }] - }, - { - question: "Where should the project be created?", - options: [{ label: "Current directory" }, { label: "Custom path" }] - } - ] - } - }) - ], "waiting_for_user"); + const pending = findPendingAskUserQuestion( + [ + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [ + { + question: "Use default description?", + options: [{ label: "Yes" }, { label: "Custom" }], + }, + { + question: "Where should the project be created?", + options: [{ label: "Current directory" }, { label: "Custom path" }], + }, + ], + }, + }), + ], + "waiting_for_user" + ); assert.deepEqual( pending?.questions.map((question) => question.question), @@ -79,17 +78,20 @@ test("findPendingAskUserQuestion preserves multiple pending questions in order", }); test("findPendingAskUserQuestion ignores questions unless session waits for user", () => { - const pending = findPendingAskUserQuestion([ - message({ - ok: true, - name: "AskUserQuestion", - awaitUserResponse: true, - metadata: { - kind: "ask_user_question", - questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] - } - }) - ], "processing"); + const pending = findPendingAskUserQuestion( + [ + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [{ question: "Continue?", options: [{ label: "Yes" }] }], + }, + }), + ], + "processing" + ); assert.equal(pending, null); }); @@ -98,9 +100,9 @@ test("formatAskUserQuestionAnswers creates model-readable answer text", () => { assert.equal( formatAskUserQuestionAnswers({ "Which package manager?": "yarn", - "Any notes?": "Use the existing lockfile" + "Any notes?": "Use the existing lockfile", }), - "User has answered your questions: \"Which package manager?\"=\"yarn\", \"Any notes?\"=\"Use the existing lockfile\". You can now continue with the user's answers in mind." + 'User has answered your questions: "Which package manager?"="yarn", "Any notes?"="Use the existing lockfile". You can now continue with the user\'s answers in mind.' ); }); diff --git a/src/tests/clipboard.test.ts b/src/tests/clipboard.test.ts index 1eda82d..022b2f8 100644 --- a/src/tests/clipboard.test.ts +++ b/src/tests/clipboard.test.ts @@ -4,6 +4,9 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +type ClipboardModule = typeof import("../ui/clipboard"); + const ORIGINAL_PATH = process.env.PATH; const ORIGINAL_PLATFORM = process.platform; @@ -28,7 +31,7 @@ function withPlatform(platform: NodeJS.Platform, fn: () => T): T { test("readClipboardImage returns null when no clipboard helpers are installed", async () => { // Reload module so it picks up the patched PATH at spawn time. const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; - const { readClipboardImage } = await import(moduleUrl) as typeof import("../ui/clipboard"); + const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; const result = withCleanPath(() => readClipboardImage()); assert.equal(result, null); }); @@ -36,33 +39,29 @@ test("readClipboardImage returns null when no clipboard helpers are installed", test("readClipboardImage uses osascript fallback on macOS when pngpaste is missing", async () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-clipboard-test-bin-")); try { - fs.writeFileSync( - path.join(binDir, "pngpaste"), - "#!/bin/sh\nexit 1\n", - { mode: 0o755 } - ); + fs.writeFileSync(path.join(binDir, "pngpaste"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); fs.writeFileSync( path.join(binDir, "osascript"), [ "#!/bin/sh", - "for arg in \"$@\"; do", - " case \"$arg\" in", + 'for arg in "$@"; do', + ' case "$arg" in', " *'open for access POSIX file " + '"' + "'*)", - " path_part=${arg#*POSIX file \\\"}", - " out_path=${path_part%%\\\"*}", - " printf fakepng > \"$out_path\"", + ' path_part=${arg#*POSIX file \\"}', + ' out_path=${path_part%%\\"*}', + ' printf fakepng > "$out_path"', " exit 0", " ;;", " esac", "done", "exit 1", - "" + "", ].join("\n"), { mode: 0o755 } ); const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; - const { readClipboardImage } = await import(moduleUrl) as typeof import("../ui/clipboard"); + const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; process.env.PATH = binDir; const result = withPlatform("darwin", () => readClipboardImage()); diff --git a/src/tests/debug-logger.test.ts b/src/tests/debug-logger.test.ts index 578d018..1084b01 100644 --- a/src/tests/debug-logger.test.ts +++ b/src/tests/debug-logger.test.ts @@ -18,11 +18,11 @@ test("debug logger appends full entries without rotation", () => { model: "test-model", request: { model: "test-model", - messages: [{ role: "user", content: `full request content ${index}` }] + messages: [{ role: "user", content: `full request content ${index}` }], }, response: { - choices: [{ message: { content: `full response content ${index}` } }] - } + choices: [{ message: { content: `full response content ${index}` } }], + }, }); } diff --git a/src/tests/exitSummary.test.ts b/src/tests/exitSummary.test.ts index ea6a082..9cff34b 100644 --- a/src/tests/exitSummary.test.ts +++ b/src/tests/exitSummary.test.ts @@ -6,20 +6,19 @@ import type { SessionEntry, SessionMessage } from "../session"; const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); test("buildExitSummaryText only shows Goodbye and model usage with cached tokens", () => { - const summary = stripAnsi(buildExitSummaryText({ - session: buildSession({ - prompt_tokens: 11_966, - completion_tokens: 236, - total_tokens: 12_202, - prompt_tokens_details: { cached_tokens: 11_776 }, - completion_tokens_details: { reasoning_tokens: 144 }, - }), - messages: [ - buildAssistantMessage("assistant-1"), - buildAssistantMessage("assistant-2"), - ], - model: "mimo-v2.5-pro", - })); + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession({ + prompt_tokens: 11_966, + completion_tokens: 236, + total_tokens: 12_202, + prompt_tokens_details: { cached_tokens: 11_776 }, + completion_tokens_details: { reasoning_tokens: 144 }, + }), + messages: [buildAssistantMessage("assistant-1"), buildAssistantMessage("assistant-2")], + model: "mimo-v2.5-pro", + }) + ); assert.match(summary, /Goodbye!/); assert.match(summary, /╭─+╮/); diff --git a/src/tests/loadingText.test.ts b/src/tests/loadingText.test.ts index 7a568da..784fe46 100644 --- a/src/tests/loadingText.test.ts +++ b/src/tests/loadingText.test.ts @@ -9,9 +9,7 @@ test("buildLoadingText returns plain Thinking... when no progress", () => { test("buildLoadingText shows running process elapsed time before thinking progress", () => { const startedAt = "2026-04-28T00:00:00.000Z"; const now = Date.parse(startedAt) + 5_750; - const processes = new Map([ - ["123", { startTime: startedAt, command: "yarn install" }] - ]); + const processes = new Map([["123", { startTime: startedAt, command: "yarn install" }]]); const text = buildLoadingText({ processes, progress: { @@ -19,9 +17,9 @@ test("buildLoadingText shows running process elapsed time before thinking progre startedAt, estimatedTokens: 850, formattedTokens: "850", - phase: "update" + phase: "update", }, - now + now, }); assert.equal(text, "(5s) yarn install"); }); @@ -29,13 +27,8 @@ test("buildLoadingText shows running process elapsed time before thinking progre test("buildLoadingText formats long-running process time with minutes", () => { const startedAt = "2026-04-28T00:00:00.000Z"; const now = Date.parse(startedAt) + 65_250; - const processes = new Map([ - ["web-search", { startTime: startedAt, command: "WebSearch: latest node release" }] - ]); - assert.equal( - buildLoadingText({ processes, progress: null, now }), - "(1m5s) WebSearch: latest node release" - ); + const processes = new Map([["web-search", { startTime: startedAt, command: "WebSearch: latest node release" }]]); + assert.equal(buildLoadingText({ processes, progress: null, now }), "(1m5s) WebSearch: latest node release"); }); test("buildLoadingText returns plain Thinking... while elapsed below 3s", () => { @@ -47,9 +40,9 @@ test("buildLoadingText returns plain Thinking... while elapsed below 3s", () => startedAt, estimatedTokens: 12, formattedTokens: "12", - phase: "update" + phase: "update", }, - now + now, }); assert.equal(text, "Thinking..."); }); @@ -63,9 +56,9 @@ test("buildLoadingText shows elapsed seconds and tokens once past the threshold" startedAt, estimatedTokens: 850, formattedTokens: "850", - phase: "update" + phase: "update", }, - now + now, }); assert.equal(text, "Thinking... (5s) · ↓ 850 tokens"); }); @@ -79,9 +72,9 @@ test("buildLoadingText falls back to '0' when formattedTokens is missing", () => startedAt, estimatedTokens: 0, formattedTokens: "", - phase: "update" + phase: "update", }, - now + now, }); assert.equal(text, "Thinking... (4s) · ↓ 0 tokens"); }); @@ -93,9 +86,9 @@ test("buildLoadingText falls back to Thinking... when timestamp is unparseable", startedAt: "not-a-date", estimatedTokens: 0, formattedTokens: "0", - phase: "update" + phase: "update", }, - now: Date.now() + now: Date.now(), }); assert.equal(text, "Thinking..."); }); diff --git a/src/tests/markdown.test.ts b/src/tests/markdown.test.ts index cff30b8..a0127fc 100644 --- a/src/tests/markdown.test.ts +++ b/src/tests/markdown.test.ts @@ -3,7 +3,6 @@ import assert from "node:assert/strict"; import { renderMarkdown } from "../ui"; function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex return text.replace(/\[[0-9;]*m/g, ""); } diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts index 1091bc8..fef4bc3 100644 --- a/src/tests/messageView.test.ts +++ b/src/tests/messageView.test.ts @@ -4,19 +4,14 @@ import { MessageView, parseDiffPreview } from "../ui"; import type { SessionMessage } from "../session"; test("parseDiffPreview removes headers and classifies lines", () => { - const lines = parseDiffPreview([ - "--- a/file.txt", - "+++ b/file.txt", - "@@ -1,1 +1,1 @@", - " context", - "-old", - "+new" - ].join("\n")); + const lines = parseDiffPreview( + ["--- a/file.txt", "+++ b/file.txt", "@@ -1,1 +1,1 @@", " context", "-old", "+new"].join("\n") + ); assert.deepEqual(lines, [ { marker: " ", content: "context", kind: "context" }, { marker: "-", content: "old", kind: "removed" }, - { marker: "+", content: "new", kind: "added" } + { marker: "+", content: "new", kind: "added" }, ]); }); @@ -24,14 +19,14 @@ test("parseDiffPreview keeps nonstandard context lines", () => { const lines = parseDiffPreview("...\n+added"); assert.deepEqual(lines, [ { marker: " ", content: "...", kind: "context" }, - { marker: "+", content: "added", kind: "added" } + { marker: "+", content: "added", kind: "added" }, ]); }); test("MessageView summarizes thinking content across lines", () => { assert.equal( getThinkingParams({ - content: "Plan:\n\nInspect the code and update tests" + content: "Plan:\n\nInspect the code and update tests", }), "Plan: Inspect the code and update tests" ); @@ -45,7 +40,7 @@ test("MessageView falls back to a reasoning placeholder for hidden reasoning con assert.equal( getThinkingParams({ content: "", - messageParams: { reasoning_content: "hidden chain of thought" } + messageParams: { reasoning_content: "hidden chain of thought" }, }), "(reasoning...)" ); @@ -69,6 +64,6 @@ function buildAssistantMessage(overrides: Partial): SessionMessa createTime: "2026-01-01T00:00:00.000Z", updateTime: "2026-01-01T00:00:00.000Z", meta: { asThinking: true }, - ...overrides + ...overrides, }; } diff --git a/src/tests/openai-thinking.test.ts b/src/tests/openai-thinking.test.ts index 151b254..2f22c0b 100644 --- a/src/tests/openai-thinking.test.ts +++ b/src/tests/openai-thinking.test.ts @@ -4,45 +4,33 @@ import { buildThinkingRequestOptions } from "../openai-thinking"; test("buildThinkingRequestOptions explicitly disables thinking", () => { assert.deepEqual(buildThinkingRequestOptions(false, "https://api.deepseek.com"), { - thinking: { type: "disabled" } + thinking: { type: "disabled" }, }); }); test("buildThinkingRequestOptions uses the same disabled payload for volces endpoints", () => { - assert.deepEqual( - buildThinkingRequestOptions(false, "https://ark.cn-beijing.volces.com/api/v3"), - { - thinking: { type: "disabled" } - } - ); + assert.deepEqual(buildThinkingRequestOptions(false, "https://ark.cn-beijing.volces.com/api/v3"), { + thinking: { type: "disabled" }, + }); }); test("buildThinkingRequestOptions enables thinking with default reasoning effort", () => { - assert.deepEqual( - buildThinkingRequestOptions(true, "https://api.deepseek.com"), - { - thinking: { type: "enabled" }, - extra_body: { reasoning_effort: "max" } - } - ); + assert.deepEqual(buildThinkingRequestOptions(true, "https://api.deepseek.com"), { + thinking: { type: "enabled" }, + extra_body: { reasoning_effort: "max" }, + }); }); test("buildThinkingRequestOptions uses the same enabled payload for volces endpoints", () => { - assert.deepEqual( - buildThinkingRequestOptions(true, "https://ark.cn-beijing.volces.com/api/v3"), - { - thinking: { type: "enabled" }, - extra_body: { reasoning_effort: "max" } - } - ); + assert.deepEqual(buildThinkingRequestOptions(true, "https://ark.cn-beijing.volces.com/api/v3"), { + thinking: { type: "enabled" }, + extra_body: { reasoning_effort: "max" }, + }); }); test("buildThinkingRequestOptions accepts high reasoning effort", () => { - assert.deepEqual( - buildThinkingRequestOptions(true, "https://api.deepseek.com", "high"), - { - thinking: { type: "enabled" }, - extra_body: { reasoning_effort: "high" } - } - ); + assert.deepEqual(buildThinkingRequestOptions(true, "https://api.deepseek.com", "high"), { + thinking: { type: "enabled" }, + extra_body: { reasoning_effort: "high" }, + }); }); diff --git a/src/tests/promptBuffer.test.ts b/src/tests/promptBuffer.test.ts index ebad6f0..0a2e5da 100644 --- a/src/tests/promptBuffer.test.ts +++ b/src/tests/promptBuffer.test.ts @@ -15,7 +15,7 @@ import { moveRight, moveWordLeft, moveWordRight, - moveUp + moveUp, } from "../ui"; test("insertText appends text and advances the cursor", () => { diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 9a99009..5ab58f1 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -16,7 +16,7 @@ import { parseTerminalInput, removeCurrentSlashToken, toggleSkillSelection, - renderBufferWithCursor + renderBufferWithCursor, } from "../ui"; import type { SkillInfo } from "../session"; @@ -145,4 +145,4 @@ test("getPromptCursorPlacement accounts for multiline buffer rows", () => { assert.deepEqual(placement, { rowsUp: 3, column: 7 }); const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, "Enter send"); assert.deepEqual(middle, { rowsUp: 4, column: 4 }); -}); \ No newline at end of file +}); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 767610c..de311ea 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -31,11 +31,11 @@ test("SessionManager preserves structured system content when building OpenAI me createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false + thinkingEnabled: false, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); const messages: SessionMessage[] = [ @@ -47,15 +47,15 @@ test("SessionManager preserves structured system content when building OpenAI me contentParams: [ { type: "image_url", - image_url: { url: "data:image/png;base64,abc123" } - } + image_url: { url: "data:image/png;base64,abc123" }, + }, ], messageParams: null, compacted: false, visible: false, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" - } + updateTime: "2026-01-01T00:00:00.000Z", + }, ]; const openAIMessages = (manager as any).buildOpenAIMessages(messages) as Array<{ @@ -69,8 +69,8 @@ test("SessionManager preserves structured system content when building OpenAI me { type: "text", text: "The read tool has loaded `pixel.png`." }, { type: "image_url", - image_url: { url: "data:image/png;base64,abc123" } - } + image_url: { url: "data:image/png;base64,abc123" }, + }, ]); }); @@ -80,11 +80,11 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false + thinkingEnabled: false, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); const message = (manager as any).buildAssistantMessage( @@ -94,8 +94,8 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" } - } + function: { name: "read", arguments: "{}" }, + }, ], "" ) as SessionMessage; @@ -105,10 +105,10 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" } - } + function: { name: "read", arguments: "{}" }, + }, ], - reasoning_content: "" + reasoning_content: "", }); const openAIMessages = (manager as any).buildOpenAIMessages([message], true) as Array<{ @@ -124,11 +124,11 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false + thinkingEnabled: false, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); const messages: SessionMessage[] = [ @@ -143,15 +143,15 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" } - } - ] + function: { name: "read", arguments: "{}" }, + }, + ], }, compacted: false, visible: false, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" - } + updateTime: "2026-01-01T00:00:00.000Z", + }, ]; const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true) as Array<{ @@ -162,10 +162,7 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten }>; assert.equal(thinkingMessages[0]?.reasoning_content, ""); - assert.equal( - Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), - false - ); + assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); }); test("SessionManager replays normal assistant messages with reasoning content in thinking mode", () => { @@ -174,11 +171,11 @@ test("SessionManager replays normal assistant messages with reasoning content in createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false + thinkingEnabled: false, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); const messages: SessionMessage[] = [ @@ -192,8 +189,8 @@ test("SessionManager replays normal assistant messages with reasoning content in compacted: false, visible: true, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" - } + updateTime: "2026-01-01T00:00:00.000Z", + }, ]; const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true) as Array<{ @@ -204,10 +201,7 @@ test("SessionManager replays normal assistant messages with reasoning content in }>; assert.equal(thinkingMessages[0]?.reasoning_content, ""); - assert.equal( - Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), - false - ); + assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); }); test("SessionManager normalizes legacy sessions without activeTokens to zero", () => { @@ -229,9 +223,9 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", ( status: "completed", usage: { total_tokens: 123 }, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" - } - ] + updateTime: "2026-01-01T00:00:00.000Z", + }, + ], }), "utf8" ); @@ -275,16 +269,15 @@ test("SessionManager marks skills loaded from existing session messages", async name: "lessweb-starter", path: "~/.agents/skills/lessweb-starter/SKILL.md", description: "Create Lessweb projects", - isLoaded: true - } - } + isLoaded: true, + }, + }, })}\n`, "utf8" ); const manager = createSessionManager(workspace, "machine-id-loaded-skills"); - const loadedSkill = (await manager.listSkills("loaded-session")) - .find((skill) => skill.name === "lessweb-starter"); + const loadedSkill = (await manager.listSkills("loaded-session")).find((skill) => skill.name === "lessweb-starter"); assert.equal(loadedSkill?.isLoaded, true); }); @@ -368,9 +361,7 @@ test("replySession expands /init with the active root project AGENTS path", asyn const sessionId = await manager.createSession({ text: "first prompt" }); await manager.replySession(sessionId, { text: "/init" }); - const userMessages = manager - .listSessionMessages(sessionId) - .filter((message) => message.role === "user"); + const userMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "user"); const replyMessage = userMessages[userMessages.length - 1]; assert.match(replyMessage?.content ?? "", /Update \.\/AGENTS\.md/); @@ -389,9 +380,7 @@ test("createSession expands /init as generate when no project AGENTS file is eff (manager as any).activateSession = async () => {}; const sessionId = await manager.createSession({ text: "/init" }); - const userMessage = manager - .listSessionMessages(sessionId) - .find((message) => message.role === "user"); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); assert.match(userMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); @@ -407,7 +396,7 @@ test("createSession reports a new prompt with the machineId token", async () => fetchCalls.push({ input, init }); return { ok: true, - text: async () => "" + text: async () => "", } as Response; }) as typeof fetch; @@ -439,7 +428,7 @@ test("replySession reports a new prompt with the machineId token", async () => { fetchCalls.push({ input, init }); return { ok: true, - text: async () => "" + text: async () => "", } as Response; }) as typeof fetch; @@ -465,10 +454,11 @@ test("replySession preserves raw session messages when a previous tool call is p const home = createTempDir("deepcode-pending-tool-home-"); process.env.HOME = home; - globalThis.fetch = (async () => ({ - ok: true, - text: async () => "" - }) as Response) as typeof fetch; + globalThis.fetch = (async () => + ({ + ok: true, + text: async () => "", + }) as Response) as typeof fetch; const manager = createSessionManager(workspace, "machine-id-pending-tool"); (manager as any).activateSession = async () => {}; @@ -481,8 +471,8 @@ test("replySession preserves raw session messages when a previous tool call is p { id: "call-1", type: "function", - function: { name: "bash", arguments: "{\"command\":\"sleep 100\"}" } - } + function: { name: "bash", arguments: '{"command":"sleep 100"}' }, + }, ], "" ) as SessionMessage; @@ -495,7 +485,10 @@ test("replySession preserves raw session messages when a previous tool call is p assert.notEqual(assistantIndex, -1); assert.equal(messages[assistantIndex + 1]?.role, "user"); assert.equal(messages[assistantIndex + 1]?.content, "second prompt"); - assert.equal(messages.some((message) => String(message.content).includes("Previous tool call did not complete.")), false); + assert.equal( + messages.some((message) => String(message.content).includes("Previous tool call did not complete.")), + false + ); }); test("buildOpenAIMessages inserts interrupted results for missing tool messages", () => { @@ -507,8 +500,8 @@ test("buildOpenAIMessages inserts interrupted results for missing tool messages" { id: "call-1", type: "function", - function: { name: "bash", arguments: "{\"command\":\"sleep 100\"}" } - } + function: { name: "bash", arguments: '{"command":"sleep 100"}' }, + }, ], "" ) as SessionMessage; @@ -537,8 +530,8 @@ test("buildOpenAIMessages keeps only the first non-interrupted tool result for a { id: "call-1", type: "function", - function: { name: "bash", arguments: "{\"command\":\"date\"}" } - } + function: { name: "bash", arguments: '{"command":"date"}' }, + }, ], "" ) as SessionMessage; @@ -546,7 +539,7 @@ test("buildOpenAIMessages keeps only the first non-interrupted tool result for a "session-1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "2026-05-07 星期四\n" }), - { name: "bash", arguments: "{\"command\":\"date\"}" } + { name: "bash", arguments: '{"command":"date"}' } ) as SessionMessage; const interruptedToolMessage = (manager as any).buildToolMessage( "session-1", @@ -555,9 +548,9 @@ test("buildOpenAIMessages keeps only the first non-interrupted tool result for a ok: false, name: "bash", error: "Previous tool call did not complete.", - metadata: { interrupted: true } + metadata: { interrupted: true }, }), - { name: "bash", arguments: "{\"command\":\"date\"}" } + { name: "bash", arguments: '{"command":"date"}' } ) as SessionMessage; const openAIMessages = (manager as any).buildOpenAIMessages( @@ -581,8 +574,8 @@ test("buildOpenAIMessages prefers a later real tool result over an earlier inter { id: "call-1", type: "function", - function: { name: "bash", arguments: "{\"command\":\"date\"}" } - } + function: { name: "bash", arguments: '{"command":"date"}' }, + }, ], "" ) as SessionMessage; @@ -593,15 +586,15 @@ test("buildOpenAIMessages prefers a later real tool result over an earlier inter ok: false, name: "bash", error: "Previous tool call did not complete.", - metadata: { interrupted: true } + metadata: { interrupted: true }, }), - { name: "bash", arguments: "{\"command\":\"date\"}" } + { name: "bash", arguments: '{"command":"date"}' } ) as SessionMessage; const successToolMessage = (manager as any).buildToolMessage( "session-1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "real result" }), - { name: "bash", arguments: "{\"command\":\"date\"}" } + { name: "bash", arguments: '{"command":"date"}' } ) as SessionMessage; const openAIMessages = (manager as any).buildOpenAIMessages( @@ -622,15 +615,17 @@ test("buildOpenAIMessages ignores orphan tool messages", () => { "session-1", "call-orphan", JSON.stringify({ ok: true, name: "bash", output: "orphan" }), - { name: "bash", arguments: "{\"command\":\"echo orphan\"}" } + { name: "bash", arguments: '{"command":"echo orphan"}' } ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages( - [userMessage, orphanToolMessage], - false - ) as Array<{ role: string }>; + const openAIMessages = (manager as any).buildOpenAIMessages([userMessage, orphanToolMessage], false) as Array<{ + role: string; + }>; - assert.deepEqual(openAIMessages.map((message) => message.role), ["user"]); + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["user"] + ); }); test("buildOpenAIMessages moves a later paired tool message behind its assistant", () => { @@ -642,8 +637,8 @@ test("buildOpenAIMessages moves a later paired tool message behind its assistant { id: "call-1", type: "function", - function: { name: "bash", arguments: "{\"command\":\"date\"}" } - } + function: { name: "bash", arguments: '{"command":"date"}' }, + }, ], "" ) as SessionMessage; @@ -652,7 +647,7 @@ test("buildOpenAIMessages moves a later paired tool message behind its assistant "session-1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "paired later" }), - { name: "bash", arguments: "{\"command\":\"date\"}" } + { name: "bash", arguments: '{"command":"date"}' } ) as SessionMessage; const openAIMessages = (manager as any).buildOpenAIMessages( @@ -660,7 +655,10 @@ test("buildOpenAIMessages moves a later paired tool message behind its assistant false ) as Array<{ role: string; content: string }>; - assert.deepEqual(openAIMessages.map((message) => message.role), ["assistant", "tool", "user"]); + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "user"] + ); assert.match(openAIMessages[1]?.content ?? "", /paired later/); }); @@ -673,13 +671,13 @@ test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { { id: "call-1", type: "function", - function: { name: "read", arguments: "{\"file_path\":\"/tmp/a.txt\"}" } + function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' }, }, { id: "call-2", type: "function", - function: { name: "bash", arguments: "{\"command\":\"pwd\"}" } - } + function: { name: "bash", arguments: '{"command":"pwd"}' }, + }, ], "" ) as SessionMessage; @@ -687,13 +685,13 @@ test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { "session-1", "call-1", JSON.stringify({ ok: true, name: "read", content: "file content" }), - { name: "read", arguments: "{\"file_path\":\"/tmp/a.txt\"}" } + { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } ) as SessionMessage; const secondToolMessage = (manager as any).buildToolMessage( "session-1", "call-2", JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), - { name: "bash", arguments: "{\"command\":\"pwd\"}" } + { name: "bash", arguments: '{"command":"pwd"}' } ) as SessionMessage; const userMessage = buildTestMessage("user-after-complete-tools", "session-1", "user", "thanks"); @@ -702,12 +700,18 @@ test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { false ) as Array<{ role: string; content: string; tool_call_id?: string }>; - assert.deepEqual(openAIMessages.map((message) => message.role), ["assistant", "tool", "tool", "user"]); + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "tool", "user"] + ); assert.deepEqual( openAIMessages.filter((message) => message.role === "tool").map((message) => message.tool_call_id), ["call-1", "call-2"] ); - assert.equal(openAIMessages.some((message) => message.content.includes("Previous tool call did not complete.")), false); + assert.equal( + openAIMessages.some((message) => message.content.includes("Previous tool call did not complete.")), + false + ); }); test("buildOpenAIMessages preserves a real failed tool result", () => { @@ -719,8 +723,8 @@ test("buildOpenAIMessages preserves a real failed tool result", () => { { id: "call-1", type: "function", - function: { name: "bash", arguments: "{\"command\":\"false\"}" } - } + function: { name: "bash", arguments: '{"command":"false"}' }, + }, ], "" ) as SessionMessage; @@ -728,15 +732,19 @@ test("buildOpenAIMessages preserves a real failed tool result", () => { "session-1", "call-1", JSON.stringify({ ok: false, name: "bash", error: "Command failed", metadata: { exitCode: 1 } }), - { name: "bash", arguments: "{\"command\":\"false\"}" } + { name: "bash", arguments: '{"command":"false"}' } ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages( - [assistantMessage, failedToolMessage], - false - ) as Array<{ role: string; content: string; tool_call_id?: string }>; + const openAIMessages = (manager as any).buildOpenAIMessages([assistantMessage, failedToolMessage], false) as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; - assert.deepEqual(openAIMessages.map((message) => message.role), ["assistant", "tool"]); + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool"] + ); assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); assert.match(openAIMessages[1]?.content ?? "", /Command failed/); assert.doesNotMatch(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); @@ -751,13 +759,13 @@ test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messag { id: "call-1", type: "function", - function: { name: "read", arguments: "{\"file_path\":\"/tmp/missing.txt\"}" } + function: { name: "read", arguments: '{"file_path":"/tmp/missing.txt"}' }, }, { id: "call-2", type: "function", - function: { name: "bash", arguments: "{\"command\":\"pwd\"}" } - } + function: { name: "bash", arguments: '{"command":"pwd"}' }, + }, ], "" ) as SessionMessage; @@ -765,19 +773,19 @@ test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messag "session-1", "call-orphan", JSON.stringify({ ok: true, name: "bash", output: "orphan" }), - { name: "bash", arguments: "{\"command\":\"echo orphan\"}" } + { name: "bash", arguments: '{"command":"echo orphan"}' } ) as SessionMessage; const pairedToolMessage = (manager as any).buildToolMessage( "session-1", "call-2", JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), - { name: "bash", arguments: "{\"command\":\"pwd\"}" } + { name: "bash", arguments: '{"command":"pwd"}' } ) as SessionMessage; const duplicateToolMessage = (manager as any).buildToolMessage( "session-1", "call-2", JSON.stringify({ ok: true, name: "bash", output: "duplicate" }), - { name: "bash", arguments: "{\"command\":\"pwd\"}" } + { name: "bash", arguments: '{"command":"pwd"}' } ) as SessionMessage; const userMessage = buildTestMessage("user-after-mixed-tools", "session-1", "user", "continue"); @@ -787,12 +795,24 @@ test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messag ) as Array<{ role: string; content: string; tool_call_id?: string }>; const toolMessages = openAIMessages.filter((message) => message.role === "tool"); - assert.deepEqual(openAIMessages.map((message) => message.role), ["assistant", "tool", "tool", "user"]); - assert.deepEqual(toolMessages.map((message) => message.tool_call_id), ["call-1", "call-2"]); + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + toolMessages.map((message) => message.tool_call_id), + ["call-1", "call-2"] + ); assert.match(toolMessages[0]?.content ?? "", /Previous tool call did not complete/); assert.match(toolMessages[1]?.content ?? "", /\/tmp/); - assert.equal(openAIMessages.some((message) => message.content.includes("orphan")), false); - assert.equal(openAIMessages.some((message) => message.content.includes("duplicate")), false); + assert.equal( + openAIMessages.some((message) => message.content.includes("orphan")), + false + ); + assert.equal( + openAIMessages.some((message) => message.content.includes("duplicate")), + false + ); }); test("buildOpenAIMessages ignores tool messages that appear before their assistant", () => { @@ -801,7 +821,7 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista "session-1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "too early" }), - { name: "bash", arguments: "{\"command\":\"date\"}" } + { name: "bash", arguments: '{"command":"date"}' } ) as SessionMessage; const assistantMessage = (manager as any).buildAssistantMessage( "session-1", @@ -810,18 +830,22 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista { id: "call-1", type: "function", - function: { name: "bash", arguments: "{\"command\":\"date\"}" } - } + function: { name: "bash", arguments: '{"command":"date"}' }, + }, ], "" ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages( - [earlyToolMessage, assistantMessage], - false - ) as Array<{ role: string; content: string; tool_call_id?: string }>; + const openAIMessages = (manager as any).buildOpenAIMessages([earlyToolMessage, assistantMessage], false) as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; - assert.deepEqual(openAIMessages.map((message) => message.role), ["assistant", "tool"]); + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool"] + ); assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); assert.match(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); assert.doesNotMatch(openAIMessages[1]?.content ?? "", /too early/); @@ -840,7 +864,7 @@ test("SessionManager accumulates response usage while active tokens track the la prompt_tokens_details: { cached_tokens: 7 }, completion_tokens_details: { reasoning_tokens: 3 }, prompt_cache_hit_tokens: 7, - prompt_cache_miss_tokens: 3 + prompt_cache_miss_tokens: 3, }), createChatResponse("second", { prompt_tokens: 20, @@ -849,8 +873,8 @@ test("SessionManager accumulates response usage while active tokens track the la prompt_tokens_details: { cached_tokens: 11 }, completion_tokens_details: { reasoning_tokens: 4 }, prompt_cache_hit_tokens: 11, - prompt_cache_miss_tokens: 9 - }) + prompt_cache_miss_tokens: 9, + }), ]; const manager = createMockedClientSessionManager(workspace, responses); @@ -878,18 +902,18 @@ test("SessionManager resets active tokens to latest post-compaction response usa createChatResponse("large", { prompt_tokens: 139_990, completion_tokens: 10, - total_tokens: 140_000 + total_tokens: 140_000, }), createChatResponse("summary", { prompt_tokens: 100, completion_tokens: 23, - total_tokens: 123 + total_tokens: 123, }), createChatResponse("after compact", { prompt_tokens: 5, completion_tokens: 2, - total_tokens: 7 - }) + total_tokens: 7, + }), ]; const manager = createMockedClientSessionManager(workspace, responses); @@ -930,13 +954,13 @@ test("SessionManager streams chat completions and counts reasoning progress", as usage: { prompt_tokens: 2, completion_tokens: 3, - total_tokens: 5 - } - } + total_tokens: 5, + }, + }, ]); - } - } - } + }, + }, + }, }; const manager = new SessionManager({ @@ -945,7 +969,7 @@ test("SessionManager streams chat completions and counts reasoning progress", as client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false + thinkingEnabled: false, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, @@ -954,15 +978,13 @@ test("SessionManager streams chat completions and counts reasoning progress", as progressEvents.push({ phase: progress.phase, estimatedTokens: progress.estimatedTokens, - formattedTokens: progress.formattedTokens + formattedTokens: progress.formattedTokens, }); - } + }, }); const sessionId = await manager.createSession({ text: "" }); - const assistantMessage = manager - .listSessionMessages(sessionId) - .find((message) => message.role === "assistant"); + const assistantMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "assistant"); assert.equal(assistantMessage?.content, "hello"); assert.equal((assistantMessage?.messageParams as any)?.reasoning_content, "思考"); @@ -982,12 +1004,9 @@ test("SessionManager cancels skill matching before a session is created", async const skillDir = path.join(home, ".agents", "skills", "demo"); fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync( - path.join(skillDir, "SKILL.md"), - "---\nname: demo\ndescription: Demo skill\n---\n# Demo\n", - "utf8" - ); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: demo\ndescription: Demo skill\n---\n# Demo\n", "utf8"); + // eslint-disable-next-line prefer-const -- must be declared before client which references it let manager: SessionManager; const client = { chat: { @@ -998,9 +1017,9 @@ test("SessionManager cancels skill matching before a session is created", async signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); queueMicrotask(() => manager.interruptActiveSession()); }); - } - } - } + }, + }, + }, }; manager = createMockedClientSessionManagerWithClient(workspace, client); @@ -1024,18 +1043,19 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = const signal = options?.signal; signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); }); - } - } - } + }, + }, + }, }; + // eslint-disable-next-line prefer-const -- declared before client, assigned after manager = new SessionManager({ projectRoot: workspace, createOpenAIClient: () => ({ client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false + thinkingEnabled: false, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, @@ -1044,7 +1064,7 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = if (entry.status === "processing") { queueMicrotask(() => manager.interruptActiveSession()); } - } + }, }); await manager.handleUserPrompt({ text: "" }); @@ -1064,11 +1084,11 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa model: "test-model", baseURL: "https://api.deepseek.com", thinkingEnabled: false, - machineId + machineId, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); } @@ -1080,9 +1100,9 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow const response = responses.shift(); assert.ok(response, "expected a queued chat response"); return response; - } - } - } + }, + }, + }, }; return new SessionManager({ @@ -1091,11 +1111,11 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false + thinkingEnabled: false, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); } @@ -1106,11 +1126,11 @@ function createMockedClientSessionManagerWithClient(projectRoot: string, client: client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false + thinkingEnabled: false, }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); } @@ -1119,7 +1139,7 @@ class APIUserAbortError extends Error {} function createChatResponse(content: string, usage: Record): unknown { return { choices: [{ message: { content } }], - usage + usage, }; } @@ -1139,7 +1159,7 @@ function buildTestMessage( compacted: false, visible: true, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" + updateTime: "2026-01-01T00:00:00.000Z", }; } diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index fb7123b..15f2ae0 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -9,17 +9,17 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool env: { MODEL: "deepseek-v3.2", BASE_URL: "https://example.com/v1", - API_KEY: "sk-test" + API_KEY: "sk-test", }, thinkingEnabled: true, reasoningEffort: "high", debugLogEnabled: true, notify: " /tmp/notify.sh ", - webSearchTool: " /tmp/web-search.sh " + webSearchTool: " /tmp/web-search.sh ", }, { model: "default-model", - baseURL: "https://default.example.com" + baseURL: "https://default.example.com", } ); @@ -37,12 +37,12 @@ test("resolveSettings still accepts legacy env.THINKING and defaults reasoning e const resolved = resolveSettings( { env: { - THINKING: "enabled" - } + THINKING: "enabled", + }, }, { model: "default-model", - baseURL: "https://default.example.com" + baseURL: "https://default.example.com", } ); @@ -56,12 +56,12 @@ test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { const resolved = resolveSettings( { env: { - MODEL: "deepseek-v4-flash" - } + MODEL: "deepseek-v4-flash", + }, }, { model: "default-model", - baseURL: "https://default.example.com" + baseURL: "https://default.example.com", } ); @@ -73,7 +73,7 @@ test("resolveSettings applies thinking defaults to the fallback model", () => { {}, { model: "deepseek-v4-pro", - baseURL: "https://default.example.com" + baseURL: "https://default.example.com", } ); @@ -85,12 +85,12 @@ test("resolveSettings keeps thinking mode off by default for other models", () = const resolved = resolveSettings( { env: { - MODEL: "deepseek-v3.2" - } + MODEL: "deepseek-v3.2", + }, }, { model: "default-model", - baseURL: "https://default.example.com" + baseURL: "https://default.example.com", } ); @@ -101,13 +101,13 @@ test("resolveSettings allows explicit thinkingEnabled to override model defaults const resolved = resolveSettings( { env: { - MODEL: "deepseek-v4-pro" + MODEL: "deepseek-v4-pro", }, - thinkingEnabled: false + thinkingEnabled: false, }, { model: "default-model", - baseURL: "https://default.example.com" + baseURL: "https://default.example.com", } ); @@ -117,11 +117,11 @@ test("resolveSettings allows explicit thinkingEnabled to override model defaults test("resolveSettings defaults invalid reasoning effort to max", () => { const resolved = resolveSettings( { - reasoningEffort: "medium" as never + reasoningEffort: "medium" as never, }, { model: "default-model", - baseURL: "https://default.example.com" + baseURL: "https://default.example.com", } ); @@ -159,7 +159,7 @@ test("launchNotifyScript passes DURATION and falls back to /bin/sh for non-execu }, unref() { return undefined; - } + }, }; }; diff --git a/src/tests/shell-utils.test.ts b/src/tests/shell-utils.test.ts index a8f2ff2..7d19357 100644 --- a/src/tests/shell-utils.test.ts +++ b/src/tests/shell-utils.test.ts @@ -5,7 +5,7 @@ import { getShellKind, posixPathToWindowsPath, rewriteWindowsNullRedirect, - windowsPathToPosixPath + windowsPathToPosixPath, } from "../tools/shell-utils"; import { isAbsoluteFilePath, normalizeFilePath } from "../tools/state"; @@ -31,7 +31,10 @@ test("Windows nul redirects are rewritten for POSIX bash", () => { test("Shell kind detection supports Windows bash.exe paths", () => { assert.equal(getShellKind("C:\\Program Files\\Git\\bin\\bash.exe"), "bash"); assert.equal(getShellKind("/bin/zsh"), "zsh"); - assert.equal(buildDisableExtglobCommand("C:\\Program Files\\Git\\bin\\bash.exe"), "shopt -u extglob 2>/dev/null || true"); + assert.equal( + buildDisableExtglobCommand("C:\\Program Files\\Git\\bin\\bash.exe"), + "shopt -u extglob 2>/dev/null || true" + ); assert.equal(buildDisableExtglobCommand("/bin/zsh"), "setopt NO_EXTENDED_GLOB 2>/dev/null || true"); }); @@ -40,10 +43,7 @@ test("File tool path normalization converts Git Bash drive paths on Windows", () normalizeFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), "D:\\IdeaProjects\\guesswho-api\\API_DOCUMENTATION.md" ); - assert.equal( - normalizeFilePath("/cygdrive/c/Users/foo/file.txt", "win32"), - "C:\\Users\\foo\\file.txt" - ); + assert.equal(normalizeFilePath("/cygdrive/c/Users/foo/file.txt", "win32"), "C:\\Users\\foo\\file.txt"); assert.equal(normalizeFilePath("/dev/null", "win32"), "\\dev\\null"); }); diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 7af6053..6dcc840 100644 --- a/src/tests/slashCommands.test.ts +++ b/src/tests/slashCommands.test.ts @@ -5,13 +5,13 @@ import { filterSlashCommands, findExactSlashCommand, formatSlashCommandDescription, - formatSlashCommandLabel + formatSlashCommandLabel, } from "../ui"; import type { SkillInfo } from "../session"; const skills: SkillInfo[] = [ { name: "skill-writer", path: "~/.agents/skills/skill-writer/SKILL.md", description: "Write a SKILL.md" }, - { name: "code-review", path: "~/.agents/skills/code-review/SKILL.md", description: "Review code" } + { name: "code-review", path: "~/.agents/skills/code-review/SKILL.md", description: "Review code" }, ]; test("buildSlashCommands prefixes skills before built-ins", () => { @@ -81,7 +81,7 @@ test("formatSlashCommandDescription keeps descriptions on one line", () => { test("formatSlashCommandLabel marks loaded skills", () => { const items = buildSlashCommands([ { name: "loaded", path: "/skills/loaded/SKILL.md", description: "Loaded skill", isLoaded: true }, - { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" } + { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" }, ]); assert.equal(formatSlashCommandLabel(items[0]), "/loaded ✓"); diff --git a/src/tests/thinkingState.test.ts b/src/tests/thinkingState.test.ts index cb0f0a0..8f2a0e3 100644 --- a/src/tests/thinkingState.test.ts +++ b/src/tests/thinkingState.test.ts @@ -19,7 +19,7 @@ function buildMessage( visible: true, createTime: "2026-04-28T00:00:00.000Z", updateTime: "2026-04-28T00:00:00.000Z", - meta: options.asThinking ? { asThinking: true } : undefined + meta: options.asThinking ? { asThinking: true } : undefined, }; } @@ -28,10 +28,7 @@ test("findExpandedThinkingId returns null on an empty list", () => { }); test("findExpandedThinkingId returns the only thinking id when there is no final reply", () => { - const messages = [ - buildMessage("user", "user"), - buildMessage("a-1", "assistant", { asThinking: true }) - ]; + const messages = [buildMessage("user", "user"), buildMessage("a-1", "assistant", { asThinking: true })]; assert.equal(findExpandedThinkingId(messages), "a-1"); }); @@ -39,16 +36,13 @@ test("findExpandedThinkingId always picks the latest thinking id", () => { const messages = [ buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("tool", "tool"), - buildMessage("a-2", "assistant", { asThinking: true }) + buildMessage("a-2", "assistant", { asThinking: true }), ]; assert.equal(findExpandedThinkingId(messages), "a-2"); }); test("findExpandedThinkingId returns null after a non-thinking assistant reply", () => { - const messages = [ - buildMessage("a-1", "assistant", { asThinking: true }), - buildMessage("a-final", "assistant") - ]; + const messages = [buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("a-final", "assistant")]; assert.equal(findExpandedThinkingId(messages), null); }); @@ -57,7 +51,7 @@ test("findExpandedThinkingId picks the thinking id that follows the last final r buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("a-final", "assistant"), buildMessage("a-2", "assistant", { asThinking: true }), - buildMessage("a-3", "assistant", { asThinking: true }) + buildMessage("a-3", "assistant", { asThinking: true }), ]; assert.equal(findExpandedThinkingId(messages), "a-3"); }); diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index cc03a07..256e0d6 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -22,11 +22,7 @@ afterEach(() => { test("Read returns snippet metadata and Edit can scope replacements by snippet_id", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "sample.txt"); - fs.writeFileSync( - filePath, - ["alpha", "target = 1", "omega", "beta", "target = 1", "done"].join("\n"), - "utf8" - ); + fs.writeFileSync(filePath, ["alpha", "target = 1", "omega", "beta", "target = 1", "done"].join("\n"), "utf8"); const sessionId = "snippet-scope"; const readResult = await handleReadTool( @@ -35,9 +31,7 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i ); assert.equal(readResult.ok, true); - const snippet = (readResult.metadata?.snippet ?? null) as - | { id: string; startLine: number; endLine: number } - | null; + const snippet = (readResult.metadata?.snippet ?? null) as { id: string; startLine: number; endLine: number } | null; assert.ok(snippet); assert.equal(snippet?.startLine, 4); assert.equal(snippet?.endLine, 5); @@ -46,7 +40,7 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i { snippet_id: snippet?.id, old_string: "target = 1", - new_string: "target = 2" + new_string: "target = 2", }, createContext(sessionId, workspace) ); @@ -75,16 +69,13 @@ test("Edit returns candidate match snippets when old_string is not unique", asyn { file_path: filePath, old_string: "city", - new_string: "location" + new_string: "location", }, createContext(sessionId, workspace) ); assert.equal(editResult.ok, false); - assert.equal( - editResult.error, - "old_string is not unique; use snippet_id, replace_all, or provide more context." - ); + assert.equal(editResult.error, "old_string is not unique; use snippet_id, replace_all, or provide more context."); const candidates = (editResult.metadata?.candidates ?? []) as Array<{ snippet_id: string; start_line: number; @@ -111,16 +102,13 @@ test("replace_all requires expected_occurrences for broad short-fragment replace file_path: filePath, old_string: fragment, new_string: " schema:\n type: array", - replace_all: true + replace_all: true, }, createContext(sessionId, workspace) ); assert.equal(blockedResult.ok, false); - assert.match( - blockedResult.error ?? "", - /provide expected_occurrences to confirm this broader replacement/ - ); + assert.match(blockedResult.error ?? "", /provide expected_occurrences to confirm this broader replacement/); const allowedResult = await handleEditTool( { @@ -128,7 +116,7 @@ test("replace_all requires expected_occurrences for broad short-fragment replace old_string: fragment, new_string: " schema:\n type: array", replace_all: true, - expected_occurrences: 3 + expected_occurrences: 3, }, createContext(sessionId, workspace) ); @@ -139,7 +127,7 @@ test("replace_all requires expected_occurrences for broad short-fragment replace [ " schema:\n type: array", " schema:\n type: array", - " schema:\n type: array" + " schema:\n type: array", ].join("\n---\n") ); }); @@ -156,7 +144,7 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn { file_path: filePath, old_string: "params['city_json'] = f'\\\\\"{city}\\\\\"'", - new_string: "params['city_json'] = city" + new_string: "params['city_json'] = city", }, createContext(sessionId, workspace, { createOpenAIClient: () => ({ @@ -171,17 +159,17 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn "" + "" + "" + - "" - } - } - ] - }) - } - } + "", + }, + }, + ], + }), + }, + }, } as any, model: "test-model", - thinkingEnabled: false - }) + thinkingEnabled: false, + }), }) ); @@ -199,8 +187,8 @@ test("Write repairs JSON object content for .json files", async () => { file_path: filePath, content: { name: "demo", - private: true - } as unknown as string + private: true, + } as unknown as string, }, createContext("write-json-object", workspace) ); @@ -212,10 +200,7 @@ test("Write repairs JSON object content for .json files", async () => { assert.equal(writeResult.metadata?.line_endings, "LF"); assert.equal(writeResult.metadata?.input_repaired, true); assert.match(String(writeResult.metadata?.diff_preview ?? ""), /\+\s*"name": "demo"|^\+\{/m); - assert.equal( - fs.readFileSync(filePath, "utf8"), - '{\n "name": "demo",\n "private": true\n}' - ); + assert.equal(fs.readFileSync(filePath, "utf8"), '{\n "name": "demo",\n "private": true\n}'); }); test("Write updates file state so a follow-up Edit can succeed without another Read", async () => { @@ -225,7 +210,7 @@ test("Write updates file state so a follow-up Edit can succeed without another R const writeResult = await handleWriteTool( { file_path: filePath, - content: "alpha\nbeta\n" + content: "alpha\nbeta\n", }, createContext("write-then-edit", workspace) ); @@ -238,7 +223,7 @@ test("Write updates file state so a follow-up Edit can succeed without another R { file_path: filePath, old_string: "beta", - new_string: "gamma" + new_string: "gamma", }, createContext("write-then-edit", workspace) ); @@ -261,7 +246,7 @@ test("Write requires a full read before overwriting an existing file", async () const blockedResult = await handleWriteTool( { file_path: filePath, - content: "rewritten" + content: "rewritten", }, createContext(sessionId, workspace) ); @@ -278,7 +263,7 @@ test("Write can overwrite an existing empty file without a prior read", async () const writeResult = await handleWriteTool( { file_path: filePath, - content: "initialized\n" + content: "initialized\n", }, createContext("write-empty-existing", workspace) ); @@ -306,7 +291,7 @@ test("Edit rejects stale reads after the file changes on disk", async () => { { file_path: filePath, old_string: "after", - new_string: "final" + new_string: "final", }, createContext(sessionId, workspace) ); @@ -322,7 +307,7 @@ test("Write preserves the exact trailing newline policy from the provided conten const writeResult = await handleWriteTool( { file_path: filePath, - content: "no trailing newline" + content: "no trailing newline", }, createContext("write-no-newline", workspace) ); @@ -344,7 +329,7 @@ test("Edit preserves CRLF line endings for existing files", async () => { { file_path: filePath, old_string: "beta", - new_string: "gamma" + new_string: "gamma", }, createContext(sessionId, workspace) ); @@ -365,10 +350,7 @@ test("Read returns an acknowledgement for images and attaches the image as a fol ) ); - const readResult = await handleReadTool( - { file_path: filePath }, - createContext("image-read", workspace) - ); + const readResult = await handleReadTool({ file_path: filePath }, createContext("image-read", workspace)); assert.equal(readResult.ok, true); assert.equal(readResult.output, "File loaded."); @@ -379,15 +361,11 @@ test("Read returns an acknowledgement for images and attaches the image as a fol const followUpMessage = readResult.followUpMessages?.[0]; assert.equal(followUpMessage?.role, "system"); assert.match(followUpMessage?.content ?? "", /pixel\.png/); - const contentParams = Array.isArray(followUpMessage?.contentParams) - ? followUpMessage.contentParams - : []; + const contentParams = Array.isArray(followUpMessage?.contentParams) ? followUpMessage.contentParams : []; assert.equal(contentParams.length, 1); assert.equal((contentParams[0] as { type?: unknown }).type, "image_url"); assert.match( - String( - ((contentParams[0] as { image_url?: { url?: unknown } }).image_url?.url ?? "") - ), + String((contentParams[0] as { image_url?: { url?: unknown } }).image_url?.url ?? ""), /^data:image\/png;base64,/ ); }); @@ -405,10 +383,10 @@ function createContext( type: "function", function: { name: "test", - arguments: "{}" - } + arguments: "{}", + }, }, - ...overrides + ...overrides, }; } diff --git a/src/tests/updateCheck.test.ts b/src/tests/updateCheck.test.ts index 19341f6..ce77fe5 100644 --- a/src/tests/updateCheck.test.ts +++ b/src/tests/updateCheck.test.ts @@ -10,7 +10,7 @@ test("compareVersions orders semantic versions", () => { }); test("parseNpmViewVersion parses npm view JSON and plain output", () => { - assert.equal(parseNpmViewVersion("\"0.1.4\"\n"), "0.1.4"); + assert.equal(parseNpmViewVersion('"0.1.4"\n'), "0.1.4"); assert.equal(parseNpmViewVersion("0.1.5\n"), "0.1.5"); assert.equal(parseNpmViewVersion("\n"), null); }); diff --git a/src/tests/web-search-handler.test.ts b/src/tests/web-search-handler.test.ts index 2f69a10..eaa536e 100644 --- a/src/tests/web-search-handler.test.ts +++ b/src/tests/web-search-handler.test.ts @@ -25,11 +25,7 @@ test("WebSearch executes the configured script with the query as one argument", const scriptPath = path.join(workspace, "web-search.sh"); fs.writeFileSync( scriptPath, - [ - "#!/bin/sh", - "printf 'query=%s\\n' \"$1\"", - "printf 'cwd=%s\\n' \"$PWD\"" - ].join("\n"), + ["#!/bin/sh", "printf 'query=%s\\n' \"$1\"", "printf 'cwd=%s\\n' \"$PWD\""].join("\n"), "utf8" ); fs.chmodSync(scriptPath, 0o755); @@ -41,16 +37,13 @@ test("WebSearch executes the configured script with the query as one argument", createContext(workspace, { webSearchTool: scriptPath, onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id) + onProcessExit: (id) => exits.push(id), }) ); const realWorkspace = fs.realpathSync(workspace); assert.equal(result.ok, true); - assert.equal( - result.output, - `query=latest node release\ncwd=${realWorkspace}\n` - ); + assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\n`); assert.equal(starts.length, 1); assert.match(starts[0].command, /^WebSearch: latest node release$/); assert.deepEqual(exits, [starts[0].id]); @@ -72,15 +65,16 @@ test("WebSearch uses the default API when no script is configured", async () => choices: [ { message: { - content: "{\"dominant_language\":\"en\",\"reason\":\"Most Node.js release notes are published in English.\"}" - } - } - ] + content: + '{"dominant_language":"en","reason":"Most Node.js release notes are published in English."}', + }, + }, + ], }; } throw new Error(`Unexpected chat prompt: ${prompt}`); - } - } + }, + }, }, } as unknown as OpenAI; @@ -95,14 +89,14 @@ test("WebSearch uses the default API when no script is configured", async () => organic_results: [ { title: "Node.js Releases", - link: "https://nodejs.org/en/about/previous-releases" - } - ] + link: "https://nodejs.org/en/about/previous-releases", + }, + ], }, null, 2 - ) - }) + ), + }), } as Response; }) as typeof fetch; @@ -112,7 +106,7 @@ test("WebSearch uses the default API when no script is configured", async () => client: fakeClient, machineId: "machine-id-123", onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id) + onProcessExit: (id) => exits.push(id), }) ); @@ -131,16 +125,10 @@ test("WebSearch uses the default API when no script is configured", async () => test("WebSearch returns a configuration error when neither a script nor an LLM client is available", async () => { const workspace = createTempWorkspace(); - const result = await handleWebSearchTool( - { query: "latest node release" }, - createContext(workspace) - ); + const result = await handleWebSearchTool({ query: "latest node release" }, createContext(workspace)); assert.equal(result.ok, false); - assert.equal( - result.error, - "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json." - ); + assert.equal(result.error, "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json."); }); function createContext( @@ -161,18 +149,18 @@ function createContext( type: "function", function: { name: "WebSearch", - arguments: "{}" - } + arguments: "{}", + }, }, createOpenAIClient: () => ({ client: options.client ?? null, model: "test-model", thinkingEnabled: false, webSearchTool: options.webSearchTool, - machineId: options.machineId + machineId: options.machineId, }), onProcessStart: options.onProcessStart, - onProcessExit: options.onProcessExit + onProcessExit: options.onProcessExit, }; } diff --git a/src/tests/welcomeScreen.test.ts b/src/tests/welcomeScreen.test.ts index 1eca364..1e5bc19 100644 --- a/src/tests/welcomeScreen.test.ts +++ b/src/tests/welcomeScreen.test.ts @@ -17,7 +17,7 @@ test("formatHomeRelativePath keeps paths outside the home directory absolute", ( test("buildWelcomeTips includes built-in slash commands and loaded skills", () => { const tips = buildWelcomeTips([ { name: "loaded", path: "/skills/loaded/SKILL.md", description: "Loaded skill", isLoaded: true }, - { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" } + { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" }, ]); const labels = tips.map((tip) => tip.label); diff --git a/src/tools/ask-user-question-handler.ts b/src/tools/ask-user-question-handler.ts index 4654066..8608508 100644 --- a/src/tools/ask-user-question-handler.ts +++ b/src/tools/ask-user-question-handler.ts @@ -25,13 +25,13 @@ export async function handleAskUserQuestionTool( return { ok: false, name: "AskUserQuestion", - error: questions.error + error: questions.error, }; } const metadata: AskUserQuestionMetadata = { kind: "ask_user_question", - questions: questions.value + questions: questions.value, }; return { @@ -39,17 +39,15 @@ export async function handleAskUserQuestionTool( name: "AskUserQuestion", output: buildQuestionSummary(questions.value), metadata, - awaitUserResponse: true + awaitUserResponse: true, }; } -function parseQuestions( - raw: unknown -): { ok: true; value: AskUserQuestionItem[] } | { ok: false; error: string } { +function parseQuestions(raw: unknown): { ok: true; value: AskUserQuestionItem[] } | { ok: false; error: string } { if (!Array.isArray(raw) || raw.length === 0) { return { ok: false, - error: "\"questions\" must be a non-empty array." + error: '"questions" must be a non-empty array.', }; } @@ -59,17 +57,18 @@ function parseQuestions( if (!item || typeof item !== "object" || Array.isArray(item)) { return { ok: false, - error: `Question at index ${index} must be an object.` + error: `Question at index ${index} must be an object.`, }; } - const question = typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; + const question = + typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; if (!question) { return { ok: false, - error: `Question at index ${index} is missing a non-empty "question" string.` + error: `Question at index ${index} is missing a non-empty "question" string.`, }; } @@ -77,7 +76,7 @@ function parseQuestions( if (!Array.isArray(rawOptions) || rawOptions.length === 0) { return { ok: false, - error: `Question at index ${index} must include a non-empty "options" array.` + error: `Question at index ${index} must include a non-empty "options" array.`, }; } @@ -87,44 +86,45 @@ function parseQuestions( if (!option || typeof option !== "object" || Array.isArray(option)) { return { ok: false, - error: `Option ${optionIndex} for question ${index} must be an object.` + error: `Option ${optionIndex} for question ${index} must be an object.`, }; } - const label = typeof (option as { label?: unknown }).label === "string" - ? (option as { label: string }).label.trim() - : ""; + const label = + typeof (option as { label?: unknown }).label === "string" ? (option as { label: string }).label.trim() : ""; if (!label) { return { ok: false, - error: `Option ${optionIndex} for question ${index} is missing a non-empty "label" string.` + error: `Option ${optionIndex} for question ${index} is missing a non-empty "label" string.`, }; } - const description = typeof (option as { description?: unknown }).description === "string" - ? (option as { description: string }).description.trim() - : undefined; + const description = + typeof (option as { description?: unknown }).description === "string" + ? (option as { description: string }).description.trim() + : undefined; options.push({ label, - description: description || undefined + description: description || undefined, }); } - const multiSelect = typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" - ? (item as { multiSelect: boolean }).multiSelect - : undefined; + const multiSelect = + typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" + ? (item as { multiSelect: boolean }).multiSelect + : undefined; questions.push({ question, multiSelect, - options + options, }); } return { ok: true, - value: questions + value: questions, }; } diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index da8f9ef..155d82a 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -6,7 +6,7 @@ import { buildShellInitCommand, resolveShellPath, rewriteWindowsNullRedirect, - toNativeCwd + toNativeCwd, } from "./shell-utils"; const MAX_OUTPUT_CHARS = 30000; @@ -33,7 +33,7 @@ export async function handleBashTool( return { ok: false, name: "bash", - error: "Missing required \"command\" string." + error: 'Missing required "command" string.', }; } @@ -54,11 +54,7 @@ export async function handleBashTool( if (execution.error || result.exitCode !== 0 || result.signal !== null) { const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error); - return formatResult( - { ...result, ok: false }, - "bash", - errorMessage - ); + return formatResult({ ...result, ok: false }, "bash", errorMessage); } return formatResult(result, "bash"); @@ -114,7 +110,7 @@ async function executeShellCommand( env: buildShellEnv(shellPath), detached, windowsHide: true, - stdio: ["ignore", "pipe", "pipe"] + stdio: ["ignore", "pipe", "pipe"], }); const pid = child.pid; if (typeof pid === "number") { @@ -145,7 +141,7 @@ async function executeShellCommand( stderr, exitCode: typeof code === "number" ? code : null, signal: signal ?? null, - error + error, }); }); }); @@ -185,7 +181,7 @@ function buildToolCommandResult( signal, truncated, shellPath, - startCwd + startCwd, }; } @@ -243,18 +239,14 @@ function buildErrorMessage(exitCode: number | null, signal: string | null, error return "Command failed."; } -function formatResult( - result: ToolCommandResult, - name: string, - errorMessage?: string -): ToolExecutionResult { +function formatResult(result: ToolCommandResult, name: string, errorMessage?: string): ToolExecutionResult { const metadata: Record = { exitCode: result.exitCode, signal: result.signal, cwd: result.cwd, truncated: result.truncated, shellPath: result.shellPath, - startCwd: result.startCwd + startCwd: result.startCwd, }; const outputValue = result.output ? result.output : undefined; @@ -264,6 +256,6 @@ function formatResult( name, output: outputValue, error: errorMessage, - metadata + metadata, }; } diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 7f8c27d..403f984 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -2,12 +2,7 @@ import * as fs from "fs"; import { z } from "zod"; import { buildThinkingRequestOptions } from "../openai-thinking"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { - buildDiffPreview, - hasFileChangedSinceState, - readTextFileWithMetadata, - writeTextFile -} from "./file-utils"; +import { buildDiffPreview, hasFileChangedSinceState, readTextFileWithMetadata, writeTextFile } from "./file-utils"; import { executeValidatedTool, semanticBoolean } from "./runtime"; import { createSnippet, @@ -16,7 +11,7 @@ import { isAbsoluteFilePath, isFullFileView, normalizeFilePath, - recordFileState + recordFileState, } from "./state"; const MAX_CANDIDATE_COUNT = 5; @@ -77,7 +72,7 @@ const editSchema = z.strictObject({ return Number(value); } return value; - }, z.number().int().min(1, "expected_occurrences must be >= 1.").optional()) + }, z.number().int().min(1, "expected_occurrences must be >= 1.").optional()), }); export async function handleEditTool( @@ -98,7 +93,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "Missing required \"file_path\" string or \"snippet_id\" string." + error: 'Missing required "file_path" string or "snippet_id" string.', }; } @@ -111,7 +106,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "file_path must be an absolute path." + error: "file_path must be an absolute path.", }; } @@ -119,7 +114,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `Unknown snippet_id: ${snippetId}` + error: `Unknown snippet_id: ${snippetId}`, }; } @@ -127,7 +122,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "snippet_id does not belong to the provided file_path." + error: "snippet_id does not belong to the provided file_path.", }; } @@ -135,7 +130,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "old_string must not be empty." + error: "old_string must not be empty.", }; } @@ -143,7 +138,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "new_string must differ from old_string." + error: "new_string must differ from old_string.", }; } @@ -151,7 +146,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `File not found: ${filePath}` + error: `File not found: ${filePath}`, }; } @@ -163,7 +158,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `Failed to stat file: ${message}` + error: `Failed to stat file: ${message}`, }; } @@ -171,7 +166,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "file_path points to a directory." + error: "file_path points to a directory.", }; } @@ -180,7 +175,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "Must read file before editing." + error: "Must read file before editing.", }; } @@ -188,7 +183,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "File was only partially read. Use snippet_id or read the full file before editing." + error: "File was only partially read. Use snippet_id or read the full file before editing.", }; } @@ -196,7 +191,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "File has been modified since read. Read it again before editing." + error: "File has been modified since read. Read it again before editing.", }; } @@ -250,15 +245,11 @@ export async function handleEditTool( metadata: closestMatch ? { scope: formatScopeMetadata(scope), - closest_match: buildClosestMatchMetadata( - context.sessionId, - filePath, - closestMatch - ) + closest_match: buildClosestMatchMetadata(context.sessionId, filePath, closestMatch), } : { - scope: formatScopeMetadata(scope) - } + scope: formatScopeMetadata(scope), + }, }; } @@ -270,8 +261,8 @@ export async function handleEditTool( metadata: { match_count: matches.length, scope: formatScopeMetadata(scope), - candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches) - } + candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches), + }, }; } @@ -280,7 +271,7 @@ export async function handleEditTool( replaceAll, matchCount: matches.length, oldString: replacementOldString, - expectedOccurrences + expectedOccurrences, }); if (replaceAllGuardError) { return { @@ -290,18 +281,12 @@ export async function handleEditTool( metadata: { match_count: matches.length, scope: formatScopeMetadata(scope), - candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches) - } + candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches), + }, }; } - const updated = applyReplacement( - raw, - replacementOldString, - replacementNewString, - matches, - replaceAll - ); + const updated = applyReplacement(raw, replacementOldString, replacementNewString, matches, replaceAll); const diffPreview = buildDiffPreview(filePath, raw, updated); writeTextFile(filePath, updated, metadata.encoding, metadata.lineEndings); const freshMetadata = readTextFileWithMetadata(filePath); @@ -310,7 +295,7 @@ export async function handleEditTool( content: freshMetadata.content, timestamp: freshMetadata.timestamp, encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings + lineEndings: freshMetadata.lineEndings, }); const replacedCount = replaceAll ? matches.length : 1; return { @@ -326,15 +311,15 @@ export async function handleEditTool( encoding: freshMetadata.encoding, line_endings: freshMetadata.lineEndings, diff_preview: diffPreview, - scope: formatScopeMetadata(scope) - } + scope: formatScopeMetadata(scope), + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "edit", - error: message + error: message, }; } }, @@ -348,7 +333,7 @@ export async function handleEditTool( nextInput.snippet_id = nextInput.snippet_id.trim(); } return { ok: true, input: nextInput }; - } + }, } ); } @@ -387,7 +372,7 @@ function buildSearchScope( endOffset: raw.length, startLine: 1, endLine: lineIndex.lines.length, - snippetId: null + snippetId: null, }; } @@ -399,7 +384,7 @@ function buildSearchScope( endOffset: lineIndex.lineStarts[safeEndLine + 1], startLine: safeStartLine, endLine: safeEndLine, - snippetId: snippet.id + snippetId: snippet.id, }; } @@ -427,7 +412,7 @@ function findOccurrences(raw: string, needle: string, scope: SearchScope): Match startOffset, endOffset, startLine: offsetToLine(raw, startOffset), - endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)) + endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)), }); searchIndex = found + needle.length; } @@ -462,7 +447,7 @@ function findLooseEscapeMatches(raw: string, needle: string, scope: SearchScope) startOffset, endOffset, startLine: offsetToLine(raw, startOffset), - endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)) + endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)), }); } @@ -496,10 +481,7 @@ function validateReplaceAllGuard(input: { } if (input.expectedOccurrences !== null && input.expectedOccurrences !== input.matchCount) { - return ( - `replace_all expected ${input.expectedOccurrences} occurrence(s), ` + - `but found ${input.matchCount}.` - ); + return `replace_all expected ${input.expectedOccurrences} occurrence(s), ` + `but found ${input.matchCount}.`; } const isShortFragment = input.oldString.trim().length < SHORT_REPLACE_ALL_LENGTH; @@ -552,7 +534,7 @@ function buildCandidateMetadata( snippet_id: snippet?.id ?? null, start_line: match.startLine, end_line: match.endLine, - preview + preview, }; }); } @@ -562,17 +544,8 @@ function buildClosestMatchMetadata( filePath: string, closestMatch: ClosestMatch ): Record { - const preview = formatWithLineNumbers( - closestMatch.text.split(/\r?\n/), - closestMatch.startLine - ); - const snippet = createSnippet( - sessionId, - filePath, - closestMatch.startLine, - closestMatch.endLine, - preview - ); + const preview = formatWithLineNumbers(closestMatch.text.split(/\r?\n/), closestMatch.startLine); + const snippet = createSnippet(sessionId, filePath, closestMatch.startLine, closestMatch.endLine, preview); return { snippet_id: snippet?.id ?? null, @@ -580,7 +553,7 @@ function buildClosestMatchMetadata( end_line: closestMatch.endLine, similarity: Number(closestMatch.score.toFixed(3)), strategy: closestMatch.strategy, - preview + preview, }; } @@ -589,7 +562,7 @@ function formatScopeMetadata(scope: SearchScope): Record { file_path: scope.filePath, start_line: scope.startLine, end_line: scope.endLine, - snippet_id: scope.snippetId + snippet_id: scope.snippetId, }; } @@ -600,9 +573,7 @@ function buildPreview(raw: string, startLine: number, endLine: number): string { } function formatWithLineNumbers(lines: string[], startLine: number): string { - return lines - .map((line, index) => `${String(startLine + index).padStart(6, " ")}\t${line}`) - .join("\n"); + return lines.map((line, index) => `${String(startLine + index).padStart(6, " ")}\t${line}`).join("\n"); } function findClosestMatch( @@ -620,7 +591,7 @@ function findClosestMatch( startLine: match.startLine, endLine: match.endLine, score: match.score, - strategy: "loose_escape" + strategy: "loose_escape", }; if (!bestLooseMatch || candidate.score > bestLooseMatch.score) { bestLooseMatch = candidate; @@ -655,7 +626,7 @@ function findClosestMatch( startLine, endLine, score, - strategy: "fuzzy_window" + strategy: "fuzzy_window", }; if (!bestMatch || candidate.score > bestMatch.score) { @@ -724,7 +695,7 @@ async function correctEscapedStringsWithLLM( content: "You correct file-edit strings when the only problem is escaping. " + "Return XML only using ....... " + - "Do not change semantics; only fix quoting or escaping so corrected_old_string matches the snippet exactly." + "Do not change semantics; only fix quoting or escaping so corrected_old_string matches the snippet exactly.", }, { role: "user", @@ -740,10 +711,10 @@ async function correctEscapedStringsWithLLM( " \n" + " \n" + " \n" + - "" - } + "", + }, ], - ...buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort) + ...buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort), }); const content = response.choices?.[0]?.message?.content ?? ""; @@ -786,13 +757,10 @@ function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null const correctedOldString = oldMatch?.[1] ?? oldMatch?.[2]; const correctedNewString = newMatch?.[1] ?? newMatch?.[2]; - if ( - typeof correctedOldString === "string" && - typeof correctedNewString === "string" - ) { + if (typeof correctedOldString === "string" && typeof correctedNewString === "string") { return { oldString: correctedOldString, - newString: correctedNewString + newString: correctedNewString, }; } @@ -800,7 +768,7 @@ function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null } function isEscapeSensitiveChar(value: string): boolean { - return value === "\"" || value === "'" || value === "`" || value === "\\"; + return value === '"' || value === "'" || value === "`" || value === "\\"; } function escapeRegExp(value: string): string { diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 3b16a4b..eec0b20 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -99,7 +99,7 @@ export class ToolExecutor { executions.push({ toolCallId: toolCall.id, content: this.formatToolResult(result), - result + result, }); if (hooks?.shouldStop?.()) { break; @@ -141,16 +141,15 @@ export class ToolExecutor { return null; } - const rawArguments = - typeof functionRecord.arguments === "string" ? functionRecord.arguments : ""; + const rawArguments = typeof functionRecord.arguments === "string" ? functionRecord.arguments : ""; return { id: record.id, type: "function", function: { name: functionRecord.name, - arguments: rawArguments - } + arguments: rawArguments, + }, }; } @@ -165,7 +164,7 @@ export class ToolExecutor { return { ok: false, name: toolName, - error: `Unknown tool: ${toolName}` + error: `Unknown tool: ${toolName}`, }; } @@ -174,7 +173,7 @@ export class ToolExecutor { return { ok: false, name: toolName, - error: parsedArgs.error + error: parsedArgs.error, }; } @@ -185,14 +184,14 @@ export class ToolExecutor { toolCall, createOpenAIClient: this.createOpenAIClient, onProcessStart: hooks?.onProcessStart, - onProcessExit: hooks?.onProcessExit + onProcessExit: hooks?.onProcessExit, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: toolName, - error: message + error: message, }; } } @@ -216,7 +215,7 @@ export class ToolExecutor { ok: false, error: `InputParseError: Failed to parse tool arguments: ${message}. ` + - "Ensure the tool call arguments are valid JSON. Prefer Edit over Write for large existing-file changes." + "Ensure the tool call arguments are valid JSON. Prefer Edit over Write for large existing-file changes.", }; } } @@ -224,7 +223,7 @@ export class ToolExecutor { private formatToolResult(result: ToolExecutionResult): string { const payload: Record = { ok: result.ok, - name: result.name + name: result.name, }; if (typeof result.output !== "undefined") { @@ -245,5 +244,4 @@ export class ToolExecutor { return JSON.stringify(payload, null, 2); } - } diff --git a/src/tools/file-utils.ts b/src/tools/file-utils.ts index b5705d9..6656172 100644 --- a/src/tools/file-utils.ts +++ b/src/tools/file-utils.ts @@ -35,7 +35,7 @@ export function readTextFileWithMetadata(filePath: string): FileReadMetadata { content: normalizeContent(raw), encoding, lineEndings: detectLineEndings(raw), - timestamp: Math.floor(stat.mtimeMs) + timestamp: Math.floor(stat.mtimeMs), }; } @@ -61,10 +61,7 @@ export function hasFileChangedSinceState(filePath: string, state: FileState): bo return false; } - const isFullRead = - !state.isPartialView && - typeof state.offset === "undefined" && - typeof state.limit === "undefined"; + const isFullRead = !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined"; return !(isFullRead && current.content === state.content); } @@ -86,11 +83,7 @@ export function buildDiffPreview( const newLines = toDiffLines(updated); let prefix = 0; - while ( - prefix < oldLines.length && - prefix < newLines.length && - oldLines[prefix] === newLines[prefix] - ) { + while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) { prefix += 1; } @@ -111,7 +104,7 @@ export function buildDiffPreview( const previewLines = [ `--- ${original === null ? "/dev/null" : `a/${filePath}`}`, `+++ b/${filePath}`, - `@@ -${oldStart},${oldChanged.length} +${newStart},${newChanged.length} @@` + `@@ -${oldStart},${oldChanged.length} +${newStart},${newChanged.length} @@`, ]; if (prefix > 0) { diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts index 118f8eb..548bcfd 100644 --- a/src/tools/read-handler.ts +++ b/src/tools/read-handler.ts @@ -1,11 +1,7 @@ import * as fs from "fs"; import * as path from "path"; import ignore from "ignore"; -import type { - ToolExecutionContext, - ToolExecutionFollowUpMessage, - ToolExecutionResult -} from "./executor"; +import type { ToolExecutionContext, ToolExecutionFollowUpMessage, ToolExecutionResult } from "./executor"; import { readTextFileWithMetadata } from "./file-utils"; import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "./state"; @@ -36,7 +32,7 @@ const DEFAULT_GITIGNORE = [ "*.class", "*.jar", "*.war", - "target/" + "target/", ]; type PageRange = { @@ -66,7 +62,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: "Missing required \"file_path\" string." + error: 'Missing required "file_path" string.', }; } @@ -75,14 +71,12 @@ export async function handleReadTool( return { ok: false, name: "read", - error: "file_path must be an absolute path." + error: "file_path must be an absolute path.", }; } const normalizedSuffix = normalizeRelativeSuffix(filePath); const isIgnored = loadGitignoreMatcher(context.projectRoot); - const matches = normalizedSuffix - ? findSuffixMatches(context.projectRoot, normalizedSuffix, isIgnored) - : []; + const matches = normalizedSuffix ? findSuffixMatches(context.projectRoot, normalizedSuffix, isIgnored) : []; if (matches.length > 1) { return { ok: false, @@ -90,7 +84,7 @@ export async function handleReadTool( error: "file_path must be an absolute path. " + `The file_path is ambiguous and may refer to multiple files:\n${matches.slice(0, 3).join("\n")}` + - (matches.length > 3 ? `\n...and ${matches.length - 3} more.` : "") + (matches.length > 3 ? `\n...and ${matches.length - 3} more.` : ""), }; } @@ -100,15 +94,13 @@ export async function handleReadTool( return { ok: false, name: "read", - error: - "file_path must be an absolute path. " + - `The file_path "${filePath}" is ambiguous.` + error: "file_path must be an absolute path. " + `The file_path "${filePath}" is ambiguous.`, }; } else { return { ok: false, name: "read", - error: `File not found: ${filePath}` + error: `File not found: ${filePath}`, }; } } @@ -120,7 +112,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `File not found: ${filePath}` + error: `File not found: ${filePath}`, }; } @@ -132,7 +124,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `Failed to stat file: ${message}` + error: `Failed to stat file: ${message}`, }; } @@ -140,7 +132,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: "file_path points to a directory. Use bash ls for directories." + error: "file_path points to a directory. Use bash ls for directories.", }; } @@ -151,12 +143,12 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true + isPartialView: true, }); return { ok: true, name: "read", - output + output, }; } @@ -170,7 +162,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF has ${pageCount} pages; provide \"pages\" to read a range.` + error: `PDF has ${pageCount} pages; provide "pages" to read a range.`, }; } @@ -178,7 +170,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.` + error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.`, }; } @@ -186,7 +178,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF page range exceeds total page count (${pageCount}).` + error: `PDF page range exceeds total page count (${pageCount}).`, }; } @@ -194,7 +186,7 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true + isPartialView: true, }); return { ok: true, @@ -205,8 +197,8 @@ export async function handleReadTool( encoding: "base64", bytes: buffer.length, pageCount, - pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null - } + pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null, + }, }; } @@ -216,7 +208,7 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true + isPartialView: true, }); return { ok: true, @@ -224,11 +216,9 @@ export async function handleReadTool( output: "File loaded.", metadata: { mime, - bytes: buffer.length + bytes: buffer.length, }, - followUpMessages: [ - buildImageFollowUpMessage(filePath, mime, buffer) - ] + followUpMessages: [buildImageFollowUpMessage(filePath, mime, buffer)], }; } @@ -238,14 +228,14 @@ export async function handleReadTool( return { ok: false, name: "read", - error: offset.error + error: offset.error, }; } if (!limit.ok) { return { ok: false, name: "read", - error: limit.error + error: limit.error, }; } @@ -254,13 +244,10 @@ export async function handleReadTool( content: textResult.content, timestamp: textResult.timestamp, offset: textResult.isPartialView ? textResult.startLine : undefined, - limit: - textResult.isPartialView - ? Math.max(1, textResult.endLine - textResult.startLine + 1) - : undefined, + limit: textResult.isPartialView ? Math.max(1, textResult.endLine - textResult.startLine + 1) : undefined, isPartialView: textResult.isPartialView, encoding: textResult.encoding, - lineEndings: textResult.lineEndings + lineEndings: textResult.lineEndings, }); const snippet = createSnippet( context.sessionId, @@ -279,17 +266,17 @@ export async function handleReadTool( id: snippet.id, filePath: snippet.filePath, startLine: snippet.startLine, - endLine: snippet.endLine - } + endLine: snippet.endLine, + }, } - : undefined + : undefined, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "read", - error: message + error: message, }; } } @@ -339,9 +326,7 @@ function findSuffixMatches( return matches; } -function loadGitignoreMatcher( - projectRoot: string -): ((relPath: string, isDir: boolean) => boolean) | null { +function loadGitignoreMatcher(projectRoot: string): ((relPath: string, isDir: boolean) => boolean) | null { const gitignorePath = path.join(projectRoot, ".gitignore"); if (!fs.existsSync(gitignorePath)) { const ig = ignore(); @@ -400,9 +385,7 @@ function parseLineNumber( return { ok: true, value: integer }; } -function parseLineLimit( - value: unknown -): { ok: true; value: number } | { ok: false; error: string } { +function parseLineLimit(value: unknown): { ok: true; value: number } | { ok: false; error: string } { if (value === undefined || value === null) { return { ok: true, value: DEFAULT_LINE_LIMIT }; } @@ -430,7 +413,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView: false, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp + timestamp: metadata.timestamp, }; } @@ -445,7 +428,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView: false, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp + timestamp: metadata.timestamp, }; } @@ -464,7 +447,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp + timestamp: metadata.timestamp, }; } @@ -479,19 +462,7 @@ function formatWithLineNumbers(lines: string[], startLineNumber: number): string } function isImageExtension(ext: string): boolean { - return [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".tif", - ".tiff", - ".svg", - ".ico", - ".avif" - ].includes(ext); + return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".svg", ".ico", ".avif"].includes(ext); } function getImageMimeType(ext: string): string { @@ -520,25 +491,20 @@ function getImageMimeType(ext: string): string { } } -function buildImageFollowUpMessage( - filePath: string, - mime: string, - buffer: Buffer -): ToolExecutionFollowUpMessage { +function buildImageFollowUpMessage(filePath: string, mime: string, buffer: Buffer): ToolExecutionFollowUpMessage { const fileName = path.basename(filePath); return { role: "system", content: - `The read tool has loaded \`${fileName}\`. ` + - "Use the attached image content to answer the original request.", + `The read tool has loaded \`${fileName}\`. ` + "Use the attached image content to answer the original request.", contentParams: [ { type: "image_url", image_url: { - url: `data:${mime};base64,${buffer.toString("base64")}` - } - } - ] + url: `data:${mime};base64,${buffer.toString("base64")}`, + }, + }, + ], }; } @@ -558,7 +524,7 @@ function parsePageRange(input: string): PageRange { throw new Error("pages must be a non-empty string."); } if (trimmed.includes(",")) { - throw new Error("pages must be a single range like \"1-5\" or \"3\"."); + throw new Error('pages must be a single range like "1-5" or "3".'); } const parts = trimmed.split("-").map((part) => part.trim()); @@ -576,7 +542,7 @@ function parsePageRange(input: string): PageRange { return { start, end, count: end - start + 1 }; } - throw new Error("pages must be a single range like \"1-5\" or \"3\"."); + throw new Error('pages must be a single range like "1-5" or "3".'); } function parsePositiveInt(value: string, label: string): number { @@ -618,8 +584,7 @@ function readNotebook(filePath: string): string { const outputs = Array.isArray(cell.outputs) ? cell.outputs : []; outputs.forEach((output, outputIndex) => { - const outputType = - typeof output.output_type === "string" ? output.output_type : "output"; + const outputType = typeof output.output_type === "string" ? output.output_type : "output"; lines.push(`# Output ${outputIndex + 1} (${outputType})`); lines.push(...formatNotebookOutput(output)); }); diff --git a/src/tools/runtime.ts b/src/tools/runtime.ts index acb6bcd..9a4620b 100644 --- a/src/tools/runtime.ts +++ b/src/tools/runtime.ts @@ -1,34 +1,35 @@ import { z } from "zod"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -export type ValidationResult = - | { ok: true; input: Record } - | { ok: false; error: string }; +export type ValidationResult = { ok: true; input: Record } | { ok: false; error: string }; export function semanticBoolean(defaultValue = false) { + return z.preprocess((value) => { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + return value; + }, z.boolean().default(defaultValue)); +} + +export function semanticInteger(label: string, options: { min?: number } = {}) { return z.preprocess( (value) => { - if (value === "true") { - return true; - } - if (value === "false") { - return false; + if (typeof value === "string" && value.trim()) { + return Number(value); } return value; }, - z.boolean().default(defaultValue) + z + .number() + .int() + .min(options.min ?? Number.MIN_SAFE_INTEGER, `${label} must be >= ${options.min ?? Number.MIN_SAFE_INTEGER}.`) ); } -export function semanticInteger(label: string, options: { min?: number } = {}) { - return z.preprocess((value) => { - if (typeof value === "string" && value.trim()) { - return Number(value); - } - return value; - }, z.number().int().min(options.min ?? Number.MIN_SAFE_INTEGER, `${label} must be >= ${options.min ?? Number.MIN_SAFE_INTEGER}.`)); -} - export async function executeValidatedTool>>( name: string, schema: TSchema, @@ -46,7 +47,7 @@ export async function executeValidatedTool+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g; let cachedGitBashPath: string | null = null; @@ -65,15 +62,9 @@ export function getShellKind(shellPath: string): ShellKind { export function buildShellInitCommand(shellPath: string): string | null { switch (getShellKind(shellPath)) { case "zsh": - return [ - 'ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', - 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi' - ].join("; "); + return ['ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi'].join("; "); case "bash": - return [ - 'BASHRC="${BASH_ENV:-$HOME/.bashrc}"', - 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi' - ].join("; "); + return ['BASHRC="${BASH_ENV:-$HOME/.bashrc}"', 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi'].join("; "); default: return null; } @@ -141,7 +132,7 @@ export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, SHELL: shellPath, - GIT_EDITOR: "true" + GIT_EDITOR: "true", }; if (process.platform === "win32") { @@ -160,11 +151,14 @@ function findAllWindowsExecutableCandidates(executable: string): string[] { const output = execFileSync("where.exe", [executable], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - windowsHide: true + windowsHide: true, }); return filterWindowsExecutableCandidates([ - ...output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean), - ...extraCandidates + ...output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + ...extraCandidates, ]); } catch { return filterWindowsExecutableCandidates(extraCandidates); diff --git a/src/tools/state.ts b/src/tools/state.ts index d254086..7816573 100644 --- a/src/tools/state.ts +++ b/src/tools/state.ts @@ -50,10 +50,7 @@ export function isAbsoluteFilePath(filePath: string, platform: NodeJS.Platform = } const normalized = path.win32.normalize(nativePath); - return ( - path.win32.isAbsolute(normalized) && - (/^[A-Za-z]:[\\/]/.test(normalized) || /^\\\\/.test(normalized)) - ); + return path.win32.isAbsolute(normalized) && (/^[A-Za-z]:[\\/]/.test(normalized) || /^\\\\/.test(normalized)); } function isGitBashAbsolutePath(filePath: string): boolean { @@ -74,7 +71,7 @@ export function recordFileState(sessionId: string, state: FileState): void { const normalizedPath = normalizeFilePath(state.filePath); sessionState.set(normalizedPath, { ...state, - filePath: normalizedPath + filePath: normalizedPath, }); } @@ -95,7 +92,7 @@ export function markFileRead( limit: state?.limit, isPartialView: state?.isPartialView, encoding: state?.encoding, - lineEndings: state?.lineEndings + lineEndings: state?.lineEndings, }); } @@ -113,10 +110,7 @@ export function wasFileRead(sessionId: string, filePath: string): boolean { export function isFullFileView(state: FileState | null): boolean { return Boolean( - state && - !state.isPartialView && - typeof state.offset === "undefined" && - typeof state.limit === "undefined" + state && !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined" ); } @@ -139,7 +133,7 @@ export function createSnippet( filePath: normalizeFilePath(filePath), startLine, endLine, - preview + preview, }; let snippets = snippetsBySession.get(sessionId); diff --git a/src/tools/web-search-handler.ts b/src/tools/web-search-handler.ts index 1389699..558271b 100644 --- a/src/tools/web-search-handler.ts +++ b/src/tools/web-search-handler.ts @@ -39,7 +39,7 @@ export async function handleWebSearchTool( return { ok: false, name: "WebSearch", - error: "Missing required \"query\" string." + error: 'Missing required "query" string.', }; } @@ -53,8 +53,7 @@ export async function handleWebSearchTool( return { ok: false, name: "WebSearch", - error: - "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json." + error: "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json.", }; } @@ -84,8 +83,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, stderr: execution.stderr || undefined, - truncated - } + truncated, + }, }; } @@ -99,8 +98,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, stderr: execution.stderr || undefined, - truncated - } + truncated, + }, }; } @@ -112,8 +111,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, truncated, - stderr: execution.stderr || undefined - } + stderr: execution.stderr || undefined, + }, }; } @@ -124,11 +123,7 @@ async function executeDefaultWebSearch( ): Promise { try { const prepared = await prepareSearchQuery(query, llmContext); - const output = await runDefaultWebSearchRequest( - prepared.resolvedQuery, - llmContext.machineId, - context - ); + const output = await runDefaultWebSearchRequest(prepared.resolvedQuery, llmContext.machineId, context); return { ok: true, @@ -139,15 +134,15 @@ async function executeDefaultWebSearch( resolvedQuery: prepared.resolvedQuery, translated: prepared.translated, dominantLanguage: prepared.decision.dominantLanguage, - languageReason: prepared.decision.reason - } + languageReason: prepared.decision.reason, + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "WebSearch", - error: `WebSearch default mode failed: ${message}` + error: `WebSearch default mode failed: ${message}`, }; } } @@ -161,7 +156,7 @@ async function runWebSearchScript( const child = spawn(scriptPath, [query], { cwd: context.projectRoot, env: process.env, - stdio: ["ignore", "pipe", "pipe"] + stdio: ["ignore", "pipe", "pipe"], }); const pid = child.pid; if (typeof pid === "number") { @@ -192,7 +187,7 @@ async function runWebSearchScript( stderr, exitCode: typeof code === "number" ? code : null, signal: signal ?? null, - error + error, }); }); }); @@ -208,7 +203,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: translatedQuery, decision, - translated: true + translated: true, }; } } @@ -219,7 +214,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: translatedQuery, decision, - translated: true + translated: true, }; } } @@ -227,7 +222,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: query, decision, - translated: false + translated: false, }; } @@ -235,10 +230,7 @@ function containsChineseChar(text: string): boolean { return /[\u4e00-\u9fff]/.test(text); } -async function decideSearchLanguage( - query: string, - llmContext: LLMClientContext -): Promise { +async function decideSearchLanguage(query: string, llmContext: LLMClientContext): Promise { const prompt = `Decide whether the topic below has more useful online material in English or Chinese. Topic: @@ -259,7 +251,7 @@ Do not include markdown or any extra text.`; return { dominantLanguage, - reason: typeof result.reason === "string" ? result.reason : "" + reason: typeof result.reason === "string" ? result.reason : "", }; } @@ -279,13 +271,15 @@ Query: ${query} \`\`\``; - return stripCodeFence(await chat(llmContext, prompt)).trim().replace(/^['"]|['"]$/g, ""); + return stripCodeFence(await chat(llmContext, prompt)) + .trim() + .replace(/^['"]|['"]$/g, ""); } async function chat(llmContext: LLMClientContext, prompt: string): Promise { const response = await llmContext.client.chat.completions.create({ model: llmContext.model, - messages: [{ role: "user", content: prompt }] + messages: [{ role: "user", content: prompt }], }); const content = response.choices?.[0]?.message?.content as unknown; @@ -337,16 +331,14 @@ async function runDefaultWebSearchRequest( method: "POST", headers: { "Content-Type": "application/json", - Token: machineId + Token: machineId, }, - body: JSON.stringify({ query }) + body: JSON.stringify({ query }), }); if (!response.ok) { const body = await response.text().catch(() => ""); - throw new Error( - `WebSearch API request failed with status ${response.status}${body ? `: ${body}` : ""}` - ); + throw new Error(`WebSearch API request failed with status ${response.status}${body ? `: ${body}` : ""}`); } const payload = (await response.json()) as { @@ -377,9 +369,7 @@ function formatWebSearchActivityLabel(query: string): string { const normalizedQuery = query.replace(/\s+/g, " ").trim(); const maxQueryLength = 180; const clippedQuery = - normalizedQuery.length > maxQueryLength - ? `${normalizedQuery.slice(0, maxQueryLength - 3)}...` - : normalizedQuery; + normalizedQuery.length > maxQueryLength ? `${normalizedQuery.slice(0, maxQueryLength - 3)}...` : normalizedQuery; return `${WEB_SEARCH_TOOL_ACTIVITY_PREFIX} ${clippedQuery}`; } diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts index 7b6e6c6..4524e21 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -7,27 +7,19 @@ import { hasFileChangedSinceState, normalizeContent, readTextFileWithMetadata, - writeTextFile + writeTextFile, } from "./file-utils"; import { executeValidatedTool } from "./runtime"; -import { - getFileState, - isAbsoluteFilePath, - isFullFileView, - normalizeFilePath, - recordFileState -} from "./state"; +import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "./state"; const writeSchema = z.strictObject({ file_path: z.string().min(1, "file_path is required."), content: z.string({ error: - "content must be a string. If you are writing JSON, serialize the full document to text before calling write." - }) + "content must be a string. If you are writing JSON, serialize the full document to text before calling write.", + }), }); -type WriteInput = z.infer; - type WriteRepairMetadata = { input_repaired: boolean; repair_kind: "json-stringify-content"; @@ -50,7 +42,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "file_path must be an absolute path." + error: "file_path must be an absolute path.", }; } @@ -64,7 +56,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: `Failed to stat file: ${message}` + error: `Failed to stat file: ${message}`, }; } @@ -72,7 +64,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "file_path points to a directory." + error: "file_path points to a directory.", }; } @@ -82,7 +74,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "Must read the full existing file before writing." + error: "Must read the full existing file before writing.", }; } @@ -90,7 +82,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "File has been modified since read. Read it again before writing." + error: "File has been modified since read. Read it again before writing.", }; } } @@ -103,14 +95,8 @@ export async function handleWriteTool( const existingMetadata = existingFile ? readTextFileWithMetadata(filePath) : null; const encoding = existingMetadata?.encoding ?? "utf8"; - const lineEndings = - existingMetadata?.lineEndings ?? - (input.content.includes("\r\n") ? "CRLF" : "LF"); - const diffPreview = buildDiffPreview( - filePath, - existingMetadata?.content ?? null, - normalizedContent - ); + const lineEndings = existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : "LF"); + const diffPreview = buildDiffPreview(filePath, existingMetadata?.content ?? null, normalizedContent); const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings); const freshMetadata = readTextFileWithMetadata(filePath); @@ -119,7 +105,7 @@ export async function handleWriteTool( content: freshMetadata.content, timestamp: freshMetadata.timestamp, encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings + lineEndings: freshMetadata.lineEndings, }); return { @@ -134,22 +120,21 @@ export async function handleWriteTool( line_endings: freshMetadata.lineEndings, cache_refreshed: true, diff_preview: diffPreview, - ...repairMetadata - } + ...repairMetadata, + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "write", - error: message + error: message, }; } }, { preprocess: (rawInput) => { - const filePath = - typeof rawInput.file_path === "string" ? normalizeFilePath(rawInput.file_path) : ""; + const filePath = typeof rawInput.file_path === "string" ? normalizeFilePath(rawInput.file_path) : ""; const content = rawInput.content; if ( filePath.toLowerCase().endsWith(".json") && @@ -159,7 +144,7 @@ export async function handleWriteTool( ) { repairMetadata = { input_repaired: true, - repair_kind: "json-stringify-content" + repair_kind: "json-stringify-content", }; return { @@ -167,19 +152,17 @@ export async function handleWriteTool( input: { ...rawInput, file_path: filePath, - content: JSON.stringify(content, null, 2) - } + content: JSON.stringify(content, null, 2), + }, }; } repairMetadata = null; return { ok: true, - input: typeof rawInput.file_path === "string" - ? { ...rawInput, file_path: filePath } - : rawInput + input: typeof rawInput.file_path === "string" ? { ...rawInput, file_path: filePath } : rawInput, }; - } + }, } ); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1fba48b..ffe480d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -12,7 +12,7 @@ import { type SessionMessage, type SessionStatus, type SkillInfo, - type UserPromptContent + type UserPromptContent, } from "../session"; import { resolveSettings, type DeepcodingSettings } from "../settings"; import { PromptInput, type PromptSubmission } from "./PromptInput"; @@ -25,7 +25,7 @@ import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, - type AskUserQuestionAnswers + type AskUserQuestionAnswers, } from "./askUserQuestion"; import { buildExitSummaryText } from "./exitSummary"; @@ -83,7 +83,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return; } setStreamProgress(progress); - } + }, }); }, [projectRoot]); @@ -95,28 +95,30 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return () => clearInterval(id); }, [busy]); - useEffect(() => { - refreshSessionsList(); - void refreshSkills(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - function loadVisibleMessages(manager: SessionManager, sessionId: string): SessionMessage[] { return manager.listSessionMessages(sessionId).filter((m) => m.visible); } - function refreshSessionsList(): void { + const refreshSessionsList = useCallback((): void => { setSessions(sessionManager.listSessions()); - } + }, [sessionManager]); - async function refreshSkills(sessionId?: string): Promise { - try { - const list = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); - setSkills(list); - } catch { - // ignore - } - } + const refreshSkills = useCallback( + async (sessionId?: string): Promise => { + try { + const list = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); + setSkills(list); + } catch { + // ignore + } + }, + [sessionManager] + ); + + useEffect(() => { + refreshSessionsList(); + void refreshSkills(); + }, [refreshSessionsList, refreshSkills]); const writeRef = useRef(write); writeRef.current = write; @@ -170,22 +172,19 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const prompt: UserPromptContent = { text: submission.text, imageUrls: submission.imageUrls, - skills: submission.selectedSkills && submission.selectedSkills.length > 0 - ? submission.selectedSkills - : undefined + skills: + submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, }; const trimmedText = (submission.text ?? "").trim(); const selectedSkillNames = submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; - const userDisplayContent = trimmedText - || (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") - || (submission.imageUrls.length > 0 ? "[Image]" : ""); + const userDisplayContent = + trimmedText || + (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") || + (submission.imageUrls.length > 0 ? "[Image]" : ""); if (userDisplayContent) { - setMessages((prev) => [ - ...prev, - buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length) - ]); + setMessages((prev) => [...prev, buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length)]); } setBusy(true); @@ -204,7 +203,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setRunningProcesses(null); } }, - [exit, onRestart, sessionManager] + [exit, onRestart, sessionManager, refreshSkills, refreshSessionsList] ); const handleInterrupt = useCallback(() => { @@ -212,7 +211,9 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R }, [sessionManager]); const handleSubmit = useCallback( - (submission: PromptSubmission) => { void handlePrompt(submission); }, + (submission: PromptSubmission) => { + void handlePrompt(submission); + }, [handlePrompt] ); @@ -239,7 +240,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setActiveStatus(session?.status ?? null); await refreshSkills(sessionId); }, - [sessionManager] + [sessionManager, refreshSkills] ); const [stableColumns, setStableColumns] = useState(columns); @@ -255,32 +256,29 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R .filter((content) => content.length > 0); }, [messages]); const expandedThinkingId = findExpandedThinkingId(messages); - const pendingQuestion = useMemo( - () => findPendingAskUserQuestion(messages, activeStatus), - [activeStatus, messages] - ); - const shouldShowQuestionPrompt = Boolean( - pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId) - ); + const pendingQuestion = useMemo(() => findPendingAskUserQuestion(messages, activeStatus), [activeStatus, messages]); + const shouldShowQuestionPrompt = Boolean(pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId)); const loadingText = useMemo( - () => busy - ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) - : null, + () => (busy ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation [busy, streamProgress, runningProcesses, nowTick] ); const welcomeSettings = useMemo(() => resolveCurrentSettings(), []); - const welcomeItem: SessionMessage = useMemo(() => ({ - id: `__welcome__${welcomeNonce}`, - sessionId: "", - role: "system", - content: "", - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: "", - updateTime: "" - }), [welcomeNonce]); + const welcomeItem: SessionMessage = useMemo( + () => ({ + id: `__welcome__${welcomeNonce}`, + sessionId: "", + role: "system", + content: "", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: "", + updateTime: "", + }), + [welcomeNonce] + ); const staticItems = useMemo(() => { if (showWelcome && view === "chat") { return [welcomeItem, ...messages]; @@ -292,7 +290,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R (answers: AskUserQuestionAnswers) => { void handlePrompt({ text: formatAskUserQuestionAnswers(answers), - imageUrls: [] + imageUrls: [], }); }, [handlePrompt] @@ -306,7 +304,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R }, [pendingQuestion]); return ( - + {(item) => { if (item.id.startsWith("__welcome__")) { @@ -321,13 +319,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R /> ); } - return ( - - ); + return ; }} {statusLine ? ( @@ -361,7 +353,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R loadingText={loadingText} onSubmit={handleSubmit} onInterrupt={handleInterrupt} - placeholder='Type your message...' + placeholder="Type your message..." /> )} @@ -389,14 +381,14 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session imageCount > 0 ? Array.from({ length: imageCount }, () => ({ type: "image_url", - image_url: { url: "" } + image_url: { url: "" }, })) : null, messageParams: null, compacted: false, visible: true, createTime: now, - updateTime: now + updateTime: now, }; } @@ -428,7 +420,7 @@ export function readSettings(): DeepcodingSettings | null { export function resolveCurrentSettings(): ReturnType { return resolveSettings(readSettings(), { model: DEFAULT_MODEL, - baseURL: DEFAULT_BASE_URL + baseURL: DEFAULT_BASE_URL, }); } @@ -454,13 +446,13 @@ export function createOpenAIClient(): { debugLogEnabled: settings.debugLogEnabled, notify: settings.notify, webSearchTool: settings.webSearchTool, - machineId: getMachineId() + machineId: getMachineId(), }; } const client = new OpenAI({ apiKey: settings.apiKey, - baseURL: settings.baseURL || undefined + baseURL: settings.baseURL || undefined, }); return { client, @@ -471,7 +463,7 @@ export function createOpenAIClient(): { debugLogEnabled: settings.debugLogEnabled, notify: settings.notify, webSearchTool: settings.webSearchTool, - machineId: getMachineId() + machineId: getMachineId(), }; } diff --git a/src/ui/AskUserQuestionPrompt.tsx b/src/ui/AskUserQuestionPrompt.tsx index 5f0b3e3..952f9cf 100644 --- a/src/ui/AskUserQuestionPrompt.tsx +++ b/src/ui/AskUserQuestionPrompt.tsx @@ -83,7 +83,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): if (key.backspace && isCurrentOther) { setOtherTexts((prev) => ({ ...prev, - [questionIndex]: (prev[questionIndex] ?? "").slice(0, -1) + [questionIndex]: (prev[questionIndex] ?? "").slice(0, -1), })); return; } @@ -98,7 +98,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): if (sanitized) { setOtherTexts((prev) => ({ ...prev, - [questionIndex]: `${prev[questionIndex] ?? ""}${sanitized}` + [questionIndex]: `${prev[questionIndex] ?? ""}${sanitized}`, })); } return; @@ -131,9 +131,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): function toggleOption(value: string): void { setSelectedValues((prev) => { const current = prev[questionIndex] ?? []; - const next = current.includes(value) - ? current.filter((item) => item !== value) - : [...current, value]; + const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value]; return { ...prev, [questionIndex]: next }; }); } @@ -141,15 +139,17 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): function commitCurrentQuestion(): void { const answer = buildAnswerForQuestion(question, options[cursorIndex], selectedForQuestion, otherText); if (!answer) { - setStatusMessage(question.multiSelect - ? "Select at least one option with Space, or type an Other answer." - : "Select an option, or type an Other answer."); + setStatusMessage( + question.multiSelect + ? "Select at least one option with Space, or type an Other answer." + : "Select an option, or type an Other answer." + ); return; } const nextAnswers = { ...answers, - [question.question]: answer + [question.question]: answer, }; setAnswers(nextAnswers); @@ -165,8 +165,13 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): return ( - Answer questions - {questionIndex + 1}/{questions.length} + + Answer questions + + + {" "} + {questionIndex + 1}/{questions.length} + {question.question} @@ -175,35 +180,45 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): const isSelected = option.isOther ? selectedForQuestion.includes(OTHER_VALUE) || Boolean(otherText.trim()) : selectedForQuestion.includes(option.value) || answers[question.question] === option.label; - const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : (isSelected ? "●" : "○"); + const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : isSelected ? "●" : "○"; return ( - {isCursor ? "› " : " "}{marker} {option.label} + {isCursor ? "› " : " "} + {marker} {option.label} {option.isOther ? ( - + {otherText ? ( - {otherText}{isCursor ? : null} + + {otherText} + {isCursor ? : null} + ) : ( {isCursor ? "type your answer here" : "type a custom answer"} )} ) : null} - {option.description ? ( - {option.description} - ) : null} + {option.description ? {option.description} : null} ); })} - {statusMessage ?? (isCurrentOther - ? "Type your answer · Backspace edit · Enter submit/next · ↑ choose presets · Esc type manually" - : question.multiSelect - ? "↑/↓ move · Space toggle · Enter submit/next · Esc type manually" - : "↑/↓ move · Enter select/next · Esc type manually")} + {statusMessage ?? + (isCurrentOther + ? "Type your answer · Backspace edit · Enter submit/next · ↑ choose presets · Esc type manually" + : question.multiSelect + ? "↑/↓ move · Space toggle · Enter submit/next · Esc type manually" + : "↑/↓ move · Enter select/next · Esc type manually")} @@ -218,13 +233,13 @@ function buildOptions(question: AskUserQuestionItem | undefined): OptionEntry[] ...question.options.map((option) => ({ label: option.label, description: option.description, - value: option.label + value: option.label, })), { label: "Other", value: OTHER_VALUE, - isOther: true - } + isOther: true, + }, ]; } diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index 77d1ee9..ae9dd19 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -1,5 +1,5 @@ import React from "react"; -import {Box, Newline, Text} from "ink"; +import { Box, Text } from "ink"; import { renderMarkdown } from "./markdown"; import type { SessionMessage } from "../session"; @@ -16,9 +16,11 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | if (message.role === "user") { const text = message.content || "(no content)"; return ( - + - {`>`} + + {`>`} + {text} {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( @@ -46,16 +48,16 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | return ( - - {content ? {renderMarkdown(content)} : null} - + {content ? {renderMarkdown(content)} : null} ); } return ( - + + + {content ? {renderMarkdown(content)} : null} @@ -89,7 +91,9 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | if (message.meta?.isSummary) { return ( - (conversation summary inserted) + + (conversation summary inserted) + ); } @@ -102,7 +106,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | function StatusLine({ bulletColor, name, - params + params, }: { bulletColor: "gray" | "green" | "red"; name: string; @@ -111,10 +115,14 @@ function StatusLine({ return ( {[ - , + + ✧ + , " ", - {name}, - params ? {` ${params}`} : null + + {name} + , + params ? {` ${params}`} : null, ]} ); @@ -145,15 +153,16 @@ function buildToolSummary(message: SessionMessage): ToolSummary { ? (message.meta.function as { name: string }).name : null; const name = payload.name || metaFunctionName || "tool"; - const params = name === "AskUserQuestion" - ? extractAskUserQuestionParams(message) || getMetaParams(message) - : getMetaParams(message); + const params = + name === "AskUserQuestion" + ? extractAskUserQuestionParams(message) || getMetaParams(message) + : getMetaParams(message); return { name, params, ok: payload.ok !== false, - metadata: payload.metadata + metadata: payload.metadata, }; } @@ -213,9 +222,11 @@ function extractQuestionsFromValue(value: unknown): string { .join(" / "); } -function parseToolPayload( - content: string | null -): { name: string | null; ok: boolean; metadata: Record | null } { +function parseToolPayload(content: string | null): { + name: string | null; + ok: boolean; + metadata: Record | null; +} { if (!content) { return { name: null, ok: true, metadata: null }; } @@ -225,7 +236,7 @@ function parseToolPayload( return { name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null, ok: parsed.ok !== false, - metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null + metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null, }; } catch { return { name: null, ok: true, metadata: null }; @@ -257,7 +268,7 @@ export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { return { marker: " ", content: line.startsWith(" ") ? line.slice(1) : line, - kind: "context" + kind: "context", }; }); } diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 0c68d38..c0a8085 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,9 +1,8 @@ -import React, {useEffect, useState} from "react"; +import React, { useEffect, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; import { EMPTY_BUFFER, - PromptBufferState, backspace, deleteForward, deleteWordBefore, @@ -18,14 +17,11 @@ import { moveRight, moveWordLeft, moveWordRight, - moveUp + moveUp, } from "./promptBuffer"; -import { - SlashCommandItem, - buildSlashCommands, - filterSlashCommands, - findExactSlashCommand, -} from "./slashCommands"; +import type { PromptBufferState } from "./promptBuffer"; +import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "./slashCommands"; +import type { SlashCommandItem } from "./slashCommands"; import { readClipboardImageAsync } from "./clipboard"; import type { SkillInfo } from "../session"; @@ -33,7 +29,7 @@ import type { SkillInfo } from "../session"; export { useTerminalInput, parseTerminalInput } from "./prompt"; export type { InputKey } from "./prompt"; -import { useTerminalInput, parseTerminalInput } from "./prompt"; +import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; @@ -78,16 +74,16 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }); export const PromptInput = React.memo(function PromptInput({ - skills, - screenWidth, - promptHistory, - busy, - loadingText, - disabled, - placeholder, - onSubmit, - onInterrupt - }: Props): React.ReactElement { + skills, + screenWidth, + promptHistory, + busy, + loadingText, + disabled, + placeholder, + onSubmit, + onInterrupt, +}: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); const [buffer, setBuffer] = useState(EMPTY_BUFFER); @@ -105,7 +101,10 @@ export const PromptInput = React.memo(function PromptInput({ const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); - const slashMenu = showSkillsDropdown ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : []; + const slashMenu = React.useMemo( + () => (showSkillsDropdown ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : []), + [showSkillsDropdown, slashToken, slashItems] + ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); const footerText = statusMessage @@ -147,272 +146,277 @@ export const PromptInput = React.memo(function PromptInput({ setDraftBeforeHistory(null); }, [promptHistoryKey]); - useTerminalInput((input, key) => { - if (key.focusIn) { - setHasTerminalFocus(true); - return; - } - if (key.focusOut) { - setHasTerminalFocus(false); - return; - } - - if (disabled) { - return; - } - - if (key.escape) { - if (showSkillsDropdown) { - setShowSkillsDropdown(false); - return; - } - if (busy) { - onInterrupt(); - setStatusMessage("Interrupting…"); - } - return; - } - - if (key.ctrl && (input === "d" || input === "D")) { - if (!isEmpty(buffer)) { - updateBuffer((s) => deleteForward(s)); + useTerminalInput( + (input, key) => { + if (key.focusIn) { + setHasTerminalFocus(true); return; } - const now = Date.now(); - if (pendingExit && now - lastCtrlDAt.current < 2000) { - exit(); + if (key.focusOut) { + setHasTerminalFocus(false); return; } - lastCtrlDAt.current = now; - setPendingExit(true); - setStatusMessage("press ctrl+d again to exit"); - return; - } - if (key.ctrl && (input === "c" || input === "C")) { - if (busy) { - onInterrupt(); - setStatusMessage("Interrupting…"); - } else if (!isEmpty(buffer)) { - setBuffer(EMPTY_BUFFER); - } else { - setStatusMessage("press ctrl+d to exit"); + if (disabled) { + return; } - return; - } - if (pendingExit && (!key.ctrl || (input !== "d" && input !== "D"))) { - setPendingExit(false); - } - - if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { - exitHistoryBrowsing(); - } - - if (showSkillsDropdown) { - if (skills.length === 0) { - setShowSkillsDropdown(false); - } else { - if (key.upArrow) { - setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); + if (key.escape) { + if (showSkillsDropdown) { + setShowSkillsDropdown(false); return; } - if (key.downArrow) { - setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); - return; + if (busy) { + onInterrupt(); + setStatusMessage("Interrupting…"); } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - const skill = skills[skillsDropdownIndex]; - if (skill) { - toggleSelectedSkill(skill); - } + return; + } + + if (key.ctrl && (input === "d" || input === "D")) { + if (!isEmpty(buffer)) { + updateBuffer((s) => deleteForward(s)); return; } - if (key.tab) { - setShowSkillsDropdown(false); + const now = Date.now(); + if (pendingExit && now - lastCtrlDAt.current < 2000) { + exit(); return; } + lastCtrlDAt.current = now; + setPendingExit(true); + setStatusMessage("press ctrl+d again to exit"); + return; } - } - if (key.ctrl && (input === "v" || input === "V")) { - setStatusMessage("Reading clipboard..."); - readClipboardImageAsync().then((image) => { - if (image) { - setImageUrls((prev) => [...prev, image.dataUrl]); - setStatusMessage("Attached image from clipboard"); + if (key.ctrl && (input === "c" || input === "C")) { + if (busy) { + onInterrupt(); + setStatusMessage("Interrupting…"); + } else if (!isEmpty(buffer)) { + setBuffer(EMPTY_BUFFER); } else { - setStatusMessage("No image found in clipboard"); + setStatusMessage("press ctrl+d to exit"); } - }).catch(() => { - setStatusMessage("Failed to read clipboard"); - }); - return; - } + return; + } - if (isClearImageAttachmentsShortcut(input, key)) { - if (imageUrls.length > 0) { - setImageUrls([]); - setStatusMessage("Cleared attached images"); - } else { - setStatusMessage("No attached images to clear"); + if (pendingExit && (!key.ctrl || (input !== "d" && input !== "D"))) { + setPendingExit(false); } - return; - } - const noModifier = !key.shift && !key.ctrl && !key.meta; - const isPlainReturn = key.return && !key.shift && !key.meta; + if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { + exitHistoryBrowsing(); + } - if (showMenu) { - if (key.upArrow) { - setMenuIndex((idx) => (idx - 1 + slashMenu.length) % slashMenu.length); + if (showSkillsDropdown) { + if (skills.length === 0) { + setShowSkillsDropdown(false); + } else { + if (key.upArrow) { + setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); + return; + } + if (key.downArrow) { + setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + const skill = skills[skillsDropdownIndex]; + if (skill) { + toggleSelectedSkill(skill); + } + return; + } + if (key.tab) { + setShowSkillsDropdown(false); + return; + } + } + } + + if (key.ctrl && (input === "v" || input === "V")) { + setStatusMessage("Reading clipboard..."); + readClipboardImageAsync() + .then((image) => { + if (image) { + setImageUrls((prev) => [...prev, image.dataUrl]); + setStatusMessage("Attached image from clipboard"); + } else { + setStatusMessage("No image found in clipboard"); + } + }) + .catch(() => { + setStatusMessage("Failed to read clipboard"); + }); return; } - if (key.downArrow) { - setMenuIndex((idx) => (idx + 1) % slashMenu.length); + + if (isClearImageAttachmentsShortcut(input, key)) { + if (imageUrls.length > 0) { + setImageUrls([]); + setStatusMessage("Cleared attached images"); + } else { + setStatusMessage("No attached images to clear"); + } return; } - if (key.tab || (key.return && !key.shift && !key.meta)) { - const selected = slashMenu[menuIndex]; - if (selected) { - handleSlashSelection(selected); + + const noModifier = !key.shift && !key.ctrl && !key.meta; + const isPlainReturn = key.return && !key.shift && !key.meta; + + if (showMenu) { + if (key.upArrow) { + setMenuIndex((idx) => (idx - 1 + slashMenu.length) % slashMenu.length); return; } + if (key.downArrow) { + setMenuIndex((idx) => (idx + 1) % slashMenu.length); + return; + } + if (key.tab || (key.return && !key.shift && !key.meta)) { + const selected = slashMenu[menuIndex]; + if (selected) { + handleSlashSelection(selected); + return; + } + } } - } - if (busy && isPlainReturn) { - setStatusMessage("wait for the current response or press esc to interrupt"); - return; - } + if (busy && isPlainReturn) { + setStatusMessage("wait for the current response or press esc to interrupt"); + return; + } - if (key.return) { - const isShiftEnter = key.shift || key.meta; - if (isShiftEnter) { - updateBuffer((s) => insertText(s, "\n")); + if (key.return) { + const isShiftEnter = key.shift || key.meta; + if (isShiftEnter) { + updateBuffer((s) => insertText(s, "\n")); + return; + } + submitCurrentBuffer(); return; } - submitCurrentBuffer(); - return; - } - if (key.delete) { - updateBuffer((s) => deleteForward(s)); - return; - } + if (key.delete) { + updateBuffer((s) => deleteForward(s)); + return; + } - if (key.backspace) { - updateBuffer((s) => backspace(s)); - return; - } + if (key.backspace) { + updateBuffer((s) => backspace(s)); + return; + } - if ((key.ctrl || key.meta) && key.leftArrow) { - updateBuffer((s) => moveWordLeft(s)); - return; - } + if ((key.ctrl || key.meta) && key.leftArrow) { + updateBuffer((s) => moveWordLeft(s)); + return; + } - if ((key.ctrl || key.meta) && key.rightArrow) { - updateBuffer((s) => moveWordRight(s)); - return; - } + if ((key.ctrl || key.meta) && key.rightArrow) { + updateBuffer((s) => moveWordRight(s)); + return; + } - if (key.leftArrow) { - updateBuffer((s) => moveLeft(s)); - return; - } + if (key.leftArrow) { + updateBuffer((s) => moveLeft(s)); + return; + } - if (key.rightArrow) { - updateBuffer((s) => moveRight(s)); - return; - } + if (key.rightArrow) { + updateBuffer((s) => moveRight(s)); + return; + } - if (key.home) { - updateBuffer((s) => moveLineStart(s)); - return; - } + if (key.home) { + updateBuffer((s) => moveLineStart(s)); + return; + } - if (key.end) { - updateBuffer((s) => moveLineEnd(s)); - return; - } + if (key.end) { + updateBuffer((s) => moveLineEnd(s)); + return; + } - if (key.upArrow) { - if (noModifier && (historyCursor !== -1 || buffer.cursor === 0) && promptHistory.length > 0) { - navigateHistory(-1); + if (key.upArrow) { + if (noModifier && (historyCursor !== -1 || buffer.cursor === 0) && promptHistory.length > 0) { + navigateHistory(-1); + return; + } + updateBuffer((s) => moveUp(s)); return; } - updateBuffer((s) => moveUp(s)); - return; - } - if (key.downArrow) { - if (noModifier && (historyCursor !== -1 || buffer.cursor === buffer.text.length)) { - navigateHistory(1); + if (key.downArrow) { + if (noModifier && (historyCursor !== -1 || buffer.cursor === buffer.text.length)) { + navigateHistory(1); + return; + } + updateBuffer((s) => moveDown(s)); return; } - updateBuffer((s) => moveDown(s)); - return; - } - if (key.ctrl && (input === "p" || input === "P")) { - navigateHistory(-1); - return; - } - if (key.ctrl && (input === "n" || input === "N")) { - navigateHistory(1); - return; - } - if (key.ctrl && (input === "a" || input === "A")) { - updateBuffer((s) => moveLineStart(s)); - return; - } - if (key.ctrl && (input === "e" || input === "E")) { - updateBuffer((s) => moveLineEnd(s)); - return; - } - if (key.ctrl && (input === "b" || input === "B")) { - updateBuffer((s) => moveLeft(s)); - return; - } - if (key.ctrl && (input === "f" || input === "F")) { - updateBuffer((s) => moveRight(s)); - return; - } - if (key.meta && (input === "b" || input === "B")) { - updateBuffer((s) => moveWordLeft(s)); - return; - } - if (key.meta && (input === "f" || input === "F")) { - updateBuffer((s) => moveWordRight(s)); - return; - } - if (key.ctrl && (input === "k" || input === "K")) { - updateBuffer((s) => killLine(s)); - return; - } - if (key.ctrl && (input === "u" || input === "U")) { - updateBuffer(() => EMPTY_BUFFER); - return; - } - if (key.ctrl && (input === "w" || input === "W")) { - updateBuffer((s) => deleteWordBefore(s)); - return; - } - if (key.ctrl && (input === "j" || input === "J")) { - updateBuffer((s) => insertText(s, "\n")); - return; - } + if (key.ctrl && (input === "p" || input === "P")) { + navigateHistory(-1); + return; + } + if (key.ctrl && (input === "n" || input === "N")) { + navigateHistory(1); + return; + } + if (key.ctrl && (input === "a" || input === "A")) { + updateBuffer((s) => moveLineStart(s)); + return; + } + if (key.ctrl && (input === "e" || input === "E")) { + updateBuffer((s) => moveLineEnd(s)); + return; + } + if (key.ctrl && (input === "b" || input === "B")) { + updateBuffer((s) => moveLeft(s)); + return; + } + if (key.ctrl && (input === "f" || input === "F")) { + updateBuffer((s) => moveRight(s)); + return; + } + if (key.meta && (input === "b" || input === "B")) { + updateBuffer((s) => moveWordLeft(s)); + return; + } + if (key.meta && (input === "f" || input === "F")) { + updateBuffer((s) => moveWordRight(s)); + return; + } + if (key.ctrl && (input === "k" || input === "K")) { + updateBuffer((s) => killLine(s)); + return; + } + if (key.ctrl && (input === "u" || input === "U")) { + updateBuffer(() => EMPTY_BUFFER); + return; + } + if (key.ctrl && (input === "w" || input === "W")) { + updateBuffer((s) => deleteWordBefore(s)); + return; + } + if (key.ctrl && (input === "j" || input === "J")) { + updateBuffer((s) => insertText(s, "\n")); + return; + } - if (input.startsWith("\u001B")) { - // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. - return; - } + if (input.startsWith("\u001B")) { + // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. + return; + } - if (input && !key.ctrl && !key.meta) { - const sanitized = input.replace(/\r/g, ""); - updateBuffer((s) => insertText(s, sanitized)); - } - }, { isActive: !disabled }); + if (input && !key.ctrl && !key.meta) { + const sanitized = input.replace(/\r/g, ""); + updateBuffer((s) => insertText(s, sanitized)); + } + }, + { isActive: !disabled } + ); function exitHistoryBrowsing(): void { setHistoryCursor(-1); @@ -520,7 +524,7 @@ export const PromptInput = React.memo(function PromptInput({ onSubmit({ text: buffer.text, imageUrls, - selectedSkills + selectedSkills, }); setBuffer(EMPTY_BUFFER); setImageUrls([]); @@ -541,10 +545,7 @@ export const PromptInput = React.memo(function PromptInput({ setBuffer((state) => removeCurrentSlashToken(state)); } - const visibleSkillStart = Math.min( - Math.max(0, skillsDropdownIndex - 7), - Math.max(0, skills.length - 8) - ); + const visibleSkillStart = Math.min(Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8)); const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); return ( @@ -557,23 +558,29 @@ export const PromptInput = React.memo(function PromptInput({ ) : null} {selectedSkills.length > 0 ? ( - {formatSelectedSkillsStatus(selectedSkills)} + + {formatSelectedSkillsStatus(selectedSkills)} + (use /skills to edit) ) : null} {/* Input */} - + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} {showSkillsDropdown ? ( - Select Skills + + Select Skills + {skills.length === 0 ? ( No skills found ) : ( @@ -584,9 +591,8 @@ export const PromptInput = React.memo(function PromptInput({ return ( {active ? "› " : " "} - {selected ? "●" : "○"}{" "} - {skill.name} - {skill.isLoaded ? : null} + {selected ? "●" : "○"} {skill.name} + {skill.isLoaded ? : null} {` ${skill.path}`} ); @@ -600,9 +606,11 @@ export const PromptInput = React.memo(function PromptInput({ ) : null} - {!showMenu && - {footerText} - } + {!showMenu && ( + + {footerText} + + )} ); }); @@ -636,9 +644,7 @@ export function addUniqueSkill(skills: SkillInfo[], skill: SkillInfo): SkillInfo } export function toggleSkillSelection(skills: SkillInfo[], skill: SkillInfo): SkillInfo[] { - return isSkillSelected(skills, skill) - ? skills.filter((item) => item.name !== skill.name) - : [...skills, skill]; + return isSkillSelected(skills, skill) ? skills.filter((item) => item.name !== skill.name) : [...skills, skill]; } export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 64ef77a..67f2e10 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,6 +1,6 @@ -import React, {useState, useMemo} from "react"; -import {Box, Text, useInput, useWindowSize} from "ink"; -import type {SessionEntry} from "../session"; +import React, { useState, useMemo } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { SessionEntry } from "../session"; type Props = { sessions: SessionEntry[]; @@ -8,9 +8,9 @@ type Props = { onCancel: () => void; }; -export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactElement { +export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { const [index, setIndex] = useState(0); - const {columns, rows} = useWindowSize(); + const { columns, rows } = useWindowSize(); // Dynamically calculate the number of visible sessions based on terminal height const maxVisibleSessions = useMemo(() => { @@ -97,42 +97,60 @@ export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactE paddingX={1} marginTop={1} > - + {/* Header row */} - Resume a session - ({sessions.length} total) + + Resume a session + + + {" "} + ({sessions.length} total) + {/* Session list */} - + {visibleSessions.map((session, i) => { const actualIndex = scrollOffset + i; return ( - - {actualIndex === safeIndex ? "› " : " "} - + {actualIndex === safeIndex ? "› " : " "} - - - + + + {formatSessionTitle(session.summary || "Untitled")} ({session.status}) - + {formatTimestamp(session.updateTime)} ); })} - {(scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length) ? ( + {scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length ? ( {scrollOffset > 0 ? … {scrollOffset} newer sessions above. : null} - {scrollOffset + maxVisibleSessions < sessions.length ? … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below. : null} + {scrollOffset + maxVisibleSessions < sessions.length ? ( + … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below. + ) : null} ) : null} diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index c47b8df..9b79293 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -1,6 +1,7 @@ -import {formatSlashCommandDescription, formatSlashCommandLabel, SlashCommandItem} from "./slashCommands"; +import { formatSlashCommandDescription, formatSlashCommandLabel } from "./slashCommands"; +import type { SlashCommandItem } from "./slashCommands"; import React from "react"; -import {Box, Text} from "ink"; +import { Box, Text } from "ink"; type SlashCommandMenuProps = { items: SlashCommandItem[]; @@ -10,11 +11,22 @@ type SlashCommandMenuProps = { }; const SlashCommandMenu = React.memo(function SlashCommandMenu({ - items, - activeIndex, - maxVisible = 6, - width - }: SlashCommandMenuProps): React.ReactElement | null { + items, + activeIndex, + maxVisible = 6, + width, +}: SlashCommandMenuProps): React.ReactElement | null { + // 计算标签列最佳宽度:包含前缀"› "或" "(2字符),不超过容器一半(扣除gap) + const labelColumnWidth = React.useMemo(() => { + if (items.length === 0) { + return 0; + } + const longestLabel = Math.max(...items.map((s) => s.label.length)); + const contentWidth = longestLabel + 2; // +2 for prefix "› " or " " + const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 + return Math.min(contentWidth, maxAllowed); + }, [items, width]); + if (items.length === 0) { return null; } @@ -26,18 +38,12 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ ); const visibleItems = items.slice(visibleStart, visibleStart + maxVisible); - // 计算标签列最佳宽度:包含前缀"› "或" "(2字符),不超过容器一半(扣除gap) - const labelColumnWidth = React.useMemo(() => { - const longestLabel = Math.max(...items.map((s) => s.label.length)); - const contentWidth = longestLabel + 2; // +2 for prefix "› " or " " - const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 - return Math.min(contentWidth, maxAllowed); - }, [items, width]); - return ( {visibleStart > 0 ? ( - + + + ) : null} {visibleItems.map((item, idx) => { const actualIndex = visibleStart + idx; @@ -50,19 +56,21 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ - {formatSlashCommandDescription(item.description)} + + {formatSlashCommandDescription(item.description)} + ); })} - - {visibleStart + visibleItems.length < items.length ? ( - - ) : null} - ({activeIndex + 1}/{items.length}) ↑↓ to navigate · Enter to select + + {visibleStart + visibleItems.length < items.length ? : null} + + ({activeIndex + 1}/{items.length}) ↑↓ to navigate · Enter to select + ); }); -export default SlashCommandMenu; \ No newline at end of file +export default SlashCommandMenu; diff --git a/src/ui/ThemedGradient.tsx b/src/ui/ThemedGradient.tsx index 333a531..f2c2369 100644 --- a/src/ui/ThemedGradient.tsx +++ b/src/ui/ThemedGradient.tsx @@ -1,10 +1,9 @@ -import type React from 'react'; -import { Text, type TextProps } from 'ink'; -import Gradient from 'ink-gradient'; - +import type React from "react"; +import { Text, type TextProps } from "ink"; +import Gradient from "ink-gradient"; export const ThemedGradient: React.FC = ({ children, ...props }) => { - const gradient = ['#229ac3e6', '#229ac3e6']; // Use solid color for now + const gradient = ["#229ac3e6", "#229ac3e6"]; // Use solid color for now if (gradient && gradient.length >= 2) { return ( @@ -24,8 +23,8 @@ export const ThemedGradient: React.FC = ({ children, ...props }) => { // Fallback to accent color if no gradient return ( - + {children} ); -}; \ No newline at end of file +}; diff --git a/src/ui/UpdatePrompt.tsx b/src/ui/UpdatePrompt.tsx index 93bd62b..f2b9e21 100644 --- a/src/ui/UpdatePrompt.tsx +++ b/src/ui/UpdatePrompt.tsx @@ -15,27 +15,22 @@ type Props = { onSelect: (choice: UpdatePromptChoice) => void; }; -export function UpdatePrompt({ - currentVersion, - latestVersion, - installCommand, - onSelect -}: Props): React.ReactElement { +export function UpdatePrompt({ currentVersion, latestVersion, installCommand, onSelect }: Props): React.ReactElement { const { exit } = useApp(); const [selectedIndex, setSelectedIndex] = useState(0); const options: UpdatePromptOption[] = [ { value: "install", - label: `Install the latest version with \`${installCommand}\`` + label: `Install the latest version with \`${installCommand}\``, }, { value: "ignore-once", - label: "Ignore once" + label: "Ignore once", }, { value: "ignore-version", - label: `Ignore this version (${latestVersion})` - } + label: `Ignore this version (${latestVersion})`, + }, ]; useInput((input, key) => { diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 8407f54..5e25379 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -1,11 +1,12 @@ -import React, {useMemo, useState} from "react"; -import {Box, Text} from "ink"; +import React, { useMemo, useState } from "react"; +import { Box, Text } from "ink"; import * as os from "node:os"; -import path from 'node:path'; -import type {SkillInfo} from "../session"; -import type {ResolvedDeepcodingSettings} from "../settings"; -import {buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription} from "./slashCommands"; -import {ThemedGradient} from "./ThemedGradient"; +import path from "node:path"; +import type { SkillInfo } from "../session"; +import type { ResolvedDeepcodingSettings } from "../settings"; +import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription } from "./slashCommands"; +import { ThemedGradient } from "./ThemedGradient"; +import { AsciiLogo } from "../AsciiArt"; type WelcomeScreenProps = { projectRoot: string; @@ -24,7 +25,7 @@ const SHORTCUT_TIPS = [ { label: "Ctrl+V", description: "Paste an image from the clipboard" }, { label: "Esc", description: "Interrupt the current model turn" }, { label: "/", description: "Open the skills and commands menu" }, - { label: "Ctrl+D twice", description: "Quit Deep Code CLI" } + { label: "Ctrl+D twice", description: "Quit Deep Code CLI" }, ]; export function WelcomeScreen({ @@ -44,21 +45,10 @@ export function WelcomeScreen({ return ( - - + + - - ██████╗ ███████╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗ - ██╔══██╗██╔════╝██╔════╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ - ██║ ██║█████╗ █████╗ ██████╔╝ ██║ ██║ ██║██║ ██║█████╗ - ██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ██║ ██║ ██║██║ ██║██╔══╝ - ██████╔╝███████╗███████╗██║ ╚██████╗╚██████╔╝██████╔╝███████╗ - ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ - + {AsciiLogo} @@ -73,7 +63,7 @@ export function WelcomeScreen({ > {">"}_ Deep Code - (v{version || "unknown"}) + (v{version || "unknown"}) {!compact ? : null} @@ -84,7 +74,7 @@ export function WelcomeScreen({ - + {tip ? ( @@ -129,12 +119,12 @@ export function buildWelcomeTips(skills: SkillInfo[]): Array<{ label: string; de .filter((item) => item.kind !== "skill" || item.skill?.isLoaded) .map((item) => ({ label: item.label, - description: formatSlashCommandDescription(item.description) + description: formatSlashCommandDescription(item.description), })); return [ ...slashTips, - ...SHORTCUT_TIPS.filter((tip) => !BUILTIN_SLASH_COMMANDS.some((command) => command.label === tip.label)) + ...SHORTCUT_TIPS.filter((tip) => !BUILTIN_SLASH_COMMANDS.some((command) => command.label === tip.label)), ]; } diff --git a/src/ui/askUserQuestion.ts b/src/ui/askUserQuestion.ts index f3e7e6c..8d168d8 100644 --- a/src/ui/askUserQuestion.ts +++ b/src/ui/askUserQuestion.ts @@ -39,7 +39,7 @@ export function findPendingAskUserQuestion( return { messageId: message.id, sessionId: message.sessionId, - questions + questions, }; } @@ -87,9 +87,10 @@ function normalizeQuestions(raw: unknown): AskUserQuestionItem[] { if (!item || typeof item !== "object" || Array.isArray(item)) { continue; } - const question = typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; + const question = + typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; const rawOptions = (item as { options?: unknown }).options; if (!question || !Array.isArray(rawOptions) || rawOptions.length === 0) { continue; @@ -100,9 +101,10 @@ function normalizeQuestions(raw: unknown): AskUserQuestionItem[] { if (options.length === 0) { continue; } - const multiSelect = typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" - ? (item as { multiSelect: boolean }).multiSelect - : undefined; + const multiSelect = + typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" + ? (item as { multiSelect: boolean }).multiSelect + : undefined; questions.push({ question, multiSelect, options }); } return questions; @@ -112,21 +114,20 @@ function normalizeOption(raw: unknown): AskUserQuestionOption | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return null; } - const label = typeof (raw as { label?: unknown }).label === "string" - ? (raw as { label: string }).label.trim() - : ""; + const label = typeof (raw as { label?: unknown }).label === "string" ? (raw as { label: string }).label.trim() : ""; if (!label) { return null; } - const description = typeof (raw as { description?: unknown }).description === "string" - ? (raw as { description: string }).description.trim() - : ""; + const description = + typeof (raw as { description?: unknown }).description === "string" + ? (raw as { description: string }).description.trim() + : ""; return { label, - description: description || undefined + description: description || undefined, }; } function escapeAnswerPart(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\s+/g, " ").trim(); + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim(); } diff --git a/src/ui/clipboard.ts b/src/ui/clipboard.ts index e66b9b4..c9e30e9 100644 --- a/src/ui/clipboard.ts +++ b/src/ui/clipboard.ts @@ -14,7 +14,7 @@ const IMAGE_MIME_BY_EXT = new Map([ [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".gif", "image/gif"], - [".webp", "image/webp"] + [".webp", "image/webp"], ]); function bufferToDataUrl(buffer: Buffer, mimeType: string): string { @@ -83,7 +83,7 @@ function readMacClipboardImage(): ClipboardImage | null { "-e", "write png_data to fp", "-e", - "close access fp" + "close access fp", ]); if (saved) { diff --git a/src/ui/exitSummary.ts b/src/ui/exitSummary.ts index 3e13d5f..910cceb 100644 --- a/src/ui/exitSummary.ts +++ b/src/ui/exitSummary.ts @@ -45,12 +45,8 @@ function extractUsageFields(usage: unknown | null): UsageFields { } const record = usage as Record; - const promptTokens = - typeof record.prompt_tokens === "number" ? record.prompt_tokens : 0; - const completionTokens = - typeof record.completion_tokens === "number" - ? record.completion_tokens - : 0; + const promptTokens = typeof record.prompt_tokens === "number" ? record.prompt_tokens : 0; + const completionTokens = typeof record.completion_tokens === "number" ? record.completion_tokens : 0; let cachedTokens = 0; const promptDetails = record.prompt_tokens_details; if (promptDetails && typeof promptDetails === "object" && !Array.isArray(promptDetails)) { @@ -79,16 +75,11 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const borderColor = chalk.hex("#229ac3e6"); const titleColor = gradientString("#229ac3e6", "rgb(125 51 247 / 0.7)"); - const line = (text: string) => - `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; + const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; const header = chalk.bold(titleColor("Goodbye!")); - const rows: string[] = [ - "", - `${header}`, - "", - ]; + const rows: string[] = ["", `${header}`, ""]; const usage = extractUsageFields(session?.usage ?? null); const modelName = model ?? "unknown"; diff --git a/src/ui/index.ts b/src/ui/index.ts index b290950..a151e9c 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -15,7 +15,7 @@ export { useTerminalInput, parseTerminalInput, type PromptSubmission, - type InputKey + type InputKey, } from "./PromptInput"; export { getPromptCursorPlacement } from "./prompt/cursor"; export { SessionList, formatSessionTitle } from "./SessionList"; @@ -29,7 +29,7 @@ export { type AskUserQuestionOption, type AskUserQuestionItem, type PendingAskUserQuestion, - type AskUserQuestionAnswers + type AskUserQuestionAnswers, } from "./askUserQuestion"; export { readClipboardImage, type ClipboardImage } from "./clipboard"; export { buildLoadingText, type LoadingTextInput } from "./loadingText"; @@ -52,7 +52,7 @@ export { reset, isEmpty, getCurrentSlashToken, - type PromptBufferState + type PromptBufferState, } from "./promptBuffer"; export { BUILTIN_SLASH_COMMANDS, @@ -62,7 +62,7 @@ export { formatSlashCommandDescription, formatSlashCommandLabel, type SlashCommandKind, - type SlashCommandItem + type SlashCommandItem, } from "./slashCommands"; export { findExpandedThinkingId } from "./thinkingState"; export { buildExitSummaryText } from "./exitSummary"; diff --git a/src/ui/markdown.ts b/src/ui/markdown.ts index aa77423..11fb0ea 100644 --- a/src/ui/markdown.ts +++ b/src/ui/markdown.ts @@ -17,9 +17,7 @@ export function renderMarkdown(text: string): string { .join(""); } -type FenceSegment = - | { kind: "text"; body: string } - | { kind: "code"; lang: string; body: string }; +type FenceSegment = { kind: "text"; body: string } | { kind: "code"; lang: string; body: string }; function splitByFences(text: string): FenceSegment[] { const segments: FenceSegment[] = []; diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 19f5cb9..8ccdc60 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -50,16 +50,19 @@ export function getPromptCursorPlacement( const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); const beforeCursor = state.text.slice(0, cursor); const at = state.text[cursor]; - const displayText = beforeCursor + (typeof at === "undefined" || at === "\n" ? " " : at) + - (at === "\n" ? "\n" : "") + (typeof at === "undefined" ? "" : state.text.slice(cursor + 1)); + const displayText = + beforeCursor + + (typeof at === "undefined" || at === "\n" ? " " : at) + + (at === "\n" ? "\n" : "") + + (typeof at === "undefined" ? "" : state.text.slice(cursor + 1)); const cursorPosition = measureTextPosition(beforeCursor, width, prefixWidth); const promptRows = measureTextRows(displayText, width, prefixWidth); const footerRows = 1 + measureTextRows(footerText, width, 0); return { - rowsUp: (promptRows - 1 - cursorPosition.row) + footerRows + 1, - column: cursorPosition.column + rowsUp: promptRows - 1 - cursorPosition.row + footerRows + 1, + column: cursorPosition.column, }; } @@ -208,7 +211,7 @@ export function usePromptTerminalCursor( directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); activePlacementRef.current = null; }; - }, [isActive, placement.column, placement.rowsUp, stdout]); + }, [isActive, placement, stdout]); } export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { @@ -235,4 +238,4 @@ export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined stdout.write(disableTerminalFocusReporting()); }; }, [isActive, stdout]); -} \ No newline at end of file +} diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index 1542ef8..857b8ac 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -1,4 +1,9 @@ export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; export type { InputKey } from "./useTerminalInput"; -export { useHiddenTerminalCursor, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./cursor"; \ No newline at end of file +export { + useHiddenTerminalCursor, + usePromptTerminalCursor, + useTerminalFocusReporting, + getPromptCursorPlacement, +} from "./cursor"; diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index 8f7942e..a0f454f 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -56,7 +56,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: delete: FORWARD_DELETE_SEQUENCES.has(raw), meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), focusIn: raw === TERMINAL_FOCUS_IN, - focusOut: raw === TERMINAL_FOCUS_OUT + focusOut: raw === TERMINAL_FOCUS_OUT, }; if (input <= "\u001A" && !key.return) { @@ -136,4 +136,4 @@ export function useTerminalInput( stdin?.off("data", handleData); }; }, [isActive, stdin]); -} \ No newline at end of file +} diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts index f45d422..fb65179 100644 --- a/src/ui/promptBuffer.ts +++ b/src/ui/promptBuffer.ts @@ -123,7 +123,7 @@ export function deleteWordBefore(state: PromptBufferState): PromptBufferState { } return { text: state.text.slice(0, start) + state.text.slice(end), - cursor: start + cursor: start, }; } @@ -169,6 +169,6 @@ function locate(state: PromptBufferState): { line: lineNumber, column: state.cursor - lineStart, lineStart, - lineEnd + lineEnd, }; } diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index ca330ef..274940f 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -15,32 +15,32 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ kind: "skills", name: "skills", label: "/skills", - description: "List available skills" + description: "List available skills", }, { kind: "new", name: "new", label: "/new", - description: "Start a fresh conversation" + description: "Start a fresh conversation", }, { kind: "init", name: "init", label: "/init", - description: "Initialize an AGENTS.md file with instructions for LLM" + description: "Initialize an AGENTS.md file with instructions for LLM", }, { kind: "resume", name: "resume", label: "/resume", - description: "Pick a previous conversation to continue" + description: "Pick a previous conversation to continue", }, { kind: "exit", name: "exit", label: "/exit", - description: "Quit Deep Code CLI" - } + description: "Quit Deep Code CLI", + }, ]; export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { @@ -49,15 +49,12 @@ export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { name: skill.name, label: `/${skill.name}`, description: skill.description || "(no description)", - skill + skill, })); return [...skillItems, ...BUILTIN_SLASH_COMMANDS]; } -export function filterSlashCommands( - items: SlashCommandItem[], - token: string -): SlashCommandItem[] { +export function filterSlashCommands(items: SlashCommandItem[], token: string): SlashCommandItem[] { if (!token.startsWith("/")) { return []; } @@ -68,10 +65,7 @@ export function filterSlashCommands( return items.filter((item) => item.name.toLowerCase().includes(query)); } -export function findExactSlashCommand( - items: SlashCommandItem[], - token: string -): SlashCommandItem | null { +export function findExactSlashCommand(items: SlashCommandItem[], token: string): SlashCommandItem | null { if (!token.startsWith("/")) { return null; } diff --git a/src/updateCheck.ts b/src/updateCheck.ts index 8078f51..626e529 100644 --- a/src/updateCheck.ts +++ b/src/updateCheck.ts @@ -49,14 +49,16 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< const choice = await promptUpdateChoice({ currentVersion: packageInfo.version, latestVersion: pending.latestVersion, - installCommand + installCommand, }); if (choice === "install") { const ok = await runNpmInstallGlobal(installSpec); if (ok) { writeUpdateState({ ...state, pending: null }); - process.stdout.write(`\n${chalk.red("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n`); + process.stdout.write( + `\n${chalk.red("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` + ); } return { installed: ok }; } @@ -95,8 +97,8 @@ export async function checkForNpmUpdate(packageInfo: PackageInfo): Promise currentVersion: packageInfo.version, latestVersion, packageName: packageInfo.name, - checkedAt: new Date().toISOString() - } + checkedAt: new Date().toISOString(), + }, }); } catch { // Update checks must never affect CLI startup or normal operation. @@ -127,7 +129,7 @@ export function getUpdateStatePath(): string { async function promptUpdateChoice({ currentVersion, latestVersion, - installCommand + installCommand, }: { currentVersion: string; latestVersion: string; @@ -150,7 +152,7 @@ async function promptUpdateChoice({ currentVersion, latestVersion, installCommand, - onSelect: handleSelect + onSelect: handleSelect, }), { exitOnCtrlC: false } ); @@ -161,7 +163,7 @@ async function runNpmInstallGlobal(installSpec: string): Promise { return new Promise((resolve) => { const child = spawn("npm", ["install", "-g", installSpec], { stdio: "inherit", - shell: process.platform === "win32" + shell: process.platform === "win32", }); child.on("error", (error) => { process.stderr.write(`Failed to start npm install: ${error.message}\n`); @@ -205,7 +207,7 @@ function runNpmViewLatestVersion( } const child = spawn("npm", args, { stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32" + shell: process.platform === "win32", }); let stdout = ""; @@ -262,8 +264,10 @@ function readUpdateState(): UpdateState { return { pending: parsed.pending ?? null, ignoredVersions: Array.isArray(parsed.ignoredVersions) - ? parsed.ignoredVersions.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : [] + ? parsed.ignoredVersions.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0 + ) + : [], }; } catch { return {}; From 22cc16c158d6864190d4ebc2049994d1f163eead Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 11 May 2026 08:01:05 +0800 Subject: [PATCH 039/217] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96App?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=9A=84hook=E5=92=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将refreshSessionsList和refreshSkills两个函数改为useCallback,避免不必要的重新创建 - 调整代码格式,统一加逗号和缩进,提高代码可读性 - 优化useEffect依赖,确保正确响应依赖变化 - 简化部分三元表达式和数组操作,提升代码简洁性 - 调整PromptInput中的jsx格式,统一引号和缩进样式 - 修复代码中多处格式和逗号缺失的问题 - 统一引入和导出语句的逗号,符合代码风格规范 --- src/session.ts | 393 ++++++++++++++++++++++--------------------------- 1 file changed, 180 insertions(+), 213 deletions(-) diff --git a/src/session.ts b/src/session.ts index aa83894..ebe0619 100644 --- a/src/session.ts +++ b/src/session.ts @@ -46,9 +46,7 @@ function summarizeCompletionOptions(options?: Record): Record | null; // {pid: {startTime, command}} + processes: Map | null; // {pid: {startTime, command}} }; export type SessionsIndex = { @@ -245,7 +237,7 @@ export class SessionManager { startedAt, estimatedTokens: Math.round(estimatedTokens), formattedTokens: this.formatEstimatedTokens(estimatedTokens), - phase + phase, }); } @@ -288,16 +280,18 @@ export class SessionManager { stream: true, stream_options: { ...(isUsageRecord(request.stream_options) ? request.stream_options : {}), - include_usage: true - } + include_usage: true, + }, }; let response: unknown; try { - response = await (client.chat.completions.create as unknown as ( - body: Record, - options?: Record - ) => Promise)(streamRequest, options); + response = await ( + client.chat.completions.create as unknown as ( + body: Record, + options?: Record + ) => Promise + )(streamRequest, options); } catch (error) { this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), @@ -309,7 +303,7 @@ export class SessionManager { durationMs: Date.now() - startedAtMs, params: { ...debug?.params, options: summarizeCompletionOptions(options) }, request: streamRequest, - error: normalizeDebugError(error) + error: normalizeDebugError(error), }); logApiError({ timestamp: new Date().toISOString(), @@ -320,9 +314,9 @@ export class SessionManager { error: { name: error instanceof Error ? error.name : "UnknownError", message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined + stack: error instanceof Error ? error.stack : undefined, }, - request: streamRequest + request: streamRequest, }); this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId); throw error; @@ -340,7 +334,7 @@ export class SessionManager { durationMs: Date.now() - startedAtMs, params: { ...debug?.params, options: summarizeCompletionOptions(options) }, request: streamRequest, - response + response, }); return response as { choices?: Array<{ message?: Record }>; usage?: unknown }; } @@ -350,11 +344,14 @@ export class SessionManager { let refusal: string | null = null; let usage: unknown = null; const responseChunks: unknown[] = []; - const toolCallsByIndex = new Map(); + const toolCallsByIndex = new Map< + number, + { + id?: string; + type?: string; + function?: { name?: string; arguments?: string }; + } + >(); const trackText = (value: unknown) => { if (typeof value !== "string" || value.length === 0) { @@ -440,7 +437,7 @@ export class SessionManager { params: { ...debug?.params, options: summarizeCompletionOptions(options) }, request: streamRequest, responseChunks, - error: normalizeDebugError(error) + error: normalizeDebugError(error), }); logApiError({ timestamp: new Date().toISOString(), @@ -451,9 +448,9 @@ export class SessionManager { error: { name: error instanceof Error ? error.name : "UnknownError", message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined + stack: error instanceof Error ? error.stack : undefined, }, - request: streamRequest + request: streamRequest, }); throw error; } finally { @@ -476,7 +473,7 @@ export class SessionManager { const finalResponse = { choices: [{ message }], - usage + usage, }; this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), @@ -489,7 +486,7 @@ export class SessionManager { params: { ...debug?.params, options: summarizeCompletionOptions(options) }, request: streamRequest, responseChunks, - response: finalResponse + response: finalResponse, }); return finalResponse; } @@ -519,35 +516,43 @@ Response in JSON format: \`\`\`\n If none of the available skills match, respond with an empty array, i.e. \`{"skillNames": []}\`.\n The candidate skills are as follows:\n\n`; - const simpleSkills = skills.filter((x) => !x.isLoaded).map((x) => { - return {name: x.name, description: x.description}; - }) + const simpleSkills = skills + .filter((x) => !x.isLoaded) + .map((x) => { + return { name: x.name, description: x.description }; + }); if (simpleSkills.length === 0) { return []; } systemPrompt += "```\n" + JSON.stringify(simpleSkills, null, 2) + "\n```"; - + const { client, model, baseURL, debugLogEnabled } = this.createOpenAIClient(); if (!client) { return []; } try { - const response = await this.createChatCompletionStream(client, { - model, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt } - ], - response_format: { type: "json_object" } - }, options?.signal ? { signal: options.signal } : undefined, options?.sessionId, { - enabled: debugLogEnabled, - location: "SessionManager.identifyMatchingSkillNames", - baseURL, - params: { purpose: "skill-matching" } - }); + const response = await this.createChatCompletionStream( + client, + { + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + }, + options?.signal ? { signal: options.signal } : undefined, + options?.sessionId, + { + enabled: debugLogEnabled, + location: "SessionManager.identifyMatchingSkillNames", + baseURL, + params: { purpose: "skill-matching" }, + } + ); this.throwIfAborted(options?.signal); - + const rawContent = response.choices?.[0]?.message?.content; const content = typeof rawContent === "string" ? rawContent : ""; if (!content) { @@ -558,7 +563,7 @@ The candidate skills are as follows:\n\n`; if (parsed && Array.isArray(parsed.skillNames)) { return parsed.skillNames; } - + return []; } catch (error) { if (this.isAbortLikeError(error) || options?.signal?.aborted) { @@ -622,10 +627,7 @@ The candidate skills are as follows:\n\n`; if (sessionId) { const loadedSkillKeys = this.getLoadedSkillKeys(sessionId); for (const skill of skillsByName.values()) { - if ( - loadedSkillKeys.has(this.getSkillKey(skill)) - || loadedSkillKeys.has(this.getSkillKeyByName(skill.name)) - ) { + if (loadedSkillKeys.has(this.getSkillKey(skill)) || loadedSkillKeys.has(this.getSkillKeyByName(skill.name))) { skill.isLoaded = true; } } @@ -669,10 +671,7 @@ The candidate skills are as follows:\n\n`; ? parsed.data.name.trim() : fallbackSkill.name, path: displayPath, - description: - typeof parsed.data.description === "string" - ? parsed.data.description.trim() - : "", + description: typeof parsed.data.description === "string" ? parsed.data.description.trim() : "", }; } catch { return fallbackSkill; @@ -737,8 +736,8 @@ The candidate skills are as follows:\n\n`; return dedupedSkills.map((skill) => { const matchedSkill = - availableSkillsByKey.get(this.getSkillKey(skill)) - ?? availableSkillsByKey.get(this.getSkillKeyByName(skill.name)); + availableSkillsByKey.get(this.getSkillKey(skill)) ?? + availableSkillsByKey.get(this.getSkillKeyByName(skill.name)); if (!matchedSkill) { return skill; } @@ -816,19 +815,17 @@ The candidate skills are as follows:\n\n`; activeTokens: 0, createTime: now, updateTime: now, - processes: null + processes: null, }; index.entries.push(entry); - const sortedEntries = index.entries - .slice() - .sort((a, b) => { - const aTime = Date.parse(a.updateTime); - const bTime = Date.parse(b.updateTime); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.updateTime.localeCompare(a.updateTime); - } - return bTime - aTime; - }); + const sortedEntries = index.entries.slice().sort((a, b) => { + const aTime = Date.parse(a.updateTime); + const bTime = Date.parse(b.updateTime); + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return b.updateTime.localeCompare(a.updateTime); + } + return bTime - aTime; + }); const keptEntries = sortedEntries.slice(0, MAX_SESSION_ENTRIES); const keptIds = new Set(keptEntries.map((item) => item.id)); const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); @@ -883,7 +880,7 @@ ${skillMd} ...entry, status: "pending", failReason: null, - updateTime: now + updateTime: now, })); if (!updated) { @@ -932,7 +929,8 @@ ${skillMd} async activateSession(sessionId: string, controller?: AbortController): Promise { const startedAt = Date.now(); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify } = this.createOpenAIClient(); + const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify } = + this.createOpenAIClient(); const now = new Date().toISOString(); if (!client) { @@ -940,11 +938,15 @@ ${skillMd} ...entry, status: "failed", failReason: "OpenAI API key not found", - updateTime: now + updateTime: now, })); this.onAssistantMessage( - this.buildAssistantMessage(sessionId, "OpenAI API key not found. Please configure ~/.deepcode/settings.json.", null), - false, + this.buildAssistantMessage( + sessionId, + "OpenAI API key not found. Please configure ~/.deepcode/settings.json.", + null + ), + false ); this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); return; @@ -956,7 +958,7 @@ ${skillMd} ...entry, status: "interrupted", failReason: "interrupted", - updateTime: now + updateTime: now, })); this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); return; @@ -965,13 +967,13 @@ ${skillMd} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "processing", - updateTime: now + updateTime: now, })); this.sessionControllers.set(sessionId, sessionController); try { - const maxIterations = 80000; // about 1K RMB cost + const maxIterations = 80000; // about 1K RMB cost let toolCalls: unknown[] | null = null; for (let iteration = 0; iteration < maxIterations; iteration++) { @@ -986,7 +988,11 @@ ${skillMd} const compactPromptTokenThreshold = getCompactPromptTokenThreshold(model); if (session.activeTokens > compactPromptTokenThreshold) { - const message = this.buildAssistantMessage(sessionId, "The conversation is getting long, compacting...", null); + const message = this.buildAssistantMessage( + sessionId, + "The conversation is getting long, compacting...", + null + ); message.meta = { asThinking: true }; this.onAssistantMessage(message, false); await this.compactSession(sessionId, sessionController.signal); @@ -1000,7 +1006,7 @@ ${skillMd} model, messages, tools: getTools(this.getPromptToolOptions()), - ...thinkingOptions + ...thinkingOptions, }, { signal: sessionController.signal }, sessionId, @@ -1008,7 +1014,7 @@ ${skillMd} enabled: debugLogEnabled, location: "SessionManager.activateSession", baseURL, - params: { iteration, thinkingEnabled, reasoningEffort } + params: { iteration, thinkingEnabled, reasoningEffort }, } ); @@ -1048,15 +1054,9 @@ ${skillMd} toolCalls, usage: accumulateUsage(entry.usage, responseUsage), activeTokens: getTotalTokens(responseUsage), - status: refusal - ? "failed" - : waitingForUser - ? "waiting_for_user" - : toolCalls - ? "processing" - : "completed", + status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed", failReason: refusal ? refusal : entry.failReason, - updateTime: new Date().toISOString() + updateTime: new Date().toISOString(), })); if (refusal) { @@ -1075,12 +1075,16 @@ ${skillMd} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "completed", - updateTime: new Date().toISOString() + updateTime: new Date().toISOString(), })); this.onAssistantMessage( - this.buildAssistantMessage(sessionId, "The AI agent has taken several steps but hasn't reached a conclusion yet. Do you want to continue?", null), - false, - ) + this.buildAssistantMessage( + sessionId, + "The AI agent has taken several steps but hasn't reached a conclusion yet. Do you want to continue?", + null + ), + false + ); } catch (error) { const errMessage = error instanceof Error ? error.message : String(error); const aborted = this.isAbortLikeError(error) || sessionController.signal.aborted; @@ -1088,14 +1092,11 @@ ${skillMd} ...entry, status: aborted ? "interrupted" : "failed", failReason: aborted ? "interrupted" : errMessage, - updateTime: new Date().toISOString() + updateTime: new Date().toISOString(), })); if (!aborted) { - this.onAssistantMessage( - this.buildAssistantMessage(sessionId, `Request failed: ${errMessage}`, null), - false, - ); + this.onAssistantMessage(this.buildAssistantMessage(sessionId, `Request failed: ${errMessage}`, null), false); } } finally { if (this.sessionControllers.get(sessionId) === sessionController) { @@ -1116,14 +1117,12 @@ ${skillMd} return; } - const startIndex = sessionMessages.findIndex( - (message) => message.role !== "system" - ); + const startIndex = sessionMessages.findIndex((message) => message.role !== "system"); if (startIndex === -1) { return; } - const searchStart = Math.floor(startIndex + (sessionMessages.length - startIndex) * 2 / 3); + const searchStart = Math.floor(startIndex + ((sessionMessages.length - startIndex) * 2) / 3); let endIndex = -1; for (let i = Math.max(searchStart, startIndex); i < sessionMessages.length; i += 1) { if (sessionMessages[i].role !== "tool") { @@ -1137,16 +1136,22 @@ ${skillMd} const compactPrompt = getCompactPrompt(sessionMessages.slice(startIndex, endIndex)); const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); - const response = await this.createChatCompletionStream(client, { - model, - messages: [{ role: "user", content: compactPrompt }], - ...thinkingOptions - }, signal ? { signal } : undefined, sessionId, { - enabled: debugLogEnabled, - location: "SessionManager.compactSession", - baseURL, - params: { thinkingEnabled, reasoningEffort } - }); + const response = await this.createChatCompletionStream( + client, + { + model, + messages: [{ role: "user", content: compactPrompt }], + ...thinkingOptions, + }, + signal ? { signal } : undefined, + sessionId, + { + enabled: debugLogEnabled, + location: "SessionManager.compactSession", + baseURL, + params: { thinkingEnabled, reasoningEffort }, + } + ); this.throwIfAborted(signal); const rawLlmResponse = response.choices?.[0]?.message?.content; const llmResponse = typeof rawLlmResponse === "string" ? rawLlmResponse : ""; @@ -1158,7 +1163,7 @@ ${skillMd} ...entry, usage: accumulateUsage(entry.usage, responseUsage), activeTokens: getTotalTokens(responseUsage), - updateTime: now + updateTime: now, })); for (let i = startIndex; i < endIndex; i += 1) { @@ -1177,8 +1182,8 @@ ${skillMd} createTime: now, updateTime: now, meta: { - isSummary: true - } + isSummary: true, + }, }; sessionMessages.splice(endIndex, 0, summaryMessage); this.saveSessionMessages(sessionId, sessionMessages); @@ -1186,7 +1191,7 @@ ${skillMd} private getPromptToolOptions(): { webSearchEnabled: boolean } { return { - webSearchEnabled: true + webSearchEnabled: true, }; } @@ -1200,9 +1205,9 @@ ${skillMd} method: "POST", headers: { "Content-Type": "application/json", - Token: machineId + Token: machineId, }, - body: JSON.stringify({}) + body: JSON.stringify({}), }) .then(async (response) => { if (response.ok) { @@ -1210,9 +1215,7 @@ ${skillMd} } const body = await response.text().catch(() => ""); - throw new Error( - `New prompt API request failed with status ${response.status}${body ? `: ${body}` : ""}` - ); + throw new Error(`New prompt API request failed with status ${response.status}${body ? `: ${body}` : ""}`); }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); @@ -1263,7 +1266,7 @@ ${skillMd} status: "interrupted", failReason: "interrupted", processes: null, - updateTime: now + updateTime: now, })); const contentParts = ["Interrupted."]; @@ -1274,10 +1277,7 @@ ${skillMd} contentParts.push(`Failed to kill processes: ${failedPids.join(", ")}.`); } - this.onAssistantMessage( - this.buildUserMessage(sessionId, { text: contentParts.join(" ") }), - false, - ); + this.onAssistantMessage(this.buildUserMessage(sessionId, { text: contentParts.join(" ") }), false); } private isInterrupted(sessionId: string): boolean { @@ -1325,8 +1325,7 @@ ${skillMd} nextMeta.paramsMd = normalizedParamsMd; } - const normalizedResultMd = - typeof message.content === "string" ? this.buildToolResultSnippet(message.content) : ""; + const normalizedResultMd = typeof message.content === "string" ? this.buildToolResultSnippet(message.content) : ""; if (nextMeta && normalizedResultMd) { nextMeta.resultMd = normalizedResultMd; } @@ -1334,7 +1333,7 @@ ${skillMd} return { ...message, visible: typeof message.content === "string" ? !this.isInvisibleExecution(message.content) : message.visible, - meta: nextMeta + meta: nextMeta, }; } @@ -1376,7 +1375,7 @@ ${skillMd} return { version: 1, entries, - originalPath: parsed.originalPath || this.projectRoot + originalPath: parsed.originalPath || this.projectRoot, }; } catch { return { version: 1, entries: [], originalPath: this.projectRoot }; @@ -1390,9 +1389,9 @@ ${skillMd} version: 1, entries: index.entries.map((entry) => ({ ...entry, - processes: this.serializeProcesses(entry.processes) + processes: this.serializeProcesses(entry.processes), })), - originalPath: this.projectRoot + originalPath: this.projectRoot, }; fs.writeFileSync(sessionsIndexPath, JSON.stringify(normalized, null, 2), "utf8"); } @@ -1428,10 +1427,7 @@ ${skillMd} fs.writeFileSync(messagePath, payload ? `${payload}\n` : "", "utf8"); } - private updateSessionEntry( - sessionId: string, - updater: (entry: SessionEntry) => SessionEntry - ): SessionEntry | null { + private updateSessionEntry(sessionId: string, updater: (entry: SessionEntry) => SessionEntry): SessionEntry | null { const index = this.loadSessionsIndex(); const entryIndex = index.entries.findIndex((entry) => entry.id === sessionId); if (entryIndex === -1) { @@ -1448,12 +1444,12 @@ ${skillMd} private buildUserMessage(sessionId: string, prompt: UserPromptContent): SessionMessage { const now = new Date().toISOString(); const imageParams = - prompt.imageUrls - ?.filter((url) => Boolean(url)) - .map((url) => ({ - type: "image_url", - image_url: { url } - })) ?? []; + prompt.imageUrls + ?.filter((url) => Boolean(url)) + .map((url) => ({ + type: "image_url", + image_url: { url }, + })) ?? []; return { id: crypto.randomUUID(), @@ -1465,7 +1461,7 @@ ${skillMd} compacted: false, visible: true, createTime: now, - updateTime: now + updateTime: now, }; } @@ -1480,7 +1476,7 @@ ${skillMd} const templatePath = path.join(getExtensionRoot(), "docs", "prompts", "init_command.md.ejs"); const template = fs.readFileSync(templatePath, "utf8"); return ejs.render(template, { - agentsMdFile: this.getEffectiveProjectAgentsMdFile() + agentsMdFile: this.getEffectiveProjectAgentsMdFile(), }); } @@ -1492,12 +1488,12 @@ ${skillMd} const candidatePaths = [ { absolutePath: path.join(this.projectRoot, ".deepcode", "AGENTS.md"), - displayPath: "./.deepcode/AGENTS.md" + displayPath: "./.deepcode/AGENTS.md", }, { absolutePath: path.join(this.projectRoot, "AGENTS.md"), - displayPath: "./AGENTS.md" - } + displayPath: "./AGENTS.md", + }, ]; for (const candidatePath of candidatePaths) { @@ -1505,7 +1501,7 @@ ${skillMd} if (content) { return { content, - displayPath: candidatePath.displayPath + displayPath: candidatePath.displayPath, }; } } @@ -1534,11 +1530,7 @@ ${skillMd} return this.readNonEmptyFile(path.join(os.homedir(), ".deepcode", "AGENTS.md")); } - private buildSystemMessage( - sessionId: string, - content: string, - contentParams: unknown | null = null - ): SessionMessage { + private buildSystemMessage(sessionId: string, content: string, contentParams: unknown | null = null): SessionMessage { const now = new Date().toISOString(); return { id: crypto.randomUUID(), @@ -1550,7 +1542,7 @@ ${skillMd} compacted: false, visible: false, createTime: now, - updateTime: now + updateTime: now, }; } @@ -1572,10 +1564,10 @@ ${skillMd} } private buildAssistantMessage( - sessionId: string, - content: string | null, - toolCalls: unknown[] | null, - reasoningContent?: string | null + sessionId: string, + content: string | null, + toolCalls: unknown[] | null, + reasoningContent?: string | null ): SessionMessage { const now = new Date().toISOString(); const hasReasoningContent = reasoningContent != null; @@ -1598,7 +1590,7 @@ ${skillMd} visible: (content || reasoningContent || "").trim() ? true : false, createTime: now, updateTime: now, - meta: toolCalls ? { asThinking: true } : undefined + meta: toolCalls ? { asThinking: true } : undefined, }; } @@ -1626,19 +1618,16 @@ ${skillMd} meta: { function: toolFunction ?? undefined, paramsMd, - resultMd - } + resultMd, + }, }; } - private async appendToolMessages( - sessionId: string, - toolCalls: unknown[] - ): Promise<{ waitingForUser: boolean }> { + private async appendToolMessages(sessionId: string, toolCalls: unknown[]): Promise<{ waitingForUser: boolean }> { const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), - shouldStop: () => this.isInterrupted(sessionId) + shouldStop: () => this.isInterrupted(sessionId), }); if (this.isInterrupted(sessionId)) { return { waitingForUser: false }; @@ -1650,12 +1639,7 @@ ${skillMd} waitingForUser = true; } const toolFunction = this.findToolFunction(toolCalls, execution.toolCallId); - const toolMessage = this.buildToolMessage( - sessionId, - execution.toolCallId, - execution.content, - toolFunction - ); + const toolMessage = this.buildToolMessage(sessionId, execution.toolCallId, execution.content, toolFunction); this.appendSessionMessage(sessionId, toolMessage); this.onAssistantMessage(toolMessage, true); @@ -1664,11 +1648,7 @@ ${skillMd} continue; } followUpMessages.push( - this.buildSystemMessage( - sessionId, - followUpMessage.content, - followUpMessage.contentParams ?? null - ) + this.buildSystemMessage(sessionId, followUpMessage.content, followUpMessage.contentParams ?? null) ); } } @@ -1679,10 +1659,7 @@ ${skillMd} return { waitingForUser }; } - private buildOpenAIMessages( - messages: SessionMessage[], - thinkingEnabled: boolean, - ): ChatCompletionMessageParam[] { + private buildOpenAIMessages(messages: SessionMessage[], thinkingEnabled: boolean): ChatCompletionMessageParam[] { const activeMessages = messages.filter((message) => !message.compacted); const toolPairings = this.pairToolMessages(activeMessages); const openAIMessages: ChatCompletionMessageParam[] = []; @@ -1719,19 +1696,16 @@ ${skillMd} return openAIMessages; } - private sessionMessageToOpenAIMessage( - message: SessionMessage, - thinkingEnabled: boolean - ): ChatCompletionMessageParam { + private sessionMessageToOpenAIMessage(message: SessionMessage, thinkingEnabled: boolean): ChatCompletionMessageParam { const base: ChatCompletionMessageParam = { role: message.role, - content: message.content ?? "" + content: message.content ?? "", } as ChatCompletionMessageParam; const messageParams = message.messageParams as - | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } - | null - | undefined; + | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } + | null + | undefined; if (messageParams?.tool_calls) { (base as { tool_calls?: unknown[] }).tool_calls = messageParams.tool_calls; } @@ -1751,16 +1725,14 @@ ${skillMd} if (message.content) { contentParts.push({ type: "text", text: message.content }); } - const params = Array.isArray(message.contentParams) - ? message.contentParams - : [message.contentParams]; + const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; for (const param of params) { if (param && typeof param === "object") { contentParts.push(param as ChatCompletionContentPart); } } const contentValue: string | ChatCompletionContentPart[] = - contentParts.length > 0 ? contentParts : message.content ?? ""; + contentParts.length > 0 ? contentParts : (message.content ?? ""); (base as { content: string | ChatCompletionContentPart[] }).content = contentValue; } @@ -1863,15 +1835,12 @@ ${skillMd} } } - private buildInterruptedOpenAIToolMessage( - toolCalls: unknown[], - toolCallId: string - ): ChatCompletionMessageParam { + private buildInterruptedOpenAIToolMessage(toolCalls: unknown[], toolCallId: string): ChatCompletionMessageParam { const toolFunction = this.findToolFunction(toolCalls, toolCallId); return { role: "tool", content: this.buildInterruptedToolResult(toolFunction, "Previous tool call did not complete."), - tool_call_id: toolCallId + tool_call_id: toolCallId, } as ChatCompletionMessageParam; } @@ -1985,11 +1954,7 @@ ${skillMd} } } - private maybeNotifyTaskCompletion( - sessionId: string, - notifyCommand: string | undefined, - startedAt: number - ): void { + private maybeNotifyTaskCompletion(sessionId: string, notifyCommand: string | undefined, startedAt: number): void { if (!notifyCommand) { return; } @@ -2010,7 +1975,7 @@ ${skillMd} return { ...entry, processes, - updateTime: now + updateTime: now, }; }); } @@ -2023,7 +1988,7 @@ ${skillMd} return { ...entry, processes: processes.size > 0 ? processes : null, - updateTime: now + updateTime: now, }; }); } @@ -2045,7 +2010,7 @@ ${skillMd} private buildInterruptedToolResult(toolFunction: unknown | null, reason: string): string { const toolName = toolFunction && typeof toolFunction === "object" && typeof (toolFunction as { name?: unknown }).name === "string" - ? ((toolFunction as { name: string }).name) + ? (toolFunction as { name: string }).name : "tool"; return JSON.stringify( { @@ -2053,8 +2018,8 @@ ${skillMd} name: toolName, error: reason, metadata: { - interrupted: true - } + interrupted: true, + }, }, null, 2 @@ -2074,7 +2039,7 @@ ${skillMd} } private normalizeSessionEntry(entry: unknown): SessionEntry { - const value = (entry && typeof entry === "object") ? (entry as Record) : {}; + const value = entry && typeof entry === "object" ? (entry as Record) : {}; return { id: typeof value.id === "string" ? value.id : crypto.randomUUID(), summary: typeof value.summary === "string" ? value.summary : null, @@ -2088,7 +2053,7 @@ ${skillMd} activeTokens: typeof value.activeTokens === "number" ? value.activeTokens : 0, createTime: typeof value.createTime === "string" ? value.createTime : new Date().toISOString(), updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(), - processes: this.deserializeProcesses(value.processes) + processes: this.deserializeProcesses(value.processes), }; } @@ -2128,7 +2093,9 @@ ${skillMd} return processes.size > 0 ? processes : null; } - private serializeProcesses(processes: Map | null): Record | null { + private serializeProcesses( + processes: Map | null + ): Record | null { if (!processes || processes.size === 0) { return null; } From 7095bfdc372c54b1b7c93ce6f5957a7284901ca2 Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 11 May 2026 09:17:10 +0800 Subject: [PATCH 040/217] chore: add husky + lint-staged git hooks --- .husky/pre-commit | 1 + .lintstagedrc | 6 + package-lock.json | 300 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 +- 4 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 .husky/pre-commit create mode 100644 .lintstagedrc diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.lintstagedrc b/.lintstagedrc new file mode 100644 index 0000000..8c024f1 --- /dev/null +++ b/.lintstagedrc @@ -0,0 +1,6 @@ +{ + "*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ] +} diff --git a/package-lock.json b/package-lock.json index b4f261e..f2f10a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,8 @@ "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", + "husky": "^9.1.7", + "lint-staged": "^17.0.4", "prettier": "^3.8.3", "tsx": "^4.21.0", "typescript": "^6.0.3", @@ -1731,6 +1733,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", @@ -2073,6 +2082,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -2306,6 +2322,22 @@ "hermes-estree": "0.25.1" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", @@ -2592,6 +2624,82 @@ "node": ">= 0.8.0" } }, + "node_modules/lint-staged": { + "version": "17.0.4", + "resolved": "https://registry.npmmirror.com/lint-staged/-/lint-staged-17.0.4.tgz", + "integrity": "sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "listr2": "^10.2.1", + "picomatch": "^4.0.4", + "string-argv": "^0.3.2", + "tinyexec": "^1.1.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.8.4" + } + }, + "node_modules/listr2": { + "version": "10.2.1", + "resolved": "https://registry.npmmirror.com/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/listr2/node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", @@ -2615,6 +2723,141 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2634,6 +2877,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", @@ -2912,6 +3168,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", @@ -3004,6 +3267,16 @@ "node": ">=10" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "8.2.1", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", @@ -3100,6 +3373,16 @@ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3844,6 +4127,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7e5f993..175ca9e 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", "test": "tsx --test src/tests/*.test.ts", "test:single": "tsx --test", - "prepack": "npm run build" + "prepack": "npm run build", + "prepare": "husky" }, "dependencies": { "chalk": "^5.6.2", @@ -56,6 +57,8 @@ "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", + "husky": "^9.1.7", + "lint-staged": "^17.0.4", "prettier": "^3.8.3", "tsx": "^4.21.0", "typescript": "^6.0.3", From b8b7b38b850c6676403a4c61dc92ecb3615a7714 Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 11 May 2026 09:36:56 +0800 Subject: [PATCH 041/217] chore: expand lint-staged to cover .js/.mjs/.json/.prettierrc files --- .lintstagedrc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.lintstagedrc b/.lintstagedrc index 8c024f1..53025dd 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,6 +1,12 @@ { - "*.{ts,tsx}": [ + "*.{ts,tsx,js,mjs,cjs}": [ "eslint --fix", "prettier --write" + ], + "*.json": [ + "prettier --write" + ], + ".prettierrc": [ + "prettier --write" ] } From e57f1bd2a967adf0a97fd8a95a0b0eee278e0050 Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 11 May 2026 09:40:13 +0800 Subject: [PATCH 042/217] =?UTF-8?q?chore(lint):=20=E6=89=A9=E5=B1=95lint-s?= =?UTF-8?q?taged=E6=94=AF=E6=8C=81=E7=9A=84=E6=96=87=E4=BB=B6=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将支持的文件类型从 ts、tsx、js、mjs、cjs 扩展到包括 ejs 和 jsx - 更新了.lintstagedrc配置文件以应用到更多文件种类 - 确保eslint和prettier针对新增文件类型自动修复和格式化 --- .lintstagedrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lintstagedrc b/.lintstagedrc index 53025dd..ef49282 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,5 +1,5 @@ { - "*.{ts,tsx,js,mjs,cjs}": [ + "*.{ts,tsx,js,mjs,cjs,ejs,jsx}": [ "eslint --fix", "prettier --write" ], From 35a436a181975f22749d1e36dc08ea48e2401dd1 Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 11 May 2026 14:04:51 +0800 Subject: [PATCH 043/217] =?UTF-8?q?chore(types):=20=E6=B7=BB=E5=8A=A0=20@t?= =?UTF-8?q?ypes/ejs=20=E4=BE=9D=E8=B5=96=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 package.json 和 package-lock.json 中新增 @types/ejs 开发依赖 - 在 session.ts 中直接使用 ejs 模块替代原先动态 require 方式 - 移除 session.ts 中的 createRequire 相关代码,简化模块引入逻辑 --- package-lock.json | 8 ++++++++ package.json | 1 + src/session.ts | 6 +----- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2f10a2..2528b4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/ejs": "^3.1.5", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "esbuild": "^0.28.0", @@ -1027,6 +1028,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", diff --git a/package.json b/package.json index 175ca9e..35a5cab 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/ejs": "^3.1.5", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "esbuild": "^0.28.0", diff --git a/src/session.ts b/src/session.ts index ebe0619..2267bb0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,9 +2,9 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; -import { createRequire } from "module"; import { fileURLToPath } from "url"; import matter from "gray-matter"; +import ejs from "ejs"; import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; import { launchNotifyScript } from "./notify"; import { buildThinkingRequestOptions } from "./openai-thinking"; @@ -18,10 +18,6 @@ const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; -const require = createRequire(import.meta.url); -const ejs = require("ejs") as { - render: (template: string, data?: Record) => string; -}; type ChatCompletionDebugOptions = { enabled?: boolean; From c7dd64036ca59c25f1e96c61b263bc692f7da0ec Mon Sep 17 00:00:00 2001 From: wenjiazhu1980 Date: Mon, 11 May 2026 14:21:40 +0800 Subject: [PATCH 044/217] fix(ui): normalize line endings in PromptInput to preserve multi-line formatting on paste --- src/ui/PromptInput.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index c0a8085..6a4f3fd 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -411,7 +411,9 @@ export const PromptInput = React.memo(function PromptInput({ } if (input && !key.ctrl && !key.meta) { - const sanitized = input.replace(/\r/g, ""); + // Normalize line endings from paste: \r\n (Windows) → \n, \r (old macOS/Enter) → \n. + // This preserves multi-line formatting when the user pastes content. + const sanitized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); updateBuffer((s) => insertText(s, sanitized)); } }, From e31f3649385786118400b007643efd089acb500c Mon Sep 17 00:00:00 2001 From: lellansin Date: Mon, 11 May 2026 14:03:25 +0800 Subject: [PATCH 045/217] feat(prompt): add emacs meta word delete shortcuts --- src/tests/promptBuffer.test.ts | 7 +++++++ src/tests/promptInputKeys.test.ts | 14 ++++++++++++++ src/ui/PromptInput.tsx | 10 +++++++++- src/ui/index.ts | 1 + src/ui/promptBuffer.ts | 18 ++++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/tests/promptBuffer.test.ts b/src/tests/promptBuffer.test.ts index 0a2e5da..67ac23a 100644 --- a/src/tests/promptBuffer.test.ts +++ b/src/tests/promptBuffer.test.ts @@ -5,6 +5,7 @@ import { backspace, deleteForward, deleteWordBefore, + deleteWordAfter, getCurrentSlashToken, insertText, killLine, @@ -94,6 +95,12 @@ test("deleteWordBefore removes the previous word and any adjacent whitespace", ( assert.equal(result.cursor, 4); }); +test("deleteWordAfter removes the next word and leading whitespace", () => { + const result = deleteWordAfter({ text: "ask the model now", cursor: 3 }); + assert.equal(result.text, "ask model now"); + assert.equal(result.cursor, 3); +}); + test("getCurrentSlashToken returns the slash word at the cursor", () => { const buffer = { text: "/skill", cursor: 6 }; assert.equal(getCurrentSlashToken(buffer), "/skill"); diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 5ab58f1..186a548 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -60,6 +60,20 @@ test("parseTerminalInput recognizes word navigation modifiers", () => { assert.equal(metaRight.key.meta, true); }); +test("parseTerminalInput keeps DEL payload for meta+backspace", () => { + const { input, key } = parseTerminalInput("\u001B\u007F"); + assert.equal(input, "\u007F"); + assert.equal(key.meta, true); + assert.equal(key.backspace, false); +}); + +test("parseTerminalInput keeps BS payload for meta+backspace", () => { + const { input, key } = parseTerminalInput("\u001B\b"); + assert.equal(input, "\b"); + assert.equal(key.meta, true); + assert.equal(key.backspace, false); +}); + test("parseTerminalInput recognizes shifted return sequences", () => { const { input, key } = parseTerminalInput("\u001B\r"); assert.equal(input, "\r"); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 6a4f3fd..2b4883a 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -6,6 +6,7 @@ import { backspace, deleteForward, deleteWordBefore, + deleteWordAfter, getCurrentSlashToken, insertText, isEmpty, @@ -400,11 +401,18 @@ export const PromptInput = React.memo(function PromptInput({ updateBuffer((s) => deleteWordBefore(s)); return; } + if (key.meta && (input === "d" || input === "D")) { + updateBuffer((s) => deleteWordAfter(s)); + return; + } + if (key.meta && (input === "\u007F" || input === "\b")) { + updateBuffer((s) => deleteWordBefore(s)); + return; + } if (key.ctrl && (input === "j" || input === "J")) { updateBuffer((s) => insertText(s, "\n")); return; } - if (input.startsWith("\u001B")) { // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. return; diff --git a/src/ui/index.ts b/src/ui/index.ts index a151e9c..9d19516 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -49,6 +49,7 @@ export { moveLineEnd, killLine, deleteWordBefore, + deleteWordAfter, reset, isEmpty, getCurrentSlashToken, diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts index fb65179..3e3c182 100644 --- a/src/ui/promptBuffer.ts +++ b/src/ui/promptBuffer.ts @@ -127,6 +127,24 @@ export function deleteWordBefore(state: PromptBufferState): PromptBufferState { }; } +export function deleteWordAfter(state: PromptBufferState): PromptBufferState { + const start = state.cursor; + let end = start; + while (end < state.text.length && /\s/.test(state.text[end] ?? "")) { + end++; + } + while (end < state.text.length && !/\s/.test(state.text[end] ?? "")) { + end++; + } + if (start === end) { + return state; + } + return { + text: state.text.slice(0, start) + state.text.slice(end), + cursor: start, + }; +} + export function reset(): PromptBufferState { return { ...EMPTY_BUFFER }; } From c206a63e1dc6405a90dd31941945d7e67a7a6e5a Mon Sep 17 00:00:00 2001 From: lellansin Date: Mon, 11 May 2026 14:42:14 +0800 Subject: [PATCH 046/217] fix(ui): redraw cleanly after terminal resize without clearing scrollback --- src/ui/App.tsx | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ffe480d..8f23ea2 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -248,6 +248,37 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const timer = setTimeout(() => setStableColumns(columns), 100); return () => clearTimeout(timer); }, [columns]); + const lastRenderedColumnsRef = useRef(null); + useEffect(() => { + if (!stdout?.isTTY) { + return; + } + if (stableColumns <= 0) { + return; + } + if (lastRenderedColumnsRef.current === null) { + lastRenderedColumnsRef.current = stableColumns; + return; + } + if (lastRenderedColumnsRef.current === stableColumns) { + return; + } + lastRenderedColumnsRef.current = stableColumns; + + // Force full redraw on terminal resize to avoid stale wrapped rows. + writeRef.current("\u001B[2J\u001B[H"); + setMessages([]); + setShowWelcome(false); + setWelcomeNonce((n) => n + 1); + + const activeSessionId = sessionManager.getActiveSessionId(); + const nextMessages = + activeSessionId && !busy ? loadVisibleMessages(sessionManager, activeSessionId) : messagesRef.current; + setTimeout(() => { + setMessages(nextMessages); + setShowWelcome(true); + }, 0); + }, [busy, sessionManager, stableColumns, stdout]); const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]); const promptHistory = useMemo(() => { return messages From bdf29fcca06a4ac291248728b562b9b5c5b60649 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 12 May 2026 08:59:31 +0800 Subject: [PATCH 047/217] chore: update error message of findGitBashPath --- src/tools/shell-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/shell-utils.ts b/src/tools/shell-utils.ts index 12ce952..223b95b 100644 --- a/src/tools/shell-utils.ts +++ b/src/tools/shell-utils.ts @@ -32,7 +32,7 @@ export function findGitBashPath(): string { } throw new Error( - "Deep Code on Windows requires Git Bash. Install Git for Windows and ensure git.exe is available in PATH." + "Deep Code on Windows requires Git Bash. Install Git Bash for Windows and ensure bash.exe is available in PATH." ); } From 0d6449b405248abbe57a14b818245614ea8f7981 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 12 May 2026 10:35:33 +0800 Subject: [PATCH 048/217] feat: fixed the /init shortcut so it now includes any manually selected skills in the submitted prompt --- src/tests/promptInputKeys.test.ts | 12 ++++++++++++ src/ui/PromptInput.tsx | 10 +++++++++- src/ui/index.ts | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 5ab58f1..a236f76 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -17,6 +17,7 @@ import { removeCurrentSlashToken, toggleSkillSelection, renderBufferWithCursor, + buildInitPromptSubmission, } from "../ui"; import type { SkillInfo } from "../session"; @@ -91,6 +92,17 @@ test("formatImageAttachmentStatus formats the image count label", () => { assert.equal(IMAGE_ATTACHMENT_CLEAR_HINT, "ctrl+x clear images"); }); +test("buildInitPromptSubmission preserves manually selected skills", () => { + const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" }; + + assert.deepEqual(buildInitPromptSubmission([skill]), { + text: "/init", + imageUrls: [], + selectedSkills: [skill], + }); + assert.deepEqual(buildInitPromptSubmission([]), { text: "/init", imageUrls: [], selectedSkills: undefined }); +}); + test("selected skill helpers format, dedupe, toggle, and clear slash tokens", () => { const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" }; const other: SkillInfo = { name: "code-review", path: "/skills/code-review/SKILL.md", description: "Review code" }; diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 6a4f3fd..622e992 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -482,7 +482,7 @@ export const PromptInput = React.memo(function PromptInput({ return; } if (item.kind === "init") { - onSubmit({ text: "/init", imageUrls: [] }); + onSubmit(buildInitPromptSubmission(selectedSkills)); setBuffer(EMPTY_BUFFER); setImageUrls([]); setSelectedSkills([]); @@ -649,6 +649,14 @@ export function toggleSkillSelection(skills: SkillInfo[], skill: SkillInfo): Ski return isSkillSelected(skills, skill) ? skills.filter((item) => item.name !== skill.name) : [...skills, skill]; } +export function buildInitPromptSubmission(selectedSkills: SkillInfo[]): PromptSubmission { + return { + text: "/init", + imageUrls: [], + selectedSkills: selectedSkills.length > 0 ? selectedSkills : undefined, + }; +} + export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { let start = state.cursor; while (start > 0 && !/\s/.test(state.text[start - 1] ?? "")) { diff --git a/src/ui/index.ts b/src/ui/index.ts index a151e9c..fd2c592 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -12,6 +12,7 @@ export { removeCurrentSlashToken, isClearImageAttachmentsShortcut, renderBufferWithCursor, + buildInitPromptSubmission, useTerminalInput, parseTerminalInput, type PromptSubmission, From 64b8e8991f86f4e062d365d03f42de3cc3103d9a Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Tue, 12 May 2026 11:30:24 +0800 Subject: [PATCH 049/217] feat: add MCP (Model Context Protocol) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP stdio client for connecting to MCP servers - Support for playwright, fetch, and memory MCP servers - Add /mcp slash command to display MCP server status 新增 MCP(模型上下文协议)支持 - 实现 MCP stdio 客户端,用于连接 MCP 服务器 - 支持 playwright、fetch、memory 三个 MCP 服务器 - 新增 /mcp 命令,显示 MCP 服务器状态和可用工具 --- src/prompt.ts | 6 +- src/session.ts | 20 +++- src/settings.ts | 11 ++ src/tests/slashCommands.test.ts | 2 +- src/tools/executor.ts | 11 +- src/tools/mcp-client.ts | 181 ++++++++++++++++++++++++++++++++ src/tools/mcp-manager.ts | 145 +++++++++++++++++++++++++ src/ui/App.tsx | 37 +++++++ src/ui/PromptInput.tsx | 10 +- src/ui/slashCommands.ts | 8 +- 10 files changed, 423 insertions(+), 8 deletions(-) create mode 100644 src/tools/mcp-client.ts create mode 100644 src/tools/mcp-manager.ts diff --git a/src/prompt.ts b/src/prompt.ts index e1def61..f2573ff 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -419,7 +419,7 @@ export type ToolDefinition = { }; }; -export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { +export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDefinition[] = []): ToolDefinition[] { const tools: ToolDefinition[] = [ { type: "function", @@ -610,5 +610,9 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { }, }); + for (const tool of externalTools) { + tools.push(tool); + } + return tools; } diff --git a/src/session.ts b/src/session.ts index 2267bb0..49d29be 100644 --- a/src/session.ts +++ b/src/session.ts @@ -9,8 +9,9 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open import { launchNotifyScript } from "./notify"; import { buildThinkingRequestOptions } from "./openai-thinking"; import { DEEPSEEK_V4_MODELS } from "./model-capabilities"; -import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL } from "./prompt"; +import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt"; import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; +import { McpManager } from "./tools/mcp-manager"; import { logApiError } from "./error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; @@ -180,6 +181,8 @@ export class SessionManager { private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); private readonly toolExecutor: ToolExecutor; + private readonly mcpManager = new McpManager(); + private mcpToolDefinitions: ToolDefinition[] = []; constructor(options: SessionManagerOptions) { this.projectRoot = options.projectRoot; @@ -188,7 +191,18 @@ export class SessionManager { this.onAssistantMessage = options.onAssistantMessage; this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; - this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient); + this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); + } + + async initMcpServers( + servers?: Record }> + ): Promise { + await this.mcpManager.initialize(servers); + this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); + } + + getMcpStatus() { + return this.mcpManager.getStatus(); } private estimateStreamTokens(text: string): number { @@ -1001,7 +1015,7 @@ ${skillMd} { model, messages, - tools: getTools(this.getPromptToolOptions()), + tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions), ...thinkingOptions, }, { signal: sessionController.signal }, diff --git a/src/settings.ts b/src/settings.ts index ffbadf0..e1cbf9a 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,6 +9,12 @@ export type DeepcodingEnv = { export type ReasoningEffort = "high" | "max"; +export type McpServerConfig = { + command: string; + args?: string[]; + env?: Record; +}; + export type DeepcodingSettings = { env?: DeepcodingEnv; thinkingEnabled?: boolean; @@ -16,6 +22,7 @@ export type DeepcodingSettings = { debugLogEnabled?: boolean; notify?: string; webSearchTool?: string; + mcpServers?: Record; }; export type ResolvedDeepcodingSettings = { @@ -27,6 +34,7 @@ export type ResolvedDeepcodingSettings = { debugLogEnabled: boolean; notify?: string; webSearchTool?: string; + mcpServers?: Record; }; function resolveReasoningEffort(value: unknown): ReasoningEffort { @@ -55,6 +63,8 @@ export function resolveSettings( const notify = typeof settings?.notify === "string" ? settings.notify.trim() : ""; const webSearchTool = typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : ""; + const mcpServers = settings?.mcpServers; + return { apiKey: env.API_KEY?.trim(), baseURL: env.BASE_URL?.trim() || defaults.baseURL, @@ -64,5 +74,6 @@ export function resolveSettings( debugLogEnabled: settings?.debugLogEnabled === true, notify: notify || undefined, webSearchTool: webSearchTool || undefined, + mcpServers, }; } diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 6dcc840..656ce1e 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", "new", "init", "resume", "exit"]); + assert.deepEqual(builtinNames, ["skills", "new", "init", "resume", "mcp", "exit"]); }); test("filterSlashCommands matches partial prefixes", () => { diff --git a/src/tools/executor.ts b/src/tools/executor.ts index eec0b20..d857ad7 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -6,6 +6,7 @@ import { handleEditTool } from "./edit-handler"; import { handleReadTool } from "./read-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; +import type { McpManager } from "./mcp-manager"; export type CreateOpenAIClient = () => { client: OpenAI | null; @@ -73,11 +74,13 @@ export type ToolCallExecution = { export class ToolExecutor { private readonly projectRoot: string; private readonly createOpenAIClient?: CreateOpenAIClient; + private readonly mcpManager?: McpManager; private readonly toolHandlers = new Map(); - constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient) { + constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient, mcpManager?: McpManager) { this.projectRoot = projectRoot; this.createOpenAIClient = createOpenAIClient; + this.mcpManager = mcpManager; this.registerToolHandlers(); } @@ -161,6 +164,12 @@ export class ToolExecutor { const toolName = toolCall.function.name; const handler = this.toolHandlers.get(toolName); if (!handler) { + // Try MCP tools + if (toolName.startsWith("mcp__") && this.mcpManager) { + const parsedArgs = this.parseToolArguments(toolCall.function.arguments); + const args = parsedArgs.ok ? parsedArgs.args : {}; + return this.mcpManager.executeMcpTool(toolName, args); + } return { ok: false, name: toolName, diff --git a/src/tools/mcp-client.ts b/src/tools/mcp-client.ts new file mode 100644 index 0000000..89b0c2c --- /dev/null +++ b/src/tools/mcp-client.ts @@ -0,0 +1,181 @@ +import { spawn, type ChildProcess } from "child_process"; +import { createInterface, type Interface } from "readline"; +import * as os from "os"; + +type JsonRpcRequest = { + jsonrpc: "2.0"; + id: number; + method: string; + params?: Record; +}; + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}; + +export type McpToolDefinition = { + name: string; + description?: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + }; +}; + +type ListToolsResult = { + tools: McpToolDefinition[]; +}; + +type CallToolResult = { + content: Array<{ type: string; text?: string }>; + isError?: boolean; +}; + +export class McpClient { + private process: ChildProcess | null = null; + private reader: Interface | null = null; + private nextId = 1; + private pendingRequests = new Map void; reject: (error: Error) => void }>(); + private buffer = ""; + + constructor( + private readonly serverName: string, + private readonly command: string, + private readonly args: string[] = [], + private readonly env?: Record + ) {} + + async connect(): Promise { + return new Promise((resolve, reject) => { + const childEnv = { + ...process.env, + ...this.env, + }; + + const isWindows = os.platform() === "win32"; + + if (isWindows) { + // On Windows, .cmd files require shell: true to be spawned. + // Build a single command string so cmd.exe handles quoting correctly. + const cmd = [this.command + ".cmd", ...this.args].join(" "); + this.process = spawn(cmd, [], { + stdio: ["pipe", "pipe", "pipe"], + env: childEnv, + shell: true, + windowsHide: true, + }); + } else { + this.process = spawn(this.command, this.args, { + stdio: ["pipe", "pipe", "pipe"], + env: childEnv, + }); + } + + this.process.on("error", (err) => { + reject(new Error(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`)); + }); + + this.process.on("exit", (code) => { + const error = new Error(`MCP server "${this.serverName}" exited with code ${code}`); + for (const [, pending] of this.pendingRequests) { + pending.reject(error); + } + this.pendingRequests.clear(); + }); + + if (this.process.stderr) { + this.process.stderr.on("data", (data: Buffer) => { + // MCP servers log to stderr; we ignore for now + }); + } + + this.reader = createInterface({ input: this.process.stdout! }); + this.reader.on("line", (line: string) => { + this.handleLine(line); + }); + + // Send initialize request (MCP protocol handshake) + this.sendRequest("initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "deepcode-cli", version: "0.1.0" }, + }) + .then(() => { + // Send initialized notification + this.sendNotification("notifications/initialized"); + resolve(); + }) + .catch(reject); + }); + } + + async listTools(): Promise { + const result = (await this.sendRequest("tools/list", {})) as ListToolsResult; + return result.tools ?? []; + } + + async callTool(name: string, args: Record): Promise { + return (await this.sendRequest("tools/call", { name, arguments: args })) as CallToolResult; + } + + disconnect(): void { + if (this.reader) { + this.reader.close(); + this.reader = null; + } + if (this.process) { + this.process.kill(); + this.process = null; + } + } + + private sendRequest(method: string, params: Record): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + params, + }; + this.pendingRequests.set(id, { resolve, reject }); + this.writeLine(JSON.stringify(request)); + }); + } + + private sendNotification(method: string, params?: Record): void { + const notification = { + jsonrpc: "2.0" as const, + method, + params, + }; + this.writeLine(JSON.stringify(notification)); + } + + private writeLine(data: string): void { + if (this.process?.stdin) { + this.process.stdin.write(data + "\n"); + } + } + + private handleLine(line: string): void { + try { + const message = JSON.parse(line) as JsonRpcResponse; + if (message.id !== undefined && this.pendingRequests.has(message.id)) { + const pending = this.pendingRequests.get(message.id)!; + this.pendingRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(`MCP error: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + } + } catch { + // Ignore unparseable lines + } + } +} diff --git a/src/tools/mcp-manager.ts b/src/tools/mcp-manager.ts new file mode 100644 index 0000000..4bac5e9 --- /dev/null +++ b/src/tools/mcp-manager.ts @@ -0,0 +1,145 @@ +import { McpClient, type McpToolDefinition } from "./mcp-client"; +import type { McpServerConfig } from "../settings"; + +type McpToolEntry = { + serverName: string; + originalName: string; + namespacedName: string; + definition: McpToolDefinition; + client: McpClient; +}; + +export type McpServerStatus = { + name: string; + connected: boolean; + error?: string; + toolCount: number; + tools: string[]; +}; + +export class McpManager { + private clients: McpClient[] = []; + private tools: McpToolEntry[] = []; + private initialized = false; + private serverStatuses: McpServerStatus[] = []; + + async initialize(servers?: Record): Promise { + if (this.initialized) return; + this.initialized = true; + + if (!servers || Object.keys(servers).length === 0) return; + + const entries = Object.entries(servers); + for (const [name, config] of entries) { + try { + const client = new McpClient(name, config.command, config.args ?? [], config.env); + await client.connect(); + this.clients.push(client); + + const serverTools = await client.listTools(); + const toolNames: string[] = []; + for (const tool of serverTools) { + const namespacedName = `mcp__${name}__${tool.name}`; + this.tools.push({ + serverName: name, + originalName: tool.name, + namespacedName, + definition: tool, + client, + }); + toolNames.push(tool.name); + } + this.serverStatuses.push({ + name, + connected: true, + toolCount: serverTools.length, + tools: toolNames, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`); + this.serverStatuses.push({ + name, + connected: false, + error: message, + toolCount: 0, + tools: [], + }); + } + } + } + + getStatus(): McpServerStatus[] { + return this.serverStatuses; + } + + getMcpToolDefinitions(): Array<{ + type: "function"; + function: { + name: string; + description: string; + parameters: { + type: "object"; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + }; + }; + }> { + return this.tools.map((t) => ({ + type: "function" as const, + function: { + name: t.namespacedName, + description: t.definition.description ?? `${t.serverName}: ${t.originalName}`, + parameters: { + type: "object" as const, + properties: t.definition.inputSchema.properties, + required: t.definition.inputSchema.required, + additionalProperties: false, + }, + }, + })); + } + + isMcpTool(name: string): boolean { + return name.startsWith("mcp__"); + } + + async executeMcpTool( + name: string, + args: Record + ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { + const tool = this.tools.find((t) => t.namespacedName === name); + if (!tool) { + return { ok: false, name, error: `Unknown MCP tool: ${name}` }; + } + + try { + const result = await tool.client.callTool(tool.originalName, args); + const text = result.content + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text) + .join("\n"); + return { + ok: !result.isError, + name, + output: text || JSON.stringify(result.content), + }; + } catch (err) { + return { + ok: false, + name, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + disconnect(): void { + for (const client of this.clients) { + client.disconnect(); + } + this.clients = []; + this.tools = []; + this.initialized = false; + } +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ffe480d..7159135 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -120,6 +120,11 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R void refreshSkills(); }, [refreshSessionsList, refreshSkills]); + useEffect(() => { + const settings = resolveCurrentSettings(); + void sessionManager.initMcpServers(settings.mcpServers); + }, [sessionManager]); + const writeRef = useRef(write); writeRef.current = write; const handlePrompt = useCallback( @@ -168,6 +173,38 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setView("session-list"); return; } + if (submission.command === "mcp") { + process.stdout.write("\n"); + process.stdout.write(chalk.bold.cyan("MCP Server Status\n")); + process.stdout.write(chalk.dim("─────────────────\n")); + const statuses = sessionManager.getMcpStatus(); + if (statuses.length === 0) { + process.stdout.write(chalk.dim(" No MCP servers configured.\n")); + } else { + for (const s of statuses) { + const icon = s.connected ? chalk.green("✔") : chalk.red("✖"); + process.stdout.write(` ${icon} ${chalk.bold(s.name)}`); + if (s.connected) { + process.stdout.write(chalk.dim(` (${s.toolCount} tools)`)); + } else { + process.stdout.write(chalk.dim(` — ${s.error ?? "failed"}`)); + } + process.stdout.write("\n"); + if (s.connected && s.tools.length > 0) { + for (const tool of s.tools) { + process.stdout.write(chalk.dim(` - mcp__${s.name}__${tool}\n`)); + } + } + } + } + process.stdout.write(chalk.dim("─────────────────\n")); + process.stdout.write( + chalk.dim(` Total: ${statuses.filter((s) => s.connected).length} connected, `) + + chalk.dim(`${statuses.filter((s) => !s.connected).length} failed\n`) + ); + process.stdout.write("\n"); + return; + } const prompt: UserPromptContent = { text: submission.text, diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 6a4f3fd..1d940cc 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -38,7 +38,7 @@ export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; - command?: "new" | "resume" | "exit"; + command?: "new" | "resume" | "mcp" | "exit"; }; type Props = { @@ -497,6 +497,14 @@ export const PromptInput = React.memo(function PromptInput({ setShowSkillsDropdown(false); return; } + if (item.kind === "mcp") { + onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); + setBuffer(EMPTY_BUFFER); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + return; + } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index 274940f..3e283b3 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,6 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "new" | "init" | "resume" | "exit"; +export type SlashCommandKind = "skill" | "skills" | "new" | "init" | "resume" | "mcp" | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -35,6 +35,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/resume", description: "Pick a previous conversation to continue", }, + { + kind: "mcp", + name: "mcp", + label: "/mcp", + description: "Show MCP server status and available tools", + }, { kind: "exit", name: "exit", From a5f1dbb7057141943800f7db1a9ea94cf196614d Mon Sep 17 00:00:00 2001 From: dengmik-commits Date: Tue, 12 May 2026 14:56:42 +0800 Subject: [PATCH 050/217] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20/mcp=20ski?= =?UTF-8?q?ll=20=E5=92=8C=20MCP=20=E5=AE=9E=E7=8E=B0=E7=9A=84=E6=96=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index ea5dcde..385dabf 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ [Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制以及 Agent Skills。 +## 🚀 新增功能(本 Fork) + +### `/mcp` Skill 与 MCP 实现 + +本 Fork 新增了 `/mcp` 命令和 MCP(Model Context Protocol)集成,让 Deep Code CLI 能够连接外部工具和服务: + +- **`/mcp` Skill**:一键管理 MCP 服务器连接,支持添加、移除、列出已配置的 MCP 服务。 +- **MCP 协议实现**:支持与 GitHub、文件系统、数据库等多种外部服务的标准化集成,大幅扩展 AI 助手的操作能力。 + +通过 MCP,你现在可以让 Deep Code 直接操作 GitHub 仓库、读取文件、查询数据库等,而无需离开终端。 + ## 安装 ```bash From cdc6a1db04bc5849ebcc9b7d86ecd60399fc25af Mon Sep 17 00:00:00 2001 From: dengmik-commits Date: Tue, 12 May 2026 15:12:41 +0800 Subject: [PATCH 051/217] docs: add MCP configuration guide --- docs/mcp.md | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/mcp.md diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..69b5ee7 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,205 @@ +# Deep Code CLI MCP 配置指南 + +Deep Code CLI 支持 MCP(Model Context Protocol),让 AI 助手能够连接外部工具和服务,如 GitHub、浏览器、数据库等。 + +## 概述 + +配置 MCP 后,Deep Code 可以: + +- 操作 GitHub 仓库(查看 Issues、创建 PR、搜索代码等) +- 操控浏览器(截图、点击、填表单等) +- 访问文件系统 +- 连接数据库和 API +- ...以及任何兼容 MCP 协议的外部服务 + +MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`,例如 `mcp__github__search_code`。 + +## 配置 MCP 服务器 + +编辑 `~/.deepcode/settings.json`,添加 `mcpServers` 字段: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "<服务名称>": { + "command": "<可执行文件>", + "args": ["<参数1>", "<参数2>"], + "env": { + "<环境变量>": "<值>" + } + } + } +} +``` + +### 配置项说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`) | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | + +## 内置 MCP 示例 + +### GitHub MCP + +让 Deep Code 直接操作 GitHub 仓库: + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +> GitHub Personal Access Token 可在 [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens) 生成。 + +### 浏览器控制(Playwright) + +让 Deep Code 操控浏览器进行截图、页面操作等: + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-playwright"] + } + } +} +``` + +### 文件系统 + +让 Deep Code 在指定目录中读写文件: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-filesystem", "/path/to/allowed/dir"] + } + } +} +``` + +### 自定义 Python MCP + +```json +{ + "mcpServers": { + "my-tool": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "env": { + "API_KEY": "xxx" + } + } + } +} +``` + +## 完整配置示例 + +以下是一个配置了 GitHub 和 Playwright 两个 MCP 服务器的完整示例: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-xxxxxxxxxxxx" + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + }, + "playwright": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-playwright"] + } + } +} +``` + +## 使用 MCP + +配置完成后,启动 `deepcode`,使用 `/mcp` 命令管理 MCP 连接: + +- `/mcp` — 查看已配置的 MCP 服务器状态 +- `/mcp add` — 添加新的 MCP 服务器 +- `/mcp remove` — 移除 MCP 服务器 +- `/mcp list` — 列出所有已连接的 MCP 服务器及其工具 + +在对话中直接使用 MCP 工具名称即可调用,例如: + +``` +帮我搜索 GitHub 上 deepcode-cli 仓库的 issues +``` + +AI 会自动调用 `mcp__github__search_issues` 工具完成操作。 + +## 工具命名规则 + +MCP 工具名称由三部分组成:`mcp__<服务名>__<工具名>` + +| 服务名 | 工具名 | 完整调用名 | +|--------|--------|-----------| +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | + +你可以通过 `/mcp list` 查看每个服务器提供的具体工具列表。 + +## 故障排查 + +### 启动失败 + +如果 MCP 服务器无法启动,检查: + +1. `command` 是否已安装(如 `npx` 需要 Node.js) +2. `env` 中的环境变量是否正确(如 `GITHUB_PERSONAL_ACCESS_TOKEN`) +3. 运行 `deepcode` 的终端是否有网络访问权限 + +### 工具不显示 + +1. 确认 `settings.json` 中的 `mcpServers` 字段格式正确 +2. 启动 deepcode 后使用 `/mcp` 查看服务器状态 +3. 如果服务器状态显示错误,根据错误信息排查 + +### Windows 用户 + +在 Windows 上,Deep Code CLI 会自动为 `.cmd` 命令添加 shell 支持。如果你的 MCP 命令是批处理脚本,确保文件名以 `.cmd` 结尾。 + +## 编写你自己的 MCP 服务器 + +MCP 服务器遵循 [Model Context Protocol](https://modelcontextprotocol.io/) 规范,使用 JSON-RPC 2.0 通信。你可以用任何语言编写 MCP 服务器,只要实现以下协议即可: + +1. `initialize` — 握手和协议协商 +2. `tools/list` — 返回可用工具列表 +3. `tools/call` — 执行工具调用 + +更多参考:[MCP 官方文档](https://modelcontextprotocol.io/) From 174b40c2202f249ebe93ede4bedd0be69783cd90 Mon Sep 17 00:00:00 2001 From: dengmik-commits Date: Tue, 12 May 2026 15:14:31 +0800 Subject: [PATCH 052/217] docs: link to MCP configuration guide from README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 385dabf..42da1b8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ 通过 MCP,你现在可以让 Deep Code 直接操作 GitHub 仓库、读取文件、查询数据库等,而无需离开终端。 +📖 **详细配置指南:** [docs/mcp.md](docs/mcp.md) + ## 安装 ```bash @@ -109,6 +111,10 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 } ``` +### 如何配置 MCP? + +Deep Code CLI 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、文件系统等外部服务。配置方法请查看:[docs/mcp.md](docs/mcp.md) + ## 获取帮助 - 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) From b417a442885292797b9b87b985025caa14b6deb7 Mon Sep 17 00:00:00 2001 From: dengmik-commits Date: Tue, 12 May 2026 15:24:36 +0800 Subject: [PATCH 053/217] docs: fix MCP package names in configuration guide --- docs/mcp.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 69b5ee7..09df8cc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -47,18 +47,18 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, | `args` | string[] | 否 | 传递给命令的参数列表 | | `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | -## 内置 MCP 示例 +## 常用 MCP 示例 ### GitHub MCP -让 Deep Code 直接操作 GitHub 仓库: +让 Deep Code 直接操作 GitHub 仓库(搜索代码、管理 Issue/PR、读写文件等): ```json { "mcpServers": { "github": { "command": "npx", - "args": ["-y", "@anthropic-ai/mcp-server-github"], + "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" } @@ -78,7 +78,7 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, "mcpServers": { "playwright": { "command": "npx", - "args": ["-y", "@anthropic-ai/mcp-server-playwright"] + "args": ["@playwright/mcp@latest"] } } } @@ -93,7 +93,7 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, "mcpServers": { "filesystem": { "command": "npx", - "args": ["-y", "@anthropic-ai/mcp-server-filesystem", "/path/to/allowed/dir"] + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"] } } } @@ -117,7 +117,7 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, ## 完整配置示例 -以下是一个配置了 GitHub 和 Playwright 两个 MCP 服务器的完整示例: +以下是一个配置了 GitHub 和 Playwright 两个 MCP 服务器的完整 `~/.deepcode/settings.json`: ```json { @@ -131,14 +131,14 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, "mcpServers": { "github": { "command": "npx", - "args": ["-y", "@anthropic-ai/mcp-server-github"], + "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" } }, "playwright": { "command": "npx", - "args": ["-y", "@anthropic-ai/mcp-server-playwright"] + "args": ["@playwright/mcp@latest"] } } } From 867ff9644dde14658bfa66aa7d79ca363928fd77 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 12 May 2026 16:22:41 +0800 Subject: [PATCH 054/217] feat: add model configuration options and /model command --- src/settings.ts | 40 ++++++- src/tests/settings-and-notify.test.ts | 99 +++++++++++++++- src/tests/slashCommands.test.ts | 9 +- src/ui/App.tsx | 58 +++++++++- src/ui/PromptInput.tsx | 156 +++++++++++++++++++++++++- src/ui/index.ts | 12 +- src/ui/slashCommands.ts | 8 +- 7 files changed, 372 insertions(+), 10 deletions(-) diff --git a/src/settings.ts b/src/settings.ts index ffbadf0..e1a9b09 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -11,6 +11,7 @@ export type ReasoningEffort = "high" | "max"; export type DeepcodingSettings = { env?: DeepcodingEnv; + model?: string; thinkingEnabled?: boolean; reasoningEffort?: ReasoningEffort; debugLogEnabled?: boolean; @@ -29,6 +30,12 @@ export type ResolvedDeepcodingSettings = { webSearchTool?: string; }; +export type ModelConfigSelection = { + model: string; + thinkingEnabled: boolean; + reasoningEffort: ReasoningEffort; +}; + function resolveReasoningEffort(value: unknown): ReasoningEffort { return value === "high" || value === "max" ? value : "max"; } @@ -51,7 +58,8 @@ export function resolveSettings( defaults: { model: string; baseURL: string } ): ResolvedDeepcodingSettings { const env = settings?.env ?? {}; - const model = env.MODEL?.trim() || defaults.model; + const topLevelModel = typeof settings?.model === "string" ? settings.model.trim() : ""; + const model = topLevelModel || env.MODEL?.trim() || defaults.model; const notify = typeof settings?.notify === "string" ? settings.notify.trim() : ""; const webSearchTool = typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : ""; @@ -66,3 +74,33 @@ export function resolveSettings( webSearchTool: webSearchTool || undefined, }; } + +export function modelConfigKey(config: Pick): string { + return config.thinkingEnabled ? `thinking:${config.reasoningEffort}` : "thinking:none"; +} + +export function applyModelConfigSelection( + settings: DeepcodingSettings | null | undefined, + current: ModelConfigSelection, + selected: ModelConfigSelection +): { settings: DeepcodingSettings; changed: boolean } { + const changed = selected.model !== current.model || modelConfigKey(selected) !== modelConfigKey(current); + const next: DeepcodingSettings = { ...(settings ?? {}) }; + + if (!changed) { + return { settings: next, changed: false }; + } + + if (selected.model !== current.model || Object.prototype.hasOwnProperty.call(next, "model")) { + next.model = selected.model; + } else { + delete next.model; + } + + next.thinkingEnabled = selected.thinkingEnabled; + if (selected.thinkingEnabled) { + next.reasoningEffort = selected.reasoningEffort; + } + + return { settings: next, changed: true }; +} diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 15f2ae0..f2c1ca1 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../notify"; -import { resolveSettings } from "../settings"; +import { applyModelConfigSelection, resolveSettings } from "../settings"; test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool", () => { const resolved = resolveSettings( @@ -33,6 +33,23 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool assert.equal(resolved.webSearchTool, "/tmp/web-search.sh"); }); +test("resolveSettings gives top-level model priority over env MODEL", () => { + const resolved = resolveSettings( + { + model: "deepseek-v4-flash", + env: { + MODEL: "deepseek-v4-pro", + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + } + ); + + assert.equal(resolved.model, "deepseek-v4-flash"); +}); + test("resolveSettings still accepts legacy env.THINKING and defaults reasoning effort when absent", () => { const resolved = resolveSettings( { @@ -128,6 +145,86 @@ test("resolveSettings defaults invalid reasoning effort to max", () => { assert.equal(resolved.reasoningEffort, "max"); }); +test("applyModelConfigSelection writes model only when the effective model changes or already exists", () => { + const result = applyModelConfigSelection( + { + env: { + MODEL: "deepseek-v4-pro", + }, + thinkingEnabled: false, + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: false, + reasoningEffort: "max", + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: true, + reasoningEffort: "high", + } + ); + + assert.equal(result.changed, true); + assert.equal(result.settings.model, undefined); + assert.equal(result.settings.thinkingEnabled, true); + assert.equal(result.settings.reasoningEffort, "high"); +}); + +test("applyModelConfigSelection persists a new selected model and thinking option", () => { + const result = applyModelConfigSelection( + { + env: { + MODEL: "deepseek-v4-pro", + BASE_URL: "https://api.deepseek.com", + API_KEY: "sk-test", + }, + thinkingEnabled: false, + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: false, + reasoningEffort: "max", + }, + { + model: "deepseek-v4-flash", + thinkingEnabled: true, + reasoningEffort: "high", + } + ); + + assert.equal(result.changed, true); + assert.equal(result.settings.env?.MODEL, "deepseek-v4-pro"); + assert.equal(result.settings.model, "deepseek-v4-flash"); + assert.equal(result.settings.thinkingEnabled, true); + assert.equal(result.settings.reasoningEffort, "high"); +}); + +test("applyModelConfigSelection leaves settings untouched when the effective selection is unchanged", () => { + const result = applyModelConfigSelection( + { + env: { + MODEL: "deepseek-v4-pro", + }, + thinkingEnabled: true, + reasoningEffort: "max", + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: true, + reasoningEffort: "max", + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: true, + reasoningEffort: "max", + } + ); + + assert.equal(result.changed, false); + assert.equal(result.settings.model, undefined); +}); + test("formatDurationSeconds preserves sub-second precision and trims trailing zeros", () => { assert.equal(formatDurationSeconds(0), "0"); assert.equal(formatDurationSeconds(1250), "1"); diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 6dcc840..7d9510a 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", "new", "init", "resume", "exit"]); + assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "exit"]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -66,6 +66,13 @@ test("findExactSlashCommand returns built-in /skills", () => { assert.equal(item?.kind, "skills"); }); +test("findExactSlashCommand returns built-in /model", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/model"); + assert.ok(item); + assert.equal(item?.kind, "model"); +}); + 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 8f23ea2..4c13909 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -14,7 +14,12 @@ import { type SkillInfo, type UserPromptContent, } from "../session"; -import { resolveSettings, type DeepcodingSettings } from "../settings"; +import { + applyModelConfigSelection, + resolveSettings, + type DeepcodingSettings, + type ModelConfigSelection, +} from "../settings"; import { PromptInput, type PromptSubmission } from "./PromptInput"; import { MessageView } from "./MessageView"; import { SessionList } from "./SessionList"; @@ -58,6 +63,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [isExiting, setIsExiting] = useState(false); const [showWelcome, setShowWelcome] = useState(true); const [welcomeNonce, setWelcomeNonce] = useState(0); + const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings()); const [nowTick, setNowTick] = useState(0); const messagesRef = useRef([]); @@ -210,6 +216,17 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R sessionManager.interruptActiveSession(); }, [sessionManager]); + const handleModelConfigChange = useCallback((selection: ModelConfigSelection): string => { + const current = resolveCurrentSettings(); + const { changed } = writeModelConfigSelection(selection, current); + const next = resolveCurrentSettings(); + setResolvedSettings(next); + if (!changed) { + return "Model settings unchanged"; + } + return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; + }, []); + const handleSubmit = useCallback( (submission: PromptSubmission) => { void handlePrompt(submission); @@ -294,7 +311,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R // eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation [busy, streamProgress, runningProcesses, nowTick] ); - const welcomeSettings = useMemo(() => resolveCurrentSettings(), []); + const welcomeSettings = resolvedSettings; const welcomeItem: SessionMessage = useMemo( () => ({ id: `__welcome__${welcomeNonce}`, @@ -379,10 +396,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R @@ -437,7 +456,7 @@ function buildStatusLine(entry: SessionEntry): string { export function readSettings(): DeepcodingSettings | null { try { - const settingsPath = path.join(os.homedir(), ".deepcode", "settings.json"); + const settingsPath = getSettingsPath(); if (!fs.existsSync(settingsPath)) { return null; } @@ -448,6 +467,24 @@ export function readSettings(): DeepcodingSettings | null { } } +export function writeSettings(settings: DeepcodingSettings): void { + const settingsPath = getSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); +} + +export function writeModelConfigSelection( + selection: ModelConfigSelection, + current: ModelConfigSelection = resolveCurrentSettings() +): { changed: boolean; settings: DeepcodingSettings } { + const rawSettings = readSettings(); + const result = applyModelConfigSelection(rawSettings, current, selection); + if (result.changed) { + writeSettings(result.settings); + } + return result; +} + export function resolveCurrentSettings(): ReturnType { return resolveSettings(readSettings(), { model: DEFAULT_MODEL, @@ -515,3 +552,18 @@ function getMachineId(): string | undefined { return undefined; } } + +function getSettingsPath(): string { + return path.join(os.homedir(), ".deepcode", "settings.json"); +} + +function formatThinkingMode(settings: Pick): string { + if (!settings.thinkingEnabled) { + return "no thinking"; + } + return `thinking ${settings.reasoningEffort}`; +} + +function formatModelConfig(settings: ModelConfigSelection): string { + return `${settings.model}, ${formatThinkingMode(settings)}`; +} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 9268728..74c38d5 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -34,6 +34,7 @@ import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; +import type { ModelConfigSelection, ReasoningEffort } from "../settings"; export type PromptSubmission = { text: string; @@ -44,6 +45,7 @@ export type PromptSubmission = { type Props = { skills: SkillInfo[]; + modelConfig: ModelConfigSelection; screenWidth: number; promptHistory: string[]; busy: boolean; @@ -51,10 +53,26 @@ type Props = { disabled?: boolean; placeholder?: string; onSubmit: (submission: PromptSubmission) => void; + onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onInterrupt: () => void; }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; + +type ThinkingModeOption = { + label: string; + thinkingEnabled: boolean; + reasoningEffort?: ReasoningEffort; +}; + +export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ + { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, + { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, + { label: "No thinking", thinkingEnabled: false }, +]; + +type ModelDropdownStep = "model" | "thinking"; const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { const [spinnerIndex, setSpinnerIndex] = useState(0); @@ -76,6 +94,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: export const PromptInput = React.memo(function PromptInput({ skills, + modelConfig, screenWidth, promptHistory, busy, @@ -83,6 +102,7 @@ export const PromptInput = React.memo(function PromptInput({ disabled, placeholder, onSubmit, + onModelConfigChange, onInterrupt, }: Props): React.ReactElement { const { exit } = useApp(); @@ -95,6 +115,9 @@ export const PromptInput = React.memo(function PromptInput({ const [menuIndex, setMenuIndex] = useState(0); const [showSkillsDropdown, setShowSkillsDropdown] = useState(false); const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); + const [modelDropdownStep, setModelDropdownStep] = useState(null); + const [modelDropdownIndex, setModelDropdownIndex] = useState(0); + const [pendingModel, setPendingModel] = useState(null); const [historyCursor, setHistoryCursor] = useState(-1); const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); @@ -103,8 +126,9 @@ export const PromptInput = React.memo(function PromptInput({ const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); const slashMenu = React.useMemo( - () => (showSkillsDropdown ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : []), - [showSkillsDropdown, slashToken, slashItems] + () => + showSkillsDropdown || modelDropdownStep ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : [], + [showSkillsDropdown, modelDropdownStep, slashToken, slashItems] ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); @@ -134,6 +158,17 @@ export const PromptInput = React.memo(function PromptInput({ } }, [skills.length, skillsDropdownIndex]); + useEffect(() => { + if (!modelDropdownStep) { + return; + } + const optionCount = + modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + if (modelDropdownIndex >= optionCount) { + setModelDropdownIndex(Math.max(0, optionCount - 1)); + } + }, [modelDropdownIndex, modelDropdownStep]); + useEffect(() => { if (!statusMessage) { return; @@ -163,6 +198,10 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.escape) { + if (modelDropdownStep) { + closeModelDropdown(); + return; + } if (showSkillsDropdown) { setShowSkillsDropdown(false); return; @@ -236,6 +275,27 @@ export const PromptInput = React.memo(function PromptInput({ } } + if (modelDropdownStep) { + const optionCount = + modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + if (key.upArrow) { + setModelDropdownIndex((idx) => (idx - 1 + optionCount) % optionCount); + return; + } + if (key.downArrow) { + setModelDropdownIndex((idx) => (idx + 1) % optionCount); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + selectModelDropdownItem(); + return; + } + if (key.tab) { + closeModelDropdown(); + return; + } + } + if (key.ctrl && (input === "v" || input === "V")) { setStatusMessage("Reading clipboard..."); readClipboardImageAsync() @@ -481,6 +541,11 @@ export const PromptInput = React.memo(function PromptInput({ setShowSkillsDropdown(true); return; } + if (item.kind === "model") { + clearSlashToken(); + openModelDropdown(); + return; + } if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); setBuffer(EMPTY_BUFFER); @@ -555,8 +620,61 @@ export const PromptInput = React.memo(function PromptInput({ setBuffer((state) => removeCurrentSlashToken(state)); } + function openModelDropdown(): void { + const currentModelIndex = MODEL_COMMAND_MODELS.findIndex((model) => model === modelConfig.model); + setPendingModel(null); + setModelDropdownStep("model"); + setModelDropdownIndex(currentModelIndex >= 0 ? currentModelIndex : 0); + setShowSkillsDropdown(false); + } + + function closeModelDropdown(): void { + setModelDropdownStep(null); + setPendingModel(null); + } + + function selectModelDropdownItem(): void { + if (modelDropdownStep === "model") { + const model = MODEL_COMMAND_MODELS[modelDropdownIndex] ?? modelConfig.model; + setPendingModel(model); + setModelDropdownStep("thinking"); + setModelDropdownIndex(getThinkingOptionIndex(modelConfig)); + return; + } + + const option = MODEL_COMMAND_THINKING_OPTIONS[modelDropdownIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]; + const selection: ModelConfigSelection = { + model: pendingModel ?? modelConfig.model, + thinkingEnabled: option.thinkingEnabled, + reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, + }; + closeModelDropdown(); + Promise.resolve(onModelConfigChange(selection)) + .then((message) => { + if (message) { + setStatusMessage(message); + } + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + setStatusMessage(`Failed to update model settings: ${message}`); + }); + } + const visibleSkillStart = Math.min(Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8)); const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); + const modelDropdownItems = + modelDropdownStep === "model" + ? MODEL_COMMAND_MODELS.map((model) => ({ + label: model, + selected: model === (pendingModel ?? modelConfig.model), + description: model === modelConfig.model ? "current model" : "", + })) + : MODEL_COMMAND_THINKING_OPTIONS.map((option) => ({ + label: option.label, + selected: getThinkingOptionIndex(modelConfig) === MODEL_COMMAND_THINKING_OPTIONS.indexOf(option), + description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", + })); return ( @@ -615,6 +733,28 @@ export const PromptInput = React.memo(function PromptInput({ space toggle · enter toggle · esc to close ) : null} + {modelDropdownStep ? ( + + + {modelDropdownStep === "model" ? "Select Model" : "Select Thinking Mode"} + + {modelDropdownItems.map((item, idx) => { + const active = idx === modelDropdownIndex; + return ( + + {active ? "› " : " "} + {item.selected ? "●" : "○"} {item.label} + {item.description ? {` ${item.description}`} : null} + + ); + })} + + {modelDropdownStep === "model" + ? "space/enter select model · esc to cancel" + : "space/enter apply · esc to cancel"} + + + ) : null} {!showMenu && ( @@ -665,6 +805,18 @@ export function buildInitPromptSubmission(selectedSkills: SkillInfo[]): PromptSu }; } +export function getThinkingOptionIndex( + config: Pick +): number { + const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { + if (!config.thinkingEnabled) { + return !option.thinkingEnabled; + } + return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort; + }); + return index >= 0 ? index : 0; +} + export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { let start = state.cursor; while (start > 0 && !/\s/.test(state.text[start - 1] ?? "")) { diff --git a/src/ui/index.ts b/src/ui/index.ts index 2120b7f..a74e330 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,4 +1,11 @@ -export { App, readSettings, resolveCurrentSettings, createOpenAIClient } from "./App"; +export { + App, + readSettings, + writeSettings, + writeModelConfigSelection, + resolveCurrentSettings, + createOpenAIClient, +} from "./App"; export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; export { MessageView, parseDiffPreview } from "./MessageView"; export { @@ -13,6 +20,9 @@ export { isClearImageAttachmentsShortcut, renderBufferWithCursor, buildInitPromptSubmission, + getThinkingOptionIndex, + MODEL_COMMAND_MODELS, + MODEL_COMMAND_THINKING_OPTIONS, useTerminalInput, parseTerminalInput, type PromptSubmission, diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index 274940f..c772124 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,6 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "new" | "init" | "resume" | "exit"; +export type SlashCommandKind = "skill" | "skills" | "model" | "new" | "init" | "resume" | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -17,6 +17,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/skills", description: "List available skills", }, + { + kind: "model", + name: "model", + label: "/model", + description: "Select model, thinking mode and thinking effort", + }, { kind: "new", name: "new", From 666c8d0bd59d6fe813eed7174c63ab85498d23c6 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 12 May 2026 16:38:58 +0800 Subject: [PATCH 055/217] feat: add .deepcode/AGENTS.md --- .deepcode/AGENTS.md | 101 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .deepcode/AGENTS.md diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md new file mode 100644 index 0000000..652cf02 --- /dev/null +++ b/.deepcode/AGENTS.md @@ -0,0 +1,101 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +``` +src/ +├── cli.tsx # Entry point — parses args, renders Ink App +├── session.ts # SessionManager — LLM loop, compaction, tool orchestration +├── settings.ts # Settings resolution from ~/.deepcode/settings.json +├── prompt.ts # System prompt builder, tool definitions, agent-drift-guard skill +├── model-capabilities.ts # Model detection and thinking-mode defaults +├── ui/ +│ ├── App.tsx # Root Ink component — state, routing, session orchestration +│ ├── PromptInput.tsx # Multi-line input with slash commands, image paste, skills +│ ├── MessageView.tsx # Renders assistant/tool messages with markdown +│ ├── SessionList.tsx # Session picker for /resume +│ └── ... +├── tools/ +│ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers +│ ├── bash-handler.ts # Executes shell commands +│ ├── read-handler.ts # Reads files and images +│ ├── write-handler.ts # Creates/overwrites files +│ ├── edit-handler.ts # Scoped string replacements in files +│ ├── web-search-handler.ts # Web search tool +│ └── ask-user-question-handler.ts # Interactive user prompts +├── tests/ # Test suite — one *.test.ts per module +docs/ +├── tools/ # Tool descriptions fed to the LLM +├── prompts/ # EJS templates (e.g., init_command.md.ejs) +dist/ # Bundled CLI output (gitignored) +``` + +## Build, Test, and Development Commands + +| Command | Purpose | +|---|---| +| `npm run typecheck` | TypeScript type checking (`tsc --noEmit`) | +| `npm run lint` | ESLint across `src/` | +| `npm run lint:fix` | ESLint with auto-fix | +| `npm run format` | Prettier on all `src/**/*.{ts,tsx}` | +| `npm run format:check` | Prettier in check-only mode | +| `npm run check` | Runs typecheck + lint + format:check together | +| `npm run bundle` | esbuild bundles `src/cli.tsx` → `dist/cli.js` (ESM, Node 18) | +| `npm run build` | `check` + `bundle` — full CI gate before publish | +| `npm test` | Runs all tests via `tsx --test src/tests/*.test.ts` | +| `npm run test:single -- ` | Run a single test file (e.g., `npm run test:single -- src/tests/session.test.ts`) | + +Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundle`). + +## Coding Style & Naming Conventions + +- **Indentation**: 2 spaces, no tabs +- **Quotes**: Double quotes (`"`) +- **Semicolons**: Required +- **Trailing commas**: `es5` (objects, arrays, etc.) +- **Line width**: 120 characters max +- **Line endings**: LF only + +**TypeScript**: Strict mode enabled. Use `import type` for type-only imports (enforced by `@typescript-eslint/consistent-type-imports`). Unused variables prefixed with `_` are allowed. + +**Formatting/Linting**: Prettier + ESLint (typescript-eslint, react-hooks). Run `npm run check` before pushing. On commit, Husky + lint-staged auto-formats staged `*.{ts,tsx,js,mjs,cjs,ejs,jsx}` and `*.json` files. + +**File naming**: `kebab-case.ts` for modules, `kebab-case.tsx` for React/Ink components. Test files: `*.test.ts`. + +## Testing Guidelines + +- **Framework**: Node.js native test runner (`node:test`) with `tsx` for TypeScript +- **Assertions**: `node:assert/strict` +- **Coverage**: Target meaningful unit tests for core logic (session management, tool handlers, settings resolution, prompt buffer). Test files are in `src/tests/` matching the source module name. +- **Test naming**: `describe`/`test` blocks with descriptive names. Example: `test("SessionManager preserves structured system content when building OpenAI messages", ...)` +- **Relaxed lint rules**: Test files allow `any` and unused vars. +- Run all tests with `npm test` before submitting a PR. + +## Commit & Pull Request Guidelines + +**Commit messages** follow conventional commits. From the project history: + +- `feat:` — new feature (e.g., `feat: add /model command`) +- `fix:` — bug fix (e.g., `fix(ui): redraw cleanly after terminal resize`) +- `chore:` — tooling, deps, hooks (e.g., `chore: add husky + lint-staged`) +- `refactor:` — code restructuring (e.g., `refactor(ui): optimize App hooks`) +- `style:` — formatting-only changes + +**Pull requests** should include: +- A clear description of what changed and why +- Link to related issue(s) if applicable +- Screenshots or terminal recordings for UI changes +- All checks passing (`npm run check && npm test`) +- No unintended changes to `dist/` or `package-lock.json` without justification + +## Architecture Overview + +The CLI renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). + +Six tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `docs/tools/`. + +## Agent-Specific Instructions + +- **AGENTS.md loading**: The CLI loads agent instructions from `./AGENTS.md`, `./.deepcode/AGENTS.md`, or `~/.deepcode/AGENTS.md` (first found wins). Write project-specific guidance for the LLM in any of these. +- **Skills**: Place skill definitions in `~/.agents/skills//SKILL.md` (user-level) or `./.agents/skills//SKILL.md` (project-level). Legacy path `./.deepcode/skills/` is also supported. Each SKILL.md uses YAML frontmatter with `name` and `description` fields. +- The built-in `agent-drift-guard` skill is always injected into every session. From 3be90b0a0e6f5843ee459eeec049a7663cc301f3 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 12 May 2026 17:26:56 +0800 Subject: [PATCH 056/217] feat: refactor /init handling --- src/session.ts | 26 ++++++++++++------------- src/tests/session.test.ts | 41 +++++++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/session.ts b/src/session.ts index 2267bb0..0116fbd 100644 --- a/src/session.ts +++ b/src/session.ts @@ -779,7 +779,6 @@ The candidate skills are as follows:\n\n`; this.reportNewPrompt(); const signal = controller?.signal; this.throwIfAborted(signal); - this.applyInitCommandPrompt(userPrompt); if (userPrompt.text) { const skills = await this.listSkills(); @@ -870,7 +869,6 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); - this.applyInitCommandPrompt(userPrompt); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, @@ -1461,13 +1459,6 @@ ${skillMd} }; } - private applyInitCommandPrompt(userPrompt: UserPromptContent): void { - if (userPrompt.text !== "/init") { - return; - } - userPrompt.text = this.renderInitCommandPrompt(); - } - private renderInitCommandPrompt(): string { const templatePath = path.join(getExtensionRoot(), "docs", "prompts", "init_command.md.ejs"); const template = fs.readFileSync(templatePath, "utf8"); @@ -1693,9 +1684,10 @@ ${skillMd} } private sessionMessageToOpenAIMessage(message: SessionMessage, thinkingEnabled: boolean): ChatCompletionMessageParam { + const content = this.renderOpenAIMessageContent(message); const base: ChatCompletionMessageParam = { role: message.role, - content: message.content ?? "", + content, } as ChatCompletionMessageParam; const messageParams = message.messageParams as @@ -1718,8 +1710,8 @@ ${skillMd} if ((message.role === "user" || message.role === "system") && message.contentParams) { const contentParts: ChatCompletionContentPart[] = []; - if (message.content) { - contentParts.push({ type: "text", text: message.content }); + if (content) { + contentParts.push({ type: "text", text: content }); } const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; for (const param of params) { @@ -1727,14 +1719,20 @@ ${skillMd} contentParts.push(param as ChatCompletionContentPart); } } - const contentValue: string | ChatCompletionContentPart[] = - contentParts.length > 0 ? contentParts : (message.content ?? ""); + const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content; (base as { content: string | ChatCompletionContentPart[] }).content = contentValue; } return base; } + private renderOpenAIMessageContent(message: SessionMessage): string { + if (message.role === "user" && message.content === "/init") { + return this.renderInitCommandPrompt(); + } + return message.content ?? ""; + } + private pairToolMessages(messages: SessionMessage[]): Map { const pairings = new Map(); const usedToolMessageIndexes = new Set(); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index de311ea..64bcb9d 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -322,7 +322,7 @@ test("SessionManager lists project skills from .agents with legacy .deepcode com assert.equal(sharedSkill?.description, "Project .agents skill"); }); -test("createSession expands /init with the active .deepcode project AGENTS path", async () => { +test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-deepcode-workspace-"); const home = createTempDir("deepcode-init-deepcode-home-"); process.env.HOME = home; @@ -338,17 +338,23 @@ test("createSession expands /init with the active .deepcode project AGENTS path" const sessionId = await manager.createSession({ text: "/init" }); const messages = manager.listSessionMessages(sessionId); const userMessage = messages.find((message) => message.role === "user"); + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + role: string; + content: string; + }>; + const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); const systemContents = messages .filter((message) => message.role === "system") .map((message) => message.content ?? ""); - assert.match(userMessage?.content ?? "", /Update \.\/\.deepcode\/AGENTS\.md/); - assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); + assert.equal(userMessage?.content, "/init"); + assert.match(openAIUserMessage?.content ?? "", /Update \.\/\.deepcode\/AGENTS\.md/); + assert.doesNotMatch(openAIUserMessage?.content ?? "", /Update \.\/AGENTS\.md/); assert.ok(systemContents.includes("deepcode project instructions")); assert.ok(!systemContents.includes("root project instructions")); }); -test("replySession expands /init with the active root project AGENTS path", async () => { +test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-root-workspace-"); const home = createTempDir("deepcode-init-root-home-"); process.env.HOME = home; @@ -361,13 +367,21 @@ test("replySession expands /init with the active root project AGENTS path", asyn const sessionId = await manager.createSession({ text: "first prompt" }); await manager.replySession(sessionId, { text: "/init" }); - const userMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "user"); + const messages = manager.listSessionMessages(sessionId); + const userMessages = messages.filter((message) => message.role === "user"); const replyMessage = userMessages[userMessages.length - 1]; + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + role: string; + content: string; + }>; + const openAIUserMessages = openAIMessages.filter((message) => message.role === "user"); + const openAIReplyMessage = openAIUserMessages[openAIUserMessages.length - 1]; - assert.match(replyMessage?.content ?? "", /Update \.\/AGENTS\.md/); + assert.equal(replyMessage?.content, "/init"); + assert.match(openAIReplyMessage?.content ?? "", /Update \.\/AGENTS\.md/); }); -test("createSession expands /init as generate when no project AGENTS file is effective", async () => { +test("createSession stores /init and sends generate prompt when no project AGENTS file is effective", async () => { const workspace = createTempDir("deepcode-init-generate-workspace-"); const home = createTempDir("deepcode-init-generate-home-"); process.env.HOME = home; @@ -380,10 +394,17 @@ test("createSession expands /init as generate when no project AGENTS file is eff (manager as any).activateSession = async () => {}; const sessionId = await manager.createSession({ text: "/init" }); - const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + const messages = manager.listSessionMessages(sessionId); + const userMessage = messages.find((message) => message.role === "user"); + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + role: string; + content: string; + }>; + const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); - assert.match(userMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); - assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); + assert.equal(userMessage?.content, "/init"); + assert.match(openAIUserMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); + assert.doesNotMatch(openAIUserMessage?.content ?? "", /Update \.\/AGENTS\.md/); }); test("createSession reports a new prompt with the machineId token", async () => { From ba6a98849a045b75d43580103324d0167ee1bc9c Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 12 May 2026 17:30:40 +0800 Subject: [PATCH 057/217] 0.1.19 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2528b4c..fa741b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.18", + "version": "0.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.18", + "version": "0.1.19", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index 35a5cab..0705a0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.18", + "version": "0.1.19", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From 4a6f569f787ba847c277c9c2c50068c5d4e9f3db Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 13 May 2026 11:52:53 +0800 Subject: [PATCH 058/217] feat: improve session manager disposal and cleanup logic --- src/cli.tsx | 5 ++++- src/session.ts | 4 ++++ src/tests/session.test.ts | 40 +++++++++++++++++++++++++++++++++++++++ src/tools/mcp-client.ts | 2 +- src/tools/mcp-manager.ts | 1 + src/ui/App.tsx | 7 +++++++ 6 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index f04125f..d4bbd62 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -64,19 +64,22 @@ async function main(): Promise { const restartRef: { current: (() => void) | null } = { current: null }; function startApp(): void { + let restarting = false; const inkInstance = render( restartRef.current?.()} />, { exitOnCtrlC: false } ); restartRef.current = () => { + restarting = true; process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); inkInstance.unmount(); startApp(); }; inkInstance.waitUntilExit().then(() => { - if (!restartRef.current) { + if (!restarting) { + restartRef.current = null; process.exit(0); } }); diff --git a/src/session.ts b/src/session.ts index a3d6a3e..e40e9aa 100644 --- a/src/session.ts +++ b/src/session.ts @@ -205,6 +205,10 @@ export class SessionManager { return this.mcpManager.getStatus(); } + dispose(): void { + this.mcpManager.disconnect(); + } + private estimateStreamTokens(text: string): number { let tokens = 0; for (const char of text) { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 64bcb9d..80fa2c6 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -322,6 +322,46 @@ test("SessionManager lists project skills from .agents with legacy .deepcode com assert.equal(sharedSkill?.description, "Project .agents skill"); }); +test("SessionManager dispose disconnects MCP servers", async () => { + const workspace = createTempDir("deepcode-mcp-dispose-workspace-"); + const serverPath = path.join(workspace, "mcp-server.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [{ name: "echo", inputSchema: { type: "object", properties: {} } }] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-dispose"); + await manager.initMcpServers({ smoke: { command: process.execPath, args: [serverPath] } }); + + assert.deepEqual(manager.getMcpStatus(), [{ name: "smoke", connected: true, toolCount: 1, tools: ["echo"] }]); + + manager.dispose(); + + assert.deepEqual(manager.getMcpStatus(), []); +}); + test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-deepcode-workspace-"); const home = createTempDir("deepcode-init-deepcode-home-"); diff --git a/src/tools/mcp-client.ts b/src/tools/mcp-client.ts index 89b0c2c..7854b0f 100644 --- a/src/tools/mcp-client.ts +++ b/src/tools/mcp-client.ts @@ -88,7 +88,7 @@ export class McpClient { }); if (this.process.stderr) { - this.process.stderr.on("data", (data: Buffer) => { + this.process.stderr.on("data", (_data: Buffer) => { // MCP servers log to stderr; we ignore for now }); } diff --git a/src/tools/mcp-manager.ts b/src/tools/mcp-manager.ts index 4bac5e9..c6cd1db 100644 --- a/src/tools/mcp-manager.ts +++ b/src/tools/mcp-manager.ts @@ -140,6 +140,7 @@ export class McpManager { } this.clients = []; this.tools = []; + this.serverStatuses = []; this.initialized = false; } } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index beab530..9186c10 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -131,6 +131,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R void sessionManager.initMcpServers(settings.mcpServers); }, [sessionManager]); + useEffect(() => { + return () => { + sessionManager.dispose(); + }; + }, [sessionManager]); + const writeRef = useRef(write); writeRef.current = write; const handlePrompt = useCallback( @@ -150,6 +156,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R process.stdout.write("\n\n"); process.stdout.write(summary); process.stdout.write("\n\n"); + sessionManager.dispose(); exit(); }, 0); return; From 238c09910deda3c455d225ce7d46ebdcd9af8f85 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 13 May 2026 15:05:45 +0800 Subject: [PATCH 059/217] feat: enhance MCP server management and improve UI feedback --- docs/mcp.md | 20 +++---- src/session.ts | 10 ++-- src/tests/session.test.ts | 120 +++++++++++++++++++++++++++++++++++++- src/tools/executor.ts | 2 +- src/tools/mcp-client.ts | 100 ++++++++++++++++++++++++------- src/tools/mcp-manager.ts | 87 +++++++++++++++++++++++---- src/ui/App.tsx | 23 ++++---- 7 files changed, 301 insertions(+), 61 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 09df8cc..fe6711d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -41,11 +41,11 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, ### 配置项说明 -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`) | -| `args` | string[] | 否 | 传递给命令的参数列表 | -| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | +| 字段 | 类型 | 必填 | 说明 | +| --------- | -------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | ## 常用 MCP 示例 @@ -165,11 +165,11 @@ AI 会自动调用 `mcp__github__search_issues` 工具完成操作。 MCP 工具名称由三部分组成:`mcp__<服务名>__<工具名>` -| 服务名 | 工具名 | 完整调用名 | -|--------|--------|-----------| -| github | search_code | `mcp__github__search_code` | -| github | create_pull_request | `mcp__github__create_pull_request` | -| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| 服务名 | 工具名 | 完整调用名 | +| ---------- | ----------------------- | ------------------------------------------ | +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | | playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | 你可以通过 `/mcp list` 查看每个服务器提供的具体工具列表。 diff --git a/src/session.ts b/src/session.ts index e40e9aa..58a2c72 100644 --- a/src/session.ts +++ b/src/session.ts @@ -12,6 +12,7 @@ import { DEEPSEEK_V4_MODELS } from "./model-capabilities"; import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt"; import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; import { McpManager } from "./tools/mcp-manager"; +import type { McpServerConfig } from "./settings"; import { logApiError } from "./error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; @@ -154,7 +155,7 @@ export type SkillInfo = { type SessionManagerOptions = { projectRoot: string; createOpenAIClient: CreateOpenAIClient; - getResolvedSettings: () => { webSearchTool?: string }; + getResolvedSettings: () => { webSearchTool?: string; mcpServers?: Record }; renderMarkdown: (text: string) => string; onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; @@ -173,7 +174,7 @@ export type LlmStreamProgress = { export class SessionManager { private readonly projectRoot: string; private readonly createOpenAIClient: CreateOpenAIClient; - private readonly getResolvedSettings: () => { webSearchTool?: string }; + private readonly getResolvedSettings: () => { webSearchTool?: string; mcpServers?: Record }; private readonly onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; @@ -192,11 +193,10 @@ export class SessionManager { this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); + this.mcpManager.prepare(this.getResolvedSettings().mcpServers); } - async initMcpServers( - servers?: Record }> - ): Promise { + async initMcpServers(servers?: Record): Promise { await this.mcpManager.initialize(servers); this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); } diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 80fa2c6..2833342 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -343,7 +343,19 @@ rl.on("line", (line) => { return; } if (request.method === "tools/list") { - send({ jsonrpc: "2.0", id: request.id, result: { tools: [{ name: "echo", inputSchema: { type: "object", properties: {} } }] } }); + if (request.params && request.params.cursor === "page-2") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "count", inputSchema: { type: "object", properties: {} } } + ] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "echo", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } } + ], nextCursor: "page-2" } }); + return; + } + if (request.method === "tools/call") { + send({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: request.params.name + ":" + (request.params.arguments.text || "") }] } }); return; } send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); @@ -353,15 +365,117 @@ rl.on("line", (line) => { ); const manager = createSessionManager(workspace, "machine-id-mcp-dispose"); - await manager.initMcpServers({ smoke: { command: process.execPath, args: [serverPath] } }); + const initPromise = manager.initMcpServers({ smoke: { command: process.execPath, args: [serverPath] } }); + + assert.deepEqual(manager.getMcpStatus(), [ + { name: "smoke", status: "starting", connected: false, toolCount: 0, tools: [] }, + ]); - assert.deepEqual(manager.getMcpStatus(), [{ name: "smoke", connected: true, toolCount: 1, tools: ["echo"] }]); + await initPromise; + + assert.deepEqual(manager.getMcpStatus(), [ + { + name: "smoke", + status: "ready", + connected: true, + toolCount: 2, + tools: ["mcp__smoke__echo", "mcp__smoke__count"], + }, + ]); + const mcpManager = (manager as any).mcpManager; + assert.equal(mcpManager.getMcpToolDefinitions()[0].function.name, "mcp__smoke__echo"); + assert.deepEqual(await mcpManager.executeMcpTool("mcp__smoke__echo", { text: "ok" }), { + ok: true, + name: "mcp__smoke__echo", + output: "echo:ok", + }); manager.dispose(); assert.deepEqual(manager.getMcpStatus(), []); }); +test("SessionManager reports configured MCP servers as starting before initialization", () => { + const workspace = createTempDir("deepcode-mcp-configured-workspace-"); + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ + mcpServers: { + playwright: { command: "npx", args: ["@playwright/mcp@latest"] }, + }, + }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + assert.deepEqual(manager.getMcpStatus(), [ + { name: "playwright", status: "starting", connected: false, toolCount: 0, tools: [] }, + ]); +}); + +test("SessionManager reports MCP startup stderr on failure", async () => { + const workspace = createTempDir("deepcode-mcp-failure-workspace-"); + const serverPath = path.join(workspace, "mcp-server-fail.cjs"); + fs.writeFileSync(serverPath, 'process.stderr.write("mcp startup boom"); process.exit(7);', "utf8"); + + const manager = createSessionManager(workspace, "machine-id-mcp-failure"); + await manager.initMcpServers({ broken: { command: process.execPath, args: [serverPath] } }); + + const [status] = manager.getMcpStatus(); + assert.equal(status?.name, "broken"); + assert.equal(status?.status, "failed"); + assert.equal(status?.connected, false); + assert.match(status?.error ?? "", /mcp startup boom/); +}); + +test("SessionManager adds -y when launching MCP servers through npx", async () => { + const workspace = createTempDir("deepcode-mcp-npx-workspace-"); + const argsPath = path.join(workspace, "args.json"); + const fakeNpxPath = path.join(workspace, "npx"); + fs.writeFileSync( + fakeNpxPath, + `#!/usr/bin/env node +const fs = require("fs"); +const readline = require("readline"); +fs.writeFileSync(process.env.ARGS_PATH, JSON.stringify(process.argv.slice(2))); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + fs.chmodSync(fakeNpxPath, 0o755); + + const manager = createSessionManager(workspace, "machine-id-mcp-npx"); + await manager.initMcpServers({ + npxed: { command: fakeNpxPath, args: ["@playwright/mcp@latest"], env: { ARGS_PATH: argsPath } }, + }); + + assert.deepEqual(JSON.parse(fs.readFileSync(argsPath, "utf8")) as string[], ["-y", "@playwright/mcp@latest"]); + manager.dispose(); +}); + test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-deepcode-workspace-"); const home = createTempDir("deepcode-init-deepcode-home-"); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index d857ad7..bf70264 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -165,7 +165,7 @@ export class ToolExecutor { const handler = this.toolHandlers.get(toolName); if (!handler) { // Try MCP tools - if (toolName.startsWith("mcp__") && this.mcpManager) { + if (this.mcpManager?.isMcpTool(toolName)) { const parsedArgs = this.parseToolArguments(toolCall.function.arguments); const args = parsedArgs.ok ? parsedArgs.args : {}; return this.mcpManager.executeMcpTool(toolName, args); diff --git a/src/tools/mcp-client.ts b/src/tools/mcp-client.ts index 7854b0f..0fad322 100644 --- a/src/tools/mcp-client.ts +++ b/src/tools/mcp-client.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from "child_process"; import { createInterface, type Interface } from "readline"; import * as os from "os"; +import * as path from "path"; type JsonRpcRequest = { jsonrpc: "2.0"; @@ -28,6 +29,7 @@ export type McpToolDefinition = { type ListToolsResult = { tools: McpToolDefinition[]; + nextCursor?: string; }; type CallToolResult = { @@ -39,8 +41,11 @@ export class McpClient { private process: ChildProcess | null = null; private reader: Interface | null = null; private nextId = 1; - private pendingRequests = new Map void; reject: (error: Error) => void }>(); - private buffer = ""; + private pendingRequests = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout } + >(); + private stderrBuffer = ""; constructor( private readonly serverName: string, @@ -49,19 +54,20 @@ export class McpClient { private readonly env?: Record ) {} - async connect(): Promise { + async connect(timeoutMs: number): Promise { return new Promise((resolve, reject) => { const childEnv = { ...process.env, ...this.env, }; + const args = this.withNpxYesArg(this.command, this.args); const isWindows = os.platform() === "win32"; if (isWindows) { // On Windows, .cmd files require shell: true to be spawned. // Build a single command string so cmd.exe handles quoting correctly. - const cmd = [this.command + ".cmd", ...this.args].join(" "); + const cmd = [this.command + ".cmd", ...args].join(" "); this.process = spawn(cmd, [], { stdio: ["pipe", "pipe", "pipe"], env: childEnv, @@ -69,27 +75,28 @@ export class McpClient { windowsHide: true, }); } else { - this.process = spawn(this.command, this.args, { + this.process = spawn(this.command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv, }); } this.process.on("error", (err) => { - reject(new Error(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`)); + reject(this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`)); }); - this.process.on("exit", (code) => { - const error = new Error(`MCP server "${this.serverName}" exited with code ${code}`); + this.process.on("close", (code) => { + const error = this.withStderr(`MCP server "${this.serverName}" exited with code ${code}`); for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); pending.reject(error); } this.pendingRequests.clear(); }); if (this.process.stderr) { - this.process.stderr.on("data", (_data: Buffer) => { - // MCP servers log to stderr; we ignore for now + this.process.stderr.on("data", (data: Buffer) => { + this.appendStderr(data.toString("utf8")); }); } @@ -99,11 +106,15 @@ export class McpClient { }); // Send initialize request (MCP protocol handshake) - this.sendRequest("initialize", { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "deepcode-cli", version: "0.1.0" }, - }) + this.sendRequest( + "initialize", + { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "deepcode-cli", version: "0.1.0" }, + }, + timeoutMs + ) .then(() => { // Send initialized notification this.sendNotification("notifications/initialized"); @@ -113,9 +124,21 @@ export class McpClient { }); } - async listTools(): Promise { - const result = (await this.sendRequest("tools/list", {})) as ListToolsResult; - return result.tools ?? []; + async listTools(timeoutMs: number): Promise { + const tools: McpToolDefinition[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 100; page++) { + const params = cursor ? { cursor } : {}; + const result = (await this.sendRequest("tools/list", params, timeoutMs)) as ListToolsResult; + tools.push(...(result.tools ?? [])); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined; + if (!cursor) { + return tools; + } + } + + throw this.withStderr(`MCP server "${this.serverName}" returned too many tools/list pages`); } async callTool(name: string, args: Record): Promise { @@ -133,7 +156,7 @@ export class McpClient { } } - private sendRequest(method: string, params: Record): Promise { + private sendRequest(method: string, params: Record, timeoutMs = 30_000): Promise { return new Promise((resolve, reject) => { const id = this.nextId++; const request: JsonRpcRequest = { @@ -142,7 +165,15 @@ export class McpClient { method, params, }; - this.pendingRequests.set(id, { resolve, reject }); + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject( + this.withStderr( + `Timed out after ${timeoutMs}ms waiting for MCP server "${this.serverName}" to respond to ${method}` + ) + ); + }, timeoutMs); + this.pendingRequests.set(id, { resolve, reject, timer }); this.writeLine(JSON.stringify(request)); }); } @@ -168,8 +199,9 @@ export class McpClient { if (message.id !== undefined && this.pendingRequests.has(message.id)) { const pending = this.pendingRequests.get(message.id)!; this.pendingRequests.delete(message.id); + clearTimeout(pending.timer); if (message.error) { - pending.reject(new Error(`MCP error: ${message.error.message}`)); + pending.reject(this.withStderr(`MCP error: ${message.error.message}`)); } else { pending.resolve(message.result); } @@ -178,4 +210,30 @@ export class McpClient { // Ignore unparseable lines } } + + private withNpxYesArg(command: string, args: string[]): string[] { + const executable = path + .basename(command) + .toLowerCase() + .replace(/\.cmd$/, ""); + if (executable !== "npx") { + return args; + } + if (args.includes("-y") || args.includes("--yes")) { + return args; + } + return ["-y", ...args]; + } + + private appendStderr(text: string): void { + this.stderrBuffer = `${this.stderrBuffer}${text}`; + if (this.stderrBuffer.length > 4000) { + this.stderrBuffer = this.stderrBuffer.slice(-4000); + } + } + + private withStderr(message: string): Error { + const stderr = this.stderrBuffer.trim(); + return new Error(stderr ? `${message}. stderr: ${stderr}` : message); + } } diff --git a/src/tools/mcp-manager.ts b/src/tools/mcp-manager.ts index c6cd1db..030d0d3 100644 --- a/src/tools/mcp-manager.ts +++ b/src/tools/mcp-manager.ts @@ -1,6 +1,8 @@ import { McpClient, type McpToolDefinition } from "./mcp-client"; import type { McpServerConfig } from "../settings"; +const MCP_STARTUP_TIMEOUT_MS = 30_000; + type McpToolEntry = { serverName: string; originalName: string; @@ -11,6 +13,7 @@ type McpToolEntry = { export type McpServerStatus = { name: string; + status: "starting" | "ready" | "failed"; connected: boolean; error?: string; toolCount: number; @@ -21,23 +24,58 @@ export class McpManager { private clients: McpClient[] = []; private tools: McpToolEntry[] = []; private initialized = false; + private disposed = false; + private configuredServerNames: string[] = []; private serverStatuses: McpServerStatus[] = []; + prepare(servers?: Record): void { + if (!servers || Object.keys(servers).length === 0) return; + // Clear the disposed flag — a re-prepare means we are live again. + // (disconnect() sets disposed=true to stop a stale initialize() loop, + // but prepare+initialize must be able to start a new one.) + this.disposed = false; + + for (const name of Object.keys(servers)) { + if (!this.configuredServerNames.includes(name)) { + this.configuredServerNames.push(name); + } + if (this.serverStatuses.some((status) => status.name === name)) { + continue; + } + this.setStatus({ + name, + status: "starting", + connected: false, + toolCount: 0, + tools: [], + }); + } + } + async initialize(servers?: Record): Promise { - if (this.initialized) return; + if (this.initialized || this.disposed) return; this.initialized = true; if (!servers || Object.keys(servers).length === 0) return; const entries = Object.entries(servers); + this.prepare(servers); + for (const [name, config] of entries) { + if (this.disposed) break; + let client: McpClient | null = null; try { - const client = new McpClient(name, config.command, config.args ?? [], config.env); - await client.connect(); + client = new McpClient(name, config.command, config.args ?? [], config.env); + await client.connect(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) { + client.disconnect(); + break; + } this.clients.push(client); - const serverTools = await client.listTools(); - const toolNames: string[] = []; + const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) break; + const toolNamespacedNames: string[] = []; for (const tool of serverTools) { const namespacedName = `mcp__${name}__${tool.name}`; this.tools.push({ @@ -47,19 +85,23 @@ export class McpManager { definition: tool, client, }); - toolNames.push(tool.name); + toolNamespacedNames.push(namespacedName); } - this.serverStatuses.push({ + this.setStatus({ name, + status: "ready", connected: true, toolCount: serverTools.length, - tools: toolNames, + tools: toolNamespacedNames, }); } catch (err) { + if (this.disposed) break; + client?.disconnect(); const message = err instanceof Error ? err.message : String(err); process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`); - this.serverStatuses.push({ + this.setStatus({ name, + status: "failed", connected: false, error: message, toolCount: 0, @@ -70,7 +112,20 @@ export class McpManager { } getStatus(): McpServerStatus[] { - return this.serverStatuses; + const result = [...this.serverStatuses]; + const knownNames = new Set(result.map((s) => s.name)); + for (const name of this.configuredServerNames) { + if (!knownNames.has(name)) { + result.push({ + name, + status: "starting", + connected: false, + toolCount: 0, + tools: [], + }); + } + } + return result; } getMcpToolDefinitions(): Array<{ @@ -135,12 +190,24 @@ export class McpManager { } disconnect(): void { + this.disposed = true; for (const client of this.clients) { client.disconnect(); } this.clients = []; this.tools = []; this.serverStatuses = []; + this.configuredServerNames = []; this.initialized = false; } + + private setStatus(status: McpServerStatus): void { + if (this.disposed) return; + const index = this.serverStatuses.findIndex((s) => s.name === status.name); + if (index === -1) { + this.serverStatuses.push(status); + return; + } + this.serverStatuses[index] = status; + } } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 9186c10..dd13d0f 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; import chalk from "chalk"; import * as fs from "fs"; @@ -126,7 +126,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R void refreshSkills(); }, [refreshSessionsList, refreshSkills]); - useEffect(() => { + useLayoutEffect(() => { const settings = resolveCurrentSettings(); void sessionManager.initMcpServers(settings.mcpServers); }, [sessionManager]); @@ -195,25 +195,26 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R process.stdout.write(chalk.dim(" No MCP servers configured.\n")); } else { for (const s of statuses) { - const icon = s.connected ? chalk.green("✔") : chalk.red("✖"); - process.stdout.write(` ${icon} ${chalk.bold(s.name)}`); - if (s.connected) { - process.stdout.write(chalk.dim(` (${s.toolCount} tools)`)); + if (s.status === "starting") { + process.stdout.write(`${chalk.yellow("●")} ${chalk.bold(s.name)} - Starting...`); + } else if (s.status === "failed") { + process.stdout.write(`${chalk.red("✖")} ${chalk.bold(s.name)} - Failed (${s.error ?? "unknown error"})`); } else { - process.stdout.write(chalk.dim(` — ${s.error ?? "failed"}`)); + process.stdout.write(`${chalk.green("✔")} ${chalk.bold(s.name)} - Ready (${s.toolCount} tools)`); } process.stdout.write("\n"); - if (s.connected && s.tools.length > 0) { + if (s.status === "ready" && s.tools.length > 0) { for (const tool of s.tools) { - process.stdout.write(chalk.dim(` - mcp__${s.name}__${tool}\n`)); + process.stdout.write(chalk.dim(` - ${tool}\n`)); } } } } process.stdout.write(chalk.dim("─────────────────\n")); process.stdout.write( - chalk.dim(` Total: ${statuses.filter((s) => s.connected).length} connected, `) + - chalk.dim(`${statuses.filter((s) => !s.connected).length} failed\n`) + chalk.dim(` Total: ${statuses.filter((s) => s.status === "ready").length} ready, `) + + chalk.dim(`${statuses.filter((s) => s.status === "starting").length} starting, `) + + chalk.dim(`${statuses.filter((s) => s.status === "failed").length} failed\n`) ); process.stdout.write("\n"); return; From d2ee59c16a4e35de1fc90f6e661132436a5fe5e3 Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Wed, 13 May 2026 15:08:26 +0800 Subject: [PATCH 060/217] fix: filter image_url content from API messages for DeepSeek compatibility DeepSeek API does not support the image_url message content type, causing 400 errors when messages contain images from clipboard paste or Read tool. Filter out image_url parts before sending to the API. Fixes #50 --- src/session.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/session.ts b/src/session.ts index a3d6a3e..3419457 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1729,8 +1729,9 @@ ${skillMd} } const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; for (const param of params) { - if (param && typeof param === "object") { - contentParts.push(param as ChatCompletionContentPart); + const part = param as ChatCompletionContentPart; + if (part && part.type !== "image_url") { + contentParts.push(part); } } const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content; From e56a112fba198cfebff138e2c6d40656c7563c53 Mon Sep 17 00:00:00 2001 From: lellansin Date: Wed, 13 May 2026 15:44:26 +0800 Subject: [PATCH 061/217] feat: support Shift+Enter prompt newlines Enable terminal extended key reporting while the prompt is active so terminals can distinguish Shift+Enter from plain Enter. Treat shifted return sequences as newline insertion while preserving plain Enter submission, and add unit coverage for the return-key behavior and terminal escape helpers. --- src/tests/promptInputKeys.test.ts | 29 ++++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 32 ++++++++++++++++++++++--------- src/ui/index.ts | 3 ++- src/ui/prompt/cursor.ts | 21 ++++++++++++++++++++ src/ui/prompt/index.ts | 1 + src/ui/prompt/useTerminalInput.ts | 2 +- 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index a17d7a4..704d44a 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -12,12 +12,15 @@ import { formatImageAttachmentStatus, formatSelectedSkillsStatus, getPromptCursorPlacement, + getPromptReturnKeyAction, isClearImageAttachmentsShortcut, parseTerminalInput, removeCurrentSlashToken, toggleSkillSelection, renderBufferWithCursor, buildInitPromptSubmission, + disableTerminalExtendedKeys, + enableTerminalExtendedKeys, } from "../ui"; import type { SkillInfo } from "../session"; @@ -83,6 +86,32 @@ test("parseTerminalInput recognizes shifted return sequences", () => { assert.equal(key.meta, false); }); +test("prompt return key action submits on plain enter", () => { + const { key } = parseTerminalInput("\r"); + assert.equal(getPromptReturnKeyAction(key), "submit"); +}); + +test("prompt return key action inserts newline on shift+enter", () => { + const { key } = parseTerminalInput("\u001B[13;2u"); + assert.equal(key.return, true); + assert.equal(key.shift, true); + assert.equal(getPromptReturnKeyAction(key), "newline"); +}); + +test("parseTerminalInput recognizes alternate shifted return sequences", () => { + for (const sequence of ["\u001B[13;2~", "\u001B[27;2;13~"]) { + const { key } = parseTerminalInput(sequence); + assert.equal(key.return, true); + assert.equal(key.shift, true); + assert.equal(getPromptReturnKeyAction(key), "newline"); + } +}); + +test("terminal extended key helpers request and restore modifyOtherKeys mode", () => { + assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m"); + assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); +}); + test("parseTerminalInput recognizes terminal focus events", () => { const focusIn = parseTerminalInput("\u001B[I"); const focusOut = parseTerminalInput("\u001B[O"); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 24560e2..b878961 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -32,7 +32,7 @@ export type { InputKey } from "./prompt"; import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; -import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt"; +import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; import type { ModelConfigSelection, ReasoningEffort } from "../settings"; @@ -140,6 +140,7 @@ export const PromptInput = React.memo(function PromptInput({ : "esc to interrupt · ctrl+c to cancel input" : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; useTerminalFocusReporting(stdout, !disabled); + useTerminalExtendedKeys(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); useEffect(() => { @@ -324,7 +325,8 @@ export const PromptInput = React.memo(function PromptInput({ } const noModifier = !key.shift && !key.ctrl && !key.meta; - const isPlainReturn = key.return && !key.shift && !key.meta; + const returnAction = getPromptReturnKeyAction(key); + const isPlainReturn = returnAction === "submit"; if (showMenu) { if (key.upArrow) { @@ -335,7 +337,7 @@ export const PromptInput = React.memo(function PromptInput({ setMenuIndex((idx) => (idx + 1) % slashMenu.length); return; } - if (key.tab || (key.return && !key.shift && !key.meta)) { + if (key.tab || returnAction === "submit") { const selected = slashMenu[menuIndex]; if (selected) { handleSlashSelection(selected); @@ -349,12 +351,12 @@ export const PromptInput = React.memo(function PromptInput({ return; } - if (key.return) { - const isShiftEnter = key.shift || key.meta; - if (isShiftEnter) { - updateBuffer((s) => insertText(s, "\n")); - return; - } + if (returnAction === "newline") { + updateBuffer((s) => insertText(s, "\n")); + return; + } + + if (returnAction === "submit") { submitCurrentBuffer(); return; } @@ -844,6 +846,18 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick): PromptReturnKeyAction { + if (!key.return) { + return null; + } + if (key.shift || key.meta) { + return "newline"; + } + return "submit"; +} + export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean, placeholder?: string): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); diff --git a/src/ui/index.ts b/src/ui/index.ts index a74e330..56e6ea2 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -18,6 +18,7 @@ export { toggleSkillSelection, removeCurrentSlashToken, isClearImageAttachmentsShortcut, + getPromptReturnKeyAction, renderBufferWithCursor, buildInitPromptSubmission, getThinkingOptionIndex, @@ -28,7 +29,7 @@ export { type PromptSubmission, type InputKey, } from "./PromptInput"; -export { getPromptCursorPlacement } from "./prompt/cursor"; +export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; export { SessionList, formatSessionTitle } from "./SessionList"; export { ThemedGradient } from "./ThemedGradient"; export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 8ccdc60..2668470 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,6 +40,14 @@ function disableTerminalFocusReporting(): string { return "\u001B[?1004l"; } +export function enableTerminalExtendedKeys(): string { + return "\u001B[>4;1m"; +} + +export function disableTerminalExtendedKeys(): string { + return "\u001B[>4;0m"; +} + export function getPromptCursorPlacement( state: PromptBufferState, screenWidth: number, @@ -239,3 +247,16 @@ export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined }; }, [isActive, stdout]); } + +export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableTerminalExtendedKeys()); + return () => { + stdout.write(disableTerminalExtendedKeys()); + }; + }, [isActive, stdout]); +} diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index 857b8ac..a33172c 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -3,6 +3,7 @@ export type { InputKey } from "./useTerminalInput"; export { useHiddenTerminalCursor, + useTerminalExtendedKeys, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index a0f454f..af830e5 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -26,7 +26,7 @@ const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]); const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]); const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]); -const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u"]); +const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u", "\u001B[13;2~", "\u001B[27;2;13~"]); const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]); const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]); const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]); From 6acef777320e2e03a0b6c10d38180fec2fbfc80f Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 13 May 2026 17:11:28 +0800 Subject: [PATCH 062/217] refactor: migrate MCP and common utilities to src/mcp and src/common directories --- .deepcode/AGENTS.md | 8 ++++++++ src/cli.tsx | 2 +- src/{tools => common}/file-utils.ts | 0 src/{tools => common}/runtime.ts | 2 +- src/{tools => common}/shell-utils.ts | 0 src/{tools => common}/state.ts | 0 src/{tools => mcp}/mcp-client.ts | 0 src/{tools => mcp}/mcp-manager.ts | 0 src/prompt.ts | 2 +- src/session.ts | 2 +- src/tests/shell-utils.test.ts | 4 ++-- src/tools/bash-handler.ts | 2 +- src/tools/edit-handler.ts | 11 ++++++++--- src/tools/executor.ts | 2 +- src/tools/read-handler.ts | 4 ++-- src/tools/write-handler.ts | 6 +++--- 16 files changed, 29 insertions(+), 16 deletions(-) rename src/{tools => common}/file-utils.ts (100%) rename src/{tools => common}/runtime.ts (99%) rename src/{tools => common}/shell-utils.ts (100%) rename src/{tools => common}/state.ts (100%) rename src/{tools => mcp}/mcp-client.ts (100%) rename src/{tools => mcp}/mcp-manager.ts (100%) diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index 652cf02..2c95cc3 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -15,6 +15,14 @@ src/ │ ├── MessageView.tsx # Renders assistant/tool messages with markdown │ ├── SessionList.tsx # Session picker for /resume │ └── ... +├── mcp/ +│ ├── mcp-client.ts # MCP client — JSON-RPC communication with MCP servers +│ └── mcp-manager.ts # MCP manager — lifecycle, tool registration, execution +├── common/ +│ ├── file-utils.ts # File read/write with encoding and diff preview +│ ├── shell-utils.ts # Shell path resolution (Git Bash, zsh, bash) +│ ├── state.ts # In-memory file state and snippet tracking +│ └── runtime.ts # Tool validation runtime helpers (executeValidatedTool, semanticBoolean) ├── tools/ │ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers │ ├── bash-handler.ts # Executes shell commands diff --git a/src/cli.tsx b/src/cli.tsx index d4bbd62..4026315 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,7 +1,7 @@ import React from "react"; import { render } from "ink"; import { App } from "./ui"; -import { setShellIfWindows } from "./tools/shell-utils"; +import { setShellIfWindows } from "./common/shell-utils"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck"; const args = process.argv.slice(2); diff --git a/src/tools/file-utils.ts b/src/common/file-utils.ts similarity index 100% rename from src/tools/file-utils.ts rename to src/common/file-utils.ts diff --git a/src/tools/runtime.ts b/src/common/runtime.ts similarity index 99% rename from src/tools/runtime.ts rename to src/common/runtime.ts index 9a4620b..b1195d8 100644 --- a/src/tools/runtime.ts +++ b/src/common/runtime.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import type { ToolExecutionContext, ToolExecutionResult } from "../tools/executor"; export type ValidationResult = { ok: true; input: Record } | { ok: false; error: string }; diff --git a/src/tools/shell-utils.ts b/src/common/shell-utils.ts similarity index 100% rename from src/tools/shell-utils.ts rename to src/common/shell-utils.ts diff --git a/src/tools/state.ts b/src/common/state.ts similarity index 100% rename from src/tools/state.ts rename to src/common/state.ts diff --git a/src/tools/mcp-client.ts b/src/mcp/mcp-client.ts similarity index 100% rename from src/tools/mcp-client.ts rename to src/mcp/mcp-client.ts diff --git a/src/tools/mcp-manager.ts b/src/mcp/mcp-manager.ts similarity index 100% rename from src/tools/mcp-manager.ts rename to src/mcp/mcp-manager.ts diff --git a/src/prompt.ts b/src/prompt.ts index f2573ff..f8df085 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -4,7 +4,7 @@ import * as os from "os"; import * as path from "path"; import { fileURLToPath } from "url"; import type { SessionMessage } from "./session"; -import { findGitBashPath, resolveShellPath } from "./tools/shell-utils"; +import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; export const AGENT_DRIFT_GUARD_SKILL = ` --- diff --git a/src/session.ts b/src/session.ts index 58a2c72..a482f27 100644 --- a/src/session.ts +++ b/src/session.ts @@ -11,7 +11,7 @@ import { buildThinkingRequestOptions } from "./openai-thinking"; import { DEEPSEEK_V4_MODELS } from "./model-capabilities"; import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt"; import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; -import { McpManager } from "./tools/mcp-manager"; +import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig } from "./settings"; import { logApiError } from "./error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; diff --git a/src/tests/shell-utils.test.ts b/src/tests/shell-utils.test.ts index 7d19357..648db70 100644 --- a/src/tests/shell-utils.test.ts +++ b/src/tests/shell-utils.test.ts @@ -6,8 +6,8 @@ import { posixPathToWindowsPath, rewriteWindowsNullRedirect, windowsPathToPosixPath, -} from "../tools/shell-utils"; -import { isAbsoluteFilePath, normalizeFilePath } from "../tools/state"; +} from "../common/shell-utils"; +import { isAbsoluteFilePath, normalizeFilePath } from "../common/state"; test("Windows paths convert to Git Bash POSIX paths", () => { assert.equal(windowsPathToPosixPath("C:\\Users\\foo"), "/c/Users/foo"); diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index 155d82a..549c546 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -7,7 +7,7 @@ import { resolveShellPath, rewriteWindowsNullRedirect, toNativeCwd, -} from "./shell-utils"; +} from "../common/shell-utils"; const MAX_OUTPUT_CHARS = 30000; const MAX_CAPTURE_CHARS = 10 * 1024 * 1024; diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 403f984..fd0dd8a 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -2,8 +2,13 @@ import * as fs from "fs"; import { z } from "zod"; import { buildThinkingRequestOptions } from "../openai-thinking"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { buildDiffPreview, hasFileChangedSinceState, readTextFileWithMetadata, writeTextFile } from "./file-utils"; -import { executeValidatedTool, semanticBoolean } from "./runtime"; +import { + buildDiffPreview, + hasFileChangedSinceState, + readTextFileWithMetadata, + writeTextFile, +} from "../common/file-utils"; +import { executeValidatedTool, semanticBoolean } from "../common/runtime"; import { createSnippet, getFileState, @@ -12,7 +17,7 @@ import { isFullFileView, normalizeFilePath, recordFileState, -} from "./state"; +} from "../common/state"; const MAX_CANDIDATE_COUNT = 5; const REPLACE_ALL_MATCH_THRESHOLD = 5; diff --git a/src/tools/executor.ts b/src/tools/executor.ts index bf70264..fd55b96 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -6,7 +6,7 @@ import { handleEditTool } from "./edit-handler"; import { handleReadTool } from "./read-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; -import type { McpManager } from "./mcp-manager"; +import type { McpManager } from "../mcp/mcp-manager"; export type CreateOpenAIClient = () => { client: OpenAI | null; diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts index 548bcfd..964cdd7 100644 --- a/src/tools/read-handler.ts +++ b/src/tools/read-handler.ts @@ -2,8 +2,8 @@ import * as fs from "fs"; import * as path from "path"; import ignore from "ignore"; import type { ToolExecutionContext, ToolExecutionFollowUpMessage, ToolExecutionResult } from "./executor"; -import { readTextFileWithMetadata } from "./file-utils"; -import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "./state"; +import { readTextFileWithMetadata } from "../common/file-utils"; +import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "../common/state"; const DEFAULT_LINE_LIMIT = 2000; const MAX_LINE_LENGTH = 2000; diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts index 4524e21..383eb19 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -8,9 +8,9 @@ import { normalizeContent, readTextFileWithMetadata, writeTextFile, -} from "./file-utils"; -import { executeValidatedTool } from "./runtime"; -import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "./state"; +} from "../common/file-utils"; +import { executeValidatedTool } from "../common/runtime"; +import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "../common/state"; const writeSchema = z.strictObject({ file_path: z.string().min(1, "file_path is required."), From 4d4dc4fff3f7481fb5f2b755120a1e73f1b8017c Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 13 May 2026 17:26:35 +0800 Subject: [PATCH 063/217] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=E9=80=9A?= =?UTF-8?q?=E7=94=A8DropdownMenu=E7=BB=84=E4=BB=B6=E5=B9=B6=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E6=8A=80=E8=83=BD=E4=B8=8E=E6=A8=A1=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增DropdownMenu组件,支持滚动、选中指示与自定义渲染 - 使技能选择区使用DropdownMenu替代手写列表,统一样式与行为 - 模型选择区同样改用DropdownMenu组件,简化代码 - 添加DropdownMenu相关类型定义和滚动窗口计算函数 - 优化PromptInput中颜色和显示逻辑,提升一致性和可读性 - 修正session模块中fs.readdirSync调用的变量初始化问题 --- src/session.ts | 2 +- src/ui/DropdownMenu.tsx | 191 ++++++++++++++++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 93 +++++++++---------- 3 files changed, 234 insertions(+), 52 deletions(-) create mode 100644 src/ui/DropdownMenu.tsx diff --git a/src/session.ts b/src/session.ts index 58a2c72..3382280 100644 --- a/src/session.ts +++ b/src/session.ts @@ -598,7 +598,7 @@ The candidate skills are as follows:\n\n`; if (!fs.existsSync(root)) { return []; } - let entries: fs.Dirent[] = []; + let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { diff --git a/src/ui/DropdownMenu.tsx b/src/ui/DropdownMenu.tsx new file mode 100644 index 0000000..3a963a5 --- /dev/null +++ b/src/ui/DropdownMenu.tsx @@ -0,0 +1,191 @@ +import React, { useMemo } from "react"; +import { Box, Text } from "ink"; + +/** + * Generic dropdown menu item structure + */ +export type DropdownMenuItem = { + /** Unique key for React list rendering */ + key: string; + /** Main label text (can include status indicators) */ + label: string; + /** Secondary description text (dimmed) */ + description?: string; + /** Whether this item is currently selected */ + selected?: boolean; + /** Whether to show a special status indicator (e.g., loaded checkmark) */ + statusIndicator?: { + symbol: string; + color: string; + }; +}; + +/** + * Props for the DropdownMenu component + */ +type DropdownMenuProps = { + /** List of items to display */ + items: DropdownMenuItem[]; + /** Index of the currently active/highlighted item */ + activeIndex: number; + /** Maximum number of visible items before scrolling */ + maxVisible?: number; + /** Container width in columns */ + width: number; + /** Optional title displayed at the top */ + title?: string; + /** Color for the title (default: "magenta") */ + titleColor?: string; + /** Color for the active item indicator (default: "cyanBright") */ + activeColor?: string; + /** Help text displayed at the bottom */ + helpText?: string; + /** Text to display when items list is empty */ + emptyText?: string; + /** Custom item renderer (overrides default rendering) */ + renderItem?: (item: DropdownMenuItem, isActive: boolean) => React.ReactNode; +}; + +/** + * Calculate the visible window start position for scrolling + * Ensures the activeIndex is always visible within the window + */ +export function calculateVisibleStart(activeIndex: number, totalItems: number, maxVisible: number): number { + return Math.min(Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), Math.max(0, totalItems - maxVisible)); +} + +/** + * Generic dropdown menu component with scrolling support + * Used by Skills Dropdown, Model Dropdown, and other selection menus + */ +const DropdownMenu = React.memo(function DropdownMenu({ + items, + activeIndex, + maxVisible = 8, + width, + title, + titleColor = "magenta", + activeColor = "cyanBright", + helpText, + emptyText = "No items found", + renderItem, +}: DropdownMenuProps): React.ReactElement | null { + // Calculate visible window + const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); + const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); + + // 计算标签列最佳宽度:包含所有可能的前缀和后缀 + const labelColumnWidth = useMemo(() => { + if (visibleItems.length === 0) { + return 0; + } + // 计算每个 item 实际需要的最大宽度 + const maxContentWidth = Math.max( + ...visibleItems.map((item) => { + let width = 2; // prefix "› " or " " + if (item.selected !== undefined) { + width += 2; // "● " or "○ " + } + width += item.label.length; + if (item.statusIndicator) { + width += 2; // " ✓" or similar + } + return width; + }) + ); + const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 + return Math.min(maxContentWidth, maxAllowed); + }, [visibleItems, width]); + + // Early return if no items + if (items?.length === 0) { + return ( + + {title ? ( + + {title} + + ) : null} + {emptyText} + {helpText ? {helpText} : null} + + ); + } + + return ( + + {/* Title */} + {title ? ( + + + {title} + + + ) : null} + + {/* Scroll indicator - top */} + {visibleStart > 0 ? ( + + … {visibleStart} above + + ) : null} + + {/* Visible items */} + {visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + const isActive = actualIndex === activeIndex; + + // Use custom renderer if provided + if (renderItem) { + return {renderItem(item, isActive)}; + } + + // Default rendering with selection indicator and optional features + return ( + + + + {isActive ? "› " : " "} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + {item.statusIndicator ? ( + {item.statusIndicator.symbol} + ) : null} + + + {item.description ? {`${item.description}`} : null} + + ); + })} + + {/* Scroll indicator - bottom */} + {visibleStart + visibleItems.length < items.length ? ( + + … {items.length - visibleStart - visibleItems.length} more + + ) : null} + + {/* Help text */} + {helpText ? ( + + {helpText} + + ) : null} + + ); +}); + +export default DropdownMenu; diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index b878961..a5f77f1 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; import { @@ -35,6 +35,7 @@ import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; import type { ModelConfigSelection, ReasoningEffort } from "../settings"; +import DropdownMenu from "./DropdownMenu"; export type PromptSubmission = { text: string; @@ -89,7 +90,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ @@ -671,8 +672,6 @@ export const PromptInput = React.memo(function PromptInput({ }); } - const visibleSkillStart = Math.min(Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8)); - const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); const modelDropdownItems = modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.map((model) => ({ @@ -686,6 +685,11 @@ export const PromptInput = React.memo(function PromptInput({ description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", })); + const showFooterText = useMemo( + () => showMenu || showSkillsDropdown || modelDropdownStep !== null, + [showMenu, showSkillsDropdown, modelDropdownStep] + ); + return ( {imageUrls.length > 0 ? ( @@ -715,58 +719,45 @@ export const PromptInput = React.memo(function PromptInput({ {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} {showSkillsDropdown ? ( - - - Select Skills - - {skills.length === 0 ? ( - No skills found - ) : ( - visibleSkills.map((skill, idx) => { - const skillIndex = visibleSkillStart + idx; - const selected = isSkillSelected(selectedSkills, skill); - const active = skillIndex === skillsDropdownIndex; - return ( - - {active ? "› " : " "} - {selected ? "●" : "○"} {skill.name} - {skill.isLoaded ? : null} - {` ${skill.path}`} - - ); - }) - )} - {visibleSkillStart > 0 ? … {visibleSkillStart} above : null} - {visibleSkillStart + visibleSkills.length < skills.length ? ( - … {skills.length - visibleSkillStart - visibleSkills.length} more - ) : null} - space toggle · enter toggle · esc to close - + ({ + key: skill.path || skill.name, + label: skill.name, + description: skill.path, + selected: isSkillSelected(selectedSkills, skill), + statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, + }))} + activeIndex={skillsDropdownIndex} + activeColor="#229ac3" + maxVisible={8} + /> ) : null} {modelDropdownStep ? ( - - - {modelDropdownStep === "model" ? "Select Model" : "Select Thinking Mode"} - - {modelDropdownItems.map((item, idx) => { - const active = idx === modelDropdownIndex; - return ( - - {active ? "› " : " "} - {item.selected ? "●" : "○"} {item.label} - {item.description ? {` ${item.description}`} : null} - - ); - })} - - {modelDropdownStep === "model" + - + : "space/enter apply · esc to cancel" + } + items={modelDropdownItems.map((item) => ({ + key: item.label, + label: item.label, + description: item.description, + selected: item.selected, + }))} + activeIndex={modelDropdownIndex} + activeColor="#229ac3" + maxVisible={8} + /> ) : null} - {!showMenu && ( + {!showFooterText && ( {footerText} From 4fff1eda68416f1c5994585aa41afececb5388d2 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 13 May 2026 20:54:00 +0800 Subject: [PATCH 064/217] feat: add docs/configuration.md --- docs/configuration.md | 173 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/configuration.md diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..1af33ee --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,173 @@ +# Deep Code 配置 + +## 配置层级 + +配置按以下优先级顺序应用(数字较小的会被数字较大的覆盖): + +| 层级 | 配置来源 | 说明 | +| ---- | ------------ | ------------------------------------------- | +| 1 | 默认值 | 应用程序内硬编码的默认值 | +| 2 | 用户设置文件 | 当前用户的全局设置 | +| 3 | 项目设置文件 | 项目特定的设置 | +| 4 | 环境变量 | 系统范围或会话特定的变量 | + +## 设置文件 + +Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两个层级的存放位置: + +| 文件类型 | 位置 | 作用范围 | +| ------------ | ---------------------------------- | ---------------------------------------------------- | +| 用户设置文件 | `~/.deepcode/settings.json` | 适用于当前用户的所有 Deep Code 会话。 | +| 项目设置文件 | `项目根目录/.deepcode/settings.json` | 仅在该特定项目中运行 Deep Code 时生效。项目设置会覆盖用户设置。 | + +### `settings.json` 中的可用设置 + +以下是 `settings.json` 支持的全部顶层字段,以及 `env` 内部支持的子字段: + +| 字段 | 类型 | 说明 | +| -------------------- | --------- | ------------------------------------------------------------------- | +| `env` | object | 环境变量分组(见下方子字段表) | +| `model` | string | 模型名称。优先级高于 `env.MODEL` | +| `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) | +| `reasoningEffort` | string | 推理强度,可选 `"high"` 或 `"max"`(默认 `"max"`) | +| `debugLogEnabled` | boolean | 是否启用调试日志输出(默认 `false`) | +| `notify` | string | 任务完成通知脚本的完整路径(如 Slack 通知脚本) | +| `webSearchTool` | string | 自定义联网搜索脚本的完整路径 | +| `mcpServers` | object | MCP 服务器配置(键为服务名,值为 McpServerConfig 对象) | + +#### `env` 子字段 + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------------------------------------------------ | +| `MODEL` | string | 模型名称。例如 `"deepseek-v4-pro"`、`"deepseek-v4-flash"` | +| `BASE_URL` | string | API 请求的基础 URL。例如 `"https://api.deepseek.com"` | +| `API_KEY` | string | API 密钥 | +| `THINKING_ENABLED` | string | 是否启用思考模式 | +| `REASONING_EFFORT` | string | 推理强度 | +| `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | +| `<其他任意KEY>` | string | 自定义环境变量 | + +#### `thinkingEnabled` — 思考模式 + +是否启用 DeepSeek 思考模式。设置为 `true` 启用、`false` 禁用。 + +- 对于 `deepseek-v4-pro` 和 `deepseek-v4-flash`,思考模式**默认启用**。 +- 对于其他模型,思考模式**默认关闭**。 + +#### `reasoningEffort` — 推理强度 + +当思考模式启用时,控制模型思考的深度: + +| 值 | 说明 | +| ------ | --------------------------------- | +| `max` | 最大推理深度(默认值) | +| `high` | 较高推理深度,token消耗相对较小 | + +#### `notify` — 任务完成通知 + +设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 + +```json +{ + "notify": "/path/to/slack-notify.sh" +} +``` + +#### `webSearchTool` — 自定义联网搜索 + +Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: + +```json +{ + "webSearchTool": "/path/to/my-search-script.sh" +} +``` + +脚本接收一个搜索查询参数,输出 JSON 格式的结果供 AI 使用。 + +#### `mcpServers` — MCP 服务器 + +MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。 + +```json +{ + "mcpServers": { + "<服务名>": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +| McpServerConfig 字段 | 类型 | 必填 | 说明 | +| -------------------- | -------- | ---- | -------------------------------------------------------------------- | +| `command` | string | 是 | 可执行文件路径或命令(如 `npx`、`node`、`python`) | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量 | + +> 当 `command` 为 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 + +详细 MCP 使用说明请参考 [docs/mcp.md](docs/mcp.md)。 + + +#### `debugLogEnabled` — 调试日志 + +设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 + +## 环境变量优先级 + +环境变量是配置应用程序的常用方式,尤其适用于敏感信息(如 api-key)或可能在不同环境之间更改的设置。 + +### 优先级原则 + +环境变量优先级遵循“越具体、越局部的配置,优先级越高”和“env文件默认保护现有环境,系统变量高于env文件”的覆盖逻辑。(settings.json的env对象可以认为是一种env文件) + +优先级层级 (由低到高) +1. settings.json 外层的 env:这是针对整个工具及其所有子进程的通用配置(全局变量)。可被外层环境变量覆盖,但环境变量KEY会移除`DEEPCODE_`前缀。 +2. settings.json mcpServers 内定义的 env:这是针对特定 MCP 服务的最具体配置(局部变量)。可被外层环境变量覆盖,但环境变量KEY会移除`MCP_`前缀。 +3. Shell 环境系统变量:操作系统层面的环境变量。 + +### 场景 + +#### 一、设置模型的api_key, base_url + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以api_key为例): + +1. 硬编码默认值: `""` +2. 用户级settings.json: `{"env": {"API_KEY": "abc123"}}` +3. 项目级settings.json: `{"env": {"API_KEY": "abc123"}}` +4. 系统环境变量: `DEEPCODE_API_KEY=abc123 deepcode` + +#### 二、设置模型的model, thinkingEnabled, reasoningEffort + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以thinkingEnabled为例): + +1. 硬编码默认值: `true` +2. 用户级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +3. 用户级settings.json: `{"thinkingEnabled": true}` +4. 项目级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +5. 项目级settings.json: `{"thinkingEnabled": true}` +6. 系统环境变量: `DEEPCODE_THINKING_ENABLED=true deepcode` + +#### 三、设置启动notify, webSearchTool等外挂脚本的环境变量 + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以notify为例): + +1. 硬编码默认值:`os.environ.get('WEBHOOK', '...') # notify脚本代码` +2. 用户级settings.json: `{"env": {"WEBHOOK": "..."}}` +3. 项目级settings.json: `{"env": {"WEBHOOK": "true"}}` +4. 系统环境变量: `DEEPCODE_WEBHOOK=... deepcode` + +#### 四、设置MCP Service的环境变量 + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以github MCP server为例): + +1. 用户级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +2. 用户级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +3. 项目级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +4. 项目级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +5. 系统环境变量: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` From 3328d6e60759649a52fa240a6ba203987e9cdf50 Mon Sep 17 00:00:00 2001 From: yuefengw <921592559@qq.com> Date: Wed, 13 May 2026 22:22:47 +0800 Subject: [PATCH 065/217] fix: improve Windows Git Bash detection --- src/common/shell-utils.ts | 83 +++++++++++++++++++++++++++++++---- src/tests/shell-utils.test.ts | 34 ++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/common/shell-utils.ts b/src/common/shell-utils.ts index 223b95b..a4572e4 100644 --- a/src/common/shell-utils.ts +++ b/src/common/shell-utils.ts @@ -5,12 +5,19 @@ import * as path from "path"; import * as pathWin32 from "path/win32"; const WINDOWS_GIT_LOCATIONS = ["C:\\Program Files\\Git\\cmd\\git.exe", "C:\\Program Files (x86)\\Git\\cmd\\git.exe"]; +const WINDOWS_BASH_LOCATIONS = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"]; const NUL_REDIRECT_REGEX = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g; let cachedGitBashPath: string | null = null; export type ShellKind = "bash" | "zsh" | "unknown"; +type WindowsGitBashLookup = { + findExecutableCandidates: (executable: string) => string[]; + findGitExecPath: () => string | null; + existsSync: (candidate: string) => boolean; +}; + export function setShellIfWindows(): void { if (process.platform !== "win32") { return; @@ -23,16 +30,30 @@ export function findGitBashPath(): string { return cachedGitBashPath; } - for (const gitPath of findAllWindowsExecutableCandidates("git")) { - const bashPath = pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"); - if (fs.existsSync(bashPath)) { - cachedGitBashPath = bashPath; - return bashPath; - } + const bashPath = resolveWindowsGitBashPath({ + findExecutableCandidates: findAllWindowsExecutableCandidates, + findGitExecPath, + existsSync: fs.existsSync, + }); + if (bashPath) { + cachedGitBashPath = bashPath; + return bashPath; } throw new Error( - "Deep Code on Windows requires Git Bash. Install Git Bash for Windows and ensure bash.exe is available in PATH." + "Deep Code on Windows requires Git Bash. Install Git for Windows, or ensure Git's bash.exe is available in PATH." + ); +} + +export function resolveWindowsGitBashPath(lookup: WindowsGitBashLookup): string | null { + return firstExistingWindowsPath( + [ + ...lookup.findExecutableCandidates("bash"), + ...WINDOWS_BASH_LOCATIONS, + ...gitExecPathToBashCandidates(lookup.findGitExecPath()), + ...lookup.findExecutableCandidates("git").flatMap(gitExecutableToBashCandidates), + ], + lookup.existsSync ); } @@ -145,7 +166,8 @@ export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { } function findAllWindowsExecutableCandidates(executable: string): string[] { - const extraCandidates = executable === "git" ? WINDOWS_GIT_LOCATIONS : []; + const extraCandidates = + executable === "git" ? WINDOWS_GIT_LOCATIONS : executable === "bash" ? WINDOWS_BASH_LOCATIONS : []; try { const output = execFileSync("where.exe", [executable], { @@ -165,6 +187,51 @@ function findAllWindowsExecutableCandidates(executable: string): string[] { } } +function findGitExecPath(): string | null { + try { + const output = execFileSync("git", ["--exec-path"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }).trim(); + return output || null; + } catch { + return null; + } +} + +function gitExecPathToBashCandidates(execPath: string | null): string[] { + if (!execPath) { + return []; + } + + const normalized = execPath.replace(/\//g, "\\"); + return [ + pathWin32.join(normalized, "..", "..", "..", "bin", "bash.exe"), + pathWin32.join(normalized, "..", "..", "bin", "bash.exe"), + ]; +} + +function gitExecutableToBashCandidates(gitPath: string): string[] { + return [pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"), pathWin32.join(gitPath, "..", "bin", "bash.exe")]; +} + +function firstExistingWindowsPath(candidates: string[], existsSync: (candidate: string) => boolean): string | null { + const seen = new Set(); + for (const candidate of candidates) { + const normalized = pathWin32.resolve(candidate); + const key = normalized.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + if (getShellKind(normalized) === "bash" && existsSync(normalized)) { + return normalized; + } + } + return null; +} + function filterWindowsExecutableCandidates(candidates: string[]): string[] { const cwd = process.cwd().toLowerCase(); const seen = new Set(); diff --git a/src/tests/shell-utils.test.ts b/src/tests/shell-utils.test.ts index 648db70..771545c 100644 --- a/src/tests/shell-utils.test.ts +++ b/src/tests/shell-utils.test.ts @@ -4,6 +4,7 @@ import { buildDisableExtglobCommand, getShellKind, posixPathToWindowsPath, + resolveWindowsGitBashPath, rewriteWindowsNullRedirect, windowsPathToPosixPath, } from "../common/shell-utils"; @@ -38,6 +39,39 @@ test("Shell kind detection supports Windows bash.exe paths", () => { assert.equal(buildDisableExtglobCommand("/bin/zsh"), "setopt NO_EXTENDED_GLOB 2>/dev/null || true"); }); +test("Windows Git Bash detection prefers bash.exe from PATH", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => (executable === "bash" ? [bashPath] : []), + findGitExecPath: () => null, + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + +test("Windows Git Bash detection derives bash.exe from git exec path", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: () => [], + findGitExecPath: () => "D:/Tools/Git/mingw64/libexec/git-core", + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + +test("Windows Git Bash detection derives bash.exe from git.exe candidates", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => (executable === "git" ? ["D:\\Tools\\Git\\cmd\\git.exe"] : []), + findGitExecPath: () => null, + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + test("File tool path normalization converts Git Bash drive paths on Windows", () => { assert.equal( normalizeFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), From 33e7dbd0116d5314edaa92454ff2e37eb39fc82d Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 13 May 2026 22:31:33 +0800 Subject: [PATCH 066/217] feat: enhance environment variable handling and configuration resolution --- docs/configuration.md | 2 +- src/cli.tsx | 3 +- src/common/shell-utils.ts | 3 +- src/notify.ts | 5 +- src/session.ts | 19 ++- src/settings.ts | 210 ++++++++++++++++++++++---- src/tests/settings-and-notify.test.ts | 154 +++++++++++++++++-- src/tests/web-search-handler.test.ts | 17 ++- src/tools/bash-handler.ts | 3 +- src/tools/executor.ts | 1 + src/tools/web-search-handler.ts | 16 +- src/ui/App.tsx | 101 +++++++++---- src/ui/index.ts | 2 + 13 files changed, 444 insertions(+), 92 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 1af33ee..f8e52c3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -111,7 +111,7 @@ MCP(Model Context Protocol)服务器配置。值是键值对,键为服务 > 当 `command` 为 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 -详细 MCP 使用说明请参考 [docs/mcp.md](docs/mcp.md)。 +详细 MCP 使用说明请参考 [mcp.md](mcp.md)。 #### `debugLogEnabled` — 调试日志 diff --git a/src/cli.tsx b/src/cli.tsx index 4026315..112c9dd 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -23,7 +23,8 @@ if (args.includes("--help") || args.includes("-h")) { " deepcode --help Show this help", "", "Configuration:", - " ~/.deepcode/settings.json API key, model, base URL", + " ~/.deepcode/settings.json User-level API key, model, base URL", + " ./.deepcode/settings.json Project-level settings", " ~/.agents/skills/*/SKILL.md User-level skills", " ./.agents/skills/*/SKILL.md Project-level skills", " ./.deepcode/skills/*/SKILL.md Legacy project-level skills", diff --git a/src/common/shell-utils.ts b/src/common/shell-utils.ts index 223b95b..57340c4 100644 --- a/src/common/shell-utils.ts +++ b/src/common/shell-utils.ts @@ -128,9 +128,10 @@ export function toNativeCwd(shellCwd: string): string { return posixPathToWindowsPath(shellCwd); } -export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { +export function buildShellEnv(shellPath: string, extraEnv: Record = {}): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, + ...extraEnv, SHELL: shellPath, GIT_EDITOR: "true", }; diff --git a/src/notify.ts b/src/notify.ts index 2fdc9fa..8878c50 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -27,7 +27,8 @@ export function launchNotifyScript( notifyPath: string | undefined, durationMs: number, workingDirectory?: string, - spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn + spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn, + configuredEnv: Record = {} ): void { const commandPath = notifyPath?.trim(); if (!commandPath) { @@ -37,7 +38,7 @@ export function launchNotifyScript( const options = { cwd: workingDirectory, detached: process.platform !== "win32", - env: buildNotifyEnv(durationMs), + env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }), stdio: "ignore" as const, }; diff --git a/src/session.ts b/src/session.ts index a482f27..4c42f81 100644 --- a/src/session.ts +++ b/src/session.ts @@ -941,7 +941,7 @@ ${skillMd} async activateSession(sessionId: string, controller?: AbortController): Promise { const startedAt = Date.now(); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify } = + const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = this.createOpenAIClient(); const now = new Date().toISOString(); @@ -955,12 +955,12 @@ ${skillMd} this.onAssistantMessage( this.buildAssistantMessage( sessionId, - "OpenAI API key not found. Please configure ~/.deepcode/settings.json.", + "OpenAI API key not found. Please configure ~/.deepcode/settings.json or ./.deepcode/settings.json.", null ), false ); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); return; } @@ -972,7 +972,7 @@ ${skillMd} failReason: "interrupted", updateTime: now, })); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); return; } @@ -1114,7 +1114,7 @@ ${skillMd} if (this.sessionControllers.get(sessionId) === sessionController) { this.sessionControllers.delete(sessionId); } - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); } } @@ -1966,7 +1966,12 @@ ${skillMd} } } - private maybeNotifyTaskCompletion(sessionId: string, notifyCommand: string | undefined, startedAt: number): void { + private maybeNotifyTaskCompletion( + sessionId: string, + notifyCommand: string | undefined, + startedAt: number, + configuredEnv: Record = {} + ): void { if (!notifyCommand) { return; } @@ -1976,7 +1981,7 @@ ${skillMd} return; } - launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot); + launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv); } private addSessionProcess(sessionId: string, processId: string | number, command: string): void { diff --git a/src/settings.ts b/src/settings.ts index 3c046b4..9fbcea2 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,10 +1,12 @@ import { defaultsToThinkingMode } from "./model-capabilities"; -export type DeepcodingEnv = { +export type DeepcodingEnv = Record & { MODEL?: string; BASE_URL?: string; API_KEY?: string; - THINKING?: string; + THINKING_ENABLED?: string; + REASONING_EFFORT?: string; + DEBUG_LOG_ENABLED?: string; }; export type ReasoningEffort = "high" | "max"; @@ -27,6 +29,7 @@ export type DeepcodingSettings = { }; export type ResolvedDeepcodingSettings = { + env: Record; apiKey?: string; baseURL: string; model: string; @@ -44,48 +47,203 @@ export type ModelConfigSelection = { reasoningEffort: ReasoningEffort; }; -function resolveReasoningEffort(value: unknown): ReasoningEffort { - return value === "high" || value === "max" ? value : "max"; +export type SettingsProcessEnv = Record; + +function resolveReasoningEffort(value: unknown): ReasoningEffort | undefined { + return value === "high" || value === "max" ? value : undefined; } -function resolveThinkingEnabled(settings: DeepcodingSettings | null | undefined, model: string): boolean { - if (typeof settings?.thinkingEnabled === "boolean") { - return settings.thinkingEnabled; +function parseBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return undefined; } - const legacyThinking = settings?.env?.THINKING; - if (typeof legacyThinking === "string" && legacyThinking.trim()) { - return legacyThinking.trim().toLowerCase() === "enabled"; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "enabled", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "disabled", "no", "off"].includes(normalized)) { + return false; } + return undefined; +} - return defaultsToThinkingMode(model); +function trimString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; } -export function resolveSettings( - settings: DeepcodingSettings | null | undefined, - defaults: { model: string; baseURL: string } +function normalizeEnv(env: DeepcodingSettings["env"]): Record { + const result: Record = {}; + if (!env) { + return result; + } + + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") { + result[key] = value; + } + } + return result; +} + +export function collectDeepcodeEnv(processEnv: SettingsProcessEnv = process.env): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(processEnv)) { + if (!key.startsWith("DEEPCODE_") || typeof value !== "string") { + continue; + } + const strippedKey = key.slice("DEEPCODE_".length); + if (strippedKey) { + result[strippedKey] = value; + } + } + return result; +} + +function extractMcpEnv(env: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (!key.startsWith("MCP_")) { + continue; + } + const strippedKey = key.slice("MCP_".length); + if (strippedKey) { + result[strippedKey] = value; + } + } + return result; +} + +function mergeMcpServers( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined, + userEnv: Record, + projectEnv: Record, + systemEnv: Record +): Record | undefined { + const userServers = userSettings?.mcpServers ?? {}; + const projectServers = projectSettings?.mcpServers ?? {}; + const serverNames = new Set([...Object.keys(userServers), ...Object.keys(projectServers)]); + if (serverNames.size === 0) { + return undefined; + } + + const userMcpEnv = extractMcpEnv(userEnv); + const projectMcpEnv = extractMcpEnv(projectEnv); + const systemMcpEnv = extractMcpEnv(systemEnv); + const merged: Record = {}; + + for (const name of serverNames) { + const userConfig = userServers[name]; + const projectConfig = projectServers[name]; + const command = projectConfig?.command ?? userConfig?.command; + if (!command) { + continue; + } + + const env = { + ...userEnv, + ...(userConfig?.env ?? {}), + ...userMcpEnv, + ...projectEnv, + ...(projectConfig?.env ?? {}), + ...projectMcpEnv, + ...systemEnv, + ...systemMcpEnv, + }; + const config: McpServerConfig = { + command, + args: projectConfig?.args ?? userConfig?.args, + }; + if (Object.keys(env).length > 0) { + config.env = env; + } + merged[name] = config; + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} + +export function resolveSettingsSources( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined, + defaults: { model: string; baseURL: string }, + processEnv: SettingsProcessEnv = process.env ): ResolvedDeepcodingSettings { - const env = settings?.env ?? {}; - const topLevelModel = typeof settings?.model === "string" ? settings.model.trim() : ""; - const model = topLevelModel || env.MODEL?.trim() || defaults.model; - const notify = typeof settings?.notify === "string" ? settings.notify.trim() : ""; - const webSearchTool = typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : ""; + const userEnv = normalizeEnv(userSettings?.env); + const projectEnv = normalizeEnv(projectSettings?.env); + const systemEnv = collectDeepcodeEnv(processEnv); + const env = { + ...userEnv, + ...projectEnv, + ...systemEnv, + }; + + const model = + trimString(systemEnv.MODEL) || + trimString(projectSettings?.model) || + trimString(projectEnv.MODEL) || + trimString(userSettings?.model) || + trimString(userEnv.MODEL) || + defaults.model; - const mcpServers = settings?.mcpServers; + const thinkingEnabled = + parseBoolean(systemEnv.THINKING_ENABLED) ?? + parseBoolean(projectSettings?.thinkingEnabled) ?? + parseBoolean(projectEnv.THINKING_ENABLED) ?? + parseBoolean(userSettings?.thinkingEnabled) ?? + parseBoolean(userEnv.THINKING_ENABLED) ?? + defaultsToThinkingMode(model); + + const reasoningEffort = + resolveReasoningEffort(systemEnv.REASONING_EFFORT) ?? + resolveReasoningEffort(projectSettings?.reasoningEffort) ?? + resolveReasoningEffort(projectEnv.REASONING_EFFORT) ?? + resolveReasoningEffort(userSettings?.reasoningEffort) ?? + resolveReasoningEffort(userEnv.REASONING_EFFORT) ?? + "max"; + + const debugLogEnabled = + parseBoolean(systemEnv.DEBUG_LOG_ENABLED) ?? + parseBoolean(projectSettings?.debugLogEnabled) ?? + parseBoolean(projectEnv.DEBUG_LOG_ENABLED) ?? + parseBoolean(userSettings?.debugLogEnabled) ?? + parseBoolean(userEnv.DEBUG_LOG_ENABLED) ?? + false; + + const notify = + trimString(systemEnv.NOTIFY) || trimString(projectSettings?.notify) || trimString(userSettings?.notify) || ""; + const webSearchTool = + trimString(systemEnv.WEB_SEARCH_TOOL) || + trimString(projectSettings?.webSearchTool) || + trimString(userSettings?.webSearchTool) || + ""; return { - apiKey: env.API_KEY?.trim(), - baseURL: env.BASE_URL?.trim() || defaults.baseURL, + env, + apiKey: trimString(env.API_KEY) || undefined, + baseURL: trimString(env.BASE_URL) || defaults.baseURL, model, - thinkingEnabled: resolveThinkingEnabled(settings, model), - reasoningEffort: resolveReasoningEffort(settings?.reasoningEffort), - debugLogEnabled: settings?.debugLogEnabled === true, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, notify: notify || undefined, webSearchTool: webSearchTool || undefined, - mcpServers, + mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), }; } +export function resolveSettings( + settings: DeepcodingSettings | null | undefined, + defaults: { model: string; baseURL: string }, + processEnv: SettingsProcessEnv = process.env +): ResolvedDeepcodingSettings { + return resolveSettingsSources(settings, null, defaults, processEnv); +} + export function modelConfigKey(config: Pick): string { return config.thinkingEnabled ? `thinking:${config.reasoningEffort}` : "thinking:none"; } diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index f2c1ca1..4470502 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -1,7 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../notify"; -import { applyModelConfigSelection, resolveSettings } from "../settings"; +import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; + +const TEST_PROCESS_ENV = {}; test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool", () => { const resolved = resolveSettings( @@ -20,7 +22,8 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.model, "deepseek-v3.2"); @@ -44,31 +47,150 @@ test("resolveSettings gives top-level model priority over env MODEL", () => { { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.model, "deepseek-v4-flash"); }); -test("resolveSettings still accepts legacy env.THINKING and defaults reasoning effort when absent", () => { +test("resolveSettings reads THINKING_ENABLED, REASONING_EFFORT, and DEBUG_LOG_ENABLED from env", () => { const resolved = resolveSettings( { env: { - THINKING: "enabled", + THINKING_ENABLED: "true", + REASONING_EFFORT: "high", + DEBUG_LOG_ENABLED: "true", }, }, { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, true); - assert.equal(resolved.reasoningEffort, "max"); + assert.equal(resolved.reasoningEffort, "high"); + assert.equal(resolved.debugLogEnabled, true); assert.equal(resolved.model, "default-model"); assert.equal(resolved.baseURL, "https://default.example.com"); }); +test("resolveSettings ignores removed legacy env.THINKING", () => { + const resolved = resolveSettings( + { + env: { + THINKING: "enabled", + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + {} + ); + + assert.equal(resolved.thinkingEnabled, false); +}); + +test("resolveSettingsSources applies user, project, and DEEPCODE environment precedence", () => { + const resolved = resolveSettingsSources( + { + env: { + API_KEY: "user-key", + MODEL: "user-env-model", + THINKING_ENABLED: "false", + REASONING_EFFORT: "high", + DEBUG_LOG_ENABLED: "false", + WEBHOOK: "user-webhook", + }, + model: "user-top-model", + thinkingEnabled: true, + reasoningEffort: "max", + debugLogEnabled: true, + }, + { + env: { + API_KEY: "project-key", + MODEL: "project-env-model", + THINKING_ENABLED: "false", + DEBUG_LOG_ENABLED: "false", + }, + model: "project-top-model", + thinkingEnabled: true, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + { + DEEPCODE_MODEL: "system-model", + DEEPCODE_THINKING_ENABLED: "false", + DEEPCODE_REASONING_EFFORT: "high", + DEEPCODE_DEBUG_LOG_ENABLED: "true", + DEEPCODE_WEBHOOK: "system-webhook", + } + ); + + assert.equal(resolved.model, "system-model"); + assert.equal(resolved.apiKey, "project-key"); + assert.equal(resolved.thinkingEnabled, false); + assert.equal(resolved.reasoningEffort, "high"); + assert.equal(resolved.debugLogEnabled, true); + assert.equal(resolved.env.WEBHOOK, "system-webhook"); +}); + +test("resolveSettingsSources merges MCP env with documented priority", () => { + const resolved = resolveSettingsSources( + { + env: { + MCP_GITHUB_PERSONAL_ACCESS_TOKEN: "user-global", + }, + mcpServers: { + github: { + command: "node", + args: ["user-server.js"], + env: { + GITHUB_PERSONAL_ACCESS_TOKEN: "user-local", + USER_ONLY: "1", + }, + }, + }, + }, + { + env: { + MCP_GITHUB_PERSONAL_ACCESS_TOKEN: "project-global", + }, + mcpServers: { + github: { + command: "python", + env: { + GITHUB_PERSONAL_ACCESS_TOKEN: "project-local", + PROJECT_ONLY: "1", + }, + }, + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + { + DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN: "system-global", + } + ); + + assert.equal(resolved.mcpServers?.github?.command, "python"); + assert.deepEqual(resolved.mcpServers?.github?.args, ["user-server.js"]); + assert.deepEqual(resolved.mcpServers?.github?.env, { + MCP_GITHUB_PERSONAL_ACCESS_TOKEN: "system-global", + GITHUB_PERSONAL_ACCESS_TOKEN: "system-global", + USER_ONLY: "1", + PROJECT_ONLY: "1", + }); +}); + test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { const resolved = resolveSettings( { @@ -79,7 +201,8 @@ test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, true); @@ -91,7 +214,8 @@ test("resolveSettings applies thinking defaults to the fallback model", () => { { model: "deepseek-v4-pro", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.model, "deepseek-v4-pro"); @@ -108,7 +232,8 @@ test("resolveSettings keeps thinking mode off by default for other models", () = { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, false); @@ -125,7 +250,8 @@ test("resolveSettings allows explicit thinkingEnabled to override model defaults { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, false); @@ -139,7 +265,8 @@ test("resolveSettings defaults invalid reasoning effort to max", () => { { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.reasoningEffort, "max"); @@ -260,13 +387,14 @@ test("launchNotifyScript passes DURATION and falls back to /bin/sh for non-execu }; }; - launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess); + launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }); assert.equal(calls.length, 2); assert.equal(calls[0]?.command, "/tmp/notify.sh"); assert.deepEqual(calls[0]?.args, []); assert.equal(calls[0]?.options.cwd, "/tmp/project"); assert.equal(calls[0]?.options.env?.DURATION, "2"); + assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); assert.equal(calls[1]?.command, "/bin/sh"); assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); assert.equal(calls[1]?.options.cwd, "/tmp/project"); diff --git a/src/tests/web-search-handler.test.ts b/src/tests/web-search-handler.test.ts index eaa536e..576a1cb 100644 --- a/src/tests/web-search-handler.test.ts +++ b/src/tests/web-search-handler.test.ts @@ -25,7 +25,12 @@ test("WebSearch executes the configured script with the query as one argument", const scriptPath = path.join(workspace, "web-search.sh"); fs.writeFileSync( scriptPath, - ["#!/bin/sh", "printf 'query=%s\\n' \"$1\"", "printf 'cwd=%s\\n' \"$PWD\""].join("\n"), + [ + "#!/bin/sh", + "printf 'query=%s\\n' \"$1\"", + "printf 'cwd=%s\\n' \"$PWD\"", + "printf 'webhook=%s\\n' \"$WEBHOOK\"", + ].join("\n"), "utf8" ); fs.chmodSync(scriptPath, 0o755); @@ -36,6 +41,7 @@ test("WebSearch executes the configured script with the query as one argument", { query: "latest node release" }, createContext(workspace, { webSearchTool: scriptPath, + env: { WEBHOOK: "configured" }, onProcessStart: (id, command) => starts.push({ id, command }), onProcessExit: (id) => exits.push(id), }) @@ -43,7 +49,7 @@ test("WebSearch executes the configured script with the query as one argument", const realWorkspace = fs.realpathSync(workspace); assert.equal(result.ok, true); - assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\n`); + assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\nwebhook=configured\n`); assert.equal(starts.length, 1); assert.match(starts[0].command, /^WebSearch: latest node release$/); assert.deepEqual(exits, [starts[0].id]); @@ -128,7 +134,10 @@ test("WebSearch returns a configuration error when neither a script nor an LLM c const result = await handleWebSearchTool({ query: "latest node release" }, createContext(workspace)); assert.equal(result.ok, false); - assert.equal(result.error, "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json."); + assert.equal( + result.error, + "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json or ./.deepcode/settings.json." + ); }); function createContext( @@ -136,6 +145,7 @@ function createContext( options: { client?: OpenAI | null; webSearchTool?: string; + env?: Record; machineId?: string; onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; @@ -157,6 +167,7 @@ function createContext( model: "test-model", thinkingEnabled: false, webSearchTool: options.webSearchTool, + env: options.env, machineId: options.machineId, }), onProcessStart: options.onProcessStart, diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index 549c546..95e7e76 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -105,9 +105,10 @@ async function executeShellCommand( ): Promise<{ stdout: string; stderr: string; exitCode: number | null; signal: string | null; error?: string }> { return new Promise((resolve) => { const detached = process.platform !== "win32"; + const configuredEnv = context.createOpenAIClient?.().env ?? {}; const child = spawn(shellPath, shellArgs, { cwd, - env: buildShellEnv(shellPath), + env: buildShellEnv(shellPath, configuredEnv), detached, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], diff --git a/src/tools/executor.ts b/src/tools/executor.ts index fd55b96..bc2d7d8 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -17,6 +17,7 @@ export type CreateOpenAIClient = () => { debugLogEnabled?: boolean; notify?: string; webSearchTool?: string; + env?: Record; machineId?: string; }; diff --git a/src/tools/web-search-handler.ts b/src/tools/web-search-handler.ts index 558271b..b3dde69 100644 --- a/src/tools/web-search-handler.ts +++ b/src/tools/web-search-handler.ts @@ -27,6 +27,7 @@ type LLMClientContext = { thinkingEnabled: boolean; notify?: string; webSearchTool?: string; + env?: Record; machineId?: string; }; @@ -46,14 +47,15 @@ export async function handleWebSearchTool( const llmContext = context.createOpenAIClient?.(); const scriptPath = llmContext?.webSearchTool?.trim(); if (scriptPath) { - return executeConfiguredWebSearch(query, scriptPath, context); + return executeConfiguredWebSearch(query, scriptPath, context, llmContext?.env ?? {}); } if (!hasUsableClient(llmContext)) { return { ok: false, name: "WebSearch", - error: "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json.", + error: + "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json or ./.deepcode/settings.json.", }; } @@ -67,9 +69,10 @@ function hasUsableClient(value: ReturnType | undefined): val async function executeConfiguredWebSearch( query: string, scriptPath: string, - context: ToolExecutionContext + context: ToolExecutionContext, + configuredEnv: Record ): Promise { - const execution = await runWebSearchScript(scriptPath, query, context); + const execution = await runWebSearchScript(scriptPath, query, context, configuredEnv); const output = execution.stdout.slice(0, MAX_OUTPUT_CHARS); const truncated = execution.stdout.length > MAX_OUTPUT_CHARS; @@ -150,12 +153,13 @@ async function executeDefaultWebSearch( async function runWebSearchScript( scriptPath: string, query: string, - context: ToolExecutionContext + context: ToolExecutionContext, + configuredEnv: Record ): Promise<{ stdout: string; stderr: string; exitCode: number | null; signal: string | null; error?: string }> { return new Promise((resolve) => { const child = spawn(scriptPath, [query], { cwd: context.projectRoot, - env: process.env, + env: { ...process.env, ...configuredEnv }, stdio: ["ignore", "pipe", "pipe"], }); const pid = child.pid; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index dd13d0f..9617926 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -16,9 +16,10 @@ import { } from "../session"; import { applyModelConfigSelection, - resolveSettings, + resolveSettingsSources, type DeepcodingSettings, type ModelConfigSelection, + type ResolvedDeepcodingSettings, } from "../settings"; import { PromptInput, type PromptSubmission } from "./PromptInput"; import { MessageView } from "./MessageView"; @@ -63,7 +64,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [isExiting, setIsExiting] = useState(false); const [showWelcome, setShowWelcome] = useState(true); const [welcomeNonce, setWelcomeNonce] = useState(0); - const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings()); + const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); const [nowTick, setNowTick] = useState(0); const messagesRef = useRef([]); @@ -72,8 +73,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const sessionManager = useMemo(() => { return new SessionManager({ projectRoot, - createOpenAIClient: () => createOpenAIClient(), - getResolvedSettings: () => resolveCurrentSettings(), + createOpenAIClient: () => createOpenAIClient(projectRoot), + getResolvedSettings: () => resolveCurrentSettings(projectRoot), renderMarkdown: (text) => text, onAssistantMessage: (message: SessionMessage) => { setMessages((prev) => [...prev, message]); @@ -127,9 +128,9 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R }, [refreshSessionsList, refreshSkills]); useLayoutEffect(() => { - const settings = resolveCurrentSettings(); + const settings = resolveCurrentSettings(projectRoot); void sessionManager.initMcpServers(settings.mcpServers); - }, [sessionManager]); + }, [projectRoot, sessionManager]); useEffect(() => { return () => { @@ -149,7 +150,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const allMessages = activeSessionId ? sessionManager.listSessionMessages(activeSessionId) : messagesRef.current; - const resolved = resolveCurrentSettings(); + const resolved = resolveCurrentSettings(projectRoot); const summary = buildExitSummaryText({ session, messages: allMessages, model: resolved.model }); process.stdout.write("\n"); process.stdout.write(chalk.green("> /exit ")); @@ -254,23 +255,26 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setRunningProcesses(null); } }, - [exit, onRestart, sessionManager, refreshSkills, refreshSessionsList] + [exit, onRestart, projectRoot, sessionManager, refreshSkills, refreshSessionsList] ); const handleInterrupt = useCallback(() => { sessionManager.interruptActiveSession(); }, [sessionManager]); - const handleModelConfigChange = useCallback((selection: ModelConfigSelection): string => { - const current = resolveCurrentSettings(); - const { changed } = writeModelConfigSelection(selection, current); - const next = resolveCurrentSettings(); - setResolvedSettings(next); - if (!changed) { - return "Model settings unchanged"; - } - return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; - }, []); + const handleModelConfigChange = useCallback( + (selection: ModelConfigSelection): string => { + const current = resolveCurrentSettings(projectRoot); + const { changed } = writeModelConfigSelection(selection, current, projectRoot); + const next = resolveCurrentSettings(projectRoot); + setResolvedSettings(next); + if (!changed) { + return "Model settings unchanged"; + } + return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; + }, + [projectRoot] + ); const handleSubmit = useCallback( (submission: PromptSubmission) => { @@ -500,8 +504,15 @@ function buildStatusLine(entry: SessionEntry): string { } export function readSettings(): DeepcodingSettings | null { + return readSettingsFile(getUserSettingsPath()); +} + +export function readProjectSettings(projectRoot: string = process.cwd()): DeepcodingSettings | null { + return readSettingsFile(getProjectSettingsPath(projectRoot)); +} + +function readSettingsFile(settingsPath: string): DeepcodingSettings | null { try { - const settingsPath = getSettingsPath(); if (!fs.existsSync(settingsPath)) { return null; } @@ -513,31 +524,52 @@ export function readSettings(): DeepcodingSettings | null { } export function writeSettings(settings: DeepcodingSettings): void { - const settingsPath = getSettingsPath(); + const settingsPath = getUserSettingsPath(); + writeSettingsFile(settingsPath, settings); +} + +export function writeProjectSettings(settings: DeepcodingSettings, projectRoot: string = process.cwd()): void { + const settingsPath = getProjectSettingsPath(projectRoot); + writeSettingsFile(settingsPath, settings); +} + +function writeSettingsFile(settingsPath: string, settings: DeepcodingSettings): void { fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); } export function writeModelConfigSelection( selection: ModelConfigSelection, - current: ModelConfigSelection = resolveCurrentSettings() + current: ModelConfigSelection = resolveCurrentSettings(), + projectRoot: string = process.cwd() ): { changed: boolean; settings: DeepcodingSettings } { - const rawSettings = readSettings(); + const projectSettingsPath = getProjectSettingsPath(projectRoot); + const shouldWriteProjectSettings = fs.existsSync(projectSettingsPath); + const rawSettings = shouldWriteProjectSettings ? readProjectSettings(projectRoot) : readSettings(); const result = applyModelConfigSelection(rawSettings, current, selection); if (result.changed) { - writeSettings(result.settings); + if (shouldWriteProjectSettings) { + writeProjectSettings(result.settings, projectRoot); + } else { + writeSettings(result.settings); + } } return result; } -export function resolveCurrentSettings(): ReturnType { - return resolveSettings(readSettings(), { - model: DEFAULT_MODEL, - baseURL: DEFAULT_BASE_URL, - }); +export function resolveCurrentSettings(projectRoot: string = process.cwd()): ResolvedDeepcodingSettings { + return resolveSettingsSources( + readSettings(), + readProjectSettings(projectRoot), + { + model: DEFAULT_MODEL, + baseURL: DEFAULT_BASE_URL, + }, + process.env + ); } -export function createOpenAIClient(): { +export function createOpenAIClient(projectRoot: string = process.cwd()): { client: OpenAI | null; model: string; baseURL: string; @@ -546,9 +578,10 @@ export function createOpenAIClient(): { debugLogEnabled: boolean; notify?: string; webSearchTool?: string; + env: Record; machineId?: string; } { - const settings = resolveCurrentSettings(); + const settings = resolveCurrentSettings(projectRoot); if (!settings.apiKey) { return { client: null, @@ -559,6 +592,7 @@ export function createOpenAIClient(): { debugLogEnabled: settings.debugLogEnabled, notify: settings.notify, webSearchTool: settings.webSearchTool, + env: settings.env, machineId: getMachineId(), }; } @@ -576,6 +610,7 @@ export function createOpenAIClient(): { debugLogEnabled: settings.debugLogEnabled, notify: settings.notify, webSearchTool: settings.webSearchTool, + env: settings.env, machineId: getMachineId(), }; } @@ -598,10 +633,14 @@ function getMachineId(): string | undefined { } } -function getSettingsPath(): string { +function getUserSettingsPath(): string { return path.join(os.homedir(), ".deepcode", "settings.json"); } +function getProjectSettingsPath(projectRoot: string): string { + return path.join(projectRoot, ".deepcode", "settings.json"); +} + function formatThinkingMode(settings: Pick): string { if (!settings.thinkingEnabled) { return "no thinking"; diff --git a/src/ui/index.ts b/src/ui/index.ts index 56e6ea2..5b4ff8f 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,7 +1,9 @@ export { App, readSettings, + readProjectSettings, writeSettings, + writeProjectSettings, writeModelConfigSelection, resolveCurrentSettings, createOpenAIClient, From 327605c38704b3048759f44c4efb890791bc347f Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 13 May 2026 23:05:52 +0800 Subject: [PATCH 067/217] fix: skip WSL System32 bash.exe in Windows Git Bash detection --- src/common/shell-utils.ts | 18 +++++++++++------- src/tests/shell-utils.test.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/common/shell-utils.ts b/src/common/shell-utils.ts index a4572e4..dfe964e 100644 --- a/src/common/shell-utils.ts +++ b/src/common/shell-utils.ts @@ -175,13 +175,17 @@ function findAllWindowsExecutableCandidates(executable: string): string[] { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, }); - return filterWindowsExecutableCandidates([ - ...output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ...extraCandidates, - ]); + let whereResults = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (executable === "bash") { + // Skip WSL's deprecated bash.exe launcher (C:\Windows\System32\bash.exe). + // It would start commands inside the Linux distro instead of the Windows host, + // breaking all path translations and tool invocations. + whereResults = whereResults.filter((candidate) => !/system32[\\/]bash\.exe$/i.test(candidate)); + } + return filterWindowsExecutableCandidates([...whereResults, ...extraCandidates]); } catch { return filterWindowsExecutableCandidates(extraCandidates); } diff --git a/src/tests/shell-utils.test.ts b/src/tests/shell-utils.test.ts index 771545c..50a71f4 100644 --- a/src/tests/shell-utils.test.ts +++ b/src/tests/shell-utils.test.ts @@ -72,6 +72,23 @@ test("Windows Git Bash detection derives bash.exe from git.exe candidates", () = assert.equal(resolved, bashPath); }); +test("Windows Git Bash detection skips WSL System32 bash.exe in PATH results", () => { + // When WSL1 is enabled on older Windows 10, C:\Windows\System32\bash.exe + // appears in PATH. That launcher would execute commands inside the Linux + // distro instead of the Windows host, breaking all tool invocations. + // The PATH bash strategy should ignore it and fall through. + const system32Bash = "C:\\Windows\\System32\\bash.exe"; + const gitBash = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => + executable === "bash" ? [system32Bash] : executable === "git" ? ["D:\\Tools\\Git\\cmd\\git.exe"] : [], + findGitExecPath: () => null, + existsSync: (candidate) => candidate === gitBash, + }); + + assert.equal(resolved, gitBash); +}); + test("File tool path normalization converts Git Bash drive paths on Windows", () => { assert.equal( normalizeFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), From 0c6b0549e792d063bc0889ed9bb6e4b534f10ef8 Mon Sep 17 00:00:00 2001 From: rock-solid-sites Date: Wed, 13 May 2026 23:57:29 +0200 Subject: [PATCH 068/217] docs: translate configuration.md and mcp.md to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These docs ship in Chinese only. This adds English translations for the same content. Translation was done in DeepSeek web chat (free, off-API) by a non-Chinese-speaking contributor — please verify accuracy before merging. --- docs/configuration.md | 201 +++++++++++++++++++++--------------------- docs/mcp.md | 136 ++++++++++++++-------------- 2 files changed, 168 insertions(+), 169 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f8e52c3..369f8e4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,71 +1,71 @@ -# Deep Code 配置 +# Deep Code Configuration -## 配置层级 +## Configuration Hierarchy -配置按以下优先级顺序应用(数字较小的会被数字较大的覆盖): +Configuration is applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones): -| 层级 | 配置来源 | 说明 | -| ---- | ------------ | ------------------------------------------- | -| 1 | 默认值 | 应用程序内硬编码的默认值 | -| 2 | 用户设置文件 | 当前用户的全局设置 | -| 3 | 项目设置文件 | 项目特定的设置 | -| 4 | 环境变量 | 系统范围或会话特定的变量 | +| Layer | Configuration Source | Description | +| ----- | -------------------- | ---------------------------------------------- | +| 1 | Defaults | Hardcoded defaults within the application | +| 2 | User settings file | Global settings for the current user | +| 3 | Project settings file| Project-specific settings | +| 4 | Environment variables| System-wide or session-specific variables | -## 设置文件 +## Settings File -Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两个层级的存放位置: +Deep Code uses the `settings.json` file for persistent configuration, supporting two storage locations: -| 文件类型 | 位置 | 作用范围 | -| ------------ | ---------------------------------- | ---------------------------------------------------- | -| 用户设置文件 | `~/.deepcode/settings.json` | 适用于当前用户的所有 Deep Code 会话。 | -| 项目设置文件 | `项目根目录/.deepcode/settings.json` | 仅在该特定项目中运行 Deep Code 时生效。项目设置会覆盖用户设置。 | +| File Type | Location | Scope | +| ------------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| User settings file | `~/.deepcode/settings.json` | Applies to all Deep Code sessions for the current user. | +| Project settings file | `/.deepcode/settings.json` | Takes effect only when running Deep Code in that specific project. Project settings override user settings. | -### `settings.json` 中的可用设置 +### Available Settings in `settings.json` -以下是 `settings.json` 支持的全部顶层字段,以及 `env` 内部支持的子字段: +The following are all the top-level fields supported in `settings.json`, along with the sub-fields inside `env`: -| 字段 | 类型 | 说明 | -| -------------------- | --------- | ------------------------------------------------------------------- | -| `env` | object | 环境变量分组(见下方子字段表) | -| `model` | string | 模型名称。优先级高于 `env.MODEL` | -| `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) | -| `reasoningEffort` | string | 推理强度,可选 `"high"` 或 `"max"`(默认 `"max"`) | -| `debugLogEnabled` | boolean | 是否启用调试日志输出(默认 `false`) | -| `notify` | string | 任务完成通知脚本的完整路径(如 Slack 通知脚本) | -| `webSearchTool` | string | 自定义联网搜索脚本的完整路径 | -| `mcpServers` | object | MCP 服务器配置(键为服务名,值为 McpServerConfig 对象) | +| Field | Type | Description | +| ------------------ | ------- | --------------------------------------------------------------------------- | +| `env` | object | Group of environment variables (see sub-field table below) | +| `model` | string | Model name. Takes precedence over `env.MODEL` | +| `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)| +| `reasoningEffort` | string | Reasoning intensity, either `"high"` or `"max"` (default `"max"`) | +| `debugLogEnabled` | boolean | Enable debug log output (default `false`) | +| `notify` | string | Full path to a task-completion notification script (e.g., Slack notification script) | +| `webSearchTool` | string | Full path to a custom web search script | +| `mcpServers` | object | MCP server configurations (keys are service names, values are McpServerConfig objects) | -#### `env` 子字段 +#### `env` Sub-fields -| 字段 | 类型 | 说明 | -| ---------- | ------ | ------------------------------------------------------------------ | -| `MODEL` | string | 模型名称。例如 `"deepseek-v4-pro"`、`"deepseek-v4-flash"` | -| `BASE_URL` | string | API 请求的基础 URL。例如 `"https://api.deepseek.com"` | -| `API_KEY` | string | API 密钥 | -| `THINKING_ENABLED` | string | 是否启用思考模式 | -| `REASONING_EFFORT` | string | 推理强度 | -| `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | -| `<其他任意KEY>` | string | 自定义环境变量 | +| Field | Type | Description | +| ----------------- | ------ | ---------------------------------------------------------------- | +| `MODEL` | string | Model name, e.g. `"deepseek-v4-pro"`, `"deepseek-v4-flash"` | +| `BASE_URL` | string | Base URL for API requests, e.g. `"https://api.deepseek.com"` | +| `API_KEY` | string | API key | +| `THINKING_ENABLED`| string | Enable thinking mode | +| `REASONING_EFFORT`| string | Reasoning intensity | +| `DEBUG_LOG_ENABLED`| string| Enable debug log output | +| `` | string | Custom environment variable | -#### `thinkingEnabled` — 思考模式 +#### `thinkingEnabled` — Thinking Mode -是否启用 DeepSeek 思考模式。设置为 `true` 启用、`false` 禁用。 +Whether to enable DeepSeek thinking mode. Set to `true` to enable, `false` to disable. -- 对于 `deepseek-v4-pro` 和 `deepseek-v4-flash`,思考模式**默认启用**。 -- 对于其他模型,思考模式**默认关闭**。 +- For `deepseek-v4-pro` and `deepseek-v4-flash`, thinking mode is **enabled by default**. +- For other models, thinking mode is **disabled by default**. -#### `reasoningEffort` — 推理强度 +#### `reasoningEffort` — Reasoning Intensity -当思考模式启用时,控制模型思考的深度: +When thinking mode is enabled, controls the depth of the model’s reasoning: -| 值 | 说明 | -| ------ | --------------------------------- | -| `max` | 最大推理深度(默认值) | -| `high` | 较高推理深度,token消耗相对较小 | +| Value | Description | +| ------ | --------------------------------------------------------- | +| `max` | Maximum reasoning depth (default) | +| `high` | Higher reasoning depth with relatively lower token usage | -#### `notify` — 任务完成通知 +#### `notify` — Task Completion Notification -设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 +Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). ```json { @@ -73,9 +73,9 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 } ``` -#### `webSearchTool` — 自定义联网搜索 +#### `webSearchTool` — Custom Web Search -Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: +Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script: ```json { @@ -83,16 +83,16 @@ Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索 } ``` -脚本接收一个搜索查询参数,输出 JSON 格式的结果供 AI 使用。 +The script receives a search query as an argument and outputs results in JSON format for the AI. -#### `mcpServers` — MCP 服务器 +#### `mcpServers` — MCP Servers -MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。 +Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object. ```json { "mcpServers": { - "<服务名>": { + "": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { @@ -103,71 +103,70 @@ MCP(Model Context Protocol)服务器配置。值是键值对,键为服务 } ``` -| McpServerConfig 字段 | 类型 | 必填 | 说明 | -| -------------------- | -------- | ---- | -------------------------------------------------------------------- | -| `command` | string | 是 | 可执行文件路径或命令(如 `npx`、`node`、`python`) | -| `args` | string[] | 否 | 传递给命令的参数列表 | -| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量 | +| McpServerConfig field | Type | Required | Description | +| --------------------- | -------- | -------- | ------------------------------------------------------------------------ | +| `command` | string | Yes | Executable path or command (e.g. `npx`, `node`, `python`) | +| `args` | string[] | No | List of arguments passed to the command | +| `env` | object | No | Environment variables passed to the MCP server process | -> 当 `command` 为 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 +> When `command` is `npx`, Deep Code automatically prepends `-y` to the arguments. -详细 MCP 使用说明请参考 [mcp.md](mcp.md)。 +For detailed MCP usage instructions, refer to [mcp.md](mcp.md). +#### `debugLogEnabled` — Debug Log -#### `debugLogEnabled` — 调试日志 +Set to `true` to enable detailed debug logging (default `false`), useful for troubleshooting API calls and tool execution. -设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 +## Environment Variable Priority -## 环境变量优先级 +Environment variables are a common way to configure applications, especially for sensitive information (such as api-key) or settings that may change between environments. -环境变量是配置应用程序的常用方式,尤其适用于敏感信息(如 api-key)或可能在不同环境之间更改的设置。 +### Priority Principle -### 优先级原则 +Environment variable priority follows the logic of “the more specific and localized the configuration, the higher the priority”, and the override rule of “env files protect existing environment by default, system variables override env files”. (The `env` object in settings.json can be thought of as a type of env file.) -环境变量优先级遵循“越具体、越局部的配置,优先级越高”和“env文件默认保护现有环境,系统变量高于env文件”的覆盖逻辑。(settings.json的env对象可以认为是一种env文件) +Priority levels (from lowest to highest): +1. `env` defined at the top level of `settings.json` – this is a general configuration for the entire tool and all its subprocesses (global variables). Can be overridden by outer environment variables, but the environment variable KEY has the `DEEPCODE_` prefix removed. +2. `env` defined inside `mcpServers` in `settings.json` – this is the most specific configuration for a particular MCP service (local variables). Can be overridden by outer environment variables, but the KEY has the `MCP_` prefix removed. +3. Shell/system environment variables – operating system level. -优先级层级 (由低到高) -1. settings.json 外层的 env:这是针对整个工具及其所有子进程的通用配置(全局变量)。可被外层环境变量覆盖,但环境变量KEY会移除`DEEPCODE_`前缀。 -2. settings.json mcpServers 内定义的 env:这是针对特定 MCP 服务的最具体配置(局部变量)。可被外层环境变量覆盖,但环境变量KEY会移除`MCP_`前缀。 -3. Shell 环境系统变量:操作系统层面的环境变量。 +### Scenarios -### 场景 +#### 1. Setting the model’s api_key and base_url -#### 一、设置模型的api_key, base_url +Applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones) – using api_key as an example: -按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以api_key为例): +1. Hardcoded default: `""` +2. User-level settings.json: `{"env": {"API_KEY": "abc123"}}` +3. Project-level settings.json: `{"env": {"API_KEY": "abc123"}}` +4. System environment variable: `DEEPCODE_API_KEY=abc123 deepcode` -1. 硬编码默认值: `""` -2. 用户级settings.json: `{"env": {"API_KEY": "abc123"}}` -3. 项目级settings.json: `{"env": {"API_KEY": "abc123"}}` -4. 系统环境变量: `DEEPCODE_API_KEY=abc123 deepcode` +#### 2. Setting model, thinkingEnabled, and reasoningEffort -#### 二、设置模型的model, thinkingEnabled, reasoningEffort +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using thinkingEnabled as an example: -按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以thinkingEnabled为例): +1. Hardcoded default: `true` +2. User-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +3. User-level settings.json: `{"thinkingEnabled": true}` +4. Project-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +5. Project-level settings.json: `{"thinkingEnabled": true}` +6. System environment variable: `DEEPCODE_THINKING_ENABLED=true deepcode` -1. 硬编码默认值: `true` -2. 用户级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` -3. 用户级settings.json: `{"thinkingEnabled": true}` -4. 项目级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` -5. 项目级settings.json: `{"thinkingEnabled": true}` -6. 系统环境变量: `DEEPCODE_THINKING_ENABLED=true deepcode` +#### 3. Setting environment variables for external scripts like notify and webSearchTool -#### 三、设置启动notify, webSearchTool等外挂脚本的环境变量 +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using notify as an example: -按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以notify为例): +1. Hardcoded default: `os.environ.get('WEBHOOK', '...') # notify script code` +2. User-level settings.json: `{"env": {"WEBHOOK": "..."}}` +3. Project-level settings.json: `{"env": {"WEBHOOK": "true"}}` +4. System environment variable: `DEEPCODE_WEBHOOK=... deepcode` -1. 硬编码默认值:`os.environ.get('WEBHOOK', '...') # notify脚本代码` -2. 用户级settings.json: `{"env": {"WEBHOOK": "..."}}` -3. 项目级settings.json: `{"env": {"WEBHOOK": "true"}}` -4. 系统环境变量: `DEEPCODE_WEBHOOK=... deepcode` +#### 4. Setting environment variables for an MCP Service -#### 四、设置MCP Service的环境变量 +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using a GitHub MCP server as an example: -按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以github MCP server为例): - -1. 用户级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` -2. 用户级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` -3. 项目级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` -4. 项目级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` -5. 系统环境变量: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` +1. User-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` \ No newline at end of file diff --git a/docs/mcp.md b/docs/mcp.md index fe6711d..11adda5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,22 +1,22 @@ -# Deep Code CLI MCP 配置指南 +# Deep Code CLI MCP Configuration Guide -Deep Code CLI 支持 MCP(Model Context Protocol),让 AI 助手能够连接外部工具和服务,如 GitHub、浏览器、数据库等。 +Deep Code CLI supports MCP (Model Context Protocol), enabling AI assistants to connect with external tools and services such as GitHub, browsers, databases, and more. -## 概述 +## Overview -配置 MCP 后,Deep Code 可以: +Once MCP is configured, Deep Code can: -- 操作 GitHub 仓库(查看 Issues、创建 PR、搜索代码等) -- 操控浏览器(截图、点击、填表单等) -- 访问文件系统 -- 连接数据库和 API -- ...以及任何兼容 MCP 协议的外部服务 +- Operate on GitHub repositories (view issues, create PRs, search code, etc.) +- Control browsers (screenshots, clicks, form filling, etc.) +- Access the file system +- Connect to databases and APIs +- ...and any external service compatible with the MCP protocol -MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`,例如 `mcp__github__search_code`。 +MCP tools are named in Deep Code using the format `mcp____`, for example `mcp__github__search_code`. -## 配置 MCP 服务器 +## Configuring MCP Servers -编辑 `~/.deepcode/settings.json`,添加 `mcpServers` 字段: +Edit `~/.deepcode/settings.json` and add the `mcpServers` field: ```json { @@ -28,30 +28,30 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, "thinkingEnabled": true, "reasoningEffort": "max", "mcpServers": { - "<服务名称>": { - "command": "<可执行文件>", - "args": ["<参数1>", "<参数2>"], + "": { + "command": "", + "args": ["", ""], "env": { - "<环境变量>": "<值>" + "": "" } } } } ``` -### 配置项说明 +### Configuration Fields -| 字段 | 类型 | 必填 | 说明 | -| --------- | -------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | -| `args` | string[] | 否 | 传递给命令的参数列表 | -| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | +| Field | Type | Required | Description | +| --------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `command` | string | Yes | Path or command of the MCP server executable (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | +| `args` | string[] | No | List of arguments to pass to the command | +| `env` | object | No | Environment variables (e.g., API keys) to pass to the MCP server process | -## 常用 MCP 示例 +## Common MCP Examples ### GitHub MCP -让 Deep Code 直接操作 GitHub 仓库(搜索代码、管理 Issue/PR、读写文件等): +Allows Deep Code to directly operate on GitHub repositories (search code, manage issues/PRs, read/write files, etc.): ```json { @@ -67,11 +67,11 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, } ``` -> GitHub Personal Access Token 可在 [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens) 生成。 +> Generate a GitHub Personal Access Token at [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens). -### 浏览器控制(Playwright) +### Browser Control (Playwright) -让 Deep Code 操控浏览器进行截图、页面操作等: +Lets Deep Code control a browser for screenshots, page interactions, etc.: ```json { @@ -84,9 +84,9 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, } ``` -### 文件系统 +### File System -让 Deep Code 在指定目录中读写文件: +Enables Deep Code to read and write files within a specified directory: ```json { @@ -99,7 +99,7 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, } ``` -### 自定义 Python MCP +### Custom Python MCP ```json { @@ -115,9 +115,9 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, } ``` -## 完整配置示例 +## Full Configuration Example -以下是一个配置了 GitHub 和 Playwright 两个 MCP 服务器的完整 `~/.deepcode/settings.json`: +Below is a complete `~/.deepcode/settings.json` with both GitHub and Playwright MCP servers configured: ```json { @@ -144,62 +144,62 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, } ``` -## 使用 MCP +## Using MCP -配置完成后,启动 `deepcode`,使用 `/mcp` 命令管理 MCP 连接: +After configuration, start `deepcode` and use the `/mcp` command to manage MCP connections: -- `/mcp` — 查看已配置的 MCP 服务器状态 -- `/mcp add` — 添加新的 MCP 服务器 -- `/mcp remove` — 移除 MCP 服务器 -- `/mcp list` — 列出所有已连接的 MCP 服务器及其工具 +- `/mcp` — View the status of configured MCP servers +- `/mcp add` — Add a new MCP server +- `/mcp remove` — Remove an MCP server +- `/mcp list` — List all connected MCP servers and their tools -在对话中直接使用 MCP 工具名称即可调用,例如: +Simply use the MCP tool name in your conversation to invoke it, for example: ``` -帮我搜索 GitHub 上 deepcode-cli 仓库的 issues +Help me search for issues in the deepcode-cli repository on GitHub ``` -AI 会自动调用 `mcp__github__search_issues` 工具完成操作。 +The AI will automatically invoke the `mcp__github__search_issues` tool to complete the action. -## 工具命名规则 +## Tool Naming Convention -MCP 工具名称由三部分组成:`mcp__<服务名>__<工具名>` +An MCP tool name consists of three parts: `mcp____` -| 服务名 | 工具名 | 完整调用名 | -| ---------- | ----------------------- | ------------------------------------------ | -| github | search_code | `mcp__github__search_code` | -| github | create_pull_request | `mcp__github__create_pull_request` | -| playwright | browser_navigate | `mcp__playwright__browser_navigate` | -| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | +| Service | Tool Name | Full Invocation Name | +| ---------- | ----------------------- | ------------------------------------------- | +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | -你可以通过 `/mcp list` 查看每个服务器提供的具体工具列表。 +You can view the list of tools provided by each server using `/mcp list`. -## 故障排查 +## Troubleshooting -### 启动失败 +### Startup Failure -如果 MCP 服务器无法启动,检查: +If an MCP server fails to start, check: -1. `command` 是否已安装(如 `npx` 需要 Node.js) -2. `env` 中的环境变量是否正确(如 `GITHUB_PERSONAL_ACCESS_TOKEN`) -3. 运行 `deepcode` 的终端是否有网络访问权限 +1. Whether `command` is installed (e.g., `npx` requires Node.js) +2. Whether environment variables in `env` are correct (e.g., `GITHUB_PERSONAL_ACCESS_TOKEN`) +3. Whether the terminal running `deepcode` has network access -### 工具不显示 +### Tools Not Showing Up -1. 确认 `settings.json` 中的 `mcpServers` 字段格式正确 -2. 启动 deepcode 后使用 `/mcp` 查看服务器状态 -3. 如果服务器状态显示错误,根据错误信息排查 +1. Verify that the `mcpServers` field in `settings.json` is correctly formatted +2. After starting deepcode, use `/mcp` to check server status +3. If the server status shows an error, debug based on the error message -### Windows 用户 +### Windows Users -在 Windows 上,Deep Code CLI 会自动为 `.cmd` 命令添加 shell 支持。如果你的 MCP 命令是批处理脚本,确保文件名以 `.cmd` 结尾。 +On Windows, Deep Code CLI automatically adds shell support for `.cmd` commands. If your MCP command is a batch script, ensure the filename ends with `.cmd`. -## 编写你自己的 MCP 服务器 +## Writing Your Own MCP Server -MCP 服务器遵循 [Model Context Protocol](https://modelcontextprotocol.io/) 规范,使用 JSON-RPC 2.0 通信。你可以用任何语言编写 MCP 服务器,只要实现以下协议即可: +MCP servers follow the [Model Context Protocol](https://modelcontextprotocol.io/) specification and communicate using JSON‑RPC 2.0. You can write an MCP server in any language as long as it implements the following methods: -1. `initialize` — 握手和协议协商 -2. `tools/list` — 返回可用工具列表 -3. `tools/call` — 执行工具调用 +1. `initialize` — Handshake and protocol negotiation +2. `tools/list` — Return the list of available tools +3. `tools/call` — Execute a tool call -更多参考:[MCP 官方文档](https://modelcontextprotocol.io/) +For more information, see the [official MCP documentation](https://modelcontextprotocol.io/). \ No newline at end of file From a30017237aca3658502223cb10fbbaef6d91bdd3 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 09:10:27 +0800 Subject: [PATCH 069/217] feat: update docs/mcp.md --- docs/mcp.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index fe6711d..73034a3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -146,12 +146,7 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, ## 使用 MCP -配置完成后,启动 `deepcode`,使用 `/mcp` 命令管理 MCP 连接: - -- `/mcp` — 查看已配置的 MCP 服务器状态 -- `/mcp add` — 添加新的 MCP 服务器 -- `/mcp remove` — 移除 MCP 服务器 -- `/mcp list` — 列出所有已连接的 MCP 服务器及其工具 +配置完成后,启动 `deepcode`,在聊天中输入 `/mcp` 即可查看所有已配置的 MCP 服务器状态以及每个服务器提供的工具列表。 在对话中直接使用 MCP 工具名称即可调用,例如: @@ -172,7 +167,7 @@ MCP 工具名称由三部分组成:`mcp__<服务名>__<工具名>` | playwright | browser_navigate | `mcp__playwright__browser_navigate` | | playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | -你可以通过 `/mcp list` 查看每个服务器提供的具体工具列表。 +你可以通过 `/mcp` 查看每个服务器提供的具体工具列表。 ## 故障排查 From 27000543800e7e3113c25ebac9df6d3acd60a7a4 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 09:48:56 +0800 Subject: [PATCH 070/217] =?UTF-8?q?feat(session):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=8F=98=E6=9B=B4=E6=B6=88=E6=81=AF=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=92=8C=E7=9B=B8=E5=85=B3UI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在模型配置变更时生成系统角色消息,包含模型设置详情 - 将模型变更消息添加至消息列表,支持消息追踪 - 在消息视图中新增模型变更消息渲染逻辑,展示模型名称和推理强度 - 调整用户消息视图布局,优化水平排列和间距 - 修改提示输入组件下拉菜单最大可见项数为6,提升视觉体验 - 扩展SessionMessage类型,支持模型变更相关元信息 - 新增单元测试覆盖DropdownMenu的可见起始项计算逻辑 --- src/session.ts | 6 ++ src/tests/dropdownMenu.test.ts | 148 +++++++++++++++++++++++++++++++++ src/ui/App.tsx | 51 +++++++++--- src/ui/MessageView.tsx | 45 +++++++--- src/ui/PromptInput.tsx | 4 +- 5 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 src/tests/dropdownMenu.test.ts diff --git a/src/session.ts b/src/session.ts index 3382280..1fe1f6d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -121,6 +121,12 @@ export type MessageMeta = { resultMd?: string; asThinking?: boolean; isSummary?: boolean; + isModelChange?: boolean; + modelConfig?: { + model: string; + thinkingEnabled: boolean; + reasoningEffort?: string; + }; skill?: SkillInfo; }; diff --git a/src/tests/dropdownMenu.test.ts b/src/tests/dropdownMenu.test.ts new file mode 100644 index 0000000..3e4e3ef --- /dev/null +++ b/src/tests/dropdownMenu.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { calculateVisibleStart } from "../ui/DropdownMenu"; + +test("calculateVisibleStart centers active item when possible", () => { + // 10 items, max 5 visible, active index 4 (middle) + // Should show items 2-6 (start at 2) + const start = calculateVisibleStart(4, 10, 5); + assert.equal(start, 2); +}); + +test("calculateVisibleStart handles active item at the beginning", () => { + // 10 items, max 5 visible, active index 0 + // Should show items 0-4 (start at 0) + const start = calculateVisibleStart(0, 10, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles active item at the end", () => { + // 10 items, max 5 visible, active index 9 (last) + // Should show items 5-9 (start at 5) + const start = calculateVisibleStart(9, 10, 5); + assert.equal(start, 5); +}); + +test("calculateVisibleStart handles fewer items than maxVisible", () => { + // 3 items, max 5 visible, active index 1 + // Should show all items (start at 0) + const start = calculateVisibleStart(1, 3, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles single item", () => { + // 1 item, max 5 visible, active index 0 + // Should start at 0 + const start = calculateVisibleStart(0, 1, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles empty list", () => { + // 0 items, max 5 visible, active index 0 + // Should start at 0 + const start = calculateVisibleStart(0, 0, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex near start with odd maxVisible", () => { + // 10 items, max 7 visible (odd), active index 2 + // floor((7-1)/2) = 3, so 2-3 = -1, clamped to 0 + const start = calculateVisibleStart(2, 10, 7); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex near start with even maxVisible", () => { + // 10 items, max 6 visible (even), active index 2 + // floor((6-1)/2) = 2, so 2-2 = 0 + const start = calculateVisibleStart(2, 10, 6); + assert.equal(start, 0); +}); + +test("calculateVisibleStart keeps active item centered in middle range", () => { + // 20 items, max 5 visible, active index 10 + // floor((5-1)/2) = 2, so 10-2 = 8 + const start = calculateVisibleStart(10, 20, 5); + assert.equal(start, 8); +}); + +test("calculateVisibleStart handles activeIndex at exact boundary", () => { + // 10 items, max 5 visible, active index 2 (boundary where centering starts) + // floor((5-1)/2) = 2, so 2-2 = 0 + const start = calculateVisibleStart(2, 10, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex just after boundary", () => { + // 10 items, max 5 visible, active index 3 + // floor((5-1)/2) = 2, so 3-2 = 1 + const start = calculateVisibleStart(3, 10, 5); + assert.equal(start, 1); +}); + +test("calculateVisibleStart handles large maxVisible", () => { + // 10 items, max 100 visible, active index 5 + // Should show all items (start at 0) + const start = calculateVisibleStart(5, 10, 100); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex equal to totalItems", () => { + // 10 items, max 5 visible, active index 10 (out of bounds) + // floor((5-1)/2) = 2, so 10-2 = 8, clamped to 5 (10-5) + const start = calculateVisibleStart(10, 10, 5); + assert.equal(start, 5); +}); + +test("calculateVisibleStart with maxVisible of 1", () => { + // 5 items, max 1 visible, active index 2 + // floor((1-1)/2) = 0, so 2-0 = 2, clamped to 4 (5-1) + const start = calculateVisibleStart(2, 5, 1); + assert.equal(start, 2); +}); + +test("calculateVisibleStart with maxVisible of 1 at end", () => { + // 5 items, max 1 visible, active index 4 (last) + // floor((1-1)/2) = 0, so 4-0 = 4, clamped to 4 (5-1) + const start = calculateVisibleStart(4, 5, 1); + assert.equal(start, 4); +}); + +test("calculateVisibleStart scrolling behavior - moving down", () => { + // Simulate scrolling through a list + // 10 items, max 5 visible + + // Start at index 0 + assert.equal(calculateVisibleStart(0, 10, 5), 0); + + // Move to index 2 (still centered) + assert.equal(calculateVisibleStart(2, 10, 5), 0); + + // Move to index 5 (window should scroll) + assert.equal(calculateVisibleStart(5, 10, 5), 3); + + // Move to index 8 (near end) + assert.equal(calculateVisibleStart(8, 10, 5), 5); + + // Move to index 9 (at end) + assert.equal(calculateVisibleStart(9, 10, 5), 5); +}); + +test("calculateVisibleStart scrolling behavior - moving up", () => { + // Simulate scrolling up through a list + // 10 items, max 5 visible + + // Start at index 9 (end) + assert.equal(calculateVisibleStart(9, 10, 5), 5); + + // Move to index 6 + assert.equal(calculateVisibleStart(6, 10, 5), 4); + + // Move to index 4 (window should scroll up) + assert.equal(calculateVisibleStart(4, 10, 5), 2); + + // Move to index 1 (near start) + assert.equal(calculateVisibleStart(1, 10, 5), 0); + + // Move to index 0 (at start) + assert.equal(calculateVisibleStart(0, 10, 5), 0); +}); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index dd13d0f..d1719d2 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -261,16 +261,47 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R sessionManager.interruptActiveSession(); }, [sessionManager]); - const handleModelConfigChange = useCallback((selection: ModelConfigSelection): string => { - const current = resolveCurrentSettings(); - const { changed } = writeModelConfigSelection(selection, current); - const next = resolveCurrentSettings(); - setResolvedSettings(next); - if (!changed) { - return "Model settings unchanged"; - } - return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; - }, []); + const handleModelConfigChange = useCallback( + (selection: ModelConfigSelection): string => { + const current = resolveCurrentSettings(); + const { changed } = writeModelConfigSelection(selection, current); + const next = resolveCurrentSettings(); + setResolvedSettings(next); + + if (!changed) { + return "Model settings unchanged"; + } + + // 构建模型变更消息 + const activeSessionId = sessionManager.getActiveSessionId(); + const message: SessionMessage = { + id: crypto.randomUUID(), + sessionId: activeSessionId ?? "local", + role: "system", + content: `/model\n⎿ Set model to ${selection.model}`, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + meta: { + isModelChange: true, + modelConfig: { + model: selection.model, + thinkingEnabled: selection.thinkingEnabled, + reasoningEffort: selection.reasoningEffort, + }, + }, + }; + + // 添加到消息列表 + setMessages((prev) => [...prev, message]); + + return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; + }, + [sessionManager] + ); const handleSubmit = useCallback( (submission: PromptSubmission) => { diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index ae9dd19..5f0c3df 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -16,17 +16,15 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | 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} - + + + {`>`} + + + {text} + {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( + {` 📎 ${message.contentParams.length} image attachment(s)`} + ) : null} ); @@ -48,7 +46,9 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | return ( - {content ? {renderMarkdown(content)} : null} + + {content ? {renderMarkdown(content)} : null} + ); } @@ -81,6 +81,27 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | } if (message.role === "system") { + // 渲染模型变更消息 + if (message.meta?.isModelChange) { + return ( + + + {`>`} + + + /model + + ⎿ Set model to{" "} + + {message.meta.modelConfig?.model} + + {` (${message.meta.modelConfig?.reasoningEffort || "normal"} effort)`} + + + + ); + } + if (message.meta?.skill) { return ( diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index a5f77f1..6a11431 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -733,7 +733,7 @@ export const PromptInput = React.memo(function PromptInput({ }))} activeIndex={skillsDropdownIndex} activeColor="#229ac3" - maxVisible={8} + maxVisible={6} /> ) : null} {modelDropdownStep ? ( @@ -753,7 +753,7 @@ export const PromptInput = React.memo(function PromptInput({ }))} activeIndex={modelDropdownIndex} activeColor="#229ac3" - maxVisible={8} + maxVisible={6} /> ) : null} From d5ad0fb89b122d5e0298680d2549552a086e4c45 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 10:06:34 +0800 Subject: [PATCH 071/217] feat: update README.md --- README.md | 82 ++++++++++++++++++++++++++--------------- README_cn.md | 69 ++++++++++++++++++++++++++-------- README_en.md | 73 +++++++++++++++++++++++++++--------- src/ui/slashCommands.ts | 2 +- 4 files changed, 163 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 42da1b8..69d28c8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,6 @@ # Deep Code CLI -[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制以及 Agent Skills。 - -## 🚀 新增功能(本 Fork) - -### `/mcp` Skill 与 MCP 实现 - -本 Fork 新增了 `/mcp` 命令和 MCP(Model Context Protocol)集成,让 Deep Code CLI 能够连接外部工具和服务: - -- **`/mcp` Skill**:一键管理 MCP 服务器连接,支持添加、移除、列出已配置的 MCP 服务。 -- **MCP 协议实现**:支持与 GitHub、文件系统、数据库等多种外部服务的标准化集成,大幅扩展 AI 助手的操作能力。 - -通过 MCP,你现在可以让 Deep Code 直接操作 GitHub 仓库、读取文件、查询数据库等,而无需离开终端。 - -📖 **详细配置指南:** [docs/mcp.md](docs/mcp.md) +[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 ## 安装 @@ -43,6 +30,8 @@ npm install -g @vegamo/deepcode-cli 配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 +完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 + ## 主要功能 ### **Skills** @@ -56,20 +45,26 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: - 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 - 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 -## 快捷键 - -| 键 | 操作 | -|-----------------|-----------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/skills` | 列出可用 skills | -| `/exit` | 退出 | -| 连续 `Ctrl+D` | 退出 | +## 斜杠命令与按键功能 + +| 斜杠命令 | 操作 | +|-----------------|---------------------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|-----------------|---------------------------------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | ## 支持的模型 @@ -96,6 +91,13 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 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 为例: @@ -110,10 +112,30 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 "thinkingEnabled": true } ``` +## 贡献 -### 如何配置 MCP? +欢迎贡献代码!以下是参与方式: + +```bash +# 克隆仓库 +git clone https://github.com/lessweb/deepcode-cli.git +cd deepcode-cli + +# 安装依赖 +npm install + +# 本地开发(类型检查 + lint + 格式检查 + 构建) +npm run build + +# 运行测试 +npm test + +# 链接到全局(即本地全局安装) +npm link +``` -Deep Code CLI 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、文件系统等外部服务。配置方法请查看:[docs/mcp.md](docs/mcp.md) +- 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) +- 建议在执行构建前,先执行 `npm run format` 自动格式化代码,避免构建报错 ## 获取帮助 diff --git a/README_cn.md b/README_cn.md index ea5dcde..69d28c8 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,6 +1,6 @@ # Deep Code CLI -[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制以及 Agent Skills。 +[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 ## 安装 @@ -30,6 +30,8 @@ npm install -g @vegamo/deepcode-cli 配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 +完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 + ## 主要功能 ### **Skills** @@ -43,20 +45,26 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: - 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 - 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 -## 快捷键 - -| 键 | 操作 | -|-----------------|-----------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/skills` | 列出可用 skills | -| `/exit` | 退出 | -| 连续 `Ctrl+D` | 退出 | +## 斜杠命令与按键功能 + +| 斜杠命令 | 操作 | +|-----------------|---------------------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|-----------------|---------------------------------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | ## 支持的模型 @@ -83,6 +91,13 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 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 为例: @@ -97,6 +112,30 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 "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` 自动格式化代码,避免构建报错 ## 获取帮助 diff --git a/README_en.md b/README_en.md index dc49ae0..ee5a103 100644 --- a/README_en.md +++ b/README_en.md @@ -1,6 +1,6 @@ # Deep Code CLI -[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, and Agent Skills. +[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 @@ -30,6 +30,8 @@ Create `~/.deepcode/settings.json`: The configuration file is shared with the [Deep Code VSCode extension](https://github.com/lessweb/deepcode) — configure once, use everywhere. +For complete configuration details (multi-level priority, environment variables, etc.), see [docs/configuration.md](docs/configuration.md). + ## Key Features ### **Skills** @@ -41,22 +43,28 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap ### **Optimized for DeepSeek** - Specifically tuned for DeepSeek model performance. - Reduce costs by using [Context Caching](https://api-docs.deepseek.com/guides/kv_cache). -- Natively supports [Thinking Mode](https://api-docs.deepseek.com/guides/thinking_mode) and Thinking Effort Control. - -## Keyboard Shortcuts - -| Key | Action | -|-----------------|----------------------------------------------| -| `Enter` | Send the prompt | -| `Shift+Enter` | Insert a newline (also `Ctrl+J`) | -| `Ctrl+V` | Paste an image from the clipboard | -| `Esc` | Interrupt the current model turn | -| `/` | Open the skills / commands menu | -| `/new` | Start a fresh conversation | -| `/resume` | Choose a previous conversation to continue | -| `/skills` | List available skills | -| `/exit` | Quit Deep Code | -| `Ctrl+D` twice | Quit Deep Code | +- Natively supports [Thinking Mode](https://api-docs.deepseek.com/guides/thinking_mode) and Effort Control. + +## Slash Commands & Keyboard Shortcuts + +| Slash Command | Action | +|------------------|----------------------------------------------------------| +| `/` | Open the skills / commands menu | +| `/new` | Start a fresh conversation | +| `/resume` | Choose a previous conversation to continue | +| `/model` | Switch model, thinking mode, and reasoning effort | +| `/init` | Initialize an AGENTS.md file (LLM project instructions) | +| `/skills` | List available skills | +| `/mcp` | View MCP server status and available tools | +| `/exit` | Quit (also `Ctrl+D` twice) | + +| Key | Action | +|------------------|----------------------------------------------------------| +| `Enter` | Send the prompt | +| `Shift+Enter` | Insert a newline (also `Ctrl+J`) | +| `Ctrl+V` | Paste an image from the clipboard | +| `Esc` | Interrupt the current model turn | +| `Ctrl+D` twice | Quit Deep Code | ## Supported Models @@ -97,6 +105,37 @@ Yes. Just set `env.BASE_URL` in `~/.deepcode/settings.json` to an OpenAI-compati } ``` +### How do I configure MCP? + +Deep Code supports MCP (Model Context Protocol) to connect external services such as GitHub, browsers, databases, and more. Configure the `mcpServers` field in `settings.json` to enable it, then use the `/mcp` command to view MCP server status and available tools. + +For detailed setup instructions, see: [docs/mcp.md](docs/mcp.md) + +## Contributing + +Contributions are welcome! Here's how to get started: + +```bash +# Clone the repository +git clone https://github.com/lessweb/deepcode-cli.git +cd deepcode-cli + +# Install dependencies +npm install + +# Local development (typecheck + lint + format check + bundle) +npm run build + +# Run tests +npm test + +# Link globally (local global install) +npm link +``` + +- Make sure `npm run check` passes before submitting a PR (typecheck + lint + format check) +- We recommend running `npm run format` before building to avoid errors + ## Getting Help - Report bugs or request features on GitHub Issues (https://github.com/lessweb/deepcode-cli/issues) diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index c565606..16a76ac 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -21,7 +21,7 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ kind: "model", name: "model", label: "/model", - description: "Select model, thinking mode and thinking effort", + description: "Select model, thinking mode and effort control", }, { kind: "new", From ef875a056d627d1cb9f08ea83374087e386d0f71 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 10:19:33 +0800 Subject: [PATCH 072/217] =?UTF-8?q?feat(session):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=B3=BB=E7=BB=9F=E6=B6=88=E6=81=AF=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=8A=E6=A8=A1=E5=9E=8B=E5=8F=98=E6=9B=B4=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 addSessionSystemMessage 方法,用于统一添加系统消息 - 优化 App.tsx 中模型切换逻辑,调用 sessionManager 添加系统消息 - 保持无活动会话时依旧能将消息追加到本地消息列表 - 重构消息创建流程,抽取 meta 和 content 变量提高代码清晰度 - 确保系统消息包含准确的时间戳及元数据配置 --- src/session.ts | 19 ++++++++++++++++++ src/ui/App.tsx | 52 +++++++++++++++++++++++++++++--------------------- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/session.ts b/src/session.ts index 1fe1f6d..436da8c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1332,6 +1332,25 @@ ${skillMd} return messages; } + addSessionSystemMessage(sessionId: string, content: string, meta?: MessageMeta): void { + const now = new Date().toISOString(); + const message: SessionMessage = { + id: crypto.randomUUID(), + sessionId, + role: "system", + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta, + }; + this.appendSessionMessage(sessionId, message); + this.onAssistantMessage(message, false); + } + private normalizeSessionMessage(message: SessionMessage): SessionMessage { if (message.role !== "tool") { return message; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d1719d2..ff9ed5b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -8,6 +8,7 @@ import OpenAI from "openai"; import { SessionManager, type LlmStreamProgress, + type MessageMeta, type SessionEntry, type SessionMessage, type SessionStatus, @@ -272,31 +273,38 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return "Model settings unchanged"; } - // 构建模型变更消息 const activeSessionId = sessionManager.getActiveSessionId(); - const message: SessionMessage = { - id: crypto.randomUUID(), - sessionId: activeSessionId ?? "local", - role: "system", - content: `/model\n⎿ Set model to ${selection.model}`, - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: new Date().toISOString(), - updateTime: new Date().toISOString(), - meta: { - isModelChange: true, - modelConfig: { - model: selection.model, - thinkingEnabled: selection.thinkingEnabled, - reasoningEffort: selection.reasoningEffort, - }, + const meta: MessageMeta = { + isModelChange: true, + modelConfig: { + model: selection.model, + thinkingEnabled: selection.thinkingEnabled, + reasoningEffort: selection.reasoningEffort, }, }; - - // 添加到消息列表 - setMessages((prev) => [...prev, message]); + const content = `/model\n⎿ Set model to ${selection.model}`; + + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, content, meta); + } else { + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + sessionId: "local", + role: "system" as const, + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta, + }, + ]); + } return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; }, From d3aab4de5579ea9660af2a7f0371504ee625f01f Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 10:28:58 +0800 Subject: [PATCH 073/217] =?UTF-8?q?fix(merge):=20=E5=90=88=E5=B9=B6=20main?= =?UTF-8?q?=20=E5=88=86=E6=94=AF=E4=BB=A3=E7=A0=81=E5=B9=B6=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/App.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index dbc5935..8728219 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -269,19 +269,6 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const { changed } = writeModelConfigSelection(selection, current, projectRoot); const next = resolveCurrentSettings(projectRoot); setResolvedSettings(next); - if (!changed) { - return "Model settings unchanged"; - } - return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; - }, - [projectRoot] - ); - const handleModelConfigChange = useCallback( - (selection: ModelConfigSelection): string => { - const current = resolveCurrentSettings(); - const { changed } = writeModelConfigSelection(selection, current); - const next = resolveCurrentSettings(); - setResolvedSettings(next); if (!changed) { return "Model settings unchanged"; @@ -322,7 +309,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; }, - [sessionManager] + [projectRoot, sessionManager] ); const handleSubmit = useCallback( From 71944b91579cf1ad3f61d3d28605d56bf4554288 Mon Sep 17 00:00:00 2001 From: lellansin Date: Thu, 14 May 2026 11:44:06 +0800 Subject: [PATCH 074/217] feat(prompt): add ctrl+- undo and ctrl+shift+- redo Adds Emacs-style undo/redo for the prompt input buffer: - ctrl+- (undo): reverses text changes one step at a time, all the way back to the initial empty buffer. Uses an undo stack capped at 1000 entries. - ctrl+shift+- (redo): restores undone changes one step at a time. New edits clear the redo history. - Pure cursor movement does not create undo entries. - Terminal input parsing handles modifyOtherKeys CSI sequences: \u001B[45;5u and \u001B[27;5;45~ for undo, \u001B[45;6u and \u001B[27;6;45~ for redo, plus raw 0x1F as redo fallback. --- src/tests/promptInputKeys.test.ts | 38 ++++++++++++++++++ src/tests/promptUndoRedo.test.ts | 60 ++++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 52 +++++++++++++++++++++++- src/ui/prompt/useTerminalInput.ts | 66 +++++++++++++++++++++++++++++++ src/ui/promptUndoRedo.ts | 52 ++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/tests/promptUndoRedo.test.ts create mode 100644 src/ui/promptUndoRedo.ts diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 704d44a..69d2075 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -128,6 +128,44 @@ test("parseTerminalInput recognizes ctrl+x as the image attachment clear shortcu assert.equal(isClearImageAttachmentsShortcut(input, key), true); }); +test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (standard)", () => { + const { input, key } = parseTerminalInput("\u001B[45;5u"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (extended)", () => { + const { input, key } = parseTerminalInput("\u001B[27;5;45~"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes raw 0x1F as ctrl+shift+- (redo)", () => { + const { input, key } = parseTerminalInput("\u001F"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (standard)", () => { + const { input, key } = parseTerminalInput("\u001B[45;6u"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (extended)", () => { + const { input, key } = parseTerminalInput("\u001B[27;6;45~"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + test("formatImageAttachmentStatus formats the image count label", () => { assert.equal(formatImageAttachmentStatus(0), ""); assert.equal(formatImageAttachmentStatus(1), "📎 1 image attached"); diff --git a/src/tests/promptUndoRedo.test.ts b/src/tests/promptUndoRedo.test.ts new file mode 100644 index 0000000..c1999f1 --- /dev/null +++ b/src/tests/promptUndoRedo.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { removeCurrentSlashToken } from "../ui"; +import { + clearPromptUndoRedoState, + createPromptUndoRedoState, + recordPromptEdit, + redoPromptEdit, + undoPromptEdit, +} from "../ui/promptUndoRedo"; + +test("prompt undo and redo restore edited buffer states", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const hello = { text: "hello", cursor: 5 }; + + recordPromptEdit(history, empty, hello); + + assert.deepEqual(undoPromptEdit(history, hello), empty); + assert.deepEqual(redoPromptEdit(history, empty), hello); +}); + +test("prompt redo history is cleared after a new edit", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const first = { text: "first", cursor: 5 }; + const second = { text: "second", cursor: 6 }; + + recordPromptEdit(history, empty, first); + assert.deepEqual(undoPromptEdit(history, first), empty); + + recordPromptEdit(history, empty, second); + + assert.equal(redoPromptEdit(history, second), null); +}); + +test("prompt undo ignores cursor-only movement", () => { + const history = createPromptUndoRedoState(); + const before = { text: "hello", cursor: 5 }; + const after = { text: "hello", cursor: 0 }; + + recordPromptEdit(history, before, after); + + assert.equal(undoPromptEdit(history, after), null); +}); + +test("clearing consumed slash token drops undo and redo history", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const slashCommand = { text: "/model", cursor: 6 }; + + recordPromptEdit(history, empty, slashCommand); + const cleared = removeCurrentSlashToken(slashCommand); + clearPromptUndoRedoState(history); + + assert.deepEqual(cleared, { text: "", cursor: 0 }); + assert.equal(undoPromptEdit(history, cleared), null); + assert.equal(redoPromptEdit(history, cleared), null); +}); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index b878961..ce0e4d8 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -21,6 +21,13 @@ import { moveUp, } from "./promptBuffer"; import type { PromptBufferState } from "./promptBuffer"; +import { + clearPromptUndoRedoState, + createPromptUndoRedoState, + recordPromptEdit, + redoPromptEdit, + undoPromptEdit, +} from "./promptUndoRedo"; import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "./slashCommands"; import type { SlashCommandItem } from "./slashCommands"; import { readClipboardImageAsync } from "./clipboard"; @@ -122,6 +129,7 @@ export const PromptInput = React.memo(function PromptInput({ const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); const lastCtrlDAt = React.useRef(0); + const undoRedoRef = React.useRef(createPromptUndoRedoState()); const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); @@ -236,6 +244,7 @@ export const PromptInput = React.memo(function PromptInput({ setStatusMessage("Interrupting…"); } else if (!isEmpty(buffer)) { setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); } else { setStatusMessage("press ctrl+d to exit"); } @@ -475,6 +484,14 @@ export const PromptInput = React.memo(function PromptInput({ updateBuffer((s) => insertText(s, "\n")); return; } + if (key.ctrl && key.shift && input === "-") { + redo(); + return; + } + if (key.ctrl && input === "-") { + undo(); + return; + } if (input.startsWith("\u001B")) { // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. return; @@ -490,6 +507,28 @@ export const PromptInput = React.memo(function PromptInput({ { isActive: !disabled } ); + function undo(): void { + const previous = undoPromptEdit(undoRedoRef.current, buffer); + if (!previous) { + return; + } + exitHistoryBrowsing(); + setBuffer(previous); + } + + function redo(): void { + const next = redoPromptEdit(undoRedoRef.current, buffer); + if (!next) { + return; + } + exitHistoryBrowsing(); + setBuffer(next); + } + + function clearUndoRedoStacks(): void { + clearPromptUndoRedoState(undoRedoRef.current); + } + function exitHistoryBrowsing(): void { setHistoryCursor(-1); setDraftBeforeHistory(null); @@ -497,7 +536,11 @@ export const PromptInput = React.memo(function PromptInput({ function updateBuffer(updater: (state: PromptBufferState) => PromptBufferState): void { exitHistoryBrowsing(); - setBuffer(updater); + setBuffer((current) => { + const next = updater(current); + recordPromptEdit(undoRedoRef.current, current, next); + return next; + }); } function navigateHistory(direction: -1 | 1): void { @@ -551,6 +594,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -559,6 +603,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "init") { onSubmit(buildInitPromptSubmission(selectedSkills)); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -567,6 +612,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "resume") { onSubmit({ text: "", imageUrls: [], command: "resume" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -575,6 +621,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -583,6 +630,7 @@ export const PromptInput = React.memo(function PromptInput({ if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); return; } } @@ -612,6 +660,7 @@ export const PromptInput = React.memo(function PromptInput({ selectedSkills, }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); @@ -628,6 +677,7 @@ export const PromptInput = React.memo(function PromptInput({ function clearSlashToken(): void { exitHistoryBrowsing(); setBuffer((state) => removeCurrentSlashToken(state)); + clearUndoRedoStacks(); } function openModelDropdown(): void { diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index af830e5..8013ff6 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -35,9 +35,75 @@ const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); const TERMINAL_FOCUS_IN = "\u001B[I"; const TERMINAL_FOCUS_OUT = "\u001B[O"; +// Ctrl+- (minus) sequences in modifyOtherKeys mode. +// \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl) +// \u001B[27;5;45~ — extended format for function-like reporting +const CTRL_MINUS_SEQUENCES = new Set(["\u001B[45;5u", "\u001B[27;5;45~"]); + +// Ctrl+Shift+- (minus) sequences in modifyOtherKeys mode. +// \u001B[45;6u — standard format: keycode=45 ('-'), modifier=6 (Ctrl+Shift) +// \u001B[27;6;45~ — extended format for function-like reporting +const CTRL_SHIFT_MINUS_SEQUENCES = new Set(["\u001B[45;6u", "\u001B[27;6;45~"]); + export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { const raw = String(data); let input = raw; + + // Ctrl+- undo shortcut: only via modifyOtherKeys CSI sequences. + // Raw 0x1F is NOT included here because it represents Ctrl+_ (Ctrl+Shift+- + // on US keyboards), which should trigger redo instead. + if (CTRL_MINUS_SEQUENCES.has(raw)) { + input = "-"; + const key: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: true, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + }; + return { input, key }; + } + + // Ctrl+Shift+- redo shortcut: modifyOtherKeys CSI sequences + raw 0x1F fallback. + // \x1F is Ctrl+_ which on US keyboards = Ctrl+Shift+-. + if (CTRL_SHIFT_MINUS_SEQUENCES.has(raw) || raw === "\u001F") { + input = "-"; + const key: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: true, + shift: true, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + }; + return { input, key }; + } + const key: InputKey = { upArrow: raw === "\u001B[A", downArrow: raw === "\u001B[B", diff --git a/src/ui/promptUndoRedo.ts b/src/ui/promptUndoRedo.ts new file mode 100644 index 0000000..9d30f57 --- /dev/null +++ b/src/ui/promptUndoRedo.ts @@ -0,0 +1,52 @@ +import type { PromptBufferState } from "./promptBuffer"; + +export type PromptUndoRedoState = { + undoStack: PromptBufferState[]; + redoStack: PromptBufferState[]; +}; + +export function createPromptUndoRedoState(): PromptUndoRedoState { + return { undoStack: [], redoStack: [] }; +} + +export function recordPromptEdit( + history: PromptUndoRedoState, + current: PromptBufferState, + next: PromptBufferState, + maxUndoEntries = 1000 +): void { + if (next.text === current.text || next.text === history.undoStack.at(-1)?.text) { + return; + } + + history.undoStack.push(current); + if (history.undoStack.length > maxUndoEntries) { + history.undoStack = history.undoStack.slice(-maxUndoEntries); + } + history.redoStack = []; +} + +export function undoPromptEdit(history: PromptUndoRedoState, current: PromptBufferState): PromptBufferState | null { + const previous = history.undoStack.pop(); + if (!previous) { + return null; + } + + history.redoStack.push(current); + return previous; +} + +export function redoPromptEdit(history: PromptUndoRedoState, current: PromptBufferState): PromptBufferState | null { + const next = history.redoStack.pop(); + if (!next) { + return null; + } + + history.undoStack.push(current); + return next; +} + +export function clearPromptUndoRedoState(history: PromptUndoRedoState): void { + history.undoStack = []; + history.redoStack = []; +} From 79fb19d4bc468df5519bfd1a1ead6c7f03a2b73d Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 11:54:22 +0800 Subject: [PATCH 075/217] style: adjust the tree structure symbols --- src/ui/App.tsx | 2 +- src/ui/MessageView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8728219..757b40c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -283,7 +283,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R reasoningEffort: selection.reasoningEffort, }, }; - const content = `/model\n⎿ Set model to ${selection.model}`; + const content = `/model\n└ Set model to ${selection.model}`; if (activeSessionId) { sessionManager.addSessionSystemMessage(activeSessionId, content, meta); diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index 5f0c3df..bd3436f 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -91,7 +91,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | /model - ⎿ Set model to{" "} + └ Set model to{" "} {message.meta.modelConfig?.model} From 80d81b208120bb84e83733c1917d6f867928a537 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 13:57:17 +0800 Subject: [PATCH 076/217] feat: update SYSTEM_PROMPT_BASE in prompt.ts --- src/prompt.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/prompt.ts b/src/prompt.ts index f8df085..6d1b00d 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -243,9 +243,9 @@ Here's an example of how your output should be structured: `; -const SYSTEM_PROMPT_BASE = `You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. +const SYSTEM_PROMPT_BASE = `你是名叫Deep Code的交互式CLI工具,帮助用户完成软件工程任务。 Use the instructions below and the tools available to you to assist the user. -IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.`; +重要:严禁编造任何非编程相关的 URL。对于编程链接,仅限使用:1) 用户提供的上下文;2) 你确定的官方文档主域名。在输出前,必须自查该链接是否存在于你的上下文记忆中;若不存在,请明确说明无法提供。`; type PromptToolOptions = { webSearchEnabled?: boolean; From fb38560830a55c217122ebff5ee42836523d7c3b Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 14:23:04 +0800 Subject: [PATCH 077/217] =?UTF-8?q?feat(mcp):=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E5=B9=B6=E5=B1=95=E7=A4=BAMCP=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=92=8C=E8=B5=84=E6=BA=90=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增McpStatusList组件,实现MCP服务器状态、工具、提示和资源的可视化展示 - 修改App组件,支持切换视图显示MCP状态列表 - MCP客户端增加对prompts和resources的分页列表及读取功能 - MCP管理器扩展,支持发现和管理prompts及resources,及工具列表变化的事件通知 - 优化MCP客户端,支持JSON-RPC通知的处理,完善请求超时控制 - 在session中添加MCP工具列表变化监听,实时更新工具定义数据 - 提供键盘操作支持,实现MCP状态列表的上下翻页和快速导航 - 美化MCP状态列表界面,显示服务器状态及能力的详细信息 --- src/mcp/mcp-client.ts | 133 ++++++++++++++++++++++-- src/mcp/mcp-manager.ts | 185 ++++++++++++++++++++++++++++++++- src/session.ts | 3 + src/ui/App.tsx | 39 ++----- src/ui/McpStatusList.tsx | 219 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 537 insertions(+), 42 deletions(-) create mode 100644 src/ui/McpStatusList.tsx diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index 0fad322..a086c1d 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -17,6 +17,12 @@ type JsonRpcResponse = { error?: { code: number; message: string; data?: unknown }; }; +type JsonRpcNotification = { + jsonrpc: "2.0"; + method: string; + params?: Record; +}; + export type McpToolDefinition = { name: string; description?: string; @@ -24,6 +30,7 @@ export type McpToolDefinition = { type: "object"; properties: Record; required?: string[]; + additionalProperties?: boolean; }; }; @@ -37,6 +44,58 @@ type CallToolResult = { isError?: boolean; }; +export type McpPromptArgument = { + name: string; + description?: string; + required?: boolean; +}; + +export type McpPromptDefinition = { + name: string; + description?: string; + arguments?: McpPromptArgument[]; +}; + +type ListPromptsResult = { + prompts: McpPromptDefinition[]; + nextCursor?: string; +}; + +export type McpPromptMessage = { + role: "user" | "assistant"; + content: { type: string; text?: string }; +}; + +type GetPromptResult = { + description?: string; + messages: McpPromptMessage[]; +}; + +export type McpResourceDefinition = { + uri: string; + name: string; + description?: string; + mimeType?: string; +}; + +type ListResourcesResult = { + resources: McpResourceDefinition[]; + nextCursor?: string; +}; + +export type McpResourceContent = { + uri: string; + mimeType?: string; + text?: string; + blob?: string; +}; + +type ReadResourceResult = { + contents: McpResourceContent[]; +}; + +export type McpNotificationHandler = (method: string, params?: Record) => void; + export class McpClient { private process: ChildProcess | null = null; private reader: Interface | null = null; @@ -46,13 +105,17 @@ export class McpClient { { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout } >(); private stderrBuffer = ""; + private notificationHandler: McpNotificationHandler | null = null; constructor( private readonly serverName: string, private readonly command: string, private readonly args: string[] = [], - private readonly env?: Record - ) {} + private readonly env?: Record, + onNotification?: McpNotificationHandler + ) { + this.notificationHandler = onNotification ?? null; + } async connect(timeoutMs: number): Promise { return new Promise((resolve, reject) => { @@ -109,7 +172,7 @@ export class McpClient { this.sendRequest( "initialize", { - protocolVersion: "2024-11-05", + protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "deepcode-cli", version: "0.1.0" }, }, @@ -141,8 +204,50 @@ export class McpClient { throw this.withStderr(`MCP server "${this.serverName}" returned too many tools/list pages`); } - async callTool(name: string, args: Record): Promise { - return (await this.sendRequest("tools/call", { name, arguments: args })) as CallToolResult; + async callTool(name: string, args: Record, timeoutMs = 60_000): Promise { + return (await this.sendRequest("tools/call", { name, arguments: args }, timeoutMs)) as CallToolResult; + } + + async listPrompts(timeoutMs: number): Promise { + const prompts: McpPromptDefinition[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 100; page++) { + const params = cursor ? { cursor } : {}; + const result = (await this.sendRequest("prompts/list", params, timeoutMs)) as ListPromptsResult; + prompts.push(...(result.prompts ?? [])); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined; + if (!cursor) { + return prompts; + } + } + + throw this.withStderr(`MCP server "${this.serverName}" returned too many prompts/list pages`); + } + + async getPrompt(name: string, args: Record, timeoutMs = 30_000): Promise { + return (await this.sendRequest("prompts/get", { name, arguments: args }, timeoutMs)) as GetPromptResult; + } + + async listResources(timeoutMs: number): Promise { + const resources: McpResourceDefinition[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 100; page++) { + const params = cursor ? { cursor } : {}; + const result = (await this.sendRequest("resources/list", params, timeoutMs)) as ListResourcesResult; + resources.push(...(result.resources ?? [])); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined; + if (!cursor) { + return resources; + } + } + + throw this.withStderr(`MCP server "${this.serverName}" returned too many resources/list pages`); + } + + async readResource(uri: string, timeoutMs = 30_000): Promise { + return (await this.sendRequest("resources/read", { uri }, timeoutMs)) as ReadResourceResult; } disconnect(): void { @@ -195,7 +300,23 @@ export class McpClient { private handleLine(line: string): void { try { - const message = JSON.parse(line) as JsonRpcResponse; + const parsed: unknown = JSON.parse(line); + + // Handle notifications (no id field — server-initiated) + if (parsed && typeof parsed === "object" && !("id" in parsed)) { + const notification = parsed as JsonRpcNotification; + if (this.notificationHandler && typeof notification.method === "string") { + try { + this.notificationHandler(notification.method, notification.params); + } catch { + // Swallow handler errors to avoid crashing the reader loop + } + } + return; + } + + // Handle responses to our requests + const message = parsed as JsonRpcResponse; if (message.id !== undefined && this.pendingRequests.has(message.id)) { const pending = this.pendingRequests.get(message.id)!; this.pendingRequests.delete(message.id); diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts index 030d0d3..8121dbc 100644 --- a/src/mcp/mcp-manager.ts +++ b/src/mcp/mcp-manager.ts @@ -1,7 +1,8 @@ -import { McpClient, type McpToolDefinition } from "./mcp-client"; +import { McpClient, type McpToolDefinition, type McpPromptDefinition, type McpResourceDefinition } from "./mcp-client"; import type { McpServerConfig } from "../settings"; const MCP_STARTUP_TIMEOUT_MS = 30_000; +const MCP_CALL_TOOL_TIMEOUT_MS = 60_000; type McpToolEntry = { serverName: string; @@ -18,15 +19,32 @@ export type McpServerStatus = { error?: string; toolCount: number; tools: string[]; + promptCount: number; + prompts: string[]; + resourceCount: number; + resources: string[]; }; export class McpManager { private clients: McpClient[] = []; private tools: McpToolEntry[] = []; + private prompts: Array<{ + serverName: string; + namespacedName: string; + definition: McpPromptDefinition; + client: McpClient; + }> = []; + private resources: Array<{ + serverName: string; + namespacedName: string; + definition: McpResourceDefinition; + client: McpClient; + }> = []; private initialized = false; private disposed = false; private configuredServerNames: string[] = []; private serverStatuses: McpServerStatus[] = []; + private onToolsListChanged: (() => void) | null = null; prepare(servers?: Record): void { if (!servers || Object.keys(servers).length === 0) return; @@ -48,6 +66,10 @@ export class McpManager { connected: false, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -65,7 +87,13 @@ export class McpManager { if (this.disposed) break; let client: McpClient | null = null; try { - client = new McpClient(name, config.command, config.args ?? [], config.env); + client = new McpClient(name, config.command, config.args ?? [], config.env, (method) => { + if (method === "notifications/tools/list_changed") { + this.refreshServerTools(name, client!).catch(() => { + // swallow refresh errors + }); + } + }); await client.connect(MCP_STARTUP_TIMEOUT_MS); if (this.disposed) { client.disconnect(); @@ -73,6 +101,7 @@ export class McpManager { } this.clients.push(client); + // Discover tools const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); if (this.disposed) break; const toolNamespacedNames: string[] = []; @@ -87,12 +116,57 @@ export class McpManager { }); toolNamespacedNames.push(namespacedName); } + + // Discover prompts + let serverPrompts: McpPromptDefinition[] = []; + try { + serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS); + } catch { + // Server may not support prompts — safe to ignore + } + if (this.disposed) break; + const promptNamespacedNames: string[] = []; + for (const prompt of serverPrompts) { + const namespacedName = `mcp__${name}__${prompt.name}`; + this.prompts.push({ + serverName: name, + namespacedName, + definition: prompt, + client, + }); + promptNamespacedNames.push(namespacedName); + } + + // Discover resources + let serverResources: McpResourceDefinition[] = []; + try { + serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS); + } catch { + // Server may not support resources — safe to ignore + } + if (this.disposed) break; + const resourceNamespacedNames: string[] = []; + for (const resource of serverResources) { + const namespacedName = `mcp__${name}__${resource.name}`; + this.resources.push({ + serverName: name, + namespacedName, + definition: resource, + client, + }); + resourceNamespacedNames.push(namespacedName); + } + this.setStatus({ name, status: "ready", connected: true, toolCount: serverTools.length, tools: toolNamespacedNames, + promptCount: serverPrompts.length, + prompts: promptNamespacedNames, + resourceCount: serverResources.length, + resources: resourceNamespacedNames, }); } catch (err) { if (this.disposed) break; @@ -106,6 +180,10 @@ export class McpManager { error: message, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -122,6 +200,10 @@ export class McpManager { connected: false, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -150,7 +232,9 @@ export class McpManager { type: "object" as const, properties: t.definition.inputSchema.properties, required: t.definition.inputSchema.required, - additionalProperties: false, + ...(t.definition.inputSchema.additionalProperties !== undefined + ? { additionalProperties: t.definition.inputSchema.additionalProperties } + : {}), }, }, })); @@ -162,7 +246,8 @@ export class McpManager { async executeMcpTool( name: string, - args: Record + args: Record, + timeoutMs = MCP_CALL_TOOL_TIMEOUT_MS ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { const tool = this.tools.find((t) => t.namespacedName === name); if (!tool) { @@ -170,7 +255,7 @@ export class McpManager { } try { - const result = await tool.client.callTool(tool.originalName, args); + const result = await tool.client.callTool(tool.originalName, args, timeoutMs); const text = result.content .filter((c) => c.type === "text" && c.text) .map((c) => c.text) @@ -189,6 +274,64 @@ export class McpManager { } } + async getMcpPrompt( + name: string, + args: Record + ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { + const prompt = this.prompts.find((p) => p.namespacedName === name); + if (!prompt) { + return { ok: false, name, error: `Unknown MCP prompt: ${name}` }; + } + + try { + const result = await prompt.client.getPrompt(prompt.definition.name, args); + const text = result.messages + .filter((m) => m.content.type === "text" && m.content.text) + .map((m) => `[${m.role}] ${m.content.text}`) + .join("\n"); + return { + ok: true, + name, + output: text || JSON.stringify(result), + }; + } catch (err) { + return { + ok: false, + name, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + async readMcpResource( + name: string, + uri: string + ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { + const resource = this.resources.find((r) => r.namespacedName === name); + if (!resource) { + return { ok: false, name, error: `Unknown MCP resource: ${name}` }; + } + + try { + const result = await resource.client.readResource(uri); + const text = result.contents + .filter((c) => c.text) + .map((c) => c.text) + .join("\n"); + return { + ok: true, + name, + output: text || JSON.stringify(result.contents), + }; + } catch (err) { + return { + ok: false, + name, + error: err instanceof Error ? err.message : String(err), + }; + } + } + disconnect(): void { this.disposed = true; for (const client of this.clients) { @@ -196,11 +339,43 @@ export class McpManager { } this.clients = []; this.tools = []; + this.prompts = []; + this.resources = []; this.serverStatuses = []; this.configuredServerNames = []; this.initialized = false; } + private async refreshServerTools(serverName: string, client: McpClient): Promise { + const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); + // Remove old tool entries for this server + this.tools = this.tools.filter((t) => t.serverName !== serverName); + const toolNamespacedNames: string[] = []; + for (const tool of serverTools) { + const namespacedName = `mcp__${serverName}__${tool.name}`; + this.tools.push({ + serverName, + originalName: tool.name, + namespacedName, + definition: tool, + client, + }); + toolNamespacedNames.push(namespacedName); + } + // Update status + const existing = this.serverStatuses.find((s) => s.name === serverName); + if (existing) { + existing.toolCount = serverTools.length; + existing.tools = toolNamespacedNames; + } + // Notify listener + this.onToolsListChanged?.(); + } + + setOnToolsListChanged(handler: () => void): void { + this.onToolsListChanged = handler; + } + private setStatus(status: McpServerStatus): void { if (this.disposed) return; const index = this.serverStatuses.findIndex((s) => s.name === status.name); diff --git a/src/session.ts b/src/session.ts index a174f9b..161ba98 100644 --- a/src/session.ts +++ b/src/session.ts @@ -203,6 +203,9 @@ export class SessionManager { } async initMcpServers(servers?: Record): Promise { + this.mcpManager.setOnToolsListChanged(() => { + this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); + }); await this.mcpManager.initialize(servers); this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 757b40c..ffe89e4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -29,6 +29,7 @@ import { buildLoadingText } from "./loadingText"; import { findExpandedThinkingId } from "./thinkingState"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; +import { McpStatusList } from "./McpStatusList"; import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, @@ -39,7 +40,7 @@ import { buildExitSummaryText } from "./exitSummary"; const DEFAULT_MODEL = "deepseek-v4-pro"; const DEFAULT_BASE_URL = "https://api.deepseek.com"; -type View = "chat" | "session-list"; +type View = "chat" | "session-list" | "mcp-status"; type AppProps = { projectRoot: string; @@ -67,6 +68,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [welcomeNonce, setWelcomeNonce] = useState(0); const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); const [nowTick, setNowTick] = useState(0); + const [mcpStatuses, setMcpStatuses] = useState>([]); const messagesRef = useRef([]); messagesRef.current = messages; @@ -189,36 +191,9 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return; } if (submission.command === "mcp") { - process.stdout.write("\n"); - process.stdout.write(chalk.bold.cyan("MCP Server Status\n")); - process.stdout.write(chalk.dim("─────────────────\n")); - const statuses = sessionManager.getMcpStatus(); - if (statuses.length === 0) { - process.stdout.write(chalk.dim(" No MCP servers configured.\n")); - } else { - for (const s of statuses) { - if (s.status === "starting") { - process.stdout.write(`${chalk.yellow("●")} ${chalk.bold(s.name)} - Starting...`); - } else if (s.status === "failed") { - process.stdout.write(`${chalk.red("✖")} ${chalk.bold(s.name)} - Failed (${s.error ?? "unknown error"})`); - } else { - process.stdout.write(`${chalk.green("✔")} ${chalk.bold(s.name)} - Ready (${s.toolCount} tools)`); - } - process.stdout.write("\n"); - if (s.status === "ready" && s.tools.length > 0) { - for (const tool of s.tools) { - process.stdout.write(chalk.dim(` - ${tool}\n`)); - } - } - } - } - process.stdout.write(chalk.dim("─────────────────\n")); - process.stdout.write( - chalk.dim(` Total: ${statuses.filter((s) => s.status === "ready").length} ready, `) + - chalk.dim(`${statuses.filter((s) => s.status === "starting").length} starting, `) + - chalk.dim(`${statuses.filter((s) => s.status === "failed").length} failed\n`) - ); - process.stdout.write("\n"); + setShowWelcome(false); + setMcpStatuses(sessionManager.getMcpStatus()); + setView("mcp-status"); return; } @@ -471,6 +446,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R onSelect={(id) => void handleSelectSession(id)} onCancel={() => setView("chat")} /> + ) : view === "mcp-status" ? ( + setView("chat")} /> ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( void; +}; + +type FlatItem = + | { kind: "server"; status: McpServerStatus; serverIndex: number } + | { kind: "tool"; name: string; serverName: string } + | { kind: "prompt"; name: string; serverName: string } + | { kind: "resource"; name: string; serverName: string }; + +function buildFlatItems(statuses: McpServerStatus[]): FlatItem[] { + const items: FlatItem[] = []; + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + items.push({ kind: "server", status, serverIndex: i }); + if (status.status === "ready") { + for (const tool of status.tools) { + items.push({ kind: "tool", name: tool, serverName: status.name }); + } + for (const prompt of status.prompts) { + items.push({ kind: "prompt", name: prompt, serverName: status.name }); + } + for (const resource of status.resources) { + items.push({ kind: "resource", name: resource, serverName: status.name }); + } + } + } + return items; +} + +export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { + const [index, setIndex] = useState(0); + const { columns, rows } = useWindowSize(); + + const flatItems = useMemo(() => buildFlatItems(statuses), [statuses]); + + const maxVisible = useMemo(() => { + const reservedLines = 8; + const linesPerItem = 2; + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); + return Math.max(1, Math.floor(availableLines / linesPerItem)); + }, [rows]); + + const safeIndex = useMemo(() => { + if (flatItems.length === 0) return 0; + return Math.max(0, Math.min(index, flatItems.length - 1)); + }, [index, flatItems.length]); + + const scrollOffset = useMemo(() => { + if (safeIndex < maxVisible) return 0; + return safeIndex - maxVisible + 1; + }, [safeIndex, maxVisible]); + + const visibleItems = useMemo(() => { + return flatItems.slice(scrollOffset, scrollOffset + maxVisible); + }, [flatItems, scrollOffset, maxVisible]); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (flatItems.length === 0) { + return; + } + if (key.upArrow) { + setIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setIndex((i) => Math.min(flatItems.length - 1, i + 1)); + return; + } + if (key.pageUp) { + setIndex((i) => Math.max(0, i - maxVisible)); + return; + } + if (key.pageDown) { + setIndex((i) => Math.min(flatItems.length - 1, i + maxVisible)); + return; + } + if (key.home) { + setIndex(0); + return; + } + if (key.end) { + setIndex(flatItems.length - 1); + } + }); + + const readyCount = statuses.filter((s) => s.status === "ready").length; + const startingCount = statuses.filter((s) => s.status === "starting").length; + const failedCount = statuses.filter((s) => s.status === "failed").length; + + if (statuses.length === 0) { + return ( + + Manage MCP servers + 0 servers + No MCP servers configured. + Add MCP servers to your settings to get started. + Esc to close + + ); + } + + return ( + + + {/* Header row */} + + + MCP Server Status + + + {" "} + ({readyCount} ready, {startingCount} starting, {failedCount} failed) + + + {/* Items list */} + + {visibleItems.map((item, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + + if (item.kind === "server") { + return ; + } + return ( + + ); + })} + {scrollOffset > 0 || scrollOffset + maxVisible < flatItems.length ? ( + + {scrollOffset > 0 ? … {scrollOffset} items above. : null} + {scrollOffset + maxVisible < flatItems.length ? ( + … {flatItems.length - scrollOffset - maxVisible} items below. + ) : null} + + ) : null} + + {/* Footer */} + + ↑/↓ navigate · PgUp/PgDn page · Esc cancel + + + + ); +} + +function ServerRow({ status, selected }: { status: McpServerStatus; selected: boolean }): React.ReactElement { + const icon = status.status === "ready" ? "✔" : status.status === "failed" ? "✖" : "●"; + const color = status.status === "ready" ? "green" : status.status === "failed" ? "red" : "yellow"; + const detail = + status.status === "ready" + ? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)` + : status.status === "failed" + ? `Failed (${status.error ?? "unknown error"})` + : "Starting..."; + + return ( + + {selected ? "› " : " "} + + {icon} + {status.name} + — {detail} + + + ); +} + +function CapabilityRow({ + kind, + name, + selected, +}: { + kind: "tool" | "prompt" | "resource"; + name: string; + selected: boolean; +}): React.ReactElement { + const prefix = kind === "tool" ? "🔧" : kind === "prompt" ? "📝" : "📦"; + return ( + + {selected ? "› " : " "} + + {prefix} {name} + + + ); +} From a49ecb44f2eafc3d0236afb0a2d4eb167440fd0b Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 15:05:44 +0800 Subject: [PATCH 078/217] =?UTF-8?q?refactor(session):=20=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E5=B9=B6=E7=BB=9F=E4=B8=80=E4=BC=9A=E8=AF=9D=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E7=9A=84=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除多余的 modelConfig 属性,改用 message.content 直接展示模型和思考力度信息 - 优化 App.tsx 中系统消息内容的格式,加入推理力度显示 - MessageView.tsx 改为直接渲染 message.content,简化展示逻辑 - 在 session.ts 中整合 addSessionSystemMessage 方法,避免重复定义 - 构建系统消息时支持传入 meta 信息,增强消息元数据灵活性 --- src/session.ts | 38 +++++++++++++------------------------- src/ui/App.tsx | 7 +------ src/ui/MessageView.tsx | 9 +-------- 3 files changed, 15 insertions(+), 39 deletions(-) diff --git a/src/session.ts b/src/session.ts index a174f9b..5ad5b23 100644 --- a/src/session.ts +++ b/src/session.ts @@ -122,11 +122,6 @@ export type MessageMeta = { asThinking?: boolean; isSummary?: boolean; isModelChange?: boolean; - modelConfig?: { - model: string; - thinkingEnabled: boolean; - reasoningEffort?: string; - }; skill?: SkillInfo; }; @@ -778,6 +773,12 @@ The candidate skills are as follows:\n\n`; this.activeSessionId = sessionId; } + addSessionSystemMessage(sessionId: string, content: string, meta?: MessageMeta): void { + const message = this.buildSystemMessage(sessionId, content, meta); + this.appendSessionMessage(sessionId, message); + this.onAssistantMessage(message, false); + } + async handleUserPrompt(userPrompt: UserPromptContent): Promise { const controller = new AbortController(); this.activePromptController = controller; @@ -1332,25 +1333,6 @@ ${skillMd} return messages; } - addSessionSystemMessage(sessionId: string, content: string, meta?: MessageMeta): void { - const now = new Date().toISOString(); - const message: SessionMessage = { - id: crypto.randomUUID(), - sessionId, - role: "system", - content, - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: now, - updateTime: now, - meta, - }; - this.appendSessionMessage(sessionId, message); - this.onAssistantMessage(message, false); - } - private normalizeSessionMessage(message: SessionMessage): SessionMessage { if (message.role !== "tool") { return message; @@ -1560,7 +1542,12 @@ ${skillMd} return this.readNonEmptyFile(path.join(os.homedir(), ".deepcode", "AGENTS.md")); } - private buildSystemMessage(sessionId: string, content: string, contentParams: unknown | null = null): SessionMessage { + private buildSystemMessage( + sessionId: string, + content: string, + contentParams: unknown | null = null, + meta?: MessageMeta + ): SessionMessage { const now = new Date().toISOString(); return { id: crypto.randomUUID(), @@ -1573,6 +1560,7 @@ ${skillMd} visible: false, createTime: now, updateTime: now, + meta, }; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 757b40c..a569dc3 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -277,13 +277,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const activeSessionId = sessionManager.getActiveSessionId(); const meta: MessageMeta = { isModelChange: true, - modelConfig: { - model: selection.model, - thinkingEnabled: selection.thinkingEnabled, - reasoningEffort: selection.reasoningEffort, - }, }; - const content = `/model\n└ Set model to ${selection.model}`; + const content = `/model\n└ Set model to ${selection.model} (${selection?.reasoningEffort || "no thinking"})`; if (activeSessionId) { sessionManager.addSessionSystemMessage(activeSessionId, content, meta); diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index bd3436f..9c6252c 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -89,14 +89,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | {`>`} - /model - - └ Set model to{" "} - - {message.meta.modelConfig?.model} - - {` (${message.meta.modelConfig?.reasoningEffort || "normal"} effort)`} - + {message.content} ); From 91e52406293ff1e8683e15ec00eac98bed175f7d Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 15:40:35 +0800 Subject: [PATCH 079/217] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在FlatItem类型中添加错误信息项 - 失败状态的服务器添加对应错误消息 - 失败错误信息单独用ErrorRow组件渲染 - 更新无服务器时的提示样式,增强视觉层次感 - 为工具项添加左侧缩进优化排版布局 --- src/ui/McpStatusList.tsx | 43 ++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index b04d7af..d3e9048 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -9,6 +9,7 @@ type Props = { type FlatItem = | { kind: "server"; status: McpServerStatus; serverIndex: number } + | { kind: "error"; error: string; serverName: string } | { kind: "tool"; name: string; serverName: string } | { kind: "prompt"; name: string; serverName: string } | { kind: "resource"; name: string; serverName: string }; @@ -18,6 +19,10 @@ function buildFlatItems(statuses: McpServerStatus[]): FlatItem[] { for (let i = 0; i < statuses.length; i++) { const status = statuses[i]; items.push({ kind: "server", status, serverIndex: i }); + // 为失败的服务添加错误消息 + if (status.status === "failed" && status.error) { + items.push({ kind: "error", error: status.error, serverName: status.name }); + } if (status.status === "ready") { for (const tool of status.tools) { items.push({ kind: "tool", name: tool, serverName: status.name }); @@ -99,11 +104,17 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement if (statuses.length === 0) { return ( - - Manage MCP servers - 0 servers - No MCP servers configured. - Add MCP servers to your settings to get started. + + + + Manage MCP servers + + 0 servers + + + No MCP servers configured. + Add MCP servers to your settings to get started. + Esc to close ); @@ -149,6 +160,9 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement if (item.kind === "server") { return ; } + if (item.kind === "error") { + return ; + } return ( + {selected ? "› " : " "} {prefix} {name} @@ -217,3 +231,20 @@ function CapabilityRow({ ); } + +function ErrorRow({ error }: { error: string }): React.ReactElement { + // 将错误消息按行分割,每行单独显示 + const lines = error.split("\n").filter((line) => line.trim().length > 0); + + return ( + + {lines.map((line, index) => ( + + + {line} + + + ))} + + ); +} From bf9126de43188862d2b757dd950a5c79986439ce Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 15:45:55 +0800 Subject: [PATCH 080/217] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E6=AD=A3=20reasoni?= =?UTF-8?q?ngEffort=20=E6=98=BE=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改模型切换消息内容,基于thinkingEnabled状态显示reasoningEffort - 调整欢迎界面中Reasoning Effort的显示,当thinkingEnabled为false时显示“-” --- src/ui/App.tsx | 2 +- src/ui/WelcomeScreen.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index a569dc3..6526bb3 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -278,7 +278,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const meta: MessageMeta = { isModelChange: true, }; - const content = `/model\n└ Set model to ${selection.model} (${selection?.reasoningEffort || "no thinking"})`; + const content = `/model\n└ Set model to ${selection.model} (${selection?.thinkingEnabled ? selection?.reasoningEffort : "no thinking"})`; if (activeSessionId) { sessionManager.addSessionSystemMessage(activeSessionId, content, meta); diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 5e25379..3d82eed 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -68,7 +68,7 @@ export function WelcomeScreen({ {!compact ? : null} - + From 812b9e18c7197af65376a9ea39ba6b9e5317dffe Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 16:41:09 +0800 Subject: [PATCH 081/217] =?UTF-8?q?fix(session):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=B6=88=E6=81=AF=E5=8F=AF=E8=A7=81=E6=80=A7?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改addSessionSystemMessage方法,新增visible参数控制消息显示 - 调整调用处传递visible参数,确保系统消息正确显示 - 更新buildSystemMessage方法接收visible参数并正确设置消息属性 - 保障当sessionId存在时才添加系统消息 - 修正消息创建时visible属性的默认值问题 --- src/session.ts | 9 +++++---- src/ui/App.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/session.ts b/src/session.ts index 5ad5b23..4eb83b8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -773,9 +773,9 @@ The candidate skills are as follows:\n\n`; this.activeSessionId = sessionId; } - addSessionSystemMessage(sessionId: string, content: string, meta?: MessageMeta): void { - const message = this.buildSystemMessage(sessionId, content, meta); - this.appendSessionMessage(sessionId, message); + addSessionSystemMessage(sessionId: string, content: string, visible?: boolean, meta?: MessageMeta): void { + const message = this.buildSystemMessage(sessionId, content, null, visible, meta); + if (sessionId) this.appendSessionMessage(sessionId, message); this.onAssistantMessage(message, false); } @@ -1546,6 +1546,7 @@ ${skillMd} sessionId: string, content: string, contentParams: unknown | null = null, + visible = false, meta?: MessageMeta ): SessionMessage { const now = new Date().toISOString(); @@ -1557,7 +1558,7 @@ ${skillMd} contentParams, messageParams: null, compacted: false, - visible: false, + visible, createTime: now, updateTime: now, meta, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6526bb3..e33735a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -281,7 +281,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const content = `/model\n└ Set model to ${selection.model} (${selection?.thinkingEnabled ? selection?.reasoningEffort : "no thinking"})`; if (activeSessionId) { - sessionManager.addSessionSystemMessage(activeSessionId, content, meta); + sessionManager.addSessionSystemMessage(activeSessionId, content, true, meta); } else { const now = new Date().toISOString(); setMessages((prev) => [ From 40025e495a6c19aa34939be78a4ace696a966ed7 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 17:59:52 +0800 Subject: [PATCH 082/217] =?UTF-8?q?feat(mcp):=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=92=8C=E5=B1=95=E7=A4=BA=20MCP=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MCP 状态变更回调,支持 UI 实时刷新显示 - MCP 管理器中添加状态变更事件处理机制 - 调整 UI 组件以支持状态计数的高亮显示 - 移除 MCP 初始化失败的控制台错误输出,避免信息泄露 - 优化退出命令行提示颜色为灰色,提升可读性 --- src/mcp/mcp-manager.ts | 14 +++++++++++--- src/session.ts | 7 +++++++ src/ui/App.tsx | 6 +++++- src/ui/McpStatusList.tsx | 21 +++++++++++++++------ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts index 8121dbc..5a9f553 100644 --- a/src/mcp/mcp-manager.ts +++ b/src/mcp/mcp-manager.ts @@ -45,6 +45,7 @@ export class McpManager { private configuredServerNames: string[] = []; private serverStatuses: McpServerStatus[] = []; private onToolsListChanged: (() => void) | null = null; + private onStatusChanged: (() => void) | null = null; prepare(servers?: Record): void { if (!servers || Object.keys(servers).length === 0) return; @@ -172,7 +173,8 @@ export class McpManager { if (this.disposed) break; client?.disconnect(); const message = err instanceof Error ? err.message : String(err); - process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`); + // 不在控制台输出错误日志,避免暴露敏感信息 + // process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`); this.setStatus({ name, status: "failed", @@ -376,13 +378,19 @@ export class McpManager { this.onToolsListChanged = handler; } + setOnStatusChanged(handler: () => void): void { + this.onStatusChanged = handler; + } + private setStatus(status: McpServerStatus): void { if (this.disposed) return; const index = this.serverStatuses.findIndex((s) => s.name === status.name); if (index === -1) { this.serverStatuses.push(status); - return; + } else { + this.serverStatuses[index] = status; } - this.serverStatuses[index] = status; + // 触发状态变更回调 + this.onStatusChanged?.(); } } diff --git a/src/session.ts b/src/session.ts index 161ba98..3f264b4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -166,6 +166,7 @@ type SessionManagerOptions = { onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; onLlmStreamProgress?: (progress: LlmStreamProgress) => void; + onMcpStatusChanged?: () => void; }; export type LlmStreamProgress = { @@ -184,6 +185,7 @@ export class SessionManager { private readonly onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; + private readonly onMcpStatusChanged?: () => void; private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); @@ -198,6 +200,7 @@ export class SessionManager { this.onAssistantMessage = options.onAssistantMessage; this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; + this.onMcpStatusChanged = options.onMcpStatusChanged; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); } @@ -206,6 +209,10 @@ export class SessionManager { this.mcpManager.setOnToolsListChanged(() => { this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); }); + // 设置状态变更回调,通知 UI 更新 + this.mcpManager.setOnStatusChanged(() => { + this.onMcpStatusChanged?.(); + }); await this.mcpManager.initialize(servers); this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ffe89e4..709df67 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -94,6 +94,10 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R } setStreamProgress(progress); }, + onMcpStatusChanged: () => { + // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 + setMcpStatuses(sessionManager.getMcpStatus()); + }, }); }, [projectRoot]); @@ -156,7 +160,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const resolved = resolveCurrentSettings(projectRoot); const summary = buildExitSummaryText({ session, messages: allMessages, model: resolved.model }); process.stdout.write("\n"); - process.stdout.write(chalk.green("> /exit ")); + process.stdout.write(chalk.rgb(128, 128, 128)("> /exit ")); process.stdout.write("\n\n"); process.stdout.write(summary); process.stdout.write("\n\n"); diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index d3e9048..455c586 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -131,14 +131,23 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement > {/* Header row */} - - - MCP Server Status - + - {" "} - ({readyCount} ready, {startingCount} starting, {failedCount} failed) + Manage MCP servers + + ( + + {readyCount} ready, + + + {startingCount} starting, + + + {failedCount} failed + + ) + {/* Items list */} Date: Thu, 14 May 2026 18:01:14 +0800 Subject: [PATCH 083/217] =?UTF-8?q?feat(ui):=20=E4=BC=98=E5=8C=96=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=A7=86=E5=9B=BE=E7=9A=84=E5=B8=83=E5=B1=80=E5=92=8C?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6=E8=87=AA=E9=80=82=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 useWindowSize 钩子动态获取终端宽度 - 调整思考状态内容左侧边距为 4,提高视觉层次感 - 为普通消息视图设置整体宽度限制,确保内容不超出窗口范围 - 消息文本启用自动换行,提升长文本的可读性 - 消息标记对齐方式调整为 stretch,保持高度一致 --- src/ui/MessageView.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index 9c6252c..937cac7 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Text } from "ink"; +import { Box, Text, useWindowSize } from "ink"; import { renderMarkdown } from "./markdown"; import type { SessionMessage } from "../session"; @@ -9,6 +9,7 @@ type Props = { }; export function MessageView({ message, collapsed }: Props): React.ReactElement | null { + const { columns } = useWindowSize(); if (!message.visible) { return null; } @@ -46,7 +47,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | return ( - + {content ? {renderMarkdown(content)} : null} @@ -54,12 +55,12 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | } return ( - - + + - - {content ? {renderMarkdown(content)} : null} + + {content ? {renderMarkdown(content)} : null} ); From 393e71bb377f1a9b887c6155af3e9f1fa510a22a Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 18:08:02 +0800 Subject: [PATCH 084/217] refactor: moved the runtime prompt assets from docs/ into templates/ and updated the code paths that load them --- .lintstagedrc | 2 +- AGENTS.md | 35 +++++++++++++++++++ package.json | 4 +-- src/prompt.ts | 2 +- src/session.ts | 2 +- src/tests/prompt.test.ts | 12 +++++++ .../prompts/init_command.md.ejs | 0 .../tools/ask-user-question.md | 0 {docs => templates}/tools/bash.md | 0 {docs => templates}/tools/edit.md | 0 {docs => templates}/tools/read.md | 0 {docs => templates}/tools/web-search.md | 0 {docs => templates}/tools/write.md | 0 13 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 AGENTS.md rename {docs => templates}/prompts/init_command.md.ejs (100%) rename {docs => templates}/tools/ask-user-question.md (100%) rename {docs => templates}/tools/bash.md (100%) rename {docs => templates}/tools/edit.md (100%) rename {docs => templates}/tools/read.md (100%) rename {docs => templates}/tools/web-search.md (100%) rename {docs => templates}/tools/write.md (100%) diff --git a/.lintstagedrc b/.lintstagedrc index ef49282..4754b68 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,5 +1,5 @@ { - "*.{ts,tsx,js,mjs,cjs,ejs,jsx}": [ + "*.{ts,tsx,js,mjs,cjs,jsx}": [ "eslint --fix", "prettier --write" ], diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..44a3a24 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `src/` contains the TypeScript CLI implementation, with tool handlers in `src/tools/`, MCP integration in `src/mcp/`, UI components in `src/ui/`, and shared helpers in `src/common/`. +- `src/tests/` contains Node test files named `*.test.ts`. +- `templates/` contains runtime prompt assets: `templates/prompts/` for EJS prompt templates and `templates/tools/` for tool instruction Markdown loaded into the system prompt. +- `docs/` is reserved for user-facing documentation such as configuration and MCP guides. +- `resources/` stores static images used by the documentation or UI. + +## Build, Test, and Development Commands + +- `npm test` runs all test files with `tsx --test`. +- `npm run test:single -- src/tests/.test.ts` runs one test file. +- `npm run typecheck` verifies TypeScript types without emitting files. +- `npm run lint` checks ESLint rules for `src/`. +- `npm run build` runs checks, bundles `src/cli.tsx` to `dist/cli.js`, and marks the bundle executable. + +## Coding Style & Naming Conventions + +- Use TypeScript ES modules and keep imports explicit. +- Prefer small, focused functions; keep filesystem path construction centralized when a path is reused. +- Use two-space indentation and Prettier-compatible formatting. +- Respond in standard technical English. Avoid nonstandard phrasing and corporate jargon. + +## Testing Guidelines + +- Add or update tests in `src/tests/` when changing command behavior, prompt rendering, session flow, tools, or settings. +- Prefer Node's built-in `node:test` and `node:assert/strict` APIs, matching the existing tests. +- Keep tests deterministic by using temporary directories and mocked network calls where needed. + +## Commit & Pull Request Guidelines + +- Keep commits focused on a single change and use concise, imperative commit messages. +- In pull requests, describe the behavior change, list verification commands, and note any packaging or template path changes. diff --git a/package.json b/package.json index 0705a0d..5686621 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "main": "./dist/cli.js", "files": [ "dist/cli.js", - "docs/tools/**", - "docs/prompts/**", + "templates/tools/**", + "templates/prompts/**", "README.md", "LICENSE" ], diff --git a/src/prompt.ts b/src/prompt.ts index 6d1b00d..352688a 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -252,7 +252,7 @@ type PromptToolOptions = { }; function readToolDocs(extensionRoot: string, _options: PromptToolOptions = {}): string { - const toolsDir = path.join(extensionRoot, "docs", "tools"); + const toolsDir = path.join(extensionRoot, "templates", "tools"); if (!fs.existsSync(toolsDir)) { return ""; } diff --git a/src/session.ts b/src/session.ts index 06436b7..8b8eb39 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1485,7 +1485,7 @@ ${skillMd} } private renderInitCommandPrompt(): string { - const templatePath = path.join(getExtensionRoot(), "docs", "prompts", "init_command.md.ejs"); + const templatePath = path.join(getExtensionRoot(), "templates", "prompts", "init_command.md.ejs"); const template = fs.readFileSync(templatePath, "utf8"); return ejs.render(template, { agentsMdFile: this.getEffectiveProjectAgentsMdFile(), diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index 664fdec..5618068 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -1,7 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; import { getSystemPrompt, getTools } from "../prompt"; +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + test("getTools always includes WebSearch", () => { const names = getTools().map((tool) => tool.function.name); assert.equal(names.includes("WebSearch"), true); @@ -11,3 +16,10 @@ test("getSystemPrompt always includes WebSearch docs", () => { const prompt = getSystemPrompt("/tmp/project"); assert.equal(prompt.includes("## WebSearch"), true); }); + +test("runtime prompt assets live under templates", () => { + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "web-search.md")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "prompts", "init_command.md.ejs")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "docs", "tools")), false); + assert.equal(fs.existsSync(path.join(repoRoot, "docs", "prompts")), false); +}); diff --git a/docs/prompts/init_command.md.ejs b/templates/prompts/init_command.md.ejs similarity index 100% rename from docs/prompts/init_command.md.ejs rename to templates/prompts/init_command.md.ejs diff --git a/docs/tools/ask-user-question.md b/templates/tools/ask-user-question.md similarity index 100% rename from docs/tools/ask-user-question.md rename to templates/tools/ask-user-question.md diff --git a/docs/tools/bash.md b/templates/tools/bash.md similarity index 100% rename from docs/tools/bash.md rename to templates/tools/bash.md diff --git a/docs/tools/edit.md b/templates/tools/edit.md similarity index 100% rename from docs/tools/edit.md rename to templates/tools/edit.md diff --git a/docs/tools/read.md b/templates/tools/read.md similarity index 100% rename from docs/tools/read.md rename to templates/tools/read.md diff --git a/docs/tools/web-search.md b/templates/tools/web-search.md similarity index 100% rename from docs/tools/web-search.md rename to templates/tools/web-search.md diff --git a/docs/tools/write.md b/templates/tools/write.md similarity index 100% rename from docs/tools/write.md rename to templates/tools/write.md From c92e429c04cdf08750a9b7c765e4e186d67dc714 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 20:07:20 +0800 Subject: [PATCH 085/217] refactor: refactor model capability management and improve multimodal model inspection --- src/common/model-capabilities.ts | 16 +++ src/model-capabilities.ts | 5 - src/prompt.ts | 13 ++- src/session.ts | 35 +++++-- src/settings.ts | 2 +- src/tests/prompt.test.ts | 8 ++ src/tests/session.test.ts | 118 +++++++++++++++++------ templates/tools/{read.md => read.md.ejs} | 4 + 8 files changed, 155 insertions(+), 46 deletions(-) create mode 100644 src/common/model-capabilities.ts delete mode 100644 src/model-capabilities.ts rename templates/tools/{read.md => read.md.ejs} (93%) diff --git a/src/common/model-capabilities.ts b/src/common/model-capabilities.ts new file mode 100644 index 0000000..4835bfe --- /dev/null +++ b/src/common/model-capabilities.ts @@ -0,0 +1,16 @@ +export const DEEPSEEK_V4_MODELS = new Set(["deepseek-v4-flash", "deepseek-v4-pro"]); + +export const NON_MULTIMODAL_MODELS = new Set([ + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-chat", + "deepseek-reasoner", +]); + +export function defaultsToThinkingMode(model: string): boolean { + return DEEPSEEK_V4_MODELS.has(model); +} + +export function supportsMultimodal(model: string): boolean { + return !NON_MULTIMODAL_MODELS.has(model.trim()); +} diff --git a/src/model-capabilities.ts b/src/model-capabilities.ts deleted file mode 100644 index fe8cd4a..0000000 --- a/src/model-capabilities.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const DEEPSEEK_V4_MODELS = new Set(["deepseek-v4-flash", "deepseek-v4-pro"]); - -export function defaultsToThinkingMode(model: string): boolean { - return DEEPSEEK_V4_MODELS.has(model); -} diff --git a/src/prompt.ts b/src/prompt.ts index 352688a..e6c0d96 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -3,8 +3,10 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { fileURLToPath } from "url"; +import ejs from "ejs"; import type { SessionMessage } from "./session"; import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; +import { supportsMultimodal } from "./common/model-capabilities"; export const AGENT_DRIFT_GUARD_SKILL = ` --- @@ -248,10 +250,11 @@ const SYSTEM_PROMPT_BASE = `你是名叫Deep Code的交互式CLI工具,帮助 重要:严禁编造任何非编程相关的 URL。对于编程链接,仅限使用:1) 用户提供的上下文;2) 你确定的官方文档主域名。在输出前,必须自查该链接是否存在于你的上下文记忆中;若不存在,请明确说明无法提供。`; type PromptToolOptions = { + model?: string; webSearchEnabled?: boolean; }; -function readToolDocs(extensionRoot: string, _options: PromptToolOptions = {}): string { +function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): string { const toolsDir = path.join(extensionRoot, "templates", "tools"); if (!fs.existsSync(toolsDir)) { return ""; @@ -259,12 +262,16 @@ function readToolDocs(extensionRoot: string, _options: PromptToolOptions = {}): const entries = fs.readdirSync(toolsDir); const docs = entries - .filter((entry) => entry.endsWith(".md")) + .filter((entry) => entry.endsWith(".md") || entry.endsWith(".md.ejs")) .sort() .map((entry) => { const fullPath = path.join(toolsDir, entry); try { - return fs.readFileSync(fullPath, "utf8").trim(); + const template = fs.readFileSync(fullPath, "utf8"); + const content = entry.endsWith(".ejs") + ? ejs.render(template, { supportsMultimodal: supportsMultimodal(options.model ?? "") }) + : template; + return content.trim(); } catch { return ""; } diff --git a/src/session.ts b/src/session.ts index 8b8eb39..586857c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -8,7 +8,7 @@ import ejs from "ejs"; import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; import { launchNotifyScript } from "./notify"; import { buildThinkingRequestOptions } from "./openai-thinking"; -import { DEEPSEEK_V4_MODELS } from "./model-capabilities"; +import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities"; import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt"; import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; @@ -156,7 +156,7 @@ export type SkillInfo = { type SessionManagerOptions = { projectRoot: string; createOpenAIClient: CreateOpenAIClient; - getResolvedSettings: () => { webSearchTool?: string; mcpServers?: Record }; + getResolvedSettings: () => { model: string; webSearchTool?: string; mcpServers?: Record }; renderMarkdown: (text: string) => string; onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; @@ -175,7 +175,11 @@ export type LlmStreamProgress = { export class SessionManager { private readonly projectRoot: string; private readonly createOpenAIClient: CreateOpenAIClient; - private readonly getResolvedSettings: () => { webSearchTool?: string; mcpServers?: Record }; + private readonly getResolvedSettings: () => { + model: string; + webSearchTool?: string; + mcpServers?: Record; + }; private readonly onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; @@ -1017,7 +1021,7 @@ ${skillMd} await this.compactSession(sessionId, sessionController.signal); } - const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled); + const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled, model); const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); const response = await this.createChatCompletionStream( client, @@ -1208,8 +1212,9 @@ ${skillMd} this.saveSessionMessages(sessionId, sessionMessages); } - private getPromptToolOptions(): { webSearchEnabled: boolean } { + private getPromptToolOptions(): { model: string; webSearchEnabled: boolean } { return { + model: this.getResolvedSettings().model, webSearchEnabled: true, }; } @@ -1678,7 +1683,11 @@ ${skillMd} return { waitingForUser }; } - private buildOpenAIMessages(messages: SessionMessage[], thinkingEnabled: boolean): ChatCompletionMessageParam[] { + private buildOpenAIMessages( + messages: SessionMessage[], + thinkingEnabled: boolean, + model: string + ): ChatCompletionMessageParam[] { const activeMessages = messages.filter((message) => !message.compacted); const toolPairings = this.pairToolMessages(activeMessages); const openAIMessages: ChatCompletionMessageParam[] = []; @@ -1689,7 +1698,7 @@ ${skillMd} continue; } - openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled)); + openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled, model)); const toolCalls = this.getAssistantToolCalls(message); if (toolCalls.length === 0) { @@ -1704,7 +1713,9 @@ ${skillMd} const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex)); if (pairedToolIndex != null) { - openAIMessages.push(this.sessionMessageToOpenAIMessage(activeMessages[pairedToolIndex], thinkingEnabled)); + openAIMessages.push( + this.sessionMessageToOpenAIMessage(activeMessages[pairedToolIndex], thinkingEnabled, model) + ); continue; } @@ -1715,7 +1726,11 @@ ${skillMd} return openAIMessages; } - private sessionMessageToOpenAIMessage(message: SessionMessage, thinkingEnabled: boolean): ChatCompletionMessageParam { + private sessionMessageToOpenAIMessage( + message: SessionMessage, + thinkingEnabled: boolean, + model: string + ): ChatCompletionMessageParam { const content = this.renderOpenAIMessageContent(message); const base: ChatCompletionMessageParam = { role: message.role, @@ -1748,7 +1763,7 @@ ${skillMd} const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; for (const param of params) { const part = param as ChatCompletionContentPart; - if (part && part.type !== "image_url") { + if (part && (part.type !== "image_url" || supportsMultimodal(model))) { contentParts.push(part); } } diff --git a/src/settings.ts b/src/settings.ts index 9fbcea2..b5bb869 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,4 @@ -import { defaultsToThinkingMode } from "./model-capabilities"; +import { defaultsToThinkingMode } from "./common/model-capabilities"; export type DeepcodingEnv = Record & { MODEL?: string; diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index 5618068..c197b44 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -17,9 +17,17 @@ test("getSystemPrompt always includes WebSearch docs", () => { assert.equal(prompt.includes("## WebSearch"), true); }); +test("getSystemPrompt renders Read docs for non-multimodal models", () => { + const prompt = getSystemPrompt("/tmp/project", { model: "deepseek-chat" }); + assert.equal(prompt.includes("the current model is not multimodal"), true); + assert.equal(prompt.includes("the contents are presented visually"), false); +}); + test("runtime prompt assets live under templates", () => { assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "web-search.md")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md.ejs")), true); assert.equal(fs.existsSync(path.join(repoRoot, "templates", "prompts", "init_command.md.ejs")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md")), false); assert.equal(fs.existsSync(path.join(repoRoot, "docs", "tools")), false); assert.equal(fs.existsSync(path.join(repoRoot, "docs", "prompts")), false); }); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 2833342..2ac6c29 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -33,7 +33,7 @@ test("SessionManager preserves structured system content when building OpenAI me model: "test-model", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -58,7 +58,7 @@ test("SessionManager preserves structured system content when building OpenAI me }, ]; - const openAIMessages = (manager as any).buildOpenAIMessages(messages) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ role: string; content: unknown; }>; @@ -74,6 +74,48 @@ test("SessionManager preserves structured system content when building OpenAI me ]); }); +test("SessionManager filters image content for non-multimodal models", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "deepseek-chat", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "deepseek-chat" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const messages: SessionMessage[] = [ + { + id: "system-image", + sessionId: "session-1", + role: "system", + content: "The read tool has loaded `pixel.png`.", + contentParams: [ + { + type: "image_url", + image_url: { url: "data:image/png;base64,abc123" }, + }, + ], + messageParams: null, + compacted: false, + visible: false, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }, + ]; + + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "deepseek-chat") as Array<{ + role: string; + content: unknown; + }>; + + assert.equal(openAIMessages.length, 1); + assert.deepEqual(openAIMessages[0]?.content, [{ type: "text", text: "The read tool has loaded `pixel.png`." }]); +}); + test("SessionManager preserves empty reasoning content on assistant tool calls", () => { const manager = new SessionManager({ projectRoot: process.cwd(), @@ -82,7 +124,7 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", model: "test-model", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -111,7 +153,7 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", reasoning_content: "", }); - const openAIMessages = (manager as any).buildOpenAIMessages([message], true) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages([message], true, "test-model") as Array<{ reasoning_content?: string; }>; @@ -126,7 +168,7 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten model: "test-model", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -154,10 +196,10 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten }, ]; - const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true) as Array<{ + const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true, "test-model") as Array<{ reasoning_content?: string; }>; - const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ reasoning_content?: string; }>; @@ -173,7 +215,7 @@ test("SessionManager replays normal assistant messages with reasoning content in model: "test-model", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -193,10 +235,10 @@ test("SessionManager replays normal assistant messages with reasoning content in }, ]; - const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true) as Array<{ + const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true, "test-model") as Array<{ reasoning_content?: string; }>; - const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ reasoning_content?: string; }>; @@ -405,6 +447,7 @@ test("SessionManager reports configured MCP servers as starting before initializ thinkingEnabled: false, }), getResolvedSettings: () => ({ + model: "test-model", mcpServers: { playwright: { command: "npx", args: ["@playwright/mcp@latest"] }, }, @@ -492,7 +535,7 @@ test("createSession stores /init and sends the active .deepcode project AGENTS p const sessionId = await manager.createSession({ text: "/init" }); const messages = manager.listSessionMessages(sessionId); const userMessage = messages.find((message) => message.role === "user"); - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ role: string; content: string; }>; @@ -524,7 +567,7 @@ test("replySession stores /init and sends the active root project AGENTS path to const messages = manager.listSessionMessages(sessionId); const userMessages = messages.filter((message) => message.role === "user"); const replyMessage = userMessages[userMessages.length - 1]; - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ role: string; content: string; }>; @@ -550,7 +593,7 @@ test("createSession stores /init and sends generate prompt when no project AGENT const sessionId = await manager.createSession({ text: "/init" }); const messages = manager.listSessionMessages(sessionId); const userMessage = messages.find((message) => message.role === "user"); - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ role: string; content: string; }>; @@ -682,7 +725,11 @@ test("buildOpenAIMessages inserts interrupted results for missing tool messages" ) as SessionMessage; const userMessage = buildTestMessage("user-after-tool-call", "session-1", "user", "continue"); - const openAIMessages = (manager as any).buildOpenAIMessages([assistantMessage, userMessage], false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, userMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string; @@ -730,7 +777,8 @@ test("buildOpenAIMessages keeps only the first non-interrupted tool result for a const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, successToolMessage, interruptedToolMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string; tool_call_id?: string }>; const toolMessages = openAIMessages.filter((message) => message.role === "tool"); @@ -774,7 +822,8 @@ test("buildOpenAIMessages prefers a later real tool result over an earlier inter const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, interruptedToolMessage, successToolMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string; tool_call_id?: string }>; const toolMessages = openAIMessages.filter((message) => message.role === "tool"); @@ -793,7 +842,11 @@ test("buildOpenAIMessages ignores orphan tool messages", () => { { name: "bash", arguments: '{"command":"echo orphan"}' } ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([userMessage, orphanToolMessage], false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages( + [userMessage, orphanToolMessage], + false, + "test-model" + ) as Array<{ role: string; }>; @@ -827,7 +880,8 @@ test("buildOpenAIMessages moves a later paired tool message behind its assistant const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, userMessage, toolMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string }>; assert.deepEqual( @@ -872,7 +926,8 @@ test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, firstToolMessage, secondToolMessage, userMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string; tool_call_id?: string }>; assert.deepEqual( @@ -910,7 +965,11 @@ test("buildOpenAIMessages preserves a real failed tool result", () => { { name: "bash", arguments: '{"command":"false"}' } ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([assistantMessage, failedToolMessage], false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, failedToolMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string; @@ -966,7 +1025,8 @@ test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messag const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, orphanToolMessage, pairedToolMessage, duplicateToolMessage, userMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string; tool_call_id?: string }>; const toolMessages = openAIMessages.filter((message) => message.role === "tool"); @@ -1011,7 +1071,11 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista "" ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([earlyToolMessage, assistantMessage], false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages( + [earlyToolMessage, assistantMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string; @@ -1146,7 +1210,7 @@ test("SessionManager streams chat completions and counts reasoning progress", as baseURL: "https://api.deepseek.com", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, onLlmStreamProgress: (progress) => { @@ -1232,7 +1296,7 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = baseURL: "https://api.deepseek.com", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, onSessionEntryUpdated: (entry) => { @@ -1261,7 +1325,7 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa thinkingEnabled: false, machineId, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -1288,7 +1352,7 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow baseURL: "https://api.deepseek.com", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -1303,7 +1367,7 @@ function createMockedClientSessionManagerWithClient(projectRoot: string, client: baseURL: "https://api.deepseek.com", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); diff --git a/templates/tools/read.md b/templates/tools/read.md.ejs similarity index 93% rename from templates/tools/read.md rename to templates/tools/read.md.ejs index 60daa3d..a9c50e5 100644 --- a/templates/tools/read.md +++ b/templates/tools/read.md.ejs @@ -10,7 +10,11 @@ Usage: - Any lines longer than 2000 characters will be truncated - Results are returned using cat -n format, with line numbers starting at 1 - Text reads return a snippet id in metadata. You can pass that snippet id to the Edit tool to constrain replacements to just that read range. +<%_ if (supportsMultimodal) { _%> - This tool allows you to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Deepseek is a multimodal LLM. +<%_ } else { _%> +- This tool can inspect image files, but the current model is not multimodal, so image reads are not presented visually to the model. +<%_ } _%> - This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request. - This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations. - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. From 7f68f4c816d65010b1d3f5715999daffea93c7de Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 20:35:28 +0800 Subject: [PATCH 086/217] feat: add currentDatePrompt, remove prompt for ast-grep --- src/prompt.ts | 7 +++++-- src/tests/prompt.test.ts | 7 +++++++ templates/tools/bash.md | 1 - 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/prompt.ts b/src/prompt.ts index e6c0d96..b854860 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -281,10 +281,14 @@ function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): s return docs.join("\n\n"); } +function getCurrentDatePrompt(date = new Date()): string { + return `今天是${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日。随着对话的进行,时间在流逝。`; +} + export function getSystemPrompt(projectRoot: string, options: PromptToolOptions = {}): string { const toolDocs = readToolDocs(getExtensionRoot(), options); const basePrompt = toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; - return `${basePrompt}\n\n${getRuntimeContext(projectRoot)}`; + return `${basePrompt}\n\n${getCurrentDatePrompt()}\n\n${getRuntimeContext(projectRoot)}`; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { @@ -317,7 +321,6 @@ function getRuntimeContext(projectRoot: string): string { ...shellModeOpts, ...runtimeVersions, "command installed": { - "ast-grep": checkToolInstalled("ast-grep"), ripgrep: checkToolInstalled("rg"), jq: checkToolInstalled("jq"), }, diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index c197b44..28c6488 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -17,6 +17,13 @@ test("getSystemPrompt always includes WebSearch docs", () => { assert.equal(prompt.includes("## WebSearch"), true); }); +test("getSystemPrompt includes current date guidance", () => { + const now = new Date(); + const expected = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes(expected), true); +}); + test("getSystemPrompt renders Read docs for non-multimodal models", () => { const prompt = getSystemPrompt("/tmp/project", { model: "deepseek-chat" }); assert.equal(prompt.includes("the current model is not multimodal"), true); diff --git a/templates/tools/bash.md b/templates/tools/bash.md index 5f57d1e..0705120 100644 --- a/templates/tools/bash.md +++ b/templates/tools/bash.md @@ -7,7 +7,6 @@ On Windows, Bash runs through Git Bash. Use POSIX commands and quote Windows pat IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. IMPORTANT: Before reaching for generic shell pipelines, prefer purpose-built CLI tools when they make the task more accurate, safer, faster, or easier to understand: -- Use `ast-grep` when you need syntax-aware code search or structural rewrites; prefer it over plain text matching for language code. - Use `ripgrep` (`rg`) when you need to search file contents by text or regex across the workspace; prefer it over slower tools like `grep`. - Use `jq` when you need to inspect, filter, or transform JSON output; prefer it over ad-hoc parsing with `sed`, `awk`, or Python one-liners. From 7f79335df43feba3e0ce1de6e6cf2df50b73f2ff Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 21:21:31 +0800 Subject: [PATCH 087/217] refactor: improve MessageView component by adding width prop and enhancing layout handling --- src/ui/App.tsx | 13 ++++++++++--- src/ui/MessageView.tsx | 15 +++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e33735a..3f32f56 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -321,12 +321,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); } sessionManager.setActiveSessionId(sessionId); - // 先清空让 的 index 重置为 0 + // Clear first so resets its index to 0. setMessages([]); setShowWelcome(false); setWelcomeNonce((n) => n + 1); setView("chat"); - // 再加载新消息,此时 index 已为 0,会渲染全部 items + // Load messages after the reset so all static items are rendered. setTimeout(() => { setMessages(loadVisibleMessages(sessionManager, sessionId)); setShowWelcome(true); @@ -447,7 +447,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R /> ); } - return ; + return ( + + ); }} {statusLine ? ( diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index 937cac7..acdc645 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -1,15 +1,15 @@ import React from "react"; -import { Box, Text, useWindowSize } from "ink"; +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 }: Props): React.ReactElement | null { - const { columns } = useWindowSize(); +export function MessageView({ message, collapsed, width = 80 }: Props): React.ReactElement | null { if (!message.visible) { return null; } @@ -54,12 +54,15 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | ); } + const containerWidth = Math.max(1, width - 2); + const contentWidth = Math.max(1, width - 4); + return ( - + - + {content ? {renderMarkdown(content)} : null} @@ -82,7 +85,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | } if (message.role === "system") { - // 渲染模型变更消息 + // Render model change messages in the same style as user commands. if (message.meta?.isModelChange) { return ( From 529f9d746a5b31240b9a02f11ef6c663b1701613 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 22:29:27 +0800 Subject: [PATCH 088/217] chore: update AGENTS.md --- .deepcode/AGENTS.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index 2c95cc3..bee51ce 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -13,7 +13,9 @@ src/ │ ├── App.tsx # Root Ink component — state, routing, session orchestration │ ├── PromptInput.tsx # Multi-line input with slash commands, image paste, skills │ ├── MessageView.tsx # Renders assistant/tool messages with markdown +│ ├── DropdownMenu.tsx # Reusable dropdown for skill/model selection │ ├── SessionList.tsx # Session picker for /resume +│ ├── promptUndoRedo.ts # Ctrl+- undo / Ctrl+Shift+- redo for prompt input │ └── ... ├── mcp/ │ ├── mcp-client.ts # MCP client — JSON-RPC communication with MCP servers @@ -22,7 +24,7 @@ src/ │ ├── file-utils.ts # File read/write with encoding and diff preview │ ├── shell-utils.ts # Shell path resolution (Git Bash, zsh, bash) │ ├── state.ts # In-memory file state and snippet tracking -│ └── runtime.ts # Tool validation runtime helpers (executeValidatedTool, semanticBoolean) +│ └── runtime.ts # Tool validation runtime helpers ├── tools/ │ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers │ ├── bash-handler.ts # Executes shell commands @@ -32,9 +34,10 @@ src/ │ ├── web-search-handler.ts # Web search tool │ └── ask-user-question-handler.ts # Interactive user prompts ├── tests/ # Test suite — one *.test.ts per module -docs/ +templates/ ├── tools/ # Tool descriptions fed to the LLM ├── prompts/ # EJS templates (e.g., init_command.md.ejs) +docs/ # User-facing documentation dist/ # Bundled CLI output (gitignored) ``` @@ -87,7 +90,8 @@ Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundl - `fix:` — bug fix (e.g., `fix(ui): redraw cleanly after terminal resize`) - `chore:` — tooling, deps, hooks (e.g., `chore: add husky + lint-staged`) - `refactor:` — code restructuring (e.g., `refactor(ui): optimize App hooks`) -- `style:` — formatting-only changes +- `style:` — formatting-only changes (e.g., `style: adjust the tree structure symbols`) +- `docs:` — documentation (e.g., `docs: add MCP configuration guide`) **Pull requests** should include: - A clear description of what changed and why @@ -100,7 +104,7 @@ Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundl The CLI renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). -Six tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `docs/tools/`. +Six tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `templates/tools/`. ## Agent-Specific Instructions From 85ab2c53e18cfa025f2895acde813405a9da809b Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 14 May 2026 22:34:30 +0800 Subject: [PATCH 089/217] 0.1.20 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa741b0..dd029a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.19", + "version": "0.1.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.19", + "version": "0.1.20", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index 5686621..0564936 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.19", + "version": "0.1.20", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From d68baf776ff6f92d112a15df4f8c6a079005308c Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 23:26:21 +0800 Subject: [PATCH 090/217] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96=20MC?= =?UTF-8?q?P=20=E7=8A=B6=E6=80=81=E5=88=97=E8=A1=A8=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E5=8F=8A=E7=95=8C=E9=9D=A2=E7=BB=86=E8=8A=82=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 MCP 状态列表拆分为服务器列表和服务器详情两种视图 - 支持通过方向键分页和滚动,确保选中项始终可见 - 服务器详情视图新增工具、提示和资源的详细列表展示及导航支持 - 为启动状态服务器添加动态加载动画,提升交互体验 - 错误信息使用带红色边框的样式包裹,更加醒目 - 统一界面中选中项前缀符号由 "› " 改为 "> " - 修正 DropdownMenu 和 SlashCommandMenu 的注释与前缀符号 - 调整 SessionList 中选中标识符,保持样式一致 - 优化行宽计算逻辑,提升标签列自适应能力 - 改善布局间距和边框样式,增强视觉层次感 --- src/ui/AskUserQuestionPrompt.tsx | 2 +- src/ui/DropdownMenu.tsx | 4 +- src/ui/McpStatusList.tsx | 482 ++++++++++++++++++++++++------- src/ui/PromptInput.tsx | 6 +- src/ui/SessionList.tsx | 2 +- src/ui/SlashCommandMenu.tsx | 6 +- 6 files changed, 380 insertions(+), 122 deletions(-) diff --git a/src/ui/AskUserQuestionPrompt.tsx b/src/ui/AskUserQuestionPrompt.tsx index 952f9cf..7c76ae3 100644 --- a/src/ui/AskUserQuestionPrompt.tsx +++ b/src/ui/AskUserQuestionPrompt.tsx @@ -184,7 +184,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): return ( - {isCursor ? "› " : " "} + {isCursor ? "> " : " "} {marker} {option.label} {option.isOther ? ( diff --git a/src/ui/DropdownMenu.tsx b/src/ui/DropdownMenu.tsx index 3a963a5..d651720 100644 --- a/src/ui/DropdownMenu.tsx +++ b/src/ui/DropdownMenu.tsx @@ -82,7 +82,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ // 计算每个 item 实际需要的最大宽度 const maxContentWidth = Math.max( ...visibleItems.map((item) => { - let width = 2; // prefix "› " or " " + let width = 2; // prefix "> " or " " if (item.selected !== undefined) { width += 2; // "● " or "○ " } @@ -152,7 +152,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ - {isActive ? "› " : " "} + {isActive ? "> " : " "} {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} {item.statusIndicator ? ( {item.statusIndicator.symbol} diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index 455c586..e448b5c 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { McpServerStatus } from "../mcp/mcp-manager"; @@ -7,94 +7,160 @@ type Props = { onCancel: () => void; }; -type FlatItem = - | { kind: "server"; status: McpServerStatus; serverIndex: number } - | { kind: "error"; error: string; serverName: string } - | { kind: "tool"; name: string; serverName: string } - | { kind: "prompt"; name: string; serverName: string } - | { kind: "resource"; name: string; serverName: string }; - -function buildFlatItems(statuses: McpServerStatus[]): FlatItem[] { - const items: FlatItem[] = []; - for (let i = 0; i < statuses.length; i++) { - const status = statuses[i]; - items.push({ kind: "server", status, serverIndex: i }); - // 为失败的服务添加错误消息 - if (status.status === "failed" && status.error) { - items.push({ kind: "error", error: status.error, serverName: status.name }); - } - if (status.status === "ready") { - for (const tool of status.tools) { - items.push({ kind: "tool", name: tool, serverName: status.name }); - } - for (const prompt of status.prompts) { - items.push({ kind: "prompt", name: prompt, serverName: status.name }); - } - for (const resource of status.resources) { - items.push({ kind: "resource", name: resource, serverName: status.name }); - } +export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { + const { columns, rows } = useWindowSize(); + + // 视图模式:server-list(服务器列表) 或 server-detail(服务器详情) + const [viewMode, setViewMode] = useState<"server-list" | "server-detail">("server-list"); + // 选中的服务器索引 + const [selectedServerIndex, setSelectedServerIndex] = useState(0); + + // 返回服务器列表 + const goBack = useCallback(() => { + setViewMode("server-list"); + }, []); + + // 进入服务器详情 + const enterDetail = useCallback(() => { + const server = statuses[selectedServerIndex]; + if (server && server.status === "ready") { + setViewMode("server-detail"); } + }, [statuses, selectedServerIndex]); + + if (statuses.length === 0) { + return ( + + + + Manage MCP servers + + 0 servers + + + No MCP servers configured. + Add MCP servers to your settings to get started. + + Esc to close + + ); } - return items; -} -export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { - const [index, setIndex] = useState(0); - const { columns, rows } = useWindowSize(); + if (viewMode === "server-detail") { + return ( + + ); + } - const flatItems = useMemo(() => buildFlatItems(statuses), [statuses]); + return ( + + ); +} + +// ==================== 服务器列表视图 ==================== +function ServerListView({ + statuses, + selectedIndex, + onSelect, + onEnter, + onCancel, + rows, + columns, +}: { + statuses: McpServerStatus[]; + selectedIndex: number; + onSelect: (index: number) => void; + onEnter: () => void; + onCancel: () => void; + rows: number; + columns: number; +}): React.ReactElement { + const [scrollOffset, setScrollOffset] = useState(0); + const serverCount = statuses.length; const maxVisible = useMemo(() => { - const reservedLines = 8; - const linesPerItem = 2; + const reservedLines = 8; // header + footer + borders const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); - return Math.max(1, Math.floor(availableLines / linesPerItem)); + // 每个服务器占用 1 行(标题)+ 1 行(错误信息或统计)+ 1 行(间隔) + return Math.max(1, Math.floor(availableLines / 3)); }, [rows]); + // 计算标签列宽度:找到最长的服务器名称,加上前缀和图标 + const labelColumnWidth = useMemo(() => { + if (serverCount === 0) return 0; + const longestName = Math.max(...statuses.map((s) => s.name.length)); + const contentWidth = longestName + 5; // +2 for prefix "> " or " ", +3 for icon "✓ " + const maxAllowed = Math.max(15, Math.floor((columns - 6) * 0.4)); // 容器40%宽度,至少15列 + return Math.min(contentWidth, maxAllowed); + }, [statuses, serverCount, columns]); + const safeIndex = useMemo(() => { - if (flatItems.length === 0) return 0; - return Math.max(0, Math.min(index, flatItems.length - 1)); - }, [index, flatItems.length]); + if (serverCount === 0) return 0; + return Math.max(0, Math.min(selectedIndex, serverCount - 1)); + }, [selectedIndex, serverCount]); - const scrollOffset = useMemo(() => { - if (safeIndex < maxVisible) return 0; - return safeIndex - maxVisible + 1; - }, [safeIndex, maxVisible]); + // 自动滚动确保选中项可见 + React.useEffect(() => { + if (safeIndex < scrollOffset) { + setScrollOffset(safeIndex); + } else if (safeIndex >= scrollOffset + maxVisible) { + setScrollOffset(safeIndex - maxVisible + 1); + } + }, [safeIndex, scrollOffset, maxVisible]); - const visibleItems = useMemo(() => { - return flatItems.slice(scrollOffset, scrollOffset + maxVisible); - }, [flatItems, scrollOffset, maxVisible]); + const visibleServers = useMemo(() => { + return statuses.slice(scrollOffset, scrollOffset + maxVisible); + }, [statuses, scrollOffset, maxVisible]); useInput((input, key) => { if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { onCancel(); return; } - if (flatItems.length === 0) { + if (serverCount === 0) { return; } if (key.upArrow) { - setIndex((i) => Math.max(0, i - 1)); + onSelect(Math.max(0, selectedIndex - 1)); return; } if (key.downArrow) { - setIndex((i) => Math.min(flatItems.length - 1, i + 1)); + onSelect(Math.min(serverCount - 1, selectedIndex + 1)); return; } if (key.pageUp) { - setIndex((i) => Math.max(0, i - maxVisible)); + onSelect(Math.max(0, selectedIndex - maxVisible)); return; } if (key.pageDown) { - setIndex((i) => Math.min(flatItems.length - 1, i + maxVisible)); + onSelect(Math.min(serverCount - 1, selectedIndex + maxVisible)); return; } if (key.home) { - setIndex(0); + onSelect(0); return; } if (key.end) { - setIndex(flatItems.length - 1); + onSelect(serverCount - 1); + } + // Enter 键进入详情 + if (key.return) { + onEnter(); + return; } }); @@ -102,24 +168,6 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement const startingCount = statuses.filter((s) => s.status === "starting").length; const failedCount = statuses.filter((s) => s.status === "failed").length; - if (statuses.length === 0) { - return ( - - - - Manage MCP servers - - 0 servers - - - No MCP servers configured. - Add MCP servers to your settings to get started. - - Esc to close - - ); - } - return ( - {visibleItems.map((item, i) => { + {visibleServers.map((status, i) => { const actualIndex = scrollOffset + i; const isSelected = actualIndex === safeIndex; - if (item.kind === "server") { - return ; - } - if (item.kind === "error") { - return ; - } return ( - ); })} - {scrollOffset > 0 || scrollOffset + maxVisible < flatItems.length ? ( + {scrollOffset > 0 || scrollOffset + maxVisible < serverCount ? ( - {scrollOffset > 0 ? … {scrollOffset} items above. : null} - {scrollOffset + maxVisible < flatItems.length ? ( - … {flatItems.length - scrollOffset - maxVisible} items below. + {scrollOffset > 0 ? … {scrollOffset} servers above. : null} + {scrollOffset + maxVisible < serverCount ? ( + … {serverCount - scrollOffset - maxVisible} servers below. ) : null} ) : null} {/* Footer */} - - ↑/↓ navigate · PgUp/PgDn page · Esc cancel + + ↑/↓ navigate · Enter view details · Esc cancel ); } -function ServerRow({ status, selected }: { status: McpServerStatus; selected: boolean }): React.ReactElement { - const icon = status.status === "ready" ? "✔" : status.status === "failed" ? "✖" : "●"; +function ServerRow({ + status, + selected, + labelColumnWidth, +}: { + status: McpServerStatus; + selected: boolean; + labelColumnWidth: number; +}): React.ReactElement { + const icon = status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : "●"; const color = status.status === "ready" ? "green" : status.status === "failed" ? "red" : "yellow"; + + // 加载动画:循环显示 (空) → . → .. → ... → (空) → ... + const [dots, setDots] = React.useState(0); + React.useEffect(() => { + if (status.status !== "starting") return; + const interval = setInterval(() => { + setDots((d) => (d + 1) % 4); // 0 → 1 → 2 → 3 → 0 ... + }, 500); + return () => clearInterval(interval); + }, [status.status]); + const detail = status.status === "ready" ? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)` : status.status === "failed" - ? `Failed (${status.error ?? "unknown error"})` - : "Starting..."; + ? `Failed` + : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); // 动态显示 (空) / . / .. / ... return ( - - {selected ? "› " : " "} - - {icon} - {status.name} - — {detail} - + + {/* Server row */} + + + + {selected ? "> " : " "} + {icon} + {status.name} + + + + {detail} + + + + {/* Error message for failed servers */} + {status.status === "failed" && status.error ? : null} ); } -function CapabilityRow({ - kind, - name, - selected, +// ==================== 服务器详情视图 ==================== +function ServerDetailView({ + server, + onBack, + onCancel, + rows, + columns, }: { - kind: "tool" | "prompt" | "resource"; - name: string; - selected: boolean; + server: McpServerStatus; + onBack: () => void; + onCancel: () => void; + rows: number; + columns: number; }): React.ReactElement { - const prefix = kind === "tool" ? "🔧" : kind === "prompt" ? "📝" : "📦"; + const [activeIndex, setActiveIndex] = useState(0); + + // 合并所有 items(tools, prompts, resources) + const allItems = useMemo(() => { + const items: { type: string; name: string }[] = []; + server.tools.forEach((tool) => items.push({ type: "tool", name: tool })); + server.prompts.forEach((prompt) => items.push({ type: "prompt", name: prompt })); + server.resources.forEach((resource) => items.push({ type: "resource", name: resource })); + return items; + }, [server]); + + const totalItems = allItems.length; + + const maxVisible = useMemo(() => { + const reservedLines = 10; // header + title + stats + footer + borders + const availableLines = Math.max(0, Math.min(rows, 28) - reservedLines); + return Math.max(1, availableLines); + }, [rows]); + + // 使用 ref 跟踪 visibleStart,避免循环依赖 + const visibleStartRef = React.useRef(0); + + // 计算可见窗口起始位置:当 activeIndex 超出可见区域时才滚动(类似终端光标行为) + const visibleStart = useMemo(() => { + if (totalItems === 0) return 0; + + const currentStart = visibleStartRef.current; + let newStart = currentStart; + + // 如果 activeIndex 在当前可见窗口之前,滚动到 activeIndex + if (activeIndex < currentStart) { + newStart = activeIndex; + } + // 如果 activeIndex 在当前可见窗口之后,滚动到 activeIndex + else if (activeIndex >= currentStart + maxVisible) { + newStart = activeIndex - maxVisible + 1; + } + + console.log("maxVisible:", maxVisible); + + // 限制在合法范围内 + newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible))); + + // 更新 ref + visibleStartRef.current = newStart; + + return newStart; + }, [activeIndex, maxVisible, totalItems]); + + const visibleItems = allItems.slice(visibleStart, visibleStart + maxVisible); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (key.escape) { + onBack(); + return; + } + // Space 或 Enter 键返回一级菜单 + if (input === " " || key.return) { + onBack(); + return; + } + if (key.upArrow) { + setActiveIndex((prev) => Math.max(0, prev - 1)); + return; + } + if (key.downArrow) { + setActiveIndex((prev) => Math.min(totalItems - 1, prev + 1)); + return; + } + if (key.pageUp) { + setActiveIndex((prev) => Math.max(0, prev - maxVisible)); + return; + } + if (key.pageDown) { + setActiveIndex((prev) => Math.min(totalItems - 1, prev + maxVisible)); + return; + } + if (key.home) { + setActiveIndex(0); + return; + } + if (key.end) { + setActiveIndex(totalItems - 1); + } + }); + + const icon = "✓"; + const color = "green"; + + return ( + + + {/* Header row */} + + {icon} + + {server.name} + + — Details + + {activeIndex + 1}/{totalItems} + + + {/* Server info */} + + + {server.toolCount} tools, {server.promptCount} prompts, {server.resourceCount} resources + + + {/* Items list */} + + {visibleStart > 0 ? ( + + + + ) : ( + + )} + + {visibleItems.length === 0 ? ( + + No items available + + ) : ( + visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + const isSelected = actualIndex === activeIndex; + return ; + }) + )} + + {visibleStart > 0 || visibleStart + maxVisible < totalItems ? ( + + {totalItems - visibleStart - maxVisible > 0 ? : } + {visibleStart > 0 ? … {visibleStart} items above. : null} + {totalItems - visibleStart - maxVisible > 0 ? ( + … {totalItems - visibleStart - maxVisible} items below. + ) : null} + + ) : null} + + {/* Footer */} + + ↑/↓ scroll · Space/Enter back · Esc close + + + + ); +} + +function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement { + const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + return ( - - {selected ? "› " : " "} - - {prefix} {name} + + {icon} + + {item.name} ); @@ -246,7 +496,15 @@ function ErrorRow({ error }: { error: string }): React.ReactElement { const lines = error.split("\n").filter((line) => line.trim().length > 0); return ( - + {lines.map((line, index) => ( diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 6a11431..6c0f8ea 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -722,7 +722,7 @@ export const PromptInput = React.memo(function PromptInput({ ({ key: skill.path || skill.name, @@ -742,8 +742,8 @@ export const PromptInput = React.memo(function PromptInput({ title={modelDropdownStep === "model" ? "Select Model" : "Select Thinking Mode"} helpText={ modelDropdownStep === "model" - ? "space/enter select model · esc to cancel" - : "space/enter apply · esc to cancel" + ? "Space/Enter select model · Esc to cancel" + : "Space/Enter apply · Esc to cancel" } items={modelDropdownItems.map((item) => ({ key: item.label, diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 67f2e10..fdbd1fe 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -126,7 +126,7 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return ( - {actualIndex === safeIndex ? "› " : " "} + {actualIndex === safeIndex ? "> " : " "} diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index 9b79293..436be83 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -16,13 +16,13 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ maxVisible = 6, width, }: SlashCommandMenuProps): React.ReactElement | null { - // 计算标签列最佳宽度:包含前缀"› "或" "(2字符),不超过容器一半(扣除gap) + // 计算标签列最佳宽度:包含前缀"> "或" "(2字符),不超过容器一半(扣除gap) const labelColumnWidth = React.useMemo(() => { if (items.length === 0) { return 0; } const longestLabel = Math.max(...items.map((s) => s.label.length)); - const contentWidth = longestLabel + 2; // +2 for prefix "› " or " " + const contentWidth = longestLabel + 2; // +2 for prefix "> " or " " const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 return Math.min(contentWidth, maxAllowed); }, [items, width]); @@ -51,7 +51,7 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ - {actualIndex === activeIndex ? "› " : " "} + {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)} From b858c8755978480321f79dd8068376c7c45408d4 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 23:58:18 +0800 Subject: [PATCH 091/217] chore: update McpStatusList --- src/ui/McpStatusList.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index e448b5c..e7912d9 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -321,7 +321,7 @@ function ServerDetailView({ const maxVisible = useMemo(() => { const reservedLines = 10; // header + title + stats + footer + borders - const availableLines = Math.max(0, Math.min(rows, 28) - reservedLines); + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); return Math.max(1, availableLines); }, [rows]); @@ -344,8 +344,6 @@ function ServerDetailView({ newStart = activeIndex - maxVisible + 1; } - console.log("maxVisible:", maxVisible); - // 限制在合法范围内 newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible))); @@ -412,7 +410,7 @@ function ServerDetailView({ {/* Header row */} {icon} - + {server.name} — Details @@ -422,7 +420,7 @@ function ServerDetailView({ {/* Server info */} - + {server.toolCount} tools, {server.promptCount} prompts, {server.resourceCount} resources @@ -455,7 +453,7 @@ function ServerDetailView({ visibleItems.map((item, idx) => { const actualIndex = visibleStart + idx; const isSelected = actualIndex === activeIndex; - return ; + return ; }) )} @@ -482,7 +480,7 @@ function ItemRow({ item, selected }: { item: { type: string; name: string }; sel const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; return ( - + {icon} {item.name} From e570d3006c8bd0fe73eb6779da17861230bf1d52 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 15 May 2026 10:09:08 +0800 Subject: [PATCH 092/217] refactor: move debug and error logging to common directory --- src/{ => common}/debug-logger.ts | 0 src/{ => common}/error-logger.ts | 0 src/{ => common}/notify.ts | 0 src/{ => common}/openai-thinking.ts | 2 +- src/session.ts | 8 ++++---- src/tests/debug-logger.test.ts | 2 +- src/tests/openai-thinking.test.ts | 2 +- src/tests/settings-and-notify.test.ts | 2 +- src/tools/edit-handler.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) rename src/{ => common}/debug-logger.ts (100%) rename src/{ => common}/error-logger.ts (100%) rename src/{ => common}/notify.ts (100%) rename src/{ => common}/openai-thinking.ts (91%) diff --git a/src/debug-logger.ts b/src/common/debug-logger.ts similarity index 100% rename from src/debug-logger.ts rename to src/common/debug-logger.ts diff --git a/src/error-logger.ts b/src/common/error-logger.ts similarity index 100% rename from src/error-logger.ts rename to src/common/error-logger.ts diff --git a/src/notify.ts b/src/common/notify.ts similarity index 100% rename from src/notify.ts rename to src/common/notify.ts diff --git a/src/openai-thinking.ts b/src/common/openai-thinking.ts similarity index 91% rename from src/openai-thinking.ts rename to src/common/openai-thinking.ts index 0726152..1585cbd 100644 --- a/src/openai-thinking.ts +++ b/src/common/openai-thinking.ts @@ -1,4 +1,4 @@ -import type { ReasoningEffort } from "./settings"; +import type { ReasoningEffort } from "../settings"; type ThinkingConfig = { type: "enabled" | "disabled"; diff --git a/src/session.ts b/src/session.ts index 586857c..894ff80 100644 --- a/src/session.ts +++ b/src/session.ts @@ -6,15 +6,15 @@ import { fileURLToPath } from "url"; import matter from "gray-matter"; import ejs from "ejs"; import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; -import { launchNotifyScript } from "./notify"; -import { buildThinkingRequestOptions } from "./openai-thinking"; +import { launchNotifyScript } from "./common/notify"; +import { buildThinkingRequestOptions } from "./common/openai-thinking"; import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities"; import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt"; import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig } from "./settings"; -import { logApiError } from "./error-logger"; -import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; +import { logApiError } from "./common/error-logger"; +import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; diff --git a/src/tests/debug-logger.test.ts b/src/tests/debug-logger.test.ts index 1084b01..7b1aad4 100644 --- a/src/tests/debug-logger.test.ts +++ b/src/tests/debug-logger.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { getDebugLogPath, logOpenAIChatCompletionDebug } from "../debug-logger"; +import { getDebugLogPath, logOpenAIChatCompletionDebug } from "../common/debug-logger"; test("debug logger appends full entries without rotation", () => { const originalHome = process.env.HOME; diff --git a/src/tests/openai-thinking.test.ts b/src/tests/openai-thinking.test.ts index 2f22c0b..78d8a00 100644 --- a/src/tests/openai-thinking.test.ts +++ b/src/tests/openai-thinking.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildThinkingRequestOptions } from "../openai-thinking"; +import { buildThinkingRequestOptions } from "../common/openai-thinking"; test("buildThinkingRequestOptions explicitly disables thinking", () => { assert.deepEqual(buildThinkingRequestOptions(false, "https://api.deepseek.com"), { diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 4470502..69b939f 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../notify"; +import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../common/notify"; import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; const TEST_PROCESS_ENV = {}; diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index fd0dd8a..6d0cab3 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -1,6 +1,6 @@ import * as fs from "fs"; import { z } from "zod"; -import { buildThinkingRequestOptions } from "../openai-thinking"; +import { buildThinkingRequestOptions } from "../common/openai-thinking"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; import { buildDiffPreview, From 55bb8b48a83af40a499fadfcb668794aceb1ede1 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 15 May 2026 10:17:50 +0800 Subject: [PATCH 093/217] chore: update AGENTS.md --- .deepcode/AGENTS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index bee51ce..9cdf64e 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -8,7 +8,12 @@ src/ ├── session.ts # SessionManager — LLM loop, compaction, tool orchestration ├── settings.ts # Settings resolution from ~/.deepcode/settings.json ├── prompt.ts # System prompt builder, tool definitions, agent-drift-guard skill -├── model-capabilities.ts # Model detection and thinking-mode defaults +├── common/ +│ ├── model-capabilities.ts # Model detection and thinking-mode defaults +│ ├── file-utils.ts # File read/write with encoding and diff preview +│ ├── shell-utils.ts # Shell path resolution (Git Bash, zsh, bash) +│ ├── state.ts # In-memory file state and snippet tracking +│ └── runtime.ts # Tool validation runtime helpers ├── ui/ │ ├── App.tsx # Root Ink component — state, routing, session orchestration │ ├── PromptInput.tsx # Multi-line input with slash commands, image paste, skills @@ -20,11 +25,6 @@ src/ ├── mcp/ │ ├── mcp-client.ts # MCP client — JSON-RPC communication with MCP servers │ └── mcp-manager.ts # MCP manager — lifecycle, tool registration, execution -├── common/ -│ ├── file-utils.ts # File read/write with encoding and diff preview -│ ├── shell-utils.ts # Shell path resolution (Git Bash, zsh, bash) -│ ├── state.ts # In-memory file state and snippet tracking -│ └── runtime.ts # Tool validation runtime helpers ├── tools/ │ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers │ ├── bash-handler.ts # Executes shell commands @@ -52,7 +52,7 @@ dist/ # Bundled CLI output (gitignored) | `npm run format:check` | Prettier in check-only mode | | `npm run check` | Runs typecheck + lint + format:check together | | `npm run bundle` | esbuild bundles `src/cli.tsx` → `dist/cli.js` (ESM, Node 18) | -| `npm run build` | `check` + `bundle` — full CI gate before publish | +| `npm run build` | `check` + `bundle` + chmod 755 — full CI gate before publish | | `npm test` | Runs all tests via `tsx --test src/tests/*.test.ts` | | `npm run test:single -- ` | Run a single test file (e.g., `npm run test:single -- src/tests/session.test.ts`) | From f3554dfedc321c67fe676ce593f98c38e816576b Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Fri, 15 May 2026 10:30:20 +0800 Subject: [PATCH 094/217] fix: add Kitty keyboard protocol Shift+Enter sequences for broader terminal compatibility SHIFT_RETURN_SEQUENCES now covers both xterm modifyOtherKeys (Shift=2) and Kitty keyboard protocol (Shift=1) encodings. Also clear input when key.return is true to prevent escape sequence artifacts from leaking into the text buffer. --- src/tests/promptInputKeys.test.ts | 2 +- src/ui/prompt/useTerminalInput.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 69d2075..372dfc7 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -80,7 +80,7 @@ test("parseTerminalInput keeps BS payload for meta+backspace", () => { test("parseTerminalInput recognizes shifted return sequences", () => { const { input, key } = parseTerminalInput("\u001B\r"); - assert.equal(input, "\r"); + assert.equal(input, ""); assert.equal(key.return, true); assert.equal(key.shift, true); assert.equal(key.meta, false); diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index 8013ff6..ea368b5 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -26,7 +26,15 @@ const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]); const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]); const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]); -const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u", "\u001B[13;2~", "\u001B[27;2;13~"]); +const SHIFT_RETURN_SEQUENCES = new Set([ + "\u001B\r", + "\u001B[13;2u", // xterm modifyOtherKeys: keycode=13 (Enter), modifier=2 (Shift) + "\u001B[13;1u", // Kitty keyboard protocol: keycode=13, modifier=1 (Shift) + "\u001B[13;2~", + "\u001B[13;1~", // tmux / alternate terminals may use ~ terminator + "\u001B[27;2;13~", + "\u001B[27;1;13~", // extended format, Kitty encoding +]); const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]); const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]); const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]); @@ -162,7 +170,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: key.shift = true; } - if (key.tab || key.backspace || key.delete) { + if (key.tab || key.backspace || key.delete || key.return) { input = ""; } From efde004299e5892b132412845d5a3f08e13242d2 Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Fri, 15 May 2026 11:25:43 +0800 Subject: [PATCH 095/217] fix: use dynamic modifier parsing for Shift+Enter recognition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace exact string matching in SHIFT_RETURN_SEQUENCES with CSI parameter parsing that checks modifier bits. Windows Terminal sends ESC[13;130u (modifier=128+2) where 128 is a terminal-specific flag — the old code only matched modifier=2 exactly. Also enable Kitty progressive enhancement (ESC[>1u) alongside xterm modifyOtherKeys, since Windows Terminal requires the Kitty protocol to report modified keys. --- src/tests/promptInputKeys.test.ts | 4 +-- src/ui/prompt/cursor.ts | 6 ++-- src/ui/prompt/useTerminalInput.ts | 52 +++++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 372dfc7..8952a3d 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -108,8 +108,8 @@ test("parseTerminalInput recognizes alternate shifted return sequences", () => { }); test("terminal extended key helpers request and restore modifyOtherKeys mode", () => { - assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m"); - assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); + assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m\u001B[>1u"); + assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m\u001B[ { diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 2668470..5eff1de 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,12 +40,14 @@ function disableTerminalFocusReporting(): string { return "\u001B[?1004l"; } +// xterm modifyOtherKeys + Kitty progressive enhancement. +// Both are needed: some terminals (incl. Windows Terminal) only respond to Kitty. export function enableTerminalExtendedKeys(): string { - return "\u001B[>4;1m"; + return "\u001B[>4;1m\u001B[>1u"; } export function disableTerminalExtendedKeys(): string { - return "\u001B[>4;0m"; + return "\u001B[>4;0m\u001B[1u) sends plain Enter as ESC[13u +// or ESC[13;NUMBERu with extra flags; xterm sends ESC[13;2u for Shift. +const CSI_RETURN_RE = /^\u001B\[13;(\d+)[u~]$/; +const CSI_EXTENDED_RETURN_RE = /^\u001B\[27;(\d+);13~$/; + +function isReturn(raw: string): boolean { + if (raw === "\r") return true; + if (SHIFT_RETURN_SEQUENCES.has(raw)) return true; + if (META_RETURN_SEQUENCES.has(raw)) return true; + return CSI_RETURN_RE.test(raw) || CSI_EXTENDED_RETURN_RE.test(raw); +} const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]); const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]); const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]); @@ -121,10 +161,10 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: end: END_SEQUENCES.has(raw), pageDown: raw === "\u001B[6~", pageUp: raw === "\u001B[5~", - return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), + return: isReturn(raw), escape: raw === "\u001B", ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw), - shift: SHIFT_RETURN_SEQUENCES.has(raw), + shift: isShiftReturn(raw), tab: raw === "\t" || raw === "\u001B[Z", backspace: BACKSPACE_BYTES.has(raw), delete: FORWARD_DELETE_SEQUENCES.has(raw), From 35b686d55fa1a6fcd91f3845d49b17d5975fcd61 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 13:34:27 +0800 Subject: [PATCH 096/217] =?UTF-8?q?test(session):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BB=A5=E5=8C=85=E6=8B=AC=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=92=8C=E8=B5=84=E6=BA=90=E8=AE=A1=E6=95=B0=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 getMcpStatus 的断言中添加 promptCount、prompts、resourceCount 和 resources 字段 - 确保各状态对象包含完整的提示和资源数组信息 - 维护现有工具和连接状态的正确性 - 扩展测试覆盖,支持提示和资源相关的状态字段验证 --- src/tests/session.test.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 2ac6c29..60147ea 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -410,7 +410,17 @@ rl.on("line", (line) => { const initPromise = manager.initMcpServers({ smoke: { command: process.execPath, args: [serverPath] } }); assert.deepEqual(manager.getMcpStatus(), [ - { name: "smoke", status: "starting", connected: false, toolCount: 0, tools: [] }, + { + name: "smoke", + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, ]); await initPromise; @@ -422,6 +432,10 @@ rl.on("line", (line) => { connected: true, toolCount: 2, tools: ["mcp__smoke__echo", "mcp__smoke__count"], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }, ]); const mcpManager = (manager as any).mcpManager; @@ -457,7 +471,17 @@ test("SessionManager reports configured MCP servers as starting before initializ }); assert.deepEqual(manager.getMcpStatus(), [ - { name: "playwright", status: "starting", connected: false, toolCount: 0, tools: [] }, + { + name: "playwright", + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, ]); }); From 5a996281dce9cb2ef6371374669aea304bdbec7c Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 13:37:13 +0800 Subject: [PATCH 097/217] =?UTF-8?q?refactor(ui):=20=E7=A7=BB=E9=99=A4McpSt?= =?UTF-8?q?atusList=E4=B8=AD=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84React?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=92=8C=E6=96=87=E6=9C=AC=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除了未使用的useEffect导入,简化依赖引入 - 移除了界面中显示的当前索引及总数文本,减少冗余信息显示 - 优化了McpStatusList组件代码的可读性和整洁度 --- src/ui/McpStatusList.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index e7912d9..1f49bd6 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useCallback, useEffect } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { McpServerStatus } from "../mcp/mcp-manager"; @@ -414,9 +414,6 @@ function ServerDetailView({ {server.name} — Details - - {activeIndex + 1}/{totalItems} - {/* Server info */} From bee349bb8ca700eefe96615cba4b51352bdc907b Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 14:09:28 +0800 Subject: [PATCH 098/217] =?UTF-8?q?fix(mcp-client):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E7=89=88=E6=9C=AC=E6=A0=A1=E9=AA=8C=E5=92=8C?= =?UTF-8?q?JSON-RPC=E6=89=B9=E5=A4=84=E7=90=86=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在初始化时验证服务器返回的MCP协议版本,拒绝不支持的版本 - 支持处理JSON-RPC批量消息,逐条分发处理 - 抽离单条消息处理逻辑,统一处理通知和响应 - 防止通知处理器异常导致读取循环崩溃 fix(ui): 优化键盘交互提示与布局间距 - 移除Esc键作为取消操作,避免与Ctrl+C冲突 - 更新底部提示,明确Ctrl+C为关闭操作,Esc为返回操作 - 调整MessageView内容左边距,使间距更合理 --- src/mcp/mcp-client.ts | 70 ++++++++++++++++++++++++++++------------ src/ui/McpStatusList.tsx | 4 +-- src/ui/MessageView.tsx | 2 +- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index a086c1d..f89e11d 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -178,7 +178,19 @@ export class McpClient { }, timeoutMs ) - .then(() => { + .then((result) => { + // Validate protocol version from server response (per MCP spec §4.2.1.2) + const initResult = result as { protocolVersion?: string } | undefined; + const serverVersion = initResult?.protocolVersion; + if (serverVersion && serverVersion !== "2025-03-26" && serverVersion !== "2024-11-05") { + reject( + new Error( + `Unsupported MCP protocol version "${serverVersion}" from server "${this.serverName}". ` + + `Client supports 2025-03-26 and 2024-11-05.` + ) + ); + return; + } // Send initialized notification this.sendNotification("notifications/initialized"); resolve(); @@ -302,36 +314,54 @@ export class McpClient { try { const parsed: unknown = JSON.parse(line); - // Handle notifications (no id field — server-initiated) - if (parsed && typeof parsed === "object" && !("id" in parsed)) { - const notification = parsed as JsonRpcNotification; - if (this.notificationHandler && typeof notification.method === "string") { - try { - this.notificationHandler(notification.method, notification.params); - } catch { - // Swallow handler errors to avoid crashing the reader loop + // Handle JSON-RPC batch (array of requests/notifications/responses) + // Per MCP 2025-03-26 §4.1.1.3: implementations MUST support receiving batches. + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item && typeof item === "object") { + this.handleSingleMessage(item); } } return; } - // Handle responses to our requests - const message = parsed as JsonRpcResponse; - if (message.id !== undefined && this.pendingRequests.has(message.id)) { - const pending = this.pendingRequests.get(message.id)!; - this.pendingRequests.delete(message.id); - clearTimeout(pending.timer); - if (message.error) { - pending.reject(this.withStderr(`MCP error: ${message.error.message}`)); - } else { - pending.resolve(message.result); - } + // Handle single message + if (parsed && typeof parsed === "object") { + this.handleSingleMessage(parsed); } } catch { // Ignore unparseable lines } } + private handleSingleMessage(msg: object): void { + // Handle notifications (no id field — server-initiated) + if (!("id" in msg)) { + const notification = msg as unknown as JsonRpcNotification; + if (this.notificationHandler && typeof notification.method === "string") { + try { + this.notificationHandler(notification.method, notification.params); + } catch { + // Swallow handler errors to avoid crashing the reader loop + } + } + return; + } + + // Handle responses to our requests + const message = msg as unknown as JsonRpcResponse; + if (message.id !== undefined && this.pendingRequests.has(message.id)) { + const pending = this.pendingRequests.get(message.id)!; + this.pendingRequests.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject(this.withStderr(`MCP error: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + } + } + private withNpxYesArg(command: string, args: string[]): string[] { const executable = path .basename(command) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index 1f49bd6..0f5f906 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -356,7 +356,7 @@ function ServerDetailView({ const visibleItems = allItems.slice(visibleStart, visibleStart + maxVisible); useInput((input, key) => { - if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + if (key.ctrl && (input === "c" || input === "C")) { onCancel(); return; } @@ -466,7 +466,7 @@ function ServerDetailView({ {/* Footer */} - ↑/↓ scroll · Space/Enter back · Esc close + ↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index acdc645..c8793fc 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -47,7 +47,7 @@ export function MessageView({ message, collapsed, width = 80 }: Props): React.Re return ( - + {content ? {renderMarkdown(content)} : null} From c81937434575bee3eb734fcf28d9bec01bad4667 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 15:10:15 +0800 Subject: [PATCH 099/217] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=E6=97=A0?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=97=B6=E6=8C=89Esc=E9=94=AE?= =?UTF-8?q?=E9=80=80=E5=87=BA=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在McpStatusList组件中监听键盘输入事件 - 当服务器列表为空时,按Esc键或Ctrl+C触发退出操作 - 确保用户可通过快捷键安全退出无服务器状态界面 --- src/ui/McpStatusList.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index 0f5f906..bdb1854 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -28,6 +28,13 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement } }, [statuses, selectedServerIndex]); + // 当没有服务器时,监听 Esc 键退出 + useInput((input, key) => { + if (statuses.length === 0 && (key.escape || (key.ctrl && (input === "c" || input === "C")))) { + onCancel(); + } + }); + if (statuses.length === 0) { return ( From 9588313e1634f316edf3464ecbcc5d39d581da45 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 15 May 2026 15:17:47 +0800 Subject: [PATCH 100/217] feat: enhance file versioning and snippet handling in edit and write tools --- src/common/state.ts | 34 +++++- src/tests/tool-handlers.test.ts | 178 ++++++++++++++++++++++++++++++++ src/tools/edit-handler.ts | 57 ++++++++-- src/tools/write-handler.ts | 18 ++-- 4 files changed, 268 insertions(+), 19 deletions(-) diff --git a/src/common/state.ts b/src/common/state.ts index 7816573..add27f3 100644 --- a/src/common/state.ts +++ b/src/common/state.ts @@ -7,6 +7,7 @@ export type FileState = { filePath: string; content: string; timestamp: number; + version?: number; offset?: number; limit?: number; isPartialView?: boolean; @@ -20,11 +21,13 @@ export type FileSnippet = { startLine: number; endLine: number; preview: string; + fileVersion: number; }; const fileStatesBySession = new Map>(); const snippetsBySession = new Map>(); const snippetCountersBySession = new Map(); +const fileVersionsBySession = new Map>(); export function normalizeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string { const nativePath = normalizeNativeFilePath(filePath, platform); @@ -57,7 +60,11 @@ function isGitBashAbsolutePath(filePath: string): boolean { return /^\/[A-Za-z](?:\/|$)/.test(filePath) || /^\/cygdrive\/[A-Za-z](?:\/|$)/.test(filePath); } -export function recordFileState(sessionId: string, state: FileState): void { +export function recordFileState( + sessionId: string, + state: FileState, + options: { incrementVersion?: boolean } = {} +): void { if (!sessionId || !state.filePath) { return; } @@ -69,9 +76,13 @@ export function recordFileState(sessionId: string, state: FileState): void { } const normalizedPath = normalizeFilePath(state.filePath); + const currentVersion = getFileVersion(sessionId, normalizedPath); + const nextVersion = options.incrementVersion ? currentVersion + 1 : currentVersion; + setFileVersion(sessionId, normalizedPath, nextVersion); sessionState.set(normalizedPath, { ...state, filePath: normalizedPath, + version: nextVersion, }); } @@ -108,6 +119,22 @@ export function wasFileRead(sessionId: string, filePath: string): boolean { return getFileState(sessionId, filePath) !== null; } +export function getFileVersion(sessionId: string, filePath: string): number { + if (!sessionId || !filePath) { + return 0; + } + return fileVersionsBySession.get(sessionId)?.get(normalizeFilePath(filePath)) ?? 0; +} + +function setFileVersion(sessionId: string, filePath: string, version: number): void { + let sessionVersions = fileVersionsBySession.get(sessionId); + if (!sessionVersions) { + sessionVersions = new Map(); + fileVersionsBySession.set(sessionId, sessionVersions); + } + sessionVersions.set(normalizeFilePath(filePath), version); +} + export function isFullFileView(state: FileState | null): boolean { return Boolean( state && !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined" @@ -134,6 +161,7 @@ export function createSnippet( startLine, endLine, preview, + fileVersion: getFileVersion(sessionId, filePath), }; let snippets = snippetsBySession.get(sessionId); @@ -151,3 +179,7 @@ export function getSnippet(sessionId: string, snippetId: string): FileSnippet | } return snippetsBySession.get(sessionId)?.get(snippetId) ?? null; } + +export function hasSnippetOutdatedFileVersion(sessionId: string, snippet: FileSnippet): boolean { + return getFileVersion(sessionId, snippet.filePath) > snippet.fileVersion; +} diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index 256e0d6..43af7ca 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -88,6 +88,184 @@ test("Edit returns candidate match snippets when old_string is not unique", asyn assert.match(candidates[0]?.preview ?? "", /city/); }); +test("Edit returns closest matches only above threshold with surrounding context", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "closest.ts"); + fs.writeFileSync( + filePath, + [ + "const before = true;", + "function computeSubtotal(value: number) {", + " return value;", + "}", + "const after = true;", + ].join("\n"), + "utf8" + ); + + const sessionId = "closest-match-context"; + await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + + const closeResult = await handleEditTool( + { + file_path: filePath, + old_string: "function computeTotal(value: number) {", + new_string: "function computeTotal(input: number) {", + }, + createContext(sessionId, workspace) + ); + + assert.equal(closeResult.ok, false); + assert.equal(closeResult.error, "old_string not found in file."); + const closestMatch = closeResult.metadata?.closest_match as + | { snippet_id?: string; start_line?: number; end_line?: number; similarity?: number; preview?: string } + | undefined; + assert.ok(closestMatch?.snippet_id); + assert.equal(closestMatch.start_line, 1); + assert.equal(closestMatch.end_line, 4); + assert.ok((closestMatch.similarity ?? 0) >= 0.8); + assert.match(closestMatch.preview ?? "", /const before = true/); + assert.match(closestMatch.preview ?? "", /return value/); + + const lowResult = await handleEditTool( + { + file_path: filePath, + old_string: 'query: string = Field(description="search query")', + new_string: "query: string", + }, + createContext(sessionId, workspace) + ); + + assert.equal(lowResult.ok, false); + assert.equal(lowResult.error, "old_string not found in file."); + assert.equal(lowResult.metadata?.closest_match, undefined); + + const partialRead = await handleReadTool( + { file_path: filePath, offset: 2, limit: 2 }, + createContext(sessionId, workspace) + ); + const snippet = (partialRead.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(snippet); + + const scopedCloseResult = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "function computeTotal(value: number) {", + new_string: "function computeTotal(input: number) {", + }, + createContext(sessionId, workspace) + ); + + assert.equal(scopedCloseResult.ok, false); + const scopedClosestMatch = scopedCloseResult.metadata?.closest_match as + | { start_line?: number; end_line?: number; preview?: string } + | undefined; + assert.equal(scopedClosestMatch?.start_line, 2); + assert.equal(scopedClosestMatch?.end_line, 3); + assert.doesNotMatch(scopedClosestMatch?.preview ?? "", /const before = true/); +}); + +test("Edit allows outdated snippet matches but reports outdated snippet when no match is found", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "snippet-outdated.txt"); + fs.writeFileSync(filePath, ["alpha = 1", "beta = 1", "gamma = 1"].join("\n"), "utf8"); + + const sessionId = "outdated-snippet-miss"; + const readResult = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = (readResult.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(snippet); + + const firstEdit = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "alpha = 1", + new_string: "alpha = 2", + }, + createContext(sessionId, workspace) + ); + assert.equal(firstEdit.ok, true); + + const secondEdit = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "beta = 1", + new_string: "beta = 2", + }, + createContext(sessionId, workspace) + ); + assert.equal(secondEdit.ok, true); + assert.equal(fs.readFileSync(filePath, "utf8"), ["alpha = 2", "beta = 2", "gamma = 1"].join("\n")); + + const missingEdit = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "delta = 1", + new_string: "delta = 2", + }, + createContext(sessionId, workspace) + ); + + assert.equal(missingEdit.ok, false); + assert.equal( + missingEdit.error, + "old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing." + ); + const outdatedScope = (missingEdit.metadata?.scope ?? {}) as { snippet_id?: string }; + assert.equal(outdatedScope.snippet_id, snippet.id); + + const freshRead = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const freshSnippet = (freshRead.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(freshSnippet); + + const freshMissingEdit = await handleEditTool( + { + snippet_id: freshSnippet.id, + old_string: "delta = 1", + new_string: "delta = 2", + }, + createContext(sessionId, workspace) + ); + + assert.equal(freshMissingEdit.ok, false); + assert.equal(freshMissingEdit.error, "old_string not found in file."); +}); + +test("Edit reports outdated snippet when a later Write changes the file and snippet matching fails", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "write-outdated.txt"); + fs.writeFileSync(filePath, ["alpha = 1", "beta = 1"].join("\n"), "utf8"); + + const sessionId = "write-outdated-snippet"; + const readResult = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = (readResult.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(snippet); + + const writeResult = await handleWriteTool( + { + file_path: filePath, + content: ["alpha = 2", "gamma = 2"].join("\n"), + }, + createContext(sessionId, workspace) + ); + + assert.equal(writeResult.ok, true); + + const editResult = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "beta = 1", + new_string: "beta = 2", + }, + createContext(sessionId, workspace) + ); + + assert.equal(editResult.ok, false); + assert.equal( + editResult.error, + "old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing." + ); +}); + test("replace_all requires expected_occurrences for broad short-fragment replacements", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "openapi.yaml"); diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 6d0cab3..5aaede8 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -13,6 +13,7 @@ import { createSnippet, getFileState, getSnippet, + hasSnippetOutdatedFileVersion, isAbsoluteFilePath, isFullFileView, normalizeFilePath, @@ -22,7 +23,10 @@ import { const MAX_CANDIDATE_COUNT = 5; const REPLACE_ALL_MATCH_THRESHOLD = 5; const SHORT_REPLACE_ALL_LENGTH = 40; -const MIN_FUZZY_SCORE = 0.45; +const MIN_FUZZY_SCORE = 0.8; +const CLOSEST_MATCH_CONTEXT_LINES = 2; +const OUTDATED_SNIPPET_NOT_FOUND_ERROR = + "old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing."; type LineIndex = { lines: string[]; @@ -242,6 +246,17 @@ export async function handleEditTool( } if (matches.length === 0) { + if (snippet && hasSnippetOutdatedFileVersion(context.sessionId, snippet)) { + return { + ok: false, + name: "edit", + error: OUTDATED_SNIPPET_NOT_FOUND_ERROR, + metadata: { + scope: formatScopeMetadata(scope), + }, + }; + } + const closestMatch = findClosestMatch(raw, oldString, scope, lineIndex); return { ok: false, @@ -295,13 +310,17 @@ export async function handleEditTool( const diffPreview = buildDiffPreview(filePath, raw, updated); writeTextFile(filePath, updated, metadata.encoding, metadata.lineEndings); const freshMetadata = readTextFileWithMetadata(filePath); - recordFileState(context.sessionId, { - filePath, - content: freshMetadata.content, - timestamp: freshMetadata.timestamp, - encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings, - }); + recordFileState( + context.sessionId, + { + filePath, + content: freshMetadata.content, + timestamp: freshMetadata.timestamp, + encoding: freshMetadata.encoding, + lineEndings: freshMetadata.lineEndings, + }, + { incrementVersion: true } + ); const replacedCount = replaceAll ? matches.length : 1; return { ok: true, @@ -603,8 +622,8 @@ function findClosestMatch( } } - if (bestLooseMatch) { - return bestLooseMatch; + if (bestLooseMatch && bestLooseMatch.score >= MIN_FUZZY_SCORE) { + return expandClosestMatch(raw, lineIndex, scope, bestLooseMatch); } } @@ -640,7 +659,23 @@ function findClosestMatch( } } - return bestMatch; + return bestMatch ? expandClosestMatch(raw, lineIndex, scope, bestMatch) : null; +} + +function expandClosestMatch( + raw: string, + lineIndex: LineIndex, + scope: SearchScope, + closestMatch: ClosestMatch +): ClosestMatch { + const startLine = clamp(closestMatch.startLine - CLOSEST_MATCH_CONTEXT_LINES, scope.startLine, scope.endLine); + const endLine = clamp(closestMatch.endLine + CLOSEST_MATCH_CONTEXT_LINES, startLine, scope.endLine); + return { + ...closestMatch, + text: sliceLines(raw, lineIndex, startLine, endLine), + startLine, + endLine, + }; } function buildLooseEscapeRegex(source: string): RegExp | null { diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts index 383eb19..153c1c6 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -100,13 +100,17 @@ export async function handleWriteTool( const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings); const freshMetadata = readTextFileWithMetadata(filePath); - recordFileState(context.sessionId, { - filePath, - content: freshMetadata.content, - timestamp: freshMetadata.timestamp, - encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings, - }); + recordFileState( + context.sessionId, + { + filePath, + content: freshMetadata.content, + timestamp: freshMetadata.timestamp, + encoding: freshMetadata.encoding, + lineEndings: freshMetadata.lineEndings, + }, + { incrementVersion: true } + ); return { ok: true, From a094524e8fd4e939b93e71d56743b0fd1a50806d Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 16:37:15 +0800 Subject: [PATCH 101/217] fix(ui): update string --- src/ui/McpStatusList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index bdb1854..a09039d 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -241,7 +241,7 @@ function ServerListView({ {/* Footer */} - ↑/↓ navigate · Enter view details · Esc cancel + ↑/↓ navigate · Enter view details · Esc close From 9a2188407e71a66a46b1937a6798c094ae2a486f Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 16:42:27 +0800 Subject: [PATCH 102/217] fix(ui): update string --- src/ui/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index eea8091..44d5a70 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -160,7 +160,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const resolved = resolveCurrentSettings(projectRoot); const summary = buildExitSummaryText({ session, messages: allMessages, model: resolved.model }); process.stdout.write("\n"); - process.stdout.write(chalk.rgb(128, 128, 128)("> /exit ")); + process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); process.stdout.write("\n\n"); process.stdout.write(summary); process.stdout.write("\n\n"); From d480486a650ace02305e45bb12bb0355a47e56be Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 17:10:13 +0800 Subject: [PATCH 103/217] =?UTF-8?q?style(DropdownMenu):=20=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E5=86=85=E8=BE=B9=E8=B7=9D=E4=BC=98=E5=8C=96=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E8=8F=9C=E5=8D=95=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为标题和底部帮助文本添加水平内边距 - 为可见条目列表及每个条目增加水平内边距 - 用包含容器包装可见条目,优化布局结构 - 保持原有选中指示和状态指示符渲染逻辑不变 --- src/ui/DropdownMenu.tsx | 48 ++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/ui/DropdownMenu.tsx b/src/ui/DropdownMenu.tsx index 3a963a5..a3083aa 100644 --- a/src/ui/DropdownMenu.tsx +++ b/src/ui/DropdownMenu.tsx @@ -123,6 +123,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ borderRight={false} borderTop={false} borderLeft={false} + paddingX={1} > {title} @@ -138,31 +139,33 @@ const DropdownMenu = React.memo(function DropdownMenu({ ) : null} {/* Visible items */} - {visibleItems.map((item, idx) => { - const actualIndex = visibleStart + idx; - const isActive = actualIndex === activeIndex; + + {visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + const isActive = actualIndex === activeIndex; - // Use custom renderer if provided - if (renderItem) { - return {renderItem(item, isActive)}; - } + // Use custom renderer if provided + if (renderItem) { + return {renderItem(item, isActive)}; + } - // Default rendering with selection indicator and optional features - return ( - - - - {isActive ? "› " : " "} - {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} - {item.statusIndicator ? ( - {item.statusIndicator.symbol} - ) : null} - + // Default rendering with selection indicator and optional features + return ( + + + + {isActive ? "› " : " "} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + {item.statusIndicator ? ( + {item.statusIndicator.symbol} + ) : null} + + + {item.description ? {`${item.description}`} : null} - {item.description ? {`${item.description}`} : null} - - ); - })} + ); + })} + {/* Scroll indicator - bottom */} {visibleStart + visibleItems.length < items.length ? ( @@ -180,6 +183,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ borderRight={false} borderTop={true} borderLeft={false} + paddingX={1} > {helpText} From 309a887c652589c9467316d3412ccff15a4aec3f Mon Sep 17 00:00:00 2001 From: rock-solid-sites Date: Fri, 15 May 2026 14:53:10 +0200 Subject: [PATCH 104/217] docs: restructure translations as _en.md siblings per maintainer request; sync mcp.md with upstream/main --- docs/configuration.md | 201 ++++++++++++++++++++------------------- docs/configuration_en.md | 172 +++++++++++++++++++++++++++++++++ docs/mcp.md | 133 +++++++++++++------------- docs/mcp_en.md | 200 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 537 insertions(+), 169 deletions(-) create mode 100644 docs/configuration_en.md create mode 100644 docs/mcp_en.md diff --git a/docs/configuration.md b/docs/configuration.md index 369f8e4..f8e52c3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,71 +1,71 @@ -# Deep Code Configuration +# Deep Code 配置 -## Configuration Hierarchy +## 配置层级 -Configuration is applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones): +配置按以下优先级顺序应用(数字较小的会被数字较大的覆盖): -| Layer | Configuration Source | Description | -| ----- | -------------------- | ---------------------------------------------- | -| 1 | Defaults | Hardcoded defaults within the application | -| 2 | User settings file | Global settings for the current user | -| 3 | Project settings file| Project-specific settings | -| 4 | Environment variables| System-wide or session-specific variables | +| 层级 | 配置来源 | 说明 | +| ---- | ------------ | ------------------------------------------- | +| 1 | 默认值 | 应用程序内硬编码的默认值 | +| 2 | 用户设置文件 | 当前用户的全局设置 | +| 3 | 项目设置文件 | 项目特定的设置 | +| 4 | 环境变量 | 系统范围或会话特定的变量 | -## Settings File +## 设置文件 -Deep Code uses the `settings.json` file for persistent configuration, supporting two storage locations: +Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两个层级的存放位置: -| File Type | Location | Scope | -| ------------------- | ----------------------------------------- | --------------------------------------------------------------------- | -| User settings file | `~/.deepcode/settings.json` | Applies to all Deep Code sessions for the current user. | -| Project settings file | `/.deepcode/settings.json` | Takes effect only when running Deep Code in that specific project. Project settings override user settings. | +| 文件类型 | 位置 | 作用范围 | +| ------------ | ---------------------------------- | ---------------------------------------------------- | +| 用户设置文件 | `~/.deepcode/settings.json` | 适用于当前用户的所有 Deep Code 会话。 | +| 项目设置文件 | `项目根目录/.deepcode/settings.json` | 仅在该特定项目中运行 Deep Code 时生效。项目设置会覆盖用户设置。 | -### Available Settings in `settings.json` +### `settings.json` 中的可用设置 -The following are all the top-level fields supported in `settings.json`, along with the sub-fields inside `env`: +以下是 `settings.json` 支持的全部顶层字段,以及 `env` 内部支持的子字段: -| Field | Type | Description | -| ------------------ | ------- | --------------------------------------------------------------------------- | -| `env` | object | Group of environment variables (see sub-field table below) | -| `model` | string | Model name. Takes precedence over `env.MODEL` | -| `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)| -| `reasoningEffort` | string | Reasoning intensity, either `"high"` or `"max"` (default `"max"`) | -| `debugLogEnabled` | boolean | Enable debug log output (default `false`) | -| `notify` | string | Full path to a task-completion notification script (e.g., Slack notification script) | -| `webSearchTool` | string | Full path to a custom web search script | -| `mcpServers` | object | MCP server configurations (keys are service names, values are McpServerConfig objects) | +| 字段 | 类型 | 说明 | +| -------------------- | --------- | ------------------------------------------------------------------- | +| `env` | object | 环境变量分组(见下方子字段表) | +| `model` | string | 模型名称。优先级高于 `env.MODEL` | +| `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) | +| `reasoningEffort` | string | 推理强度,可选 `"high"` 或 `"max"`(默认 `"max"`) | +| `debugLogEnabled` | boolean | 是否启用调试日志输出(默认 `false`) | +| `notify` | string | 任务完成通知脚本的完整路径(如 Slack 通知脚本) | +| `webSearchTool` | string | 自定义联网搜索脚本的完整路径 | +| `mcpServers` | object | MCP 服务器配置(键为服务名,值为 McpServerConfig 对象) | -#### `env` Sub-fields +#### `env` 子字段 -| Field | Type | Description | -| ----------------- | ------ | ---------------------------------------------------------------- | -| `MODEL` | string | Model name, e.g. `"deepseek-v4-pro"`, `"deepseek-v4-flash"` | -| `BASE_URL` | string | Base URL for API requests, e.g. `"https://api.deepseek.com"` | -| `API_KEY` | string | API key | -| `THINKING_ENABLED`| string | Enable thinking mode | -| `REASONING_EFFORT`| string | Reasoning intensity | -| `DEBUG_LOG_ENABLED`| string| Enable debug log output | -| `` | string | Custom environment variable | +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------------------------------------------------ | +| `MODEL` | string | 模型名称。例如 `"deepseek-v4-pro"`、`"deepseek-v4-flash"` | +| `BASE_URL` | string | API 请求的基础 URL。例如 `"https://api.deepseek.com"` | +| `API_KEY` | string | API 密钥 | +| `THINKING_ENABLED` | string | 是否启用思考模式 | +| `REASONING_EFFORT` | string | 推理强度 | +| `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | +| `<其他任意KEY>` | string | 自定义环境变量 | -#### `thinkingEnabled` — Thinking Mode +#### `thinkingEnabled` — 思考模式 -Whether to enable DeepSeek thinking mode. Set to `true` to enable, `false` to disable. +是否启用 DeepSeek 思考模式。设置为 `true` 启用、`false` 禁用。 -- For `deepseek-v4-pro` and `deepseek-v4-flash`, thinking mode is **enabled by default**. -- For other models, thinking mode is **disabled by default**. +- 对于 `deepseek-v4-pro` 和 `deepseek-v4-flash`,思考模式**默认启用**。 +- 对于其他模型,思考模式**默认关闭**。 -#### `reasoningEffort` — Reasoning Intensity +#### `reasoningEffort` — 推理强度 -When thinking mode is enabled, controls the depth of the model’s reasoning: +当思考模式启用时,控制模型思考的深度: -| Value | Description | -| ------ | --------------------------------------------------------- | -| `max` | Maximum reasoning depth (default) | -| `high` | Higher reasoning depth with relatively lower token usage | +| 值 | 说明 | +| ------ | --------------------------------- | +| `max` | 最大推理深度(默认值) | +| `high` | 较高推理深度,token消耗相对较小 | -#### `notify` — Task Completion Notification +#### `notify` — 任务完成通知 -Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). +设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 ```json { @@ -73,9 +73,9 @@ Set a full path to a shell script. When the AI assistant finishes a round of tas } ``` -#### `webSearchTool` — Custom Web Search +#### `webSearchTool` — 自定义联网搜索 -Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script: +Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: ```json { @@ -83,16 +83,16 @@ Deep Code has a built-in, free-to-use Web Search tool. If you need custom search } ``` -The script receives a search query as an argument and outputs results in JSON format for the AI. +脚本接收一个搜索查询参数,输出 JSON 格式的结果供 AI 使用。 -#### `mcpServers` — MCP Servers +#### `mcpServers` — MCP 服务器 -Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object. +MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。 ```json { "mcpServers": { - "": { + "<服务名>": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { @@ -103,70 +103,71 @@ Configuration for MCP (Model Context Protocol) servers. The value is a key-value } ``` -| McpServerConfig field | Type | Required | Description | -| --------------------- | -------- | -------- | ------------------------------------------------------------------------ | -| `command` | string | Yes | Executable path or command (e.g. `npx`, `node`, `python`) | -| `args` | string[] | No | List of arguments passed to the command | -| `env` | object | No | Environment variables passed to the MCP server process | +| McpServerConfig 字段 | 类型 | 必填 | 说明 | +| -------------------- | -------- | ---- | -------------------------------------------------------------------- | +| `command` | string | 是 | 可执行文件路径或命令(如 `npx`、`node`、`python`) | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量 | -> When `command` is `npx`, Deep Code automatically prepends `-y` to the arguments. +> 当 `command` 为 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 -For detailed MCP usage instructions, refer to [mcp.md](mcp.md). +详细 MCP 使用说明请参考 [mcp.md](mcp.md)。 -#### `debugLogEnabled` — Debug Log -Set to `true` to enable detailed debug logging (default `false`), useful for troubleshooting API calls and tool execution. +#### `debugLogEnabled` — 调试日志 -## Environment Variable Priority +设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 -Environment variables are a common way to configure applications, especially for sensitive information (such as api-key) or settings that may change between environments. +## 环境变量优先级 -### Priority Principle +环境变量是配置应用程序的常用方式,尤其适用于敏感信息(如 api-key)或可能在不同环境之间更改的设置。 -Environment variable priority follows the logic of “the more specific and localized the configuration, the higher the priority”, and the override rule of “env files protect existing environment by default, system variables override env files”. (The `env` object in settings.json can be thought of as a type of env file.) +### 优先级原则 -Priority levels (from lowest to highest): -1. `env` defined at the top level of `settings.json` – this is a general configuration for the entire tool and all its subprocesses (global variables). Can be overridden by outer environment variables, but the environment variable KEY has the `DEEPCODE_` prefix removed. -2. `env` defined inside `mcpServers` in `settings.json` – this is the most specific configuration for a particular MCP service (local variables). Can be overridden by outer environment variables, but the KEY has the `MCP_` prefix removed. -3. Shell/system environment variables – operating system level. +环境变量优先级遵循“越具体、越局部的配置,优先级越高”和“env文件默认保护现有环境,系统变量高于env文件”的覆盖逻辑。(settings.json的env对象可以认为是一种env文件) -### Scenarios +优先级层级 (由低到高) +1. settings.json 外层的 env:这是针对整个工具及其所有子进程的通用配置(全局变量)。可被外层环境变量覆盖,但环境变量KEY会移除`DEEPCODE_`前缀。 +2. settings.json mcpServers 内定义的 env:这是针对特定 MCP 服务的最具体配置(局部变量)。可被外层环境变量覆盖,但环境变量KEY会移除`MCP_`前缀。 +3. Shell 环境系统变量:操作系统层面的环境变量。 -#### 1. Setting the model’s api_key and base_url +### 场景 -Applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones) – using api_key as an example: +#### 一、设置模型的api_key, base_url -1. Hardcoded default: `""` -2. User-level settings.json: `{"env": {"API_KEY": "abc123"}}` -3. Project-level settings.json: `{"env": {"API_KEY": "abc123"}}` -4. System environment variable: `DEEPCODE_API_KEY=abc123 deepcode` +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以api_key为例): -#### 2. Setting model, thinkingEnabled, and reasoningEffort +1. 硬编码默认值: `""` +2. 用户级settings.json: `{"env": {"API_KEY": "abc123"}}` +3. 项目级settings.json: `{"env": {"API_KEY": "abc123"}}` +4. 系统环境变量: `DEEPCODE_API_KEY=abc123 deepcode` -Applied in the following priority order (lower-numbered overridden by higher-numbered) – using thinkingEnabled as an example: +#### 二、设置模型的model, thinkingEnabled, reasoningEffort -1. Hardcoded default: `true` -2. User-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` -3. User-level settings.json: `{"thinkingEnabled": true}` -4. Project-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` -5. Project-level settings.json: `{"thinkingEnabled": true}` -6. System environment variable: `DEEPCODE_THINKING_ENABLED=true deepcode` +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以thinkingEnabled为例): -#### 3. Setting environment variables for external scripts like notify and webSearchTool +1. 硬编码默认值: `true` +2. 用户级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +3. 用户级settings.json: `{"thinkingEnabled": true}` +4. 项目级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +5. 项目级settings.json: `{"thinkingEnabled": true}` +6. 系统环境变量: `DEEPCODE_THINKING_ENABLED=true deepcode` -Applied in the following priority order (lower-numbered overridden by higher-numbered) – using notify as an example: +#### 三、设置启动notify, webSearchTool等外挂脚本的环境变量 -1. Hardcoded default: `os.environ.get('WEBHOOK', '...') # notify script code` -2. User-level settings.json: `{"env": {"WEBHOOK": "..."}}` -3. Project-level settings.json: `{"env": {"WEBHOOK": "true"}}` -4. System environment variable: `DEEPCODE_WEBHOOK=... deepcode` +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以notify为例): -#### 4. Setting environment variables for an MCP Service +1. 硬编码默认值:`os.environ.get('WEBHOOK', '...') # notify脚本代码` +2. 用户级settings.json: `{"env": {"WEBHOOK": "..."}}` +3. 项目级settings.json: `{"env": {"WEBHOOK": "true"}}` +4. 系统环境变量: `DEEPCODE_WEBHOOK=... deepcode` -Applied in the following priority order (lower-numbered overridden by higher-numbered) – using a GitHub MCP server as an example: +#### 四、设置MCP Service的环境变量 -1. User-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` -2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` -3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` -4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` -5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` \ No newline at end of file +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以github MCP server为例): + +1. 用户级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +2. 用户级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +3. 项目级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +4. 项目级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +5. 系统环境变量: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` diff --git a/docs/configuration_en.md b/docs/configuration_en.md new file mode 100644 index 0000000..369f8e4 --- /dev/null +++ b/docs/configuration_en.md @@ -0,0 +1,172 @@ +# Deep Code Configuration + +## Configuration Hierarchy + +Configuration is applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones): + +| Layer | Configuration Source | Description | +| ----- | -------------------- | ---------------------------------------------- | +| 1 | Defaults | Hardcoded defaults within the application | +| 2 | User settings file | Global settings for the current user | +| 3 | Project settings file| Project-specific settings | +| 4 | Environment variables| System-wide or session-specific variables | + +## Settings File + +Deep Code uses the `settings.json` file for persistent configuration, supporting two storage locations: + +| File Type | Location | Scope | +| ------------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| User settings file | `~/.deepcode/settings.json` | Applies to all Deep Code sessions for the current user. | +| Project settings file | `/.deepcode/settings.json` | Takes effect only when running Deep Code in that specific project. Project settings override user settings. | + +### Available Settings in `settings.json` + +The following are all the top-level fields supported in `settings.json`, along with the sub-fields inside `env`: + +| Field | Type | Description | +| ------------------ | ------- | --------------------------------------------------------------------------- | +| `env` | object | Group of environment variables (see sub-field table below) | +| `model` | string | Model name. Takes precedence over `env.MODEL` | +| `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)| +| `reasoningEffort` | string | Reasoning intensity, either `"high"` or `"max"` (default `"max"`) | +| `debugLogEnabled` | boolean | Enable debug log output (default `false`) | +| `notify` | string | Full path to a task-completion notification script (e.g., Slack notification script) | +| `webSearchTool` | string | Full path to a custom web search script | +| `mcpServers` | object | MCP server configurations (keys are service names, values are McpServerConfig objects) | + +#### `env` Sub-fields + +| Field | Type | Description | +| ----------------- | ------ | ---------------------------------------------------------------- | +| `MODEL` | string | Model name, e.g. `"deepseek-v4-pro"`, `"deepseek-v4-flash"` | +| `BASE_URL` | string | Base URL for API requests, e.g. `"https://api.deepseek.com"` | +| `API_KEY` | string | API key | +| `THINKING_ENABLED`| string | Enable thinking mode | +| `REASONING_EFFORT`| string | Reasoning intensity | +| `DEBUG_LOG_ENABLED`| string| Enable debug log output | +| `` | string | Custom environment variable | + +#### `thinkingEnabled` — Thinking Mode + +Whether to enable DeepSeek thinking mode. Set to `true` to enable, `false` to disable. + +- For `deepseek-v4-pro` and `deepseek-v4-flash`, thinking mode is **enabled by default**. +- For other models, thinking mode is **disabled by default**. + +#### `reasoningEffort` — Reasoning Intensity + +When thinking mode is enabled, controls the depth of the model’s reasoning: + +| Value | Description | +| ------ | --------------------------------------------------------- | +| `max` | Maximum reasoning depth (default) | +| `high` | Higher reasoning depth with relatively lower token usage | + +#### `notify` — Task Completion Notification + +Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). + +```json +{ + "notify": "/path/to/slack-notify.sh" +} +``` + +#### `webSearchTool` — Custom Web Search + +Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script: + +```json +{ + "webSearchTool": "/path/to/my-search-script.sh" +} +``` + +The script receives a search query as an argument and outputs results in JSON format for the AI. + +#### `mcpServers` — MCP Servers + +Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object. + +```json +{ + "mcpServers": { + "": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +| McpServerConfig field | Type | Required | Description | +| --------------------- | -------- | -------- | ------------------------------------------------------------------------ | +| `command` | string | Yes | Executable path or command (e.g. `npx`, `node`, `python`) | +| `args` | string[] | No | List of arguments passed to the command | +| `env` | object | No | Environment variables passed to the MCP server process | + +> When `command` is `npx`, Deep Code automatically prepends `-y` to the arguments. + +For detailed MCP usage instructions, refer to [mcp.md](mcp.md). + +#### `debugLogEnabled` — Debug Log + +Set to `true` to enable detailed debug logging (default `false`), useful for troubleshooting API calls and tool execution. + +## Environment Variable Priority + +Environment variables are a common way to configure applications, especially for sensitive information (such as api-key) or settings that may change between environments. + +### Priority Principle + +Environment variable priority follows the logic of “the more specific and localized the configuration, the higher the priority”, and the override rule of “env files protect existing environment by default, system variables override env files”. (The `env` object in settings.json can be thought of as a type of env file.) + +Priority levels (from lowest to highest): +1. `env` defined at the top level of `settings.json` – this is a general configuration for the entire tool and all its subprocesses (global variables). Can be overridden by outer environment variables, but the environment variable KEY has the `DEEPCODE_` prefix removed. +2. `env` defined inside `mcpServers` in `settings.json` – this is the most specific configuration for a particular MCP service (local variables). Can be overridden by outer environment variables, but the KEY has the `MCP_` prefix removed. +3. Shell/system environment variables – operating system level. + +### Scenarios + +#### 1. Setting the model’s api_key and base_url + +Applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones) – using api_key as an example: + +1. Hardcoded default: `""` +2. User-level settings.json: `{"env": {"API_KEY": "abc123"}}` +3. Project-level settings.json: `{"env": {"API_KEY": "abc123"}}` +4. System environment variable: `DEEPCODE_API_KEY=abc123 deepcode` + +#### 2. Setting model, thinkingEnabled, and reasoningEffort + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using thinkingEnabled as an example: + +1. Hardcoded default: `true` +2. User-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +3. User-level settings.json: `{"thinkingEnabled": true}` +4. Project-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +5. Project-level settings.json: `{"thinkingEnabled": true}` +6. System environment variable: `DEEPCODE_THINKING_ENABLED=true deepcode` + +#### 3. Setting environment variables for external scripts like notify and webSearchTool + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using notify as an example: + +1. Hardcoded default: `os.environ.get('WEBHOOK', '...') # notify script code` +2. User-level settings.json: `{"env": {"WEBHOOK": "..."}}` +3. Project-level settings.json: `{"env": {"WEBHOOK": "true"}}` +4. System environment variable: `DEEPCODE_WEBHOOK=... deepcode` + +#### 4. Setting environment variables for an MCP Service + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using a GitHub MCP server as an example: + +1. User-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` \ No newline at end of file diff --git a/docs/mcp.md b/docs/mcp.md index 11adda5..73034a3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,22 +1,22 @@ -# Deep Code CLI MCP Configuration Guide +# Deep Code CLI MCP 配置指南 -Deep Code CLI supports MCP (Model Context Protocol), enabling AI assistants to connect with external tools and services such as GitHub, browsers, databases, and more. +Deep Code CLI 支持 MCP(Model Context Protocol),让 AI 助手能够连接外部工具和服务,如 GitHub、浏览器、数据库等。 -## Overview +## 概述 -Once MCP is configured, Deep Code can: +配置 MCP 后,Deep Code 可以: -- Operate on GitHub repositories (view issues, create PRs, search code, etc.) -- Control browsers (screenshots, clicks, form filling, etc.) -- Access the file system -- Connect to databases and APIs -- ...and any external service compatible with the MCP protocol +- 操作 GitHub 仓库(查看 Issues、创建 PR、搜索代码等) +- 操控浏览器(截图、点击、填表单等) +- 访问文件系统 +- 连接数据库和 API +- ...以及任何兼容 MCP 协议的外部服务 -MCP tools are named in Deep Code using the format `mcp____`, for example `mcp__github__search_code`. +MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`,例如 `mcp__github__search_code`。 -## Configuring MCP Servers +## 配置 MCP 服务器 -Edit `~/.deepcode/settings.json` and add the `mcpServers` field: +编辑 `~/.deepcode/settings.json`,添加 `mcpServers` 字段: ```json { @@ -28,30 +28,30 @@ Edit `~/.deepcode/settings.json` and add the `mcpServers` field: "thinkingEnabled": true, "reasoningEffort": "max", "mcpServers": { - "": { - "command": "", - "args": ["", ""], + "<服务名称>": { + "command": "<可执行文件>", + "args": ["<参数1>", "<参数2>"], "env": { - "": "" + "<环境变量>": "<值>" } } } } ``` -### Configuration Fields +### 配置项说明 -| Field | Type | Required | Description | -| --------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `command` | string | Yes | Path or command of the MCP server executable (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | -| `args` | string[] | No | List of arguments to pass to the command | -| `env` | object | No | Environment variables (e.g., API keys) to pass to the MCP server process | +| 字段 | 类型 | 必填 | 说明 | +| --------- | -------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | -## Common MCP Examples +## 常用 MCP 示例 ### GitHub MCP -Allows Deep Code to directly operate on GitHub repositories (search code, manage issues/PRs, read/write files, etc.): +让 Deep Code 直接操作 GitHub 仓库(搜索代码、管理 Issue/PR、读写文件等): ```json { @@ -67,11 +67,11 @@ Allows Deep Code to directly operate on GitHub repositories (search code, manage } ``` -> Generate a GitHub Personal Access Token at [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens). +> GitHub Personal Access Token 可在 [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens) 生成。 -### Browser Control (Playwright) +### 浏览器控制(Playwright) -Lets Deep Code control a browser for screenshots, page interactions, etc.: +让 Deep Code 操控浏览器进行截图、页面操作等: ```json { @@ -84,9 +84,9 @@ Lets Deep Code control a browser for screenshots, page interactions, etc.: } ``` -### File System +### 文件系统 -Enables Deep Code to read and write files within a specified directory: +让 Deep Code 在指定目录中读写文件: ```json { @@ -99,7 +99,7 @@ Enables Deep Code to read and write files within a specified directory: } ``` -### Custom Python MCP +### 自定义 Python MCP ```json { @@ -115,9 +115,9 @@ Enables Deep Code to read and write files within a specified directory: } ``` -## Full Configuration Example +## 完整配置示例 -Below is a complete `~/.deepcode/settings.json` with both GitHub and Playwright MCP servers configured: +以下是一个配置了 GitHub 和 Playwright 两个 MCP 服务器的完整 `~/.deepcode/settings.json`: ```json { @@ -144,62 +144,57 @@ Below is a complete `~/.deepcode/settings.json` with both GitHub and Playwright } ``` -## Using MCP +## 使用 MCP -After configuration, start `deepcode` and use the `/mcp` command to manage MCP connections: +配置完成后,启动 `deepcode`,在聊天中输入 `/mcp` 即可查看所有已配置的 MCP 服务器状态以及每个服务器提供的工具列表。 -- `/mcp` — View the status of configured MCP servers -- `/mcp add` — Add a new MCP server -- `/mcp remove` — Remove an MCP server -- `/mcp list` — List all connected MCP servers and their tools - -Simply use the MCP tool name in your conversation to invoke it, for example: +在对话中直接使用 MCP 工具名称即可调用,例如: ``` -Help me search for issues in the deepcode-cli repository on GitHub +帮我搜索 GitHub 上 deepcode-cli 仓库的 issues ``` -The AI will automatically invoke the `mcp__github__search_issues` tool to complete the action. +AI 会自动调用 `mcp__github__search_issues` 工具完成操作。 -## Tool Naming Convention +## 工具命名规则 -An MCP tool name consists of three parts: `mcp____` +MCP 工具名称由三部分组成:`mcp__<服务名>__<工具名>` -| Service | Tool Name | Full Invocation Name | -| ---------- | ----------------------- | ------------------------------------------- | -| github | search_code | `mcp__github__search_code` | -| github | create_pull_request | `mcp__github__create_pull_request` | -| playwright | browser_navigate | `mcp__playwright__browser_navigate` | -| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | +| 服务名 | 工具名 | 完整调用名 | +| ---------- | ----------------------- | ------------------------------------------ | +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | -You can view the list of tools provided by each server using `/mcp list`. +你可以通过 `/mcp` 查看每个服务器提供的具体工具列表。 -## Troubleshooting +## 故障排查 -### Startup Failure +### 启动失败 -If an MCP server fails to start, check: +如果 MCP 服务器无法启动,检查: -1. Whether `command` is installed (e.g., `npx` requires Node.js) -2. Whether environment variables in `env` are correct (e.g., `GITHUB_PERSONAL_ACCESS_TOKEN`) -3. Whether the terminal running `deepcode` has network access +1. `command` 是否已安装(如 `npx` 需要 Node.js) +2. `env` 中的环境变量是否正确(如 `GITHUB_PERSONAL_ACCESS_TOKEN`) +3. 运行 `deepcode` 的终端是否有网络访问权限 -### Tools Not Showing Up +### 工具不显示 -1. Verify that the `mcpServers` field in `settings.json` is correctly formatted -2. After starting deepcode, use `/mcp` to check server status -3. If the server status shows an error, debug based on the error message +1. 确认 `settings.json` 中的 `mcpServers` 字段格式正确 +2. 启动 deepcode 后使用 `/mcp` 查看服务器状态 +3. 如果服务器状态显示错误,根据错误信息排查 -### Windows Users +### Windows 用户 -On Windows, Deep Code CLI automatically adds shell support for `.cmd` commands. If your MCP command is a batch script, ensure the filename ends with `.cmd`. +在 Windows 上,Deep Code CLI 会自动为 `.cmd` 命令添加 shell 支持。如果你的 MCP 命令是批处理脚本,确保文件名以 `.cmd` 结尾。 -## Writing Your Own MCP Server +## 编写你自己的 MCP 服务器 -MCP servers follow the [Model Context Protocol](https://modelcontextprotocol.io/) specification and communicate using JSON‑RPC 2.0. You can write an MCP server in any language as long as it implements the following methods: +MCP 服务器遵循 [Model Context Protocol](https://modelcontextprotocol.io/) 规范,使用 JSON-RPC 2.0 通信。你可以用任何语言编写 MCP 服务器,只要实现以下协议即可: -1. `initialize` — Handshake and protocol negotiation -2. `tools/list` — Return the list of available tools -3. `tools/call` — Execute a tool call +1. `initialize` — 握手和协议协商 +2. `tools/list` — 返回可用工具列表 +3. `tools/call` — 执行工具调用 -For more information, see the [official MCP documentation](https://modelcontextprotocol.io/). \ No newline at end of file +更多参考:[MCP 官方文档](https://modelcontextprotocol.io/) diff --git a/docs/mcp_en.md b/docs/mcp_en.md new file mode 100644 index 0000000..03c4b30 --- /dev/null +++ b/docs/mcp_en.md @@ -0,0 +1,200 @@ +# Deep Code CLI MCP Configuration Guide + +Deep Code CLI supports MCP (Model Context Protocol), enabling AI assistants to connect with external tools and services such as GitHub, browsers, databases, and more. + +## Overview + +Once MCP is configured, Deep Code can: + +- Operate on GitHub repositories (view issues, create PRs, search code, etc.) +- Control browsers (screenshots, clicks, form filling, etc.) +- Access the file system +- Connect to databases and APIs +- ...and any external service compatible with the MCP protocol + +MCP tools are named in Deep Code using the format `mcp____`, for example `mcp__github__search_code`. + +## Configuring MCP Servers + +Edit `~/.deepcode/settings.json` and add the `mcpServers` field: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "": { + "command": "", + "args": ["", ""], + "env": { + "": "" + } + } + } +} +``` + +### Configuration Fields + +| Field | Type | Required | Description | +| --------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `command` | string | Yes | Path or command of the MCP server executable (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | +| `args` | string[] | No | List of arguments to pass to the command | +| `env` | object | No | Environment variables (e.g., API keys) to pass to the MCP server process | + +## Common MCP Examples + +### GitHub MCP + +Allows Deep Code to directly operate on GitHub repositories (search code, manage issues/PRs, read/write files, etc.): + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +> Generate a GitHub Personal Access Token at [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens). + +### Browser Control (Playwright) + +Lets Deep Code control a browser for screenshots, page interactions, etc.: + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +### File System + +Enables Deep Code to read and write files within a specified directory: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"] + } + } +} +``` + +### Custom Python MCP + +```json +{ + "mcpServers": { + "my-tool": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "env": { + "API_KEY": "xxx" + } + } + } +} +``` + +## Full Configuration Example + +Below is a complete `~/.deepcode/settings.json` with both GitHub and Playwright MCP servers configured: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-xxxxxxxxxxxx" + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + }, + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +## Using MCP + +After configuration, start `deepcode` and type `/mcp` in the chat to view the status of all configured MCP servers and the list of tools each server provides. + +Simply use the MCP tool name in your conversation to invoke it, for example: + +``` +Help me search for issues in the deepcode-cli repository on GitHub +``` + +The AI will automatically invoke the `mcp__github__search_issues` tool to complete the action. + +## Tool Naming Convention + +An MCP tool name consists of three parts: `mcp____` + +| Service | Tool Name | Full Invocation Name | +| ---------- | ----------------------- | ------------------------------------------- | +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | + +You can view the list of tools provided by each server using `/mcp`. + +## Troubleshooting + +### Startup Failure + +If an MCP server fails to start, check: + +1. Whether `command` is installed (e.g., `npx` requires Node.js) +2. Whether environment variables in `env` are correct (e.g., `GITHUB_PERSONAL_ACCESS_TOKEN`) +3. Whether the terminal running `deepcode` has network access + +### Tools Not Showing Up + +1. Verify that the `mcpServers` field in `settings.json` is correctly formatted +2. After starting deepcode, use `/mcp` to check server status +3. If the server status shows an error, debug based on the error message + +### Windows Users + +On Windows, Deep Code CLI automatically adds shell support for `.cmd` commands. If your MCP command is a batch script, ensure the filename ends with `.cmd`. + +## Writing Your Own MCP Server + +MCP servers follow the [Model Context Protocol](https://modelcontextprotocol.io/) specification and communicate using JSON‑RPC 2.0. You can write an MCP server in any language as long as it implements the following methods: + +1. `initialize` — Handshake and protocol negotiation +2. `tools/list` — Return the list of available tools +3. `tools/call` — Execute a tool call + +For more information, see the [official MCP documentation](https://modelcontextprotocol.io/). \ No newline at end of file From dbe859e588300c7b4309d78cc60508f0abc5e18c Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 15 May 2026 22:50:00 +0800 Subject: [PATCH 105/217] refactor: update usage types to ModelUsage in session --- src/session.ts | 29 ++++++++++++++++++++--------- src/tests/exitSummary.test.ts | 4 ++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/session.ts b/src/session.ts index 894ff80..9295ff2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -65,11 +65,11 @@ function addUsageValue(current: unknown, next: unknown): unknown { return next; } -function accumulateUsage(current: unknown | null, next: unknown | null | undefined): unknown | null { +function accumulateUsage(current: ModelUsage | null, next: unknown | null | undefined): ModelUsage | null { if (next == null) { return current ?? null; } - return addUsageValue(current, next); + return addUsageValue(current, next) as ModelUsage; } function getExtensionRoot(): string { @@ -81,7 +81,7 @@ function getExtensionRoot(): string { return path.resolve(path.dirname(currentFilePath), ".."); } -function getTotalTokens(usage: unknown | null | undefined): number { +function getTotalTokens(usage: ModelUsage | null | undefined): number { if (!isUsageRecord(usage)) { return 0; } @@ -91,6 +91,17 @@ function getTotalTokens(usage: unknown | null | undefined): number { export type SessionStatus = "failed" | "pending" | "processing" | "waiting_for_user" | "completed" | "interrupted"; +export type ModelUsage = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + completion_tokens_details?: Record; + prompt_tokens_details?: Record; + prompt_cache_hit_tokens?: number; + prompt_cache_miss_tokens?: number; + total_reqs?: number; +}; + export type SessionEntry = { id: string; summary: string | null; @@ -100,7 +111,7 @@ export type SessionEntry = { toolCalls: unknown[] | null; status: SessionStatus; failReason: string | null; - usage: unknown | null; + usage: ModelUsage | null; activeTokens: number; createTime: string; updateTime: string; @@ -286,7 +297,7 @@ export class SessionManager { debug?: ChatCompletionDebugOptions ): Promise<{ choices?: Array<{ message?: Record }>; - usage?: unknown; + usage?: ModelUsage | null; }> { const requestId = crypto.randomUUID(); const startedAt = new Date().toISOString(); @@ -355,13 +366,13 @@ export class SessionManager { request: streamRequest, response, }); - return response as { choices?: Array<{ message?: Record }>; usage?: unknown }; + return response as { choices?: Array<{ message?: Record }>; usage?: ModelUsage | null }; } let content = ""; let reasoningContent = ""; let refusal: string | null = null; - let usage: unknown = null; + let usage: ModelUsage | null = null; const responseChunks: unknown[] = []; const toolCallsByIndex = new Map< number, @@ -386,7 +397,7 @@ export class SessionManager { responseChunks.push(chunk); } if ("usage" in chunk && chunk.usage != null) { - usage = chunk.usage; + usage = chunk.usage as ModelUsage; } const choices = Array.isArray(chunk.choices) ? chunk.choices : []; @@ -2096,7 +2107,7 @@ ${skillMd} toolCalls: Array.isArray(value.toolCalls) ? value.toolCalls : null, status: this.normalizeSessionStatus(value.status), failReason: typeof value.failReason === "string" ? value.failReason : null, - usage: value.usage ?? null, + usage: (value.usage as ModelUsage) ?? null, activeTokens: typeof value.activeTokens === "number" ? value.activeTokens : 0, createTime: typeof value.createTime === "string" ? value.createTime : new Date().toISOString(), updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(), diff --git a/src/tests/exitSummary.test.ts b/src/tests/exitSummary.test.ts index 9cff34b..0257bf2 100644 --- a/src/tests/exitSummary.test.ts +++ b/src/tests/exitSummary.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildExitSummaryText } from "../ui"; -import type { SessionEntry, SessionMessage } from "../session"; +import type { SessionEntry, SessionMessage, ModelUsage } from "../session"; const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); @@ -33,7 +33,7 @@ test("buildExitSummaryText only shows Goodbye and model usage with cached tokens assert.doesNotMatch(summary, /Reasoning Tokens/); }); -function buildSession(usage: unknown): SessionEntry { +function buildSession(usage: ModelUsage | null): SessionEntry { return { id: "session-1", summary: null, From 3ef5b8e7e293d80095edcd1e8f91d5b2b1b7ad4f Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 15 May 2026 23:18:27 +0800 Subject: [PATCH 106/217] feat: add usage tracking per model and update exit summary display --- src/session.ts | 45 +++++++++++++++++ src/tests/exitSummary.test.ts | 93 +++++++++++++++++++++++++---------- src/tests/session.test.ts | 89 +++++++++++++++++++++++++++++++++ src/ui/App.tsx | 8 +-- src/ui/exitSummary.ts | 60 +++++++++++++--------- 5 files changed, 241 insertions(+), 54 deletions(-) diff --git a/src/session.ts b/src/session.ts index 9295ff2..97bc3ab 100644 --- a/src/session.ts +++ b/src/session.ts @@ -72,6 +72,29 @@ function accumulateUsage(current: ModelUsage | null, next: unknown | null | unde return addUsageValue(current, next) as ModelUsage; } +function usageWithRequestCount(usage: ModelUsage): ModelUsage { + const totalReqs = typeof usage.total_reqs === "number" ? usage.total_reqs + 1 : 1; + return { + ...usage, + total_reqs: totalReqs, + }; +} + +function accumulateUsagePerModel( + current: Record | null | undefined, + model: string, + next: ModelUsage | null | undefined +): Record | null { + if (next == null) { + return current ?? null; + } + + const usagePerModel = { ...(current ?? {}) }; + const modelName = model.trim() || "unknown"; + usagePerModel[modelName] = accumulateUsage(usagePerModel[modelName] ?? null, usageWithRequestCount(next))!; + return usagePerModel; +} + function getExtensionRoot(): string { if (typeof __dirname !== "undefined") { return path.resolve(__dirname, ".."); @@ -112,6 +135,7 @@ export type SessionEntry = { status: SessionStatus; failReason: string | null; usage: ModelUsage | null; + usagePerModel: Record | null; activeTokens: number; createTime: string; updateTime: string; @@ -847,6 +871,7 @@ The candidate skills are as follows:\n\n`; status: "pending", failReason: null, usage: null, + usagePerModel: null, activeTokens: 0, createTime: now, updateTime: now, @@ -1087,6 +1112,7 @@ ${skillMd} assistantRefusal: refusal, toolCalls, usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), activeTokens: getTotalTokens(responseUsage), status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed", failReason: refusal ? refusal : entry.failReason, @@ -1196,6 +1222,7 @@ ${skillMd} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), activeTokens: getTotalTokens(responseUsage), updateTime: now, })); @@ -2108,6 +2135,7 @@ ${skillMd} status: this.normalizeSessionStatus(value.status), failReason: typeof value.failReason === "string" ? value.failReason : null, usage: (value.usage as ModelUsage) ?? null, + usagePerModel: this.normalizeUsagePerModel(value), activeTokens: typeof value.activeTokens === "number" ? value.activeTokens : 0, createTime: typeof value.createTime === "string" ? value.createTime : new Date().toISOString(), updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(), @@ -2129,6 +2157,23 @@ ${skillMd} return "pending"; } + private normalizeUsagePerModel(entry: Record): Record | null { + if (!Object.prototype.hasOwnProperty.call(entry, "usagePerModel")) { + return null; + } + if (!isUsageRecord(entry.usagePerModel)) { + return null; + } + const usagePerModel: Record = {}; + for (const [model, usage] of Object.entries(entry.usagePerModel)) { + if (!model || !isUsageRecord(usage)) { + continue; + } + usagePerModel[model] = usage as ModelUsage; + } + return usagePerModel; + } + private deserializeProcesses(value: unknown): Map | null { if (!value || typeof value !== "object") { return null; diff --git a/src/tests/exitSummary.test.ts b/src/tests/exitSummary.test.ts index 0257bf2..5ea4b57 100644 --- a/src/tests/exitSummary.test.ts +++ b/src/tests/exitSummary.test.ts @@ -1,22 +1,23 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildExitSummaryText } from "../ui"; -import type { SessionEntry, SessionMessage, ModelUsage } from "../session"; +import type { ModelUsage, SessionEntry } from "../session"; const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); test("buildExitSummaryText only shows Goodbye and model usage with cached tokens", () => { const summary = stripAnsi( buildExitSummaryText({ - session: buildSession({ - prompt_tokens: 11_966, - completion_tokens: 236, - total_tokens: 12_202, - prompt_tokens_details: { cached_tokens: 11_776 }, - completion_tokens_details: { reasoning_tokens: 144 }, + session: buildSession(null, { + "mimo-v2.5-pro": { + prompt_tokens: 11_966, + completion_tokens: 236, + total_tokens: 12_202, + prompt_tokens_details: { cached_tokens: 11_776 }, + completion_tokens_details: { reasoning_tokens: 144 }, + total_reqs: 2, + }, }), - messages: [buildAssistantMessage("assistant-1"), buildAssistantMessage("assistant-2")], - model: "mimo-v2.5-pro", }) ); @@ -33,7 +34,63 @@ test("buildExitSummaryText only shows Goodbye and model usage with cached tokens assert.doesNotMatch(summary, /Reasoning Tokens/); }); -function buildSession(usage: ModelUsage | null): SessionEntry { +test("buildExitSummaryText shows all usagePerModel rows sorted by request count", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession( + { + prompt_tokens: 999, + completion_tokens: 999, + total_tokens: 1_998, + }, + { + "deepseek-v4-pro": { + prompt_tokens: 100, + completion_tokens: 10, + total_tokens: 110, + total_reqs: 1, + }, + "deepseek-v4-flash": { + prompt_tokens: 300, + completion_tokens: 30, + total_tokens: 330, + prompt_cache_hit_tokens: 111, + total_reqs: 3, + }, + } + ), + }) + ); + + const flashIndex = summary.indexOf("deepseek-v4-flash"); + const proIndex = summary.indexOf("deepseek-v4-pro"); + + assert.notEqual(flashIndex, -1); + assert.notEqual(proIndex, -1); + assert.ok(flashIndex < proIndex); + assert.match(summary, /deepseek-v4-flash\s+3\s+300\s+30\s+111/); + assert.match(summary, /deepseek-v4-pro\s+1\s+100\s+10\s+0/); + assert.doesNotMatch(summary, /999/); +}); + +test("buildExitSummaryText does not derive usage rows from legacy aggregate usage", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession({ + prompt_tokens: 11_966, + completion_tokens: 236, + total_tokens: 12_202, + total_reqs: 2, + }), + }) + ); + + assert.match(summary, /Goodbye!/); + assert.doesNotMatch(summary, /Model Usage/); + assert.doesNotMatch(summary, /11,966/); +}); + +function buildSession(usage: ModelUsage | null, usagePerModel: Record | null = null): SessionEntry { return { id: "session-1", summary: null, @@ -44,24 +101,10 @@ function buildSession(usage: ModelUsage | null): SessionEntry { status: "completed", failReason: null, usage, + usagePerModel, activeTokens: 0, createTime: "2026-01-01T00:00:00.000Z", updateTime: "2026-01-01T00:00:01.000Z", processes: null, }; } - -function buildAssistantMessage(id: string): SessionMessage { - return { - id, - 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", - }; -} diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 2ac6c29..1e5e5ce 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -275,6 +275,20 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", ( const manager = createSessionManager(workspace, "machine-id-legacy"); assert.equal(manager.getSession("legacy-session")?.activeTokens, 0); + assert.equal(manager.getSession("legacy-session")?.usagePerModel, null); +}); + +test("SessionManager keeps usagePerModel null until response usage is available", async () => { + const workspace = createTempDir("deepcode-null-usage-per-model-workspace-"); + const home = createTempDir("deepcode-null-usage-per-model-home-"); + process.env.HOME = home; + + const manager = createMockedClientSessionManager(workspace, [{ choices: [{ message: { content: "no usage" } }] }]); + + const sessionId = await manager.createSession({ text: "" }); + + assert.equal(manager.getSession(sessionId)?.usage, null); + assert.equal(manager.getSession(sessionId)?.usagePerModel, null); }); test("SessionManager marks skills loaded from existing session messages", async () => { @@ -1122,6 +1136,7 @@ test("SessionManager accumulates response usage while active tokens track the la const session = manager.getSession(sessionId); const usage = session?.usage as Record; + const usagePerModel = session?.usagePerModel?.["test-model"] as Record; assert.equal(session?.activeTokens, 27); assert.equal(usage.prompt_tokens, 30); assert.equal(usage.completion_tokens, 12); @@ -1130,6 +1145,75 @@ test("SessionManager accumulates response usage while active tokens track the la assert.equal(usage.completion_tokens_details.reasoning_tokens, 7); assert.equal(usage.prompt_cache_hit_tokens, 18); assert.equal(usage.prompt_cache_miss_tokens, 12); + assert.equal(usagePerModel.prompt_tokens, 30); + assert.equal(usagePerModel.completion_tokens, 12); + assert.equal(usagePerModel.total_tokens, 42); + assert.equal(usagePerModel.prompt_tokens_details.cached_tokens, 18); + assert.equal(usagePerModel.completion_tokens_details.reasoning_tokens, 7); + assert.equal(usagePerModel.prompt_cache_hit_tokens, 18); + assert.equal(usagePerModel.prompt_cache_miss_tokens, 12); + assert.equal(usagePerModel.total_reqs, 2); +}); + +test("SessionManager stores usage per model across model changes", async () => { + const workspace = createTempDir("deepcode-usage-per-model-workspace-"); + const home = createTempDir("deepcode-usage-per-model-home-"); + process.env.HOME = home; + + let currentModel = "deepseek-v4-pro"; + const responses = [ + createChatResponse("pro response", { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }), + createChatResponse("flash response", { + prompt_tokens: 20, + completion_tokens: 7, + total_tokens: 27, + prompt_cache_hit_tokens: 6, + }), + ]; + const client = { + chat: { + completions: { + create: async () => { + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + return response; + }, + }, + }, + }; + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: client as any, + model: currentModel, + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: currentModel }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const sessionId = await manager.createSession({ text: "" }); + currentModel = "deepseek-v4-flash"; + await manager.replySession(sessionId, { text: "" }); + + const session = manager.getSession(sessionId); + assert.deepEqual(Object.keys(session?.usagePerModel ?? {}).sort(), ["deepseek-v4-flash", "deepseek-v4-pro"]); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.prompt_tokens, 10); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.completion_tokens, 5); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.total_reqs, 1); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_tokens, 20); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.completion_tokens, 7); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_cache_hit_tokens, 6); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.total_reqs, 1); + assert.equal(session?.usage?.prompt_tokens, 30); + assert.equal(session?.usage?.completion_tokens, 12); + assert.equal(session?.usage?.total_tokens, 42); }); test("SessionManager resets active tokens to latest post-compaction response usage", async () => { @@ -1163,10 +1247,15 @@ test("SessionManager resets active tokens to latest post-compaction response usa const session = manager.getSession(sessionId); const usage = session?.usage as Record; + const usagePerModel = session?.usagePerModel?.["test-model"] as Record; assert.equal(session?.activeTokens, 7); assert.equal(usage.prompt_tokens, 140_095); assert.equal(usage.completion_tokens, 35); assert.equal(usage.total_tokens, 140_130); + assert.equal(usagePerModel.prompt_tokens, 140_095); + assert.equal(usagePerModel.completion_tokens, 35); + assert.equal(usagePerModel.total_tokens, 140_130); + assert.equal(usagePerModel.total_reqs, 3); }); test("SessionManager streams chat completions and counts reasoning progress", async () => { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3f32f56..25671ce 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -148,11 +148,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setTimeout(() => { const activeSessionId = sessionManager.getActiveSessionId(); const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; - const allMessages = activeSessionId - ? sessionManager.listSessionMessages(activeSessionId) - : messagesRef.current; - const resolved = resolveCurrentSettings(projectRoot); - const summary = buildExitSummaryText({ session, messages: allMessages, model: resolved.model }); + const summary = buildExitSummaryText({ session }); process.stdout.write("\n"); process.stdout.write(chalk.green("> /exit ")); process.stdout.write("\n\n"); @@ -256,7 +252,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setRunningProcesses(null); } }, - [exit, onRestart, projectRoot, sessionManager, refreshSkills, refreshSessionsList] + [exit, onRestart, sessionManager, refreshSkills, refreshSessionsList] ); const handleInterrupt = useCallback(() => { diff --git a/src/ui/exitSummary.ts b/src/ui/exitSummary.ts index 910cceb..c55d9ce 100644 --- a/src/ui/exitSummary.ts +++ b/src/ui/exitSummary.ts @@ -1,11 +1,9 @@ import chalk from "chalk"; import gradientString from "gradient-string"; -import type { SessionEntry, SessionMessage } from "../session"; +import type { ModelUsage, SessionEntry } from "../session"; type ExitSummaryInput = { session: SessionEntry | null; - messages: SessionMessage[]; - model?: string; }; const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g; @@ -32,13 +30,15 @@ type UsageFields = { promptTokens: number; completionTokens: number; cachedTokens: number; + totalReqs: number; }; -function extractUsageFields(usage: unknown | null): UsageFields { +function extractUsageFields(usage: ModelUsage | null): UsageFields { const empty: UsageFields = { promptTokens: 0, completionTokens: 0, cachedTokens: 0, + totalReqs: 0, }; if (!usage || typeof usage !== "object" || Array.isArray(usage)) { return empty; @@ -61,14 +61,13 @@ function extractUsageFields(usage: unknown | null): UsageFields { cachedTokens = record.prompt_cache_hit_tokens; } - return { promptTokens, completionTokens, cachedTokens }; + const totalReqs = typeof record.total_reqs === "number" ? record.total_reqs : 0; + + return { promptTokens, completionTokens, cachedTokens, totalReqs }; } export function buildExitSummaryText(input: ExitSummaryInput): string { - const { session, messages, model } = input; - - // Count assistant messages as the request count shown in the usage table. - const assistantCount = messages.filter((m) => m.role === "assistant").length; + const { session } = input; const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding @@ -81,9 +80,22 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const rows: string[] = ["", `${header}`, ""]; - const usage = extractUsageFields(session?.usage ?? null); - const modelName = model ?? "unknown"; - const hasUsage = usage.promptTokens > 0 || usage.completionTokens > 0; + const usageRows = Object.entries(session?.usagePerModel ?? {}) + .map(([modelName, usage]) => ({ + modelName, + usage: extractUsageFields(usage), + })) + .filter( + (row) => + row.usage.totalReqs > 0 || + row.usage.promptTokens > 0 || + row.usage.completionTokens > 0 || + row.usage.cachedTokens > 0 + ) + .sort( + (left, right) => right.usage.totalReqs - left.usage.totalReqs || left.modelName.localeCompare(right.modelName) + ); + const hasUsage = usageRows.length > 0; if (hasUsage) { const colModel = 34; @@ -103,17 +115,19 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { rows.push(chalk.bold(headerRow)); rows.push(divider); - const reqsStr = String(assistantCount).padStart(colReqs); - const inputStr = formatNumber(usage.promptTokens).padStart(colInput); - const outputStr = formatNumber(usage.completionTokens).padStart(colOutput); - const cachedStr = formatNumber(usage.cachedTokens).padStart(colCached); - const dataRow = - padRight(modelName, colModel) + - padRight(reqsStr, colReqs) + - padRight(chalk.yellow(inputStr), colInput) + - padRight(chalk.yellow(outputStr), colOutput) + - padRight(chalk.yellow(cachedStr), colCached); - rows.push(dataRow); + for (const { modelName, usage } of usageRows) { + const reqsStr = formatNumber(usage.totalReqs).padStart(colReqs); + const inputStr = formatNumber(usage.promptTokens).padStart(colInput); + const outputStr = formatNumber(usage.completionTokens).padStart(colOutput); + const cachedStr = formatNumber(usage.cachedTokens).padStart(colCached); + const dataRow = + padRight(modelName, colModel) + + padRight(reqsStr, colReqs) + + padRight(chalk.yellow(inputStr), colInput) + + padRight(chalk.yellow(outputStr), colOutput) + + padRight(chalk.yellow(cachedStr), colCached); + rows.push(dataRow); + } rows.push(""); } From 0aa3f0dfbc2549e0c507a5e2e3f71a94aab145f8 Mon Sep 17 00:00:00 2001 From: Lellansin Date: Sat, 16 May 2026 09:52:39 +0800 Subject: [PATCH 107/217] feat: add GitHub CI workflow with multi-version Node.js and cross-platform matrix --- .github/workflows/ci.yml | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11cf1a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-test: + name: Node ${{ matrix.node-version }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + node-version: + - "18" + - "20" + - "22" + - "24" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: TypeCheck + Lint + Format Check + run: npm run check + + - name: Bundle + run: npm run bundle + + - name: Test + run: npm test From 9fc53984975c15b726d56fe4e45b40cdb3170aac Mon Sep 17 00:00:00 2001 From: Lellansin Date: Sat, 16 May 2026 09:55:37 +0800 Subject: [PATCH 108/217] fix(ci): trigger CI on all push events so PRs can verify before merge --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11cf1a3..0b214bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,8 @@ name: CI on: - pull_request: - branches: [main] push: + pull_request: branches: [main] jobs: From 21a3add40b6ca57aed401aa0348d5bfd4815db09 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 16 May 2026 11:12:55 +0800 Subject: [PATCH 109/217] feat: align the project to Node >=22, because the direct dependency ink@7.0.1 already declares node >=22. --- .github/workflows/ci.yml | 2 -- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b214bf..de8600c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,6 @@ jobs: - windows-latest - macos-latest node-version: - - "18" - - "20" - "22" - "24" diff --git a/package-lock.json b/package-lock.json index dd029a3..17fa96f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "typescript-eslint": "^8.59.2" }, "engines": { - "node": ">=18.17.0" + "node": ">=22" } }, "node_modules/@alcalzone/ansi-tokenize": { diff --git a/package.json b/package.json index 0564936..5405b26 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "LICENSE" ], "engines": { - "node": ">=18.17.0" + "node": ">=22" }, "scripts": { "typecheck": "tsc -p ./ --noEmit", From f1e696834d9f3a1eda2c88c9f6a093d13d6affbb Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 11:32:30 +0800 Subject: [PATCH 110/217] fix: add .gitattributes to enforce LF line endings on Windows --- .gitattributes | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..18f5c60 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize line endings to LF across all platforms +* text=auto eol=lf + +# Binary files should not be normalized +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.eot binary +*.ttf binary From f62b1ce2286ac86a7a604e49ac22a5b25b4b5a40 Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 11:40:13 +0800 Subject: [PATCH 111/217] fix: resolve Windows CI failures (CRLF, MCP spawn, test skips) --- src/mcp/mcp-client.ts | 8 +-- src/tests/clipboard.test.ts | 72 ++++++++++++++------------ src/tests/settings-and-notify.test.ts | 74 ++++++++++++++------------- src/tests/web-search-handler.test.ts | 68 ++++++++++++------------ src/tests/welcomeScreen.test.ts | 15 ++++-- 5 files changed, 129 insertions(+), 108 deletions(-) diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index f89e11d..9636732 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -128,10 +128,10 @@ export class McpClient { const isWindows = os.platform() === "win32"; if (isWindows) { - // On Windows, .cmd files require shell: true to be spawned. - // Build a single command string so cmd.exe handles quoting correctly. - const cmd = [this.command + ".cmd", ...args].join(" "); - this.process = spawn(cmd, [], { + // On Windows, shell: true lets cmd.exe resolve the command via + // PATHEXT (npx → npx.cmd, etc.) without blindly appending .cmd, + // which would break absolute paths like process.execPath. + this.process = spawn(this.command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv, shell: true, diff --git a/src/tests/clipboard.test.ts b/src/tests/clipboard.test.ts index 022b2f8..dbe9ff9 100644 --- a/src/tests/clipboard.test.ts +++ b/src/tests/clipboard.test.ts @@ -36,40 +36,44 @@ test("readClipboardImage returns null when no clipboard helpers are installed", assert.equal(result, null); }); -test("readClipboardImage uses osascript fallback on macOS when pngpaste is missing", async () => { - const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-clipboard-test-bin-")); - try { - fs.writeFileSync(path.join(binDir, "pngpaste"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); - fs.writeFileSync( - path.join(binDir, "osascript"), - [ - "#!/bin/sh", - 'for arg in "$@"; do', - ' case "$arg" in', - " *'open for access POSIX file " + '"' + "'*)", - ' path_part=${arg#*POSIX file \\"}', - ' out_path=${path_part%%\\"*}', - ' printf fakepng > "$out_path"', - " exit 0", - " ;;", - " esac", - "done", - "exit 1", - "", - ].join("\n"), - { mode: 0o755 } - ); +test( + "readClipboardImage uses osascript fallback on macOS when pngpaste is missing", + { skip: process.platform === "win32" }, + async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-clipboard-test-bin-")); + try { + fs.writeFileSync(path.join(binDir, "pngpaste"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(binDir, "osascript"), + [ + "#!/bin/sh", + 'for arg in "$@"; do', + ' case "$arg" in', + " *'open for access POSIX file " + '"' + "'*)", + ' path_part=${arg#*POSIX file \\"}', + ' out_path=${path_part%%\\"*}', + ' printf fakepng > "$out_path"', + " exit 0", + " ;;", + " esac", + "done", + "exit 1", + "", + ].join("\n"), + { mode: 0o755 } + ); - const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; - const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; + const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; + const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; - process.env.PATH = binDir; - const result = withPlatform("darwin", () => readClipboardImage()); - assert.equal(result?.mimeType, "image/png"); - assert.equal(result?.dataUrl, `data:image/png;base64,${Buffer.from("fakepng").toString("base64")}`); - } finally { - process.env.PATH = ORIGINAL_PATH; - Object.defineProperty(process, "platform", { value: ORIGINAL_PLATFORM }); - fs.rmSync(binDir, { recursive: true, force: true }); + process.env.PATH = binDir; + const result = withPlatform("darwin", () => readClipboardImage()); + assert.equal(result?.mimeType, "image/png"); + assert.equal(result?.dataUrl, `data:image/png;base64,${Buffer.from("fakepng").toString("base64")}`); + } finally { + process.env.PATH = ORIGINAL_PATH; + Object.defineProperty(process, "platform", { value: ORIGINAL_PLATFORM }); + fs.rmSync(binDir, { recursive: true, force: true }); + } } -}); +); diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 69b939f..6990288 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -364,39 +364,43 @@ test("buildNotifyEnv injects DURATION", () => { assert.equal(env.DURATION, "2"); }); -test("launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts", () => { - const calls: Array<{ - command: string; - args: string[]; - options: { cwd?: string | URL; env?: NodeJS.ProcessEnv }; - }> = []; - - const spawnProcess: NotifySpawn = (command, args, options) => { - calls.push({ command, args, options: { cwd: options.cwd, env: options.env } }); - - return { - once(event, listener) { - if (event === "error" && calls.length === 1) { - listener({ code: "EACCES" } as NodeJS.ErrnoException); - } - return this; - }, - unref() { - return undefined; - }, +test( + "launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts", + { skip: process.platform === "win32" }, + () => { + const calls: Array<{ + command: string; + args: string[]; + options: { cwd?: string | URL; env?: NodeJS.ProcessEnv }; + }> = []; + + const spawnProcess: NotifySpawn = (command, args, options) => { + calls.push({ command, args, options: { cwd: options.cwd, env: options.env } }); + + return { + once(event, listener) { + if (event === "error" && calls.length === 1) { + listener({ code: "EACCES" } as NodeJS.ErrnoException); + } + return this; + }, + unref() { + return undefined; + }, + }; }; - }; - - launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }); - - assert.equal(calls.length, 2); - assert.equal(calls[0]?.command, "/tmp/notify.sh"); - assert.deepEqual(calls[0]?.args, []); - assert.equal(calls[0]?.options.cwd, "/tmp/project"); - assert.equal(calls[0]?.options.env?.DURATION, "2"); - assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); - assert.equal(calls[1]?.command, "/bin/sh"); - assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); - assert.equal(calls[1]?.options.cwd, "/tmp/project"); - assert.equal(calls[1]?.options.env?.DURATION, "2"); -}); + + launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }); + + assert.equal(calls.length, 2); + assert.equal(calls[0]?.command, "/tmp/notify.sh"); + assert.deepEqual(calls[0]?.args, []); + assert.equal(calls[0]?.options.cwd, "/tmp/project"); + assert.equal(calls[0]?.options.env?.DURATION, "2"); + assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); + assert.equal(calls[1]?.command, "/bin/sh"); + assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); + assert.equal(calls[1]?.options.cwd, "/tmp/project"); + assert.equal(calls[1]?.options.env?.DURATION, "2"); + } +); diff --git a/src/tests/web-search-handler.test.ts b/src/tests/web-search-handler.test.ts index 576a1cb..417c6c4 100644 --- a/src/tests/web-search-handler.test.ts +++ b/src/tests/web-search-handler.test.ts @@ -20,40 +20,44 @@ afterEach(() => { } }); -test("WebSearch executes the configured script with the query as one argument", async () => { - const workspace = createTempWorkspace(); - const scriptPath = path.join(workspace, "web-search.sh"); - fs.writeFileSync( - scriptPath, - [ - "#!/bin/sh", - "printf 'query=%s\\n' \"$1\"", - "printf 'cwd=%s\\n' \"$PWD\"", - "printf 'webhook=%s\\n' \"$WEBHOOK\"", - ].join("\n"), - "utf8" - ); - fs.chmodSync(scriptPath, 0o755); +test( + "WebSearch executes the configured script with the query as one argument", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempWorkspace(); + const scriptPath = path.join(workspace, "web-search.sh"); + fs.writeFileSync( + scriptPath, + [ + "#!/bin/sh", + "printf 'query=%s\\n' \"$1\"", + "printf 'cwd=%s\\n' \"$PWD\"", + "printf 'webhook=%s\\n' \"$WEBHOOK\"", + ].join("\n"), + "utf8" + ); + fs.chmodSync(scriptPath, 0o755); - const starts: Array<{ id: string | number; command: string }> = []; - const exits: Array = []; - const result = await handleWebSearchTool( - { query: "latest node release" }, - createContext(workspace, { - webSearchTool: scriptPath, - env: { WEBHOOK: "configured" }, - onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id), - }) - ); - const realWorkspace = fs.realpathSync(workspace); + const starts: Array<{ id: string | number; command: string }> = []; + const exits: Array = []; + const result = await handleWebSearchTool( + { query: "latest node release" }, + createContext(workspace, { + webSearchTool: scriptPath, + env: { WEBHOOK: "configured" }, + onProcessStart: (id, command) => starts.push({ id, command }), + onProcessExit: (id) => exits.push(id), + }) + ); + const realWorkspace = fs.realpathSync(workspace); - assert.equal(result.ok, true); - assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\nwebhook=configured\n`); - assert.equal(starts.length, 1); - assert.match(starts[0].command, /^WebSearch: latest node release$/); - assert.deepEqual(exits, [starts[0].id]); -}); + assert.equal(result.ok, true); + assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\nwebhook=configured\n`); + assert.equal(starts.length, 1); + assert.match(starts[0].command, /^WebSearch: latest node release$/); + assert.deepEqual(exits, [starts[0].id]); + } +); test("WebSearch uses the default API when no script is configured", async () => { const workspace = createTempWorkspace(); diff --git a/src/tests/welcomeScreen.test.ts b/src/tests/welcomeScreen.test.ts index 1e5bc19..df7e109 100644 --- a/src/tests/welcomeScreen.test.ts +++ b/src/tests/welcomeScreen.test.ts @@ -1,17 +1,26 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import * as os from "os"; +import * as path from "path"; import { buildWelcomeTips, formatHomeRelativePath } from "../ui"; test("formatHomeRelativePath returns tilde for the home directory", () => { - assert.equal(formatHomeRelativePath("/Users/example", "/Users/example"), "~"); + const home = path.resolve("/Users/example"); + assert.equal(formatHomeRelativePath(home, home), "~"); }); test("formatHomeRelativePath shortens paths inside the home directory", () => { - assert.equal(formatHomeRelativePath("/Users/example/dev/project", "/Users/example"), "~/dev/project"); + const home = path.resolve("/Users/example"); + const result = formatHomeRelativePath(path.resolve("/Users/example/dev/project"), home); + assert.equal(result, `~${path.sep}dev${path.sep}project`); }); test("formatHomeRelativePath keeps paths outside the home directory absolute", () => { - assert.equal(formatHomeRelativePath("/tmp/project", "/Users/example"), "/tmp/project"); + const home = path.resolve("/Users/example"); + const other = path.resolve("/tmp/project"); + // The result should be the absolute path since it's outside home + const result = formatHomeRelativePath(other, home); + assert.equal(result, other); }); test("buildWelcomeTips includes built-in slash commands and loaded skills", () => { From cdce3ca6470e0b5768cba308c8d44453fa30e440 Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 11:43:22 +0800 Subject: [PATCH 112/217] fix: cross-platform homedir in tests, skip Windows-incompatible npx test --- src/tests/session.test.ts | 84 ++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index ff684a3..3dad6df 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -7,8 +7,17 @@ import { SessionManager, type SessionMessage } from "../session"; const originalFetch = globalThis.fetch; const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; const tempDirs: string[] = []; +/** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */ +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + afterEach(() => { globalThis.fetch = originalFetch; if (originalHome === undefined) { @@ -16,6 +25,11 @@ afterEach(() => { } else { process.env.HOME = originalHome; } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -249,7 +263,7 @@ test("SessionManager replays normal assistant messages with reasoning content in test("SessionManager normalizes legacy sessions without activeTokens to zero", () => { const workspace = createTempDir("deepcode-legacy-active-tokens-workspace-"); const home = createTempDir("deepcode-legacy-active-tokens-home-"); - process.env.HOME = home; + setHomeDir(home); const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); const projectDir = path.join(home, ".deepcode", "projects", projectCode); @@ -281,7 +295,7 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", ( test("SessionManager keeps usagePerModel null until response usage is available", async () => { const workspace = createTempDir("deepcode-null-usage-per-model-workspace-"); const home = createTempDir("deepcode-null-usage-per-model-home-"); - process.env.HOME = home; + setHomeDir(home); const manager = createMockedClientSessionManager(workspace, [{ choices: [{ message: { content: "no usage" } }] }]); @@ -294,7 +308,7 @@ test("SessionManager keeps usagePerModel null until response usage is available" test("SessionManager marks skills loaded from existing session messages", async () => { const workspace = createTempDir("deepcode-loaded-skills-workspace-"); const home = createTempDir("deepcode-loaded-skills-home-"); - process.env.HOME = home; + setHomeDir(home); const skillDir = path.join(home, ".agents", "skills", "lessweb-starter"); fs.mkdirSync(skillDir, { recursive: true }); @@ -341,7 +355,7 @@ test("SessionManager marks skills loaded from existing session messages", async test("SessionManager lists project skills from .agents with legacy .deepcode compatibility", async () => { const workspace = createTempDir("deepcode-project-skills-workspace-"); const home = createTempDir("deepcode-project-skills-home-"); - process.env.HOME = home; + setHomeDir(home); const userSkillDir = path.join(home, ".agents", "skills", "shared"); fs.mkdirSync(userSkillDir, { recursive: true }); @@ -514,13 +528,16 @@ test("SessionManager reports MCP startup stderr on failure", async () => { assert.match(status?.error ?? "", /mcp startup boom/); }); -test("SessionManager adds -y when launching MCP servers through npx", async () => { - const workspace = createTempDir("deepcode-mcp-npx-workspace-"); - const argsPath = path.join(workspace, "args.json"); - const fakeNpxPath = path.join(workspace, "npx"); - fs.writeFileSync( - fakeNpxPath, - `#!/usr/bin/env node +test( + "SessionManager adds -y when launching MCP servers through npx", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-mcp-npx-workspace-"); + const argsPath = path.join(workspace, "args.json"); + const fakeNpxPath = path.join(workspace, "npx"); + fs.writeFileSync( + fakeNpxPath, + `#!/usr/bin/env node const fs = require("fs"); const readline = require("readline"); fs.writeFileSync(process.env.ARGS_PATH, JSON.stringify(process.argv.slice(2))); @@ -544,23 +561,24 @@ rl.on("line", (line) => { send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); }); `, - "utf8" - ); - fs.chmodSync(fakeNpxPath, 0o755); + "utf8" + ); + fs.chmodSync(fakeNpxPath, 0o755); - const manager = createSessionManager(workspace, "machine-id-mcp-npx"); - await manager.initMcpServers({ - npxed: { command: fakeNpxPath, args: ["@playwright/mcp@latest"], env: { ARGS_PATH: argsPath } }, - }); + const manager = createSessionManager(workspace, "machine-id-mcp-npx"); + await manager.initMcpServers({ + npxed: { command: fakeNpxPath, args: ["@playwright/mcp@latest"], env: { ARGS_PATH: argsPath } }, + }); - assert.deepEqual(JSON.parse(fs.readFileSync(argsPath, "utf8")) as string[], ["-y", "@playwright/mcp@latest"]); - manager.dispose(); -}); + assert.deepEqual(JSON.parse(fs.readFileSync(argsPath, "utf8")) as string[], ["-y", "@playwright/mcp@latest"]); + manager.dispose(); + } +); test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-deepcode-workspace-"); const home = createTempDir("deepcode-init-deepcode-home-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); @@ -592,7 +610,7 @@ test("createSession stores /init and sends the active .deepcode project AGENTS p test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-root-workspace-"); const home = createTempDir("deepcode-init-root-home-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); @@ -619,7 +637,7 @@ test("replySession stores /init and sends the active root project AGENTS path to test("createSession stores /init and sends generate prompt when no project AGENTS file is effective", async () => { const workspace = createTempDir("deepcode-init-generate-workspace-"); const home = createTempDir("deepcode-init-generate-home-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.mkdirSync(path.join(home, ".deepcode"), { recursive: true }); @@ -645,7 +663,7 @@ test("createSession stores /init and sends generate prompt when no project AGENT test("createSession reports a new prompt with the machineId token", async () => { const workspace = createTempDir("deepcode-session-workspace-"); const home = createTempDir("deepcode-session-home-"); - process.env.HOME = home; + setHomeDir(home); const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { @@ -677,7 +695,7 @@ test("createSession reports a new prompt with the machineId token", async () => test("replySession reports a new prompt with the machineId token", async () => { const workspace = createTempDir("deepcode-reply-workspace-"); const home = createTempDir("deepcode-reply-home-"); - process.env.HOME = home; + setHomeDir(home); const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { @@ -708,7 +726,7 @@ test("replySession reports a new prompt with the machineId token", async () => { test("replySession preserves raw session messages when a previous tool call is pending", async () => { const workspace = createTempDir("deepcode-pending-tool-workspace-"); const home = createTempDir("deepcode-pending-tool-home-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ @@ -1131,7 +1149,7 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista test("SessionManager accumulates response usage while active tokens track the latest response", async () => { const workspace = createTempDir("deepcode-usage-workspace-"); const home = createTempDir("deepcode-usage-home-"); - process.env.HOME = home; + setHomeDir(home); const responses = [ createChatResponse("first", { @@ -1182,7 +1200,7 @@ test("SessionManager accumulates response usage while active tokens track the la test("SessionManager stores usage per model across model changes", async () => { const workspace = createTempDir("deepcode-usage-per-model-workspace-"); const home = createTempDir("deepcode-usage-per-model-home-"); - process.env.HOME = home; + setHomeDir(home); let currentModel = "deepseek-v4-pro"; const responses = [ @@ -1243,7 +1261,7 @@ test("SessionManager stores usage per model across model changes", async () => { test("SessionManager resets active tokens to latest post-compaction response usage", async () => { const workspace = createTempDir("deepcode-compact-usage-workspace-"); const home = createTempDir("deepcode-compact-usage-home-"); - process.env.HOME = home; + setHomeDir(home); const responses = [ createChatResponse("large", { @@ -1285,7 +1303,7 @@ test("SessionManager resets active tokens to latest post-compaction response usa test("SessionManager streams chat completions and counts reasoning progress", async () => { const workspace = createTempDir("deepcode-stream-workspace-"); const home = createTempDir("deepcode-stream-home-"); - process.env.HOME = home; + setHomeDir(home); const progressEvents: Array<{ phase: string; @@ -1352,7 +1370,7 @@ test("SessionManager streams chat completions and counts reasoning progress", as test("SessionManager cancels skill matching before a session is created", async () => { const workspace = createTempDir("deepcode-skill-abort-workspace-"); const home = createTempDir("deepcode-skill-abort-home-"); - process.env.HOME = home; + setHomeDir(home); const skillDir = path.join(home, ".agents", "skills", "demo"); fs.mkdirSync(skillDir, { recursive: true }); @@ -1384,7 +1402,7 @@ test("SessionManager cancels skill matching before a session is created", async test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () => { const workspace = createTempDir("deepcode-api-abort-workspace-"); const home = createTempDir("deepcode-api-abort-home-"); - process.env.HOME = home; + setHomeDir(home); let manager: SessionManager; const client = { From a21e907ddcd61fee119272dadd81bdf35d40f13a Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 11:57:36 +0800 Subject: [PATCH 113/217] fix: restore Node 20 in CI matrix --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8600c..4dc891f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: - windows-latest - macos-latest node-version: + - "20" - "22" - "24" From 64bf782fb3ebbbcc7964e7df413cf2bc22bb6559 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 16 May 2026 11:59:48 +0800 Subject: [PATCH 114/217] feat: update getCurrentDateAndModelPrompt --- src/prompt.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/prompt.ts b/src/prompt.ts index b854860..4774725 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -281,14 +281,17 @@ function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): s return docs.join("\n\n"); } -function getCurrentDatePrompt(date = new Date()): string { - return `今天是${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日。随着对话的进行,时间在流逝。`; +function getCurrentDateAndModelPrompt(model?: string): string { + const date = new Date(); + let prompt = `今天是${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日。随着对话的进行,时间在流逝。`; + prompt += model ? `\n当前LLM模型为${model},对话中可通过/model命令切换模型。` : ""; + return prompt; } export function getSystemPrompt(projectRoot: string, options: PromptToolOptions = {}): string { const toolDocs = readToolDocs(getExtensionRoot(), options); const basePrompt = toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; - return `${basePrompt}\n\n${getCurrentDatePrompt()}\n\n${getRuntimeContext(projectRoot)}`; + return `${basePrompt}\n\n${getCurrentDateAndModelPrompt(options.model)}\n\n${getRuntimeContext(projectRoot)}`; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { From cf56131cbbd5c57b2079730754d3fd658f6eae71 Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 12:00:52 +0800 Subject: [PATCH 115/217] fix: quote test glob pattern for Node 20 Windows --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5405b26..ae125e0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx}'", "check": "npm run typecheck && npm run lint && npm run format:check", "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", - "test": "tsx --test src/tests/*.test.ts", + "test": "tsx --test \"src/tests/*.test.ts\"", "test:single": "tsx --test", "prepack": "npm run build", "prepare": "husky" From 16e6810875d0b4782a6ee937b8e4df39712c1bb1 Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 12:04:42 +0800 Subject: [PATCH 116/217] fix: add cross-platform test runner for Node 20 compatibility --- package.json | 2 +- src/tests/run-tests.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/tests/run-tests.mjs diff --git a/package.json b/package.json index ae125e0..aceb496 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx}'", "check": "npm run typecheck && npm run lint && npm run format:check", "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", - "test": "tsx --test \"src/tests/*.test.ts\"", + "test": "node src/tests/run-tests.mjs", "test:single": "tsx --test", "prepack": "npm run build", "prepare": "husky" diff --git a/src/tests/run-tests.mjs b/src/tests/run-tests.mjs new file mode 100644 index 0000000..653ca66 --- /dev/null +++ b/src/tests/run-tests.mjs @@ -0,0 +1,26 @@ +// Cross-platform test runner: finds all *.test.ts files and runs them via tsx. +// Needed because glob expansion in npm scripts behaves differently across +// shells and Node versions (particularly Node 20 on Windows). +/* eslint-disable */ + +import { spawnSync } from "child_process"; +import { readdirSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const testFiles = readdirSync(__dirname) + .filter((f) => f.endsWith(".test.ts")) + .map((f) => join(__dirname, f)) + .sort(); + +// Resolve tsx from the project root +const tsx = new URL("../../node_modules/.bin/tsx", import.meta.url).pathname; + +const result = spawnSync(process.execPath, [tsx, "--test", ...testFiles], { + stdio: "inherit", + cwd: join(__dirname, "../.."), +}); + +process.exit(result.status ?? 1); From 9dba13688051cf2b8fffe0e9ed8221bd2ecc0464 Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 12:07:06 +0800 Subject: [PATCH 117/217] fix: use npx for cross-platform tsx resolution in test runner --- src/tests/run-tests.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tests/run-tests.mjs b/src/tests/run-tests.mjs index 653ca66..3a8465f 100644 --- a/src/tests/run-tests.mjs +++ b/src/tests/run-tests.mjs @@ -15,10 +15,7 @@ const testFiles = readdirSync(__dirname) .map((f) => join(__dirname, f)) .sort(); -// Resolve tsx from the project root -const tsx = new URL("../../node_modules/.bin/tsx", import.meta.url).pathname; - -const result = spawnSync(process.execPath, [tsx, "--test", ...testFiles], { +const result = spawnSync("npx", ["--no-install", "tsx", "--test", ...testFiles], { stdio: "inherit", cwd: join(__dirname, "../.."), }); From 20add1c8deaab468f1802f9d1b56bfe209b0942f Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 12:09:31 +0800 Subject: [PATCH 118/217] fix: resolve tsx binary path cross-platform in test runner --- src/tests/run-tests.mjs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/tests/run-tests.mjs b/src/tests/run-tests.mjs index 3a8465f..ed1fe16 100644 --- a/src/tests/run-tests.mjs +++ b/src/tests/run-tests.mjs @@ -4,20 +4,35 @@ /* eslint-disable */ import { spawnSync } from "child_process"; -import { readdirSync } from "fs"; +import { readdirSync, existsSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, "../.."); const testFiles = readdirSync(__dirname) .filter((f) => f.endsWith(".test.ts")) .map((f) => join(__dirname, f)) .sort(); -const result = spawnSync("npx", ["--no-install", "tsx", "--test", ...testFiles], { +// Cross-platform resolution of the local tsx binary +function findTsx() { + const candidates = [ + join(projectRoot, "node_modules", ".bin", "tsx"), + join(projectRoot, "node_modules", ".bin", "tsx.cmd"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return candidates[0]; +} + +const tsx = findTsx(); + +const result = spawnSync(tsx, ["--test", ...testFiles], { stdio: "inherit", - cwd: join(__dirname, "../.."), + cwd: projectRoot, }); process.exit(result.status ?? 1); From abe5190ccc11243e10c20a11e5209ccf04b813d3 Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 12:12:54 +0800 Subject: [PATCH 119/217] fix: use node --import tsx --test with native glob expansion for cross-platform --- package.json | 2 +- src/tests/run-tests.mjs | 38 -------------------------------------- 2 files changed, 1 insertion(+), 39 deletions(-) delete mode 100644 src/tests/run-tests.mjs diff --git a/package.json b/package.json index aceb496..1bff1df 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx}'", "check": "npm run typecheck && npm run lint && npm run format:check", "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", - "test": "node src/tests/run-tests.mjs", + "test": "node --import tsx --test src/tests/*.test.ts", "test:single": "tsx --test", "prepack": "npm run build", "prepare": "husky" diff --git a/src/tests/run-tests.mjs b/src/tests/run-tests.mjs deleted file mode 100644 index ed1fe16..0000000 --- a/src/tests/run-tests.mjs +++ /dev/null @@ -1,38 +0,0 @@ -// Cross-platform test runner: finds all *.test.ts files and runs them via tsx. -// Needed because glob expansion in npm scripts behaves differently across -// shells and Node versions (particularly Node 20 on Windows). -/* eslint-disable */ - -import { spawnSync } from "child_process"; -import { readdirSync, existsSync } from "fs"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = join(__dirname, "../.."); - -const testFiles = readdirSync(__dirname) - .filter((f) => f.endsWith(".test.ts")) - .map((f) => join(__dirname, f)) - .sort(); - -// Cross-platform resolution of the local tsx binary -function findTsx() { - const candidates = [ - join(projectRoot, "node_modules", ".bin", "tsx"), - join(projectRoot, "node_modules", ".bin", "tsx.cmd"), - ]; - for (const c of candidates) { - if (existsSync(c)) return c; - } - return candidates[0]; -} - -const tsx = findTsx(); - -const result = spawnSync(tsx, ["--test", ...testFiles], { - stdio: "inherit", - cwd: projectRoot, -}); - -process.exit(result.status ?? 1); From d5da01e848a724534b22763daf6db1e64e7f191c Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 16 May 2026 12:15:53 +0800 Subject: [PATCH 120/217] fix: add glob package, use cross-platform test runner --- package-lock.json | 95 +++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- src/tests/run-tests.mjs | 13 ++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/tests/run-tests.mjs diff --git a/package-lock.json b/package-lock.json index 17fa96f..958eed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", + "glob": "^13.0.6", "husky": "^9.1.7", "lint-staged": "^17.0.4", "prettier": "^3.8.3", @@ -2249,6 +2250,24 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2262,6 +2281,45 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", @@ -2911,6 +2969,16 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -3060,6 +3128,33 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 1bff1df..f1fd660 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx}'", "check": "npm run typecheck && npm run lint && npm run format:check", "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", - "test": "node --import tsx --test src/tests/*.test.ts", + "test": "node src/tests/run-tests.mjs", "test:single": "tsx --test", "prepack": "npm run build", "prepare": "husky" @@ -58,6 +58,7 @@ "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", + "glob": "^13.0.6", "husky": "^9.1.7", "lint-staged": "^17.0.4", "prettier": "^3.8.3", diff --git a/src/tests/run-tests.mjs b/src/tests/run-tests.mjs new file mode 100644 index 0000000..4d09f5b --- /dev/null +++ b/src/tests/run-tests.mjs @@ -0,0 +1,13 @@ +// Cross-platform test runner: finds all *.test.ts files and runs them via tsx. +// Uses the glob package for reliable cross-platform pattern expansion (Node 20+). +/* eslint-disable */ + +import { globSync } from "glob"; +import { spawnSync } from "child_process"; + +const cwd = new URL("../..", import.meta.url); +const testFiles = globSync("src/tests/*.test.ts", { cwd }); + +const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { stdio: "inherit", cwd }); + +process.exit(result.status ?? 1); From be6d66f19eaabfc370776336557abf414a89af79 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 16 May 2026 13:33:07 +0800 Subject: [PATCH 121/217] revert: undo PR #70 --- src/tests/promptInputKeys.test.ts | 6 ++-- src/ui/prompt/cursor.ts | 6 ++-- src/ui/prompt/useTerminalInput.ts | 56 +++---------------------------- 3 files changed, 9 insertions(+), 59 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 8952a3d..69d2075 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -80,7 +80,7 @@ test("parseTerminalInput keeps BS payload for meta+backspace", () => { test("parseTerminalInput recognizes shifted return sequences", () => { const { input, key } = parseTerminalInput("\u001B\r"); - assert.equal(input, ""); + assert.equal(input, "\r"); assert.equal(key.return, true); assert.equal(key.shift, true); assert.equal(key.meta, false); @@ -108,8 +108,8 @@ test("parseTerminalInput recognizes alternate shifted return sequences", () => { }); test("terminal extended key helpers request and restore modifyOtherKeys mode", () => { - assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m\u001B[>1u"); - assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m\u001B[4;1m"); + assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); }); test("parseTerminalInput recognizes terminal focus events", () => { diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 5eff1de..2668470 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,14 +40,12 @@ function disableTerminalFocusReporting(): string { return "\u001B[?1004l"; } -// xterm modifyOtherKeys + Kitty progressive enhancement. -// Both are needed: some terminals (incl. Windows Terminal) only respond to Kitty. export function enableTerminalExtendedKeys(): string { - return "\u001B[>4;1m\u001B[>1u"; + return "\u001B[>4;1m"; } export function disableTerminalExtendedKeys(): string { - return "\u001B[>4;0m\u001B[4;0m"; } export function getPromptCursorPlacement( diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index e6e0e28..8013ff6 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -26,55 +26,7 @@ const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]); const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]); const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]); -// Known exact Shift+Enter sequences (both xterm modifyOtherKeys and Kitty protocol). -const SHIFT_RETURN_SEQUENCES = new Set([ - "\u001B\r", - "\u001B[13;2u", - "\u001B[13;1u", - "\u001B[13;2~", - "\u001B[13;1~", - "\u001B[27;2;13~", - "\u001B[27;1;13~", -]); - -// CSI u format: ESC [ keycode ; modifier u -// CSI ~ format: ESC [ keycode ; modifier ~ -// Extended: ESC [ 27 ; modifier ; keycode ~ -const CSI_SHIFT_RETURN_RE = /^\u001B\[13;(\d+)[u~]$/; -const CSI_EXTENDED_SHIFT_RETURN_RE = /^\u001B\[27;(\d+);13~$/; - -// Check whether a raw sequence represents Shift+Enter by parsing the modifier -// parameter dynamically. This handles terminals (e.g. Windows Terminal) that -// set extra flags on the modifier (e.g. 130 = 128 + 2) while the existing -// SHIFT_RETURN_SEQUENCES Set only covers the canonical values (2 and 1). -function isShiftReturn(raw: string): boolean { - if (SHIFT_RETURN_SEQUENCES.has(raw)) return true; - - let m: RegExpMatchArray | null; - if ((m = raw.match(CSI_SHIFT_RETURN_RE)) !== null) { - const mod = parseInt(m[1], 10); - // xterm: Shift=2 (bit 1); Kitty: Shift=1 (bit 0) - return (mod & 2) !== 0 || (mod & 1) !== 0; - } - if ((m = raw.match(CSI_EXTENDED_SHIFT_RETURN_RE)) !== null) { - const mod = parseInt(m[1], 10); - return (mod & 2) !== 0 || (mod & 1) !== 0; - } - return false; -} - -// Any CSI sequence with keycode=13 (Enter) — with or without modifiers. -// Kitty progressive enhancement (ESC[>1u) sends plain Enter as ESC[13u -// or ESC[13;NUMBERu with extra flags; xterm sends ESC[13;2u for Shift. -const CSI_RETURN_RE = /^\u001B\[13;(\d+)[u~]$/; -const CSI_EXTENDED_RETURN_RE = /^\u001B\[27;(\d+);13~$/; - -function isReturn(raw: string): boolean { - if (raw === "\r") return true; - if (SHIFT_RETURN_SEQUENCES.has(raw)) return true; - if (META_RETURN_SEQUENCES.has(raw)) return true; - return CSI_RETURN_RE.test(raw) || CSI_EXTENDED_RETURN_RE.test(raw); -} +const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u", "\u001B[13;2~", "\u001B[27;2;13~"]); const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]); const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]); const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]); @@ -161,10 +113,10 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: end: END_SEQUENCES.has(raw), pageDown: raw === "\u001B[6~", pageUp: raw === "\u001B[5~", - return: isReturn(raw), + return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), escape: raw === "\u001B", ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw), - shift: isShiftReturn(raw), + shift: SHIFT_RETURN_SEQUENCES.has(raw), tab: raw === "\t" || raw === "\u001B[Z", backspace: BACKSPACE_BYTES.has(raw), delete: FORWARD_DELETE_SEQUENCES.has(raw), @@ -210,7 +162,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: key.shift = true; } - if (key.tab || key.backspace || key.delete || key.return) { + if (key.tab || key.backspace || key.delete) { input = ""; } From 5e3540c2d503b0bfb3d6405b8b37832363e74226 Mon Sep 17 00:00:00 2001 From: Lellansin Date: Sat, 16 May 2026 15:20:08 +0800 Subject: [PATCH 122/217] feat: add Ctrl+O to view live process stdout in fullscreen overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add onProcessStdout callback chain through executor → bash-handler → session → App - Stream real-time stdout/stderr from bash commands to UI ref (capped at 1MB) - Add ProcessStdoutView fullscreen overlay with scroll support - Bind Ctrl+O in PromptInput to toggle the stdout view - Footer hint shows 'ctrl+o view output' when a process is running --- package-lock.json | 814 +++++++---------------------------- src/session.ts | 4 + src/tools/bash-handler.ts | 4 + src/tools/executor.ts | 3 + src/ui/App.tsx | 37 +- src/ui/ProcessStdoutView.tsx | 109 +++++ src/ui/PromptInput.tsx | 21 +- 7 files changed, 327 insertions(+), 665 deletions(-) create mode 100644 src/ui/ProcessStdoutView.tsx diff --git a/package-lock.json b/package-lock.json index 958eed7..f9caecd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -846,13 +846,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -863,19 +856,6 @@ "node": ">= 4" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", @@ -1060,13 +1040,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.8.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { @@ -1086,17 +1066,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", - "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/type-utils": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1109,22 +1089,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.2", + "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", - "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "engines": { @@ -1140,14 +1120,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", - "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "engines": { @@ -1162,14 +1142,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", - "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1180,9 +1160,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", - "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", "dev": true, "license": "MIT", "engines": { @@ -1197,15 +1177,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1222,9 +1202,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", "dev": true, "license": "MIT", "engines": { @@ -1236,16 +1216,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", - "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.2", - "@typescript-eslint/tsconfig-utils": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1316,16 +1296,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1340,13 +1320,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1450,13 +1430,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" }, "node_modules/auto-bind": { "version": "5.0.1", @@ -1725,7 +1703,7 @@ }, "node_modules/ejs": { "version": "5.0.2", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", + "resolved": "https://registry.npmmirror.com/ejs/-/ejs-5.0.2.tgz", "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", "license": "Apache-2.0", "bin": { @@ -1736,9 +1714,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.353", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", - "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "version": "1.5.356", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", + "integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==", "dev": true, "license": "ISC" }, @@ -1824,12 +1802,16 @@ } }, "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { @@ -1991,19 +1973,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -2226,9 +2195,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -2237,22 +2206,9 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "13.0.6", - "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", @@ -2283,7 +2239,7 @@ }, "node_modules/glob/node_modules/balanced-match": { "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", @@ -2293,7 +2249,7 @@ }, "node_modules/glob/node_modules/brace-expansion": { "version": "5.0.6", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", @@ -2306,7 +2262,7 @@ }, "node_modules/glob/node_modules/minimatch": { "version": "10.2.5", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", @@ -2361,6 +2317,28 @@ "node": ">=6.0" } }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", @@ -2453,9 +2431,9 @@ } }, "node_modules/ink": { - "version": "7.0.1", - "resolved": "https://registry.npmmirror.com/ink/-/ink-7.0.1.tgz", - "integrity": "sha512-o6LAC268PLawlGVYrXTyaTfke4VtJftEheuwbgkQf7yvSXyWp1nRwBbAyKEkWXFZZsW/la5wrMuNbuBvZK2C1w==", + "version": "7.0.3", + "resolved": "https://registry.npmmirror.com/ink/-/ink-7.0.3.tgz", + "integrity": "sha512-5kxHkIj9+RuqCU3zyvP4qvYWNOSHP2TW/SHayHGHOmk87KwfVcZwvJGemi9ch+ci2gXUqerK/Eh2DGEDt5q45g==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", @@ -2598,13 +2576,13 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -2971,7 +2949,7 @@ }, "node_modules/minipass": { "version": "7.1.3", - "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "license": "BlueOak-1.0.0", @@ -2994,9 +2972,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.44", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "dev": true, "license": "MIT" }, @@ -3016,9 +2994,9 @@ } }, "node_modules/openai": { - "version": "6.35.0", - "resolved": "https://registry.npmmirror.com/openai/-/openai-6.35.0.tgz", - "integrity": "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==", + "version": "6.37.0", + "resolved": "https://registry.npmmirror.com/openai/-/openai-6.37.0.tgz", + "integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -3130,7 +3108,7 @@ }, "node_modules/path-scurry": { "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", @@ -3147,7 +3125,7 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "11.3.6", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", @@ -3212,9 +3190,9 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3245,16 +3223,6 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -3370,6 +3338,15 @@ "node": ">=10" } }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", @@ -3527,14 +3504,13 @@ } }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.0", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.22.0.tgz", + "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -3546,490 +3522,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", @@ -4073,16 +3565,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", - "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "version": "8.59.3", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2" + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4097,9 +3589,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -4203,9 +3695,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4231,9 +3723,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "optional": true, diff --git a/src/session.ts b/src/session.ts index 8c078f3..6da8835 100644 --- a/src/session.ts +++ b/src/session.ts @@ -197,6 +197,7 @@ type SessionManagerOptions = { onSessionEntryUpdated?: (entry: SessionEntry) => void; onLlmStreamProgress?: (progress: LlmStreamProgress) => void; onMcpStatusChanged?: () => void; + onProcessStdout?: (pid: number, chunk: string) => void; }; export type LlmStreamProgress = { @@ -220,6 +221,7 @@ export class SessionManager { private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; private readonly onMcpStatusChanged?: () => void; + private readonly onProcessStdout?: (pid: number, chunk: string) => void; private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); @@ -235,6 +237,7 @@ export class SessionManager { this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; this.onMcpStatusChanged = options.onMcpStatusChanged; + this.onProcessStdout = options.onProcessStdout; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); } @@ -1699,6 +1702,7 @@ ${skillMd} const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), + onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), shouldStop: () => this.isInterrupted(sessionId), }); if (this.isInterrupted(sessionId)) { diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index 95e7e76..071da53 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -124,9 +124,13 @@ async function executeShellCommand( child.stdout?.on("data", (chunk: string | Buffer) => { stdout = appendChunk(stdout, chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + context.onProcessStdout?.(pid as number, text); }); child.stderr?.on("data", (chunk: string | Buffer) => { stderr = appendChunk(stderr, chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + context.onProcessStdout?.(pid as number, text); }); child.on("error", (spawnError) => { diff --git a/src/tools/executor.ts b/src/tools/executor.ts index bc2d7d8..e6018d9 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -37,11 +37,13 @@ export type ToolExecutionContext = { createOpenAIClient?: CreateOpenAIClient; onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; + onProcessStdout?: (processId: string | number, chunk: string) => void; }; export type ToolExecutionHooks = { onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; + onProcessStdout?: (processId: string | number, chunk: string) => void; shouldStop?: () => boolean; }; @@ -195,6 +197,7 @@ export class ToolExecutor { createOpenAIClient: this.createOpenAIClient, onProcessStart: hooks?.onProcessStart, onProcessExit: hooks?.onProcessExit, + onProcessStdout: hooks?.onProcessStdout, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8c5c375..c864187 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -30,6 +30,7 @@ import { findExpandedThinkingId } from "./thinkingState"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; +import { ProcessStdoutView } from "./ProcessStdoutView"; import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, @@ -69,6 +70,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); + const [showProcessStdout, setShowProcessStdout] = useState(false); + const processStdoutRef = useRef>(new Map()); const messagesRef = useRef([]); messagesRef.current = messages; @@ -98,6 +101,19 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 setMcpStatuses(sessionManager.getMcpStatus()); }, + onProcessStdout: (pid, chunk) => { + const buf = processStdoutRef.current; + const current = buf.get(pid) ?? ""; + // Cap at 1 MB per process to avoid unbounded memory growth + // on noisy or long-running commands like `yes` or verbose builds. + const MAX_STDOUT_BUFFER = 1_000_000; + if (current.length >= MAX_STDOUT_BUFFER) { + return; + } + const text = typeof chunk === "string" ? chunk : String(chunk); + const available = MAX_STDOUT_BUFFER - current.length; + buf.set(pid, current + text.slice(0, available)); + }, }); }, [projectRoot]); @@ -218,6 +234,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setBusy(true); setErrorLine(null); setRunningProcesses(null); + setShowProcessStdout(false); + processStdoutRef.current.clear(); try { await sessionManager.handleUserPrompt(prompt); await refreshSkills(); @@ -238,6 +256,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R sessionManager.interruptActiveSession(); }, [sessionManager]); + const handleToggleProcessStdout = useCallback(() => { + setShowProcessStdout(true); + }, []); + + const handleDismissProcessStdout = useCallback(() => { + setShowProcessStdout(false); + }, []); + const handleModelConfigChange = useCallback( (selection: ModelConfigSelection): string => { const current = resolveCurrentSettings(projectRoot); @@ -442,7 +468,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R Error: {errorLine} ) : null} - {view === "session-list" ? ( + {showProcessStdout ? ( + + ) : view === "session-list" ? ( void handleSelectSession(id)} @@ -464,9 +497,11 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R promptHistory={promptHistory} busy={busy} loadingText={loadingText} + runningProcesses={runningProcesses} onSubmit={handleSubmit} onModelConfigChange={handleModelConfigChange} onInterrupt={handleInterrupt} + onToggleProcessStdout={handleToggleProcessStdout} placeholder="Type your message..." /> )} diff --git a/src/ui/ProcessStdoutView.tsx b/src/ui/ProcessStdoutView.tsx new file mode 100644 index 0000000..a0676c6 --- /dev/null +++ b/src/ui/ProcessStdoutView.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text } from "ink"; +import type { SessionEntry } from "../session"; +import { useTerminalInput } from "./prompt"; + +type RunningProcesses = SessionEntry["processes"]; + +type ProcessStdoutViewProps = { + processStdoutRef: React.MutableRefObject>; + runningProcesses: RunningProcesses; + onDismiss: () => void; + screenWidth: number; +}; + +const REFRESH_INTERVAL_MS = 150; +const MAX_VISIBLE_LINES = 100; + +export const ProcessStdoutView = React.memo(function ProcessStdoutView({ + processStdoutRef, + runningProcesses, + onDismiss, + screenWidth, +}: ProcessStdoutViewProps): React.ReactElement { + const [stdoutText, setStdoutText] = useState(""); + const [scrollOffset, setScrollOffset] = useState(0); + const containerRef = useRef<{ lineCount: number }>({ lineCount: 0 }); + + useEffect(() => { + const updateStdout = () => { + let text = ""; + if (runningProcesses && runningProcesses.size > 0) { + for (const [pid, proc] of runningProcesses.entries()) { + const pidNum = Number(pid); + const stdout = processStdoutRef.current.get(pidNum) ?? ""; + if (text) { + text += "\n"; + } + if (runningProcesses.size > 1) { + text += `── Process ${pid} [${proc.command}] ──\n`; + } + text += stdout || "(no output yet)"; + } + } else { + text = "(no running processes)"; + } + setStdoutText(text); + }; + + updateStdout(); + const interval = setInterval(updateStdout, REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [processStdoutRef, runningProcesses]); + + // Update container line count for scroll awareness + const lines = useMemo(() => stdoutText.split("\n"), [stdoutText]); + containerRef.current.lineCount = lines.length; + + const visibleLines = useMemo(() => { + if (lines.length <= MAX_VISIBLE_LINES) { + return lines; + } + const start = Math.max(0, lines.length - MAX_VISIBLE_LINES - scrollOffset); + const slice = lines.slice(start, start + MAX_VISIBLE_LINES); + if (lines.length > MAX_VISIBLE_LINES) { + slice.unshift(`... (${start} lines above · ↑/↓ to scroll · ${lines.length} total lines) ...`); + } + return slice; + }, [lines, scrollOffset]); + + useTerminalInput( + (input, key) => { + if ((key.ctrl && (input === "o" || input === "O")) || key.escape) { + onDismiss(); + return; + } + if (key.upArrow) { + setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - MAX_VISIBLE_LINES))); + return; + } + if (key.downArrow) { + setScrollOffset((s) => Math.max(s - 10, 0)); + return; + } + if (key.pageUp) { + setScrollOffset((s) => Math.min(s + MAX_VISIBLE_LINES, Math.max(0, lines.length - MAX_VISIBLE_LINES))); + return; + } + if (key.pageDown) { + setScrollOffset((s) => Math.max(s - MAX_VISIBLE_LINES, 0)); + return; + } + }, + { isActive: true } + ); + + return ( + + + 📟 Process Output + (Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll) + + + {visibleLines.map((line, index) => ( + {line} + ))} + + + ); +}); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 3965144..db3a956 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -60,9 +60,11 @@ type Props = { loadingText?: string | null; disabled?: boolean; placeholder?: string; + runningProcesses?: Map | null; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onInterrupt: () => void; + onToggleProcessStdout?: () => void; }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -109,9 +111,11 @@ export const PromptInput = React.memo(function PromptInput({ loadingText, disabled, placeholder, + runningProcesses, onSubmit, onModelConfigChange, onInterrupt, + onToggleProcessStdout, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); @@ -141,13 +145,15 @@ export const PromptInput = React.memo(function PromptInput({ ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); + const hasRunningProcess = runningProcesses && runningProcesses.size > 0; + const processHint = hasRunningProcess ? " · ctrl+o view output" : ""; const footerText = statusMessage ? statusMessage : busy ? loadingText && loadingText.trim() - ? loadingText - : "esc to interrupt · ctrl+c to cancel input" - : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; + ? `${loadingText}${processHint}` + : `esc to interrupt · ctrl+c to cancel input${processHint}` + : `enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit${processHint}`; useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); @@ -223,6 +229,15 @@ export const PromptInput = React.memo(function PromptInput({ return; } + if (key.ctrl && (input === "o" || input === "O")) { + if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) { + onToggleProcessStdout(); + } else { + setStatusMessage("No running process to inspect"); + } + return; + } + if (key.ctrl && (input === "d" || input === "D")) { if (!isEmpty(buffer)) { updateBuffer((s) => deleteForward(s)); From 1878c15a5326630cc23e64fa35855cf5aa728220 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 16 May 2026 15:41:04 +0800 Subject: [PATCH 123/217] 0.1.21 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 958eed7..51b7a95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.20", + "version": "0.1.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.20", + "version": "0.1.21", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index f1fd660..90c2b51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.20", + "version": "0.1.21", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From 38fc20130ac418aa6b8b0da143d10f38a3bd7c81 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 16 May 2026 23:06:55 +0800 Subject: [PATCH 124/217] feat: update Edit tool with broader backslash matching; add support for stripping accidental read-result tabs in Edit tool; add /continue command to resume conversations and update related functionality --- src/cli.tsx | 1 + src/session.ts | 46 ++++++++++++++++ src/tests/session.test.ts | 94 +++++++++++++++++++++++++++++++++ src/tests/slashCommands.test.ts | 9 +++- src/tests/tool-handlers.test.ts | 74 ++++++++++++++++++++++++++ src/tools/edit-handler.ts | 25 ++++++--- src/ui/App.tsx | 13 ++++- src/ui/PromptInput.tsx | 11 +++- src/ui/slashCommands.ts | 8 ++- 9 files changed, 271 insertions(+), 10 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index 112c9dd..a0847d4 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -42,6 +42,7 @@ if (args.includes("--help") || args.includes("-h")) { " /new Start a fresh conversation", " /init Initialize an AGENTS.md file with instructions for LLM", " /resume Pick a previous conversation to continue", + " /continue Continue the active conversation, or resume one if empty", " /exit Quit", " ctrl+d twice Quit", ].join("\n") + "\n" diff --git a/src/session.ts b/src/session.ts index 8c078f3..095cd3a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -957,6 +957,12 @@ ${skillMd} return; } + if (this.isContinuePrompt(userPrompt)) { + this.activeSessionId = sessionId; + await this.activateSession(sessionId, controller); + return; + } + this.reportNewPrompt(); if (userPrompt.text) { @@ -996,6 +1002,15 @@ ${skillMd} await this.activateSession(sessionId, controller); } + private isContinuePrompt(userPrompt: UserPromptContent): boolean { + return ( + typeof userPrompt.text === "string" && + userPrompt.text.trim() === "/continue" && + (!userPrompt.imageUrls || userPrompt.imageUrls.length === 0) && + (!userPrompt.skills || userPrompt.skills.length === 0) + ); + } + async activateSession(sessionId: string, controller?: AbortController): Promise { const startedAt = Date.now(); const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = @@ -1055,6 +1070,23 @@ ${skillMd} return; } + const pendingToolCalls = this.getTrailingPendingToolCalls(this.listSessionMessages(sessionId)); + if (pendingToolCalls.length > 0) { + const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCalls); + if (this.isInterrupted(sessionId)) { + return; + } + if (toolAppendResult.waitingForUser) { + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + toolCalls: pendingToolCalls, + status: "waiting_for_user", + updateTime: new Date().toISOString(), + })); + return; + } + } + const compactPromptTokenThreshold = getCompactPromptTokenThreshold(model); if (session.activeTokens > compactPromptTokenThreshold) { const message = this.buildAssistantMessage( @@ -1859,6 +1891,20 @@ ${skillMd} return pairings; } + private getTrailingPendingToolCalls(messages: SessionMessage[]): unknown[] { + const activeMessages = messages.filter((message) => !message.compacted); + const latestMessage = activeMessages[activeMessages.length - 1]; + if (!latestMessage || latestMessage.role !== "assistant") { + return []; + } + + const toolCalls = this.getAssistantToolCalls(latestMessage); + if (toolCalls.length === 0) { + return []; + } + return toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))); + } + private findPairableToolMessageIndex( messages: SessionMessage[], assistantIndex: number, diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 3dad6df..50d016c 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -723,6 +723,100 @@ test("replySession reports a new prompt with the machineId token", async () => { assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-456"); }); +test("replySession continues without appending /continue as a user message", async () => { + const workspace = createTempDir("deepcode-continue-workspace-"); + const home = createTempDir("deepcode-continue-home-"); + setHomeDir(home); + + const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + fetchCalls.push({ input, init }); + return { + ok: true, + text: async () => "", + } as Response; + }) as typeof fetch; + + const manager = createSessionManager(workspace, "machine-id-continue"); + const activatedSessionIds: string[] = []; + (manager as any).activateSession = async (sessionId: string) => { + activatedSessionIds.push(sessionId); + }; + + const sessionId = await manager.createSession({ text: "first prompt" }); + await flushPromises(); + const messagesBefore = manager.listSessionMessages(sessionId); + fetchCalls.length = 0; + activatedSessionIds.length = 0; + + await manager.replySession(sessionId, { text: "/continue" }); + await flushPromises(); + + const messagesAfter = manager.listSessionMessages(sessionId); + const userMessages = messagesAfter.filter((message) => message.role === "user"); + + assert.equal(activatedSessionIds.length, 1); + assert.equal(activatedSessionIds[0], sessionId); + assert.equal(messagesAfter.length, messagesBefore.length); + assert.equal( + userMessages.some((message) => message.content === "/continue"), + false + ); + assert.equal(fetchCalls.length, 0); +}); + +test("replySession /continue runs trailing pending tool calls before requesting another response", async () => { + const workspace = createTempDir("deepcode-continue-tool-workspace-"); + const home = createTempDir("deepcode-continue-tool-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("continued after tool", { + prompt_tokens: 9, + completion_tokens: 2, + total_tokens: 11, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const pendingAssistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to read a file", + [ + { + id: "call-pending-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) }, + }, + ], + null + ) as SessionMessage; + fs.writeFileSync(path.join(workspace, "note.txt"), "hello from pending tool\n", "utf8"); + (manager as any).appendSessionMessage(sessionId, pendingAssistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { text: "/continue" }); + + const messages = manager.listSessionMessages(sessionId); + const toolMessage = messages.find((message) => { + const params = message.messageParams as { tool_call_id?: string } | null; + return message.role === "tool" && params?.tool_call_id === "call-pending-read"; + }); + const assistantMessages = messages.filter((message) => message.role === "assistant"); + const userMessages = messages.filter((message) => message.role === "user"); + + assert.ok(toolMessage); + assert.match(toolMessage.content ?? "", /hello from pending tool/); + assert.equal(assistantMessages[assistantMessages.length - 1]?.content, "continued after tool"); + assert.equal( + userMessages.some((message) => message.content === "/continue"), + false + ); +}); + test("replySession preserves raw session messages when a previous tool call is pending", async () => { const workspace = createTempDir("deepcode-pending-tool-workspace-"); const home = createTempDir("deepcode-pending-tool-home-"); diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 45b6fdf..bba5244 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", "mcp", "exit"]); + assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "exit"]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -59,6 +59,13 @@ test("findExactSlashCommand returns built-in /init", () => { assert.equal(item?.description, "Initialize an AGENTS.md file with instructions for LLM"); }); +test("findExactSlashCommand returns built-in /continue", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/continue"); + assert.ok(item); + assert.equal(item?.kind, "continue"); +}); + test("findExactSlashCommand returns built-in /skills", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/skills"); diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index 43af7ca..7f371be 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -356,6 +356,80 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn assert.equal(fs.readFileSync(filePath, "utf8"), "params['city_json'] = city\n"); }); +test("Edit accepts a unique loose-escape match for over-escaped unicode sequences", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "keys.ts"); + fs.writeFileSync(filePath, 'const sequence = "\\u001B[13;2~";\n', "utf8"); + + const sessionId = "unicode-loose-escape"; + await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + + let llmCalls = 0; + const editResult = await handleEditTool( + { + file_path: filePath, + old_string: 'const sequence = "\\\\u001B[13;2~";', + new_string: 'const sequence = "\\\\u001B[13;130u";', + }, + createContext(sessionId, workspace, { + createOpenAIClient: () => ({ + client: { + chat: { + completions: { + create: async (request: { messages?: Array<{ content?: string }> }) => { + llmCalls += 1; + assert.match(String(request.messages?.[1]?.content ?? ""), /" + + '' + + '' + + "", + }, + }, + ], + }; + }, + }, + }, + } as any, + model: "test-model", + thinkingEnabled: false, + }), + }) + ); + + assert.equal(editResult.ok, true); + assert.equal(llmCalls, 1); + assert.equal(editResult.metadata?.matched_via, "llm_escape_correction"); + assert.equal(fs.readFileSync(filePath, "utf8"), 'const sequence = "\\u001B[13;130u";\n'); +}); + +test("Edit strips accidental read-result tabs after newlines when that creates a unique match", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "tabs.ts"); + fs.writeFileSync(filePath, ["function demo() {", " return 1;", "}"].join("\n") + "\n", "utf8"); + + const sessionId = "line-leading-tab-correction"; + await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + + const editResult = await handleEditTool( + { + file_path: filePath, + old_string: "function demo() {\n\t return 1;\n\t}", + new_string: "function demo() {\n\t return 2;\n\t}", + }, + createContext(sessionId, workspace) + ); + + assert.equal(editResult.ok, true); + assert.equal(editResult.metadata?.matched_via, "line_leading_tab_correction"); + assert.equal(fs.readFileSync(filePath, "utf8"), ["function demo() {", " return 2;", "}"].join("\n") + "\n"); +}); + test("Write repairs JSON object content for .json files", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "package.json"); diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 5aaede8..29108e5 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -213,10 +213,23 @@ export async function handleEditTool( const lineIndex = buildLineIndex(raw); const scope = buildSearchScope(filePath, raw, lineIndex, snippet ?? null); let matches = findOccurrences(raw, oldString, scope); - let matchedVia: "exact" | "loose_escape" | "llm_escape_correction" = "exact"; + let matchedVia: "exact" | "line_leading_tab_correction" | "loose_escape" | "llm_escape_correction" = "exact"; let replacementOldString = oldString; let replacementNewString = newString; + if (matches.length === 0) { + const tabStrippedOldString = stripReadResultLineTabs(oldString); + if (tabStrippedOldString !== oldString) { + const tabStrippedMatches = findOccurrences(raw, tabStrippedOldString, scope); + if (tabStrippedMatches.length === 1) { + matches = tabStrippedMatches; + matchedVia = "line_leading_tab_correction"; + replacementOldString = tabStrippedOldString; + replacementNewString = stripReadResultLineTabs(newString); + } + } + } + if (matches.length === 0) { const looseEscapeMatches = findLooseEscapeMatches(raw, oldString, scope); if (looseEscapeMatches.length === 1 && looseEscapeMatches[0]?.score === 1) { @@ -545,6 +558,10 @@ function applyReplacement( return result; } +function stripReadResultLineTabs(value: string): string { + return value.replaceAll("\n\t", "\n"); +} + function buildCandidateMetadata( sessionId: string, filePath: string, @@ -691,7 +708,7 @@ function buildLooseEscapeRegex(source: string): RegExp | null { slashEnd += 1; } - if (slashEnd < source.length && isEscapeSensitiveChar(source[slashEnd])) { + if (slashEnd < source.length) { pattern += "\\\\*"; pattern += escapeRegExp(source[slashEnd]); index = slashEnd; @@ -807,10 +824,6 @@ function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null return null; } -function isEscapeSensitiveChar(value: string): boolean { - return value === '"' || value === "'" || value === "`" || value === "\\"; -} - function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8c5c375..bafb412 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -190,6 +190,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setView("session-list"); return; } + if (submission.command === "continue" && isCurrentSessionEmpty(sessionManager)) { + setShowWelcome(false); + refreshSessionsList(); + setView("session-list"); + return; + } if (submission.command === "mcp") { setShowWelcome(false); setMcpStatuses(sessionManager.getMcpStatus()); @@ -211,7 +217,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") || (submission.imageUrls.length > 0 ? "[Image]" : ""); - if (userDisplayContent) { + if (userDisplayContent && submission.command !== "continue") { setMessages((prev) => [...prev, buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length)]); } @@ -506,6 +512,11 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session }; } +function isCurrentSessionEmpty(sessionManager: SessionManager): boolean { + const activeSessionId = sessionManager.getActiveSessionId(); + return !activeSessionId || !sessionManager.getSession(activeSessionId); +} + function buildStatusLine(entry: SessionEntry): string { const parts: string[] = []; parts.push(`status: ${entry.status}`); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 3965144..b32d926 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -48,7 +48,7 @@ export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; - command?: "new" | "resume" | "mcp" | "exit"; + command?: "new" | "resume" | "continue" | "mcp" | "exit"; }; type Props = { @@ -619,6 +619,15 @@ export const PromptInput = React.memo(function PromptInput({ setShowSkillsDropdown(false); return; } + if (item.kind === "continue") { + onSubmit({ text: "/continue", imageUrls: [], command: "continue" }); + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + return; + } if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); setBuffer(EMPTY_BUFFER); diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index 16a76ac..6552ba0 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,6 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "model" | "new" | "init" | "resume" | "mcp" | "exit"; +export type SlashCommandKind = "skill" | "skills" | "model" | "new" | "init" | "resume" | "continue" | "mcp" | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -41,6 +41,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/resume", description: "Pick a previous conversation to continue", }, + { + kind: "continue", + name: "continue", + label: "/continue", + description: "Continue the active conversation or pick one to resume", + }, { kind: "mcp", name: "mcp", From ccaa6dd073e25bbdb61dd74192a9889d5d036c1c Mon Sep 17 00:00:00 2001 From: Yanwu Date: Sat, 16 May 2026 17:58:18 +0200 Subject: [PATCH 125/217] feat: add -p/--prompt flag to auto-submit prompt on launch Adds CLI argument -p / --prompt that takes a prompt string and auto-submits it immediately when deepcode starts. Usage: deepcode -p 'write a Python script' deepcode --prompt 'write a Python script' --- src/cli.tsx | 24 ++++++++++++++++++++---- src/ui/App.tsx | 4 +++- src/ui/PromptInput.tsx | 19 ++++++++++++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index a0847d4..5f5ccb2 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -18,9 +18,11 @@ if (args.includes("--help") || args.includes("-h")) { "deepcode - Deep Code CLI", "", "Usage:", - " deepcode Launch the interactive TUI in the current directory", - " deepcode --version Print the version", - " deepcode --help Show this help", + " deepcode Launch the interactive TUI in the current directory", + " deepcode -p Launch with a pre-filled prompt", + " deepcode --prompt Same as -p", + " deepcode --version Print the version", + " deepcode --help Show this help", "", "Configuration:", " ~/.deepcode/settings.json User-level API key, model, base URL", @@ -50,6 +52,15 @@ if (args.includes("--help") || args.includes("-h")) { process.exit(0); } +function extractInitialPrompt(args: string[]): string | undefined { + const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); + if (promptIndex !== -1 && promptIndex + 1 < args.length) { + return args[promptIndex + 1]; + } + return undefined; +} + +const initialPrompt = extractInitialPrompt(args); const projectRoot = process.cwd(); configureWindowsShell(); @@ -68,7 +79,12 @@ async function main(): Promise { function startApp(): void { let restarting = false; const inkInstance = render( - restartRef.current?.()} />, + restartRef.current?.()} + />, { exitOnCtrlC: false } ); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index bafb412..1b60618 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -45,10 +45,11 @@ type View = "chat" | "session-list" | "mcp-status"; type AppProps = { projectRoot: string; version?: string; + initialPrompt?: string; onRestart?: () => void; }; -export function App({ projectRoot, version = "", onRestart }: AppProps): React.ReactElement { +export function App({ projectRoot, version = "", initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns } = useWindowSize(); @@ -470,6 +471,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R promptHistory={promptHistory} busy={busy} loadingText={loadingText} + initialPrompt={initialPrompt} onSubmit={handleSubmit} onModelConfigChange={handleModelConfigChange} onInterrupt={handleInterrupt} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index b32d926..353a827 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -60,6 +60,7 @@ type Props = { loadingText?: string | null; disabled?: boolean; placeholder?: string; + initialPrompt?: string; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onInterrupt: () => void; @@ -109,13 +110,16 @@ export const PromptInput = React.memo(function PromptInput({ loadingText, disabled, placeholder, + initialPrompt, onSubmit, onModelConfigChange, onInterrupt, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); - const [buffer, setBuffer] = useState(EMPTY_BUFFER); + const [buffer, setBuffer] = useState(() => + initialPrompt ? { text: initialPrompt, cursor: initialPrompt.length } : EMPTY_BUFFER + ); const [imageUrls, setImageUrls] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); const [statusMessage, setStatusMessage] = useState(null); @@ -192,6 +196,19 @@ export const PromptInput = React.memo(function PromptInput({ setDraftBeforeHistory(null); }, [promptHistoryKey]); + // Auto-submit initial prompt provided via -p/--prompt CLI flag + useEffect(() => { + if (!initialPrompt || !initialPrompt.trim()) return; + + onSubmit({ + text: initialPrompt, + imageUrls: [], + selectedSkills: undefined, + }); + setBuffer(EMPTY_BUFFER); + clearPromptUndoRedoState(undoRedoRef.current); + }, []); // Only on mount + useTerminalInput( (input, key) => { if (key.focusIn) { From e87f022dd42ab08f5929162a2bd742495e2e01a6 Mon Sep 17 00:00:00 2001 From: dengm Date: Sat, 16 May 2026 18:22:28 +0800 Subject: [PATCH 126/217] feat: add manual MCP server reconnect with secondary menu Replace automatic retry with user-initiated reconnect: - Failed servers show error details and a [Reconnect] option - Reconnect reads latest config from disk (no restart needed) - Single attempt per reconnect, no backoff/retry --- src/mcp/mcp-client.ts | 34 ++++- src/mcp/mcp-manager.ts | 266 +++++++++++++++++++++++--------------- src/session.ts | 4 + src/tests/session.test.ts | 55 ++++++++ src/ui/App.tsx | 9 +- src/ui/McpStatusList.tsx | 129 ++++++++++++------ 6 files changed, 346 insertions(+), 151 deletions(-) diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index 9636732..3651c88 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -106,19 +106,24 @@ export class McpClient { >(); private stderrBuffer = ""; private notificationHandler: McpNotificationHandler | null = null; + private disconnectHandler: ((reason: string) => void) | null = null; + private intentionallyDisconnected = false; constructor( private readonly serverName: string, private readonly command: string, private readonly args: string[] = [], private readonly env?: Record, - onNotification?: McpNotificationHandler + onNotification?: McpNotificationHandler, + onDisconnect?: (reason: string) => void ) { this.notificationHandler = onNotification ?? null; + this.disconnectHandler = onDisconnect ?? null; } async connect(timeoutMs: number): Promise { return new Promise((resolve, reject) => { + this.intentionallyDisconnected = false; const childEnv = { ...process.env, ...this.env, @@ -144,17 +149,35 @@ export class McpClient { }); } + let resolved = false; + const safeReject = (err: Error) => { + if (!resolved) { + resolved = true; + reject(err); + } + }; + this.process.on("error", (err) => { - reject(this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`)); + safeReject( + this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`) + ); }); this.process.on("close", (code) => { - const error = this.withStderr(`MCP server "${this.serverName}" exited with code ${code}`); + const reason = `MCP server "${this.serverName}" exited with code ${code}`; + const error = this.withStderr(reason); for (const [, pending] of this.pendingRequests) { clearTimeout(pending.timer); pending.reject(error); } this.pendingRequests.clear(); + this.reader?.close(); + this.reader = null; + this.process = null; + if (!this.intentionallyDisconnected && this.disconnectHandler) { + this.disconnectHandler(reason); + } + safeReject(error); }); if (this.process.stderr) { @@ -263,6 +286,7 @@ export class McpClient { } disconnect(): void { + this.intentionallyDisconnected = true; if (this.reader) { this.reader.close(); this.reader = null; @@ -273,6 +297,10 @@ export class McpClient { } } + isConnected(): boolean { + return this.process !== null && this.process.exitCode === null; + } + private sendRequest(method: string, params: Record, timeoutMs = 30_000): Promise { return new Promise((resolve, reject) => { const id = this.nextId++; diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts index 5a9f553..217e3fc 100644 --- a/src/mcp/mcp-manager.ts +++ b/src/mcp/mcp-manager.ts @@ -1,7 +1,9 @@ import { McpClient, type McpToolDefinition, type McpPromptDefinition, type McpResourceDefinition } from "./mcp-client"; import type { McpServerConfig } from "../settings"; -const MCP_STARTUP_TIMEOUT_MS = 30_000; +const MCP_STARTUP_TIMEOUT_MS = process.env.DEEPCODE_MCP_TIMEOUT + ? parseInt(process.env.DEEPCODE_MCP_TIMEOUT, 10) + : 30_000; const MCP_CALL_TOOL_TIMEOUT_MS = 60_000; type McpToolEntry = { @@ -14,7 +16,7 @@ type McpToolEntry = { export type McpServerStatus = { name: string; - status: "starting" | "ready" | "failed"; + status: "starting" | "ready" | "failed" | "reconnecting"; connected: boolean; error?: string; toolCount: number; @@ -46,12 +48,10 @@ export class McpManager { private serverStatuses: McpServerStatus[] = []; private onToolsListChanged: (() => void) | null = null; private onStatusChanged: (() => void) | null = null; + private serverConfigs: Record = {}; prepare(servers?: Record): void { if (!servers || Object.keys(servers).length === 0) return; - // Clear the disposed flag — a re-prepare means we are live again. - // (disconnect() sets disposed=true to stop a stale initialize() loop, - // but prepare+initialize must be able to start a new one.) this.disposed = false; for (const name of Object.keys(servers)) { @@ -81,116 +81,175 @@ export class McpManager { if (!servers || Object.keys(servers).length === 0) return; - const entries = Object.entries(servers); + this.serverConfigs = servers; this.prepare(servers); - for (const [name, config] of entries) { + for (const [name, config] of Object.entries(servers)) { if (this.disposed) break; - let client: McpClient | null = null; - try { - client = new McpClient(name, config.command, config.args ?? [], config.env, (method) => { + await this.connectServer(name, config); + } + } + + async reconnect(name: string, config?: McpServerConfig): Promise { + if (this.disposed) return; + const effectiveConfig = config ?? this.serverConfigs[name]; + if (!effectiveConfig) return; + if (config) { + this.serverConfigs[name] = config; + } + + this.setStatus({ + name, + status: "reconnecting", + connected: false, + error: "Reconnecting...", + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + + await this.connectServer(name, effectiveConfig); + } + + private async connectServer(name: string, config: McpServerConfig): Promise { + if (this.disposed) return; + + // Clean up stale entries from previous connection attempts + this.clients = this.clients.filter((c) => c.isConnected()); + this.tools = this.tools.filter((t) => t.serverName !== name); + this.prompts = this.prompts.filter((p) => p.serverName !== name); + this.resources = this.resources.filter((r) => r.serverName !== name); + + let client: McpClient | null = null; + try { + client = new McpClient( + name, + config.command, + config.args ?? [], + config.env, + (method) => { if (method === "notifications/tools/list_changed") { - this.refreshServerTools(name, client!).catch(() => { - // swallow refresh errors - }); + this.refreshServerTools(name, client!).catch(() => {}); + } + }, + (reason) => { + if (!this.disposed && this.serverConfigs[name]) { + this.onServerCrash(name, reason); } - }); - await client.connect(MCP_STARTUP_TIMEOUT_MS); - if (this.disposed) { - client.disconnect(); - break; - } - this.clients.push(client); - - // Discover tools - const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); - if (this.disposed) break; - const toolNamespacedNames: string[] = []; - for (const tool of serverTools) { - const namespacedName = `mcp__${name}__${tool.name}`; - this.tools.push({ - serverName: name, - originalName: tool.name, - namespacedName, - definition: tool, - client, - }); - toolNamespacedNames.push(namespacedName); - } - - // Discover prompts - let serverPrompts: McpPromptDefinition[] = []; - try { - serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS); - } catch { - // Server may not support prompts — safe to ignore - } - if (this.disposed) break; - const promptNamespacedNames: string[] = []; - for (const prompt of serverPrompts) { - const namespacedName = `mcp__${name}__${prompt.name}`; - this.prompts.push({ - serverName: name, - namespacedName, - definition: prompt, - client, - }); - promptNamespacedNames.push(namespacedName); } + ); + await client.connect(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) { + client.disconnect(); + return; + } + this.clients.push(client); - // Discover resources - let serverResources: McpResourceDefinition[] = []; - try { - serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS); - } catch { - // Server may not support resources — safe to ignore - } - if (this.disposed) break; - const resourceNamespacedNames: string[] = []; - for (const resource of serverResources) { - const namespacedName = `mcp__${name}__${resource.name}`; - this.resources.push({ - serverName: name, - namespacedName, - definition: resource, - client, - }); - resourceNamespacedNames.push(namespacedName); - } + const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) return; + const toolNamespacedNames: string[] = []; + for (const tool of serverTools) { + const namespacedName = `mcp__${name}__${tool.name}`; + this.tools.push({ + serverName: name, + originalName: tool.name, + namespacedName, + definition: tool, + client, + }); + toolNamespacedNames.push(namespacedName); + } - this.setStatus({ - name, - status: "ready", - connected: true, - toolCount: serverTools.length, - tools: toolNamespacedNames, - promptCount: serverPrompts.length, - prompts: promptNamespacedNames, - resourceCount: serverResources.length, - resources: resourceNamespacedNames, + let serverPrompts: McpPromptDefinition[] = []; + try { + serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS); + } catch { + // server may not support prompts + } + if (this.disposed) return; + const promptNamespacedNames: string[] = []; + for (const prompt of serverPrompts) { + const namespacedName = `mcp__${name}__${prompt.name}`; + this.prompts.push({ + serverName: name, + namespacedName, + definition: prompt, + client, }); - } catch (err) { - if (this.disposed) break; - client?.disconnect(); - const message = err instanceof Error ? err.message : String(err); - // 不在控制台输出错误日志,避免暴露敏感信息 - // process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`); - this.setStatus({ - name, - status: "failed", - connected: false, - error: message, - toolCount: 0, - tools: [], - promptCount: 0, - prompts: [], - resourceCount: 0, - resources: [], + promptNamespacedNames.push(namespacedName); + } + + let serverResources: McpResourceDefinition[] = []; + try { + serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS); + } catch { + // server may not support resources + } + if (this.disposed) return; + const resourceNamespacedNames: string[] = []; + for (const resource of serverResources) { + const namespacedName = `mcp__${name}__${resource.name}`; + this.resources.push({ + serverName: name, + namespacedName, + definition: resource, + client, }); + resourceNamespacedNames.push(namespacedName); } + + this.setStatus({ + name, + status: "ready", + connected: true, + toolCount: serverTools.length, + tools: toolNamespacedNames, + promptCount: serverPrompts.length, + prompts: promptNamespacedNames, + resourceCount: serverResources.length, + resources: resourceNamespacedNames, + }); + } catch (err) { + client?.disconnect(); + const message = err instanceof Error ? err.message : String(err); + this.setStatus({ + name, + status: "failed", + connected: false, + error: message, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); } } + private onServerCrash(name: string, reason: string): void { + if (this.disposed) return; + this.clients = this.clients.filter((c) => c.isConnected()); + this.tools = this.tools.filter((t) => t.serverName !== name); + this.prompts = this.prompts.filter((p) => p.serverName !== name); + this.resources = this.resources.filter((r) => r.serverName !== name); + this.setStatus({ + name, + status: "failed", + connected: false, + error: reason, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + } + getStatus(): McpServerStatus[] { const result = [...this.serverStatuses]; const knownNames = new Set(result.map((s) => s.name)); @@ -345,12 +404,12 @@ export class McpManager { this.resources = []; this.serverStatuses = []; this.configuredServerNames = []; + this.serverConfigs = {}; this.initialized = false; } private async refreshServerTools(serverName: string, client: McpClient): Promise { const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); - // Remove old tool entries for this server this.tools = this.tools.filter((t) => t.serverName !== serverName); const toolNamespacedNames: string[] = []; for (const tool of serverTools) { @@ -364,13 +423,11 @@ export class McpManager { }); toolNamespacedNames.push(namespacedName); } - // Update status const existing = this.serverStatuses.find((s) => s.name === serverName); if (existing) { existing.toolCount = serverTools.length; existing.tools = toolNamespacedNames; } - // Notify listener this.onToolsListChanged?.(); } @@ -390,7 +447,6 @@ export class McpManager { } else { this.serverStatuses[index] = status; } - // 触发状态变更回调 this.onStatusChanged?.(); } } diff --git a/src/session.ts b/src/session.ts index 095cd3a..9e97f86 100644 --- a/src/session.ts +++ b/src/session.ts @@ -255,6 +255,10 @@ export class SessionManager { return this.mcpManager.getStatus(); } + async reconnectMcpServer(name: string, config?: McpServerConfig): Promise { + await this.mcpManager.reconnect(name, config); + } + dispose(): void { this.mcpManager.disconnect(); } diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 50d016c..8ecb85e 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1540,6 +1540,61 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = assert.equal(session?.failReason, "interrupted"); }); +test("SessionManager marks MCP server as failed on single failed attempt (no auto-retry)", async () => { + const workspace = createTempDir("deepcode-mcp-fail-noworkspace-"); + const serverPath = path.join(workspace, "mcp-server-fail.cjs"); + fs.writeFileSync(serverPath, "process.exit(7);", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-mcp-fail-no"); + await manager.initMcpServers({ broken: { command: process.execPath, args: [serverPath] } }); + + const status = manager.getMcpStatus(); + assert.equal(status.length, 1); + assert.equal(status[0]?.status, "failed"); + assert.match(status[0]?.error ?? "", /exited with code 7/); + + manager.dispose(); +}); + +test("SessionManager reconnect succeeds on previously failed server", async () => { + const workspace = createTempDir("deepcode-mcp-reconn-ok-workspace-"); + const serverPath = path.join(workspace, "mcp-server-ok.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) return; + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: {} } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [{ name: "ping", inputSchema: { type: "object", properties: {} } }] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-reconn-ok"); + await manager.initMcpServers({ fixable: { command: process.execPath, args: [serverPath] } }); + + const status = manager.getMcpStatus(); + assert.equal(status.length, 1); + assert.equal(status[0]?.status, "ready"); + assert.equal(status[0]?.toolCount, 1); + + manager.dispose(); +}); + function createSessionManager(projectRoot: string, machineId: string): SessionManager { return new SessionManager({ projectRoot, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index bafb412..1f12198 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -455,7 +455,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R onCancel={() => setView("chat")} /> ) : view === "mcp-status" ? ( - setView("chat")} /> + setView("chat")} + onReconnect={(name) => { + const latest = resolveCurrentSettings(projectRoot); + void sessionManager.reconnectMcpServer(name, latest.mcpServers?.[name]); + }} + /> ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( void; + onReconnect: (name: string) => void; }; -export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { +export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React.ReactElement { const { columns, rows } = useWindowSize(); // 视图模式:server-list(服务器列表) 或 server-detail(服务器详情) @@ -20,10 +21,10 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement setViewMode("server-list"); }, []); - // 进入服务器详情 + // 进入服务器详情(允许 ready、failed、reconnecting 状态) const enterDetail = useCallback(() => { const server = statuses[selectedServerIndex]; - if (server && server.status === "ready") { + if (server && (server.status === "ready" || server.status === "failed" || server.status === "reconnecting")) { setViewMode("server-detail"); } }, [statuses, selectedServerIndex]); @@ -59,6 +60,7 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement server={statuses[selectedServerIndex]} onBack={goBack} onCancel={onCancel} + onReconnect={onReconnect} rows={rows} columns={columns} /> @@ -173,6 +175,7 @@ function ServerListView({ const readyCount = statuses.filter((s) => s.status === "ready").length; const startingCount = statuses.filter((s) => s.status === "starting").length; + const reconnectingCount = statuses.filter((s) => s.status === "reconnecting").length; const failedCount = statuses.filter((s) => s.status === "failed").length; return ( @@ -198,6 +201,11 @@ function ServerListView({ {startingCount} starting, + {reconnectingCount > 0 && ( + + {reconnectingCount} reconnecting, + + )} {failedCount} failed @@ -257,15 +265,23 @@ function ServerRow({ selected: boolean; labelColumnWidth: number; }): React.ReactElement { - const icon = status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : "●"; - const color = status.status === "ready" ? "green" : status.status === "failed" ? "red" : "yellow"; + const icon = + status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : status.status === "reconnecting" ? "↻" : "●"; + const color = + status.status === "ready" + ? "green" + : status.status === "failed" + ? "red" + : status.status === "reconnecting" + ? "#ff9900" + : "yellow"; // 加载动画:循环显示 (空) → . → .. → ... → (空) → ... const [dots, setDots] = React.useState(0); React.useEffect(() => { - if (status.status !== "starting") return; + if (status.status !== "starting" && status.status !== "reconnecting") return; const interval = setInterval(() => { - setDots((d) => (d + 1) % 4); // 0 → 1 → 2 → 3 → 0 ... + setDots((d) => (d + 1) % 4); }, 500); return () => clearInterval(interval); }, [status.status]); @@ -275,7 +291,9 @@ function ServerRow({ ? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)` : status.status === "failed" ? `Failed` - : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); // 动态显示 (空) / . / .. / ... + : status.status === "reconnecting" + ? `Reconnecting${dots > 0 ? ".".repeat(dots) : " "}` + : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); return ( @@ -293,8 +311,10 @@ function ServerRow({ - {/* Error message for failed servers */} - {status.status === "failed" && status.error ? : null} + {/* Error message for failed or reconnecting servers */} + {(status.status === "failed" || status.status === "reconnecting") && status.error ? ( + + ) : null} ); } @@ -304,59 +324,54 @@ function ServerDetailView({ server, onBack, onCancel, + onReconnect, rows, columns, }: { server: McpServerStatus; onBack: () => void; onCancel: () => void; + onReconnect: (name: string) => void; rows: number; columns: number; }): React.ReactElement { - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndex] = React.useState(0); + const hasReconnect = server.status === "failed"; + const canScroll = server.status === "ready"; - // 合并所有 items(tools, prompts, resources) + // 合并所有 items(tools, prompts, resources)+ Reconnect 选项 const allItems = useMemo(() => { const items: { type: string; name: string }[] = []; + if (hasReconnect) { + items.push({ type: "action", name: "Reconnect" }); + } server.tools.forEach((tool) => items.push({ type: "tool", name: tool })); server.prompts.forEach((prompt) => items.push({ type: "prompt", name: prompt })); server.resources.forEach((resource) => items.push({ type: "resource", name: resource })); return items; - }, [server]); + }, [server, hasReconnect]); const totalItems = allItems.length; const maxVisible = useMemo(() => { - const reservedLines = 10; // header + title + stats + footer + borders + const reservedLines = 12; // header + title + stats + error + footer + borders const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); return Math.max(1, availableLines); }, [rows]); - // 使用 ref 跟踪 visibleStart,避免循环依赖 const visibleStartRef = React.useRef(0); - // 计算可见窗口起始位置:当 activeIndex 超出可见区域时才滚动(类似终端光标行为) const visibleStart = useMemo(() => { if (totalItems === 0) return 0; - const currentStart = visibleStartRef.current; let newStart = currentStart; - - // 如果 activeIndex 在当前可见窗口之前,滚动到 activeIndex if (activeIndex < currentStart) { newStart = activeIndex; - } - // 如果 activeIndex 在当前可见窗口之后,滚动到 activeIndex - else if (activeIndex >= currentStart + maxVisible) { + } else if (activeIndex >= currentStart + maxVisible) { newStart = activeIndex - maxVisible + 1; } - - // 限制在合法范围内 newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible))); - - // 更新 ref visibleStartRef.current = newStart; - return newStart; }, [activeIndex, maxVisible, totalItems]); @@ -371,11 +386,16 @@ function ServerDetailView({ onBack(); return; } - // Space 或 Enter 键返回一级菜单 - if (input === " " || key.return) { + if (key.return || input === " ") { + if (activeIndex === 0 && hasReconnect) { + onReconnect(server.name); + onBack(); + return; + } onBack(); return; } + if (!canScroll && !hasReconnect) return; if (key.upArrow) { setActiveIndex((prev) => Math.max(0, prev - 1)); return; @@ -384,25 +404,33 @@ function ServerDetailView({ setActiveIndex((prev) => Math.min(totalItems - 1, prev + 1)); return; } - if (key.pageUp) { + if (key.pageUp && canScroll) { setActiveIndex((prev) => Math.max(0, prev - maxVisible)); return; } - if (key.pageDown) { + if (key.pageDown && canScroll) { setActiveIndex((prev) => Math.min(totalItems - 1, prev + maxVisible)); return; } - if (key.home) { + if (key.home && canScroll) { setActiveIndex(0); return; } - if (key.end) { + if (key.end && canScroll) { setActiveIndex(totalItems - 1); } }); - const icon = "✓"; - const color = "green"; + const statusIcon = + server.status === "ready" ? "✓" : server.status === "failed" ? "✗" : server.status === "reconnecting" ? "↻" : "●"; + const statusColor = + server.status === "ready" + ? "green" + : server.status === "failed" + ? "red" + : server.status === "reconnecting" + ? "#ff9900" + : "yellow"; return ( {/* Header row */} - {icon} + {statusIcon} {server.name} - — Details + — {server.status === "ready" ? "Details" : "Status"} {/* Server info */} - {server.toolCount} tools, {server.promptCount} prompts, {server.resourceCount} resources + {server.status === "ready" + ? `${server.toolCount} tools, ${server.promptCount} prompts, ${server.resourceCount} resources` + : `Status: ${server.status}`} + {/* Error for failed/reconnecting */} + {server.error && (server.status === "failed" || server.status === "reconnecting") ? ( + + + + ) : null} {/* Items list */} {/* Footer */} - ↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close + + {hasReconnect + ? "Enter to reconnect · Esc back · Ctrl+C close" + : canScroll + ? "↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close" + : "Space/Enter back · Esc back · Ctrl+C close"} + @@ -481,13 +523,16 @@ function ServerDetailView({ } function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement { - const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + const isAction = item.type === "action"; + const icon = isAction ? "↻" : item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + const color = isAction && selected ? "#ff9900" : selected ? "#229ac3" : undefined; return ( + {selected ? "> " : " "} {icon} - - {item.name} + + {isAction ? `[${item.name}]` : item.name} ); From 2b509715fec54287cbc872c078e09ebb8985c2ad Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sun, 17 May 2026 10:31:18 +0800 Subject: [PATCH 127/217] fix: the -p/--prompt option causes duplicated LLM calls when using /new. --- src/cli.tsx | 6 ++++-- src/session.ts | 19 +++++++------------ src/tests/session.test.ts | 26 ++++++++++++++++++++++++++ src/ui/App.tsx | 15 ++++++++++++++- src/ui/PromptInput.tsx | 19 +------------------ 5 files changed, 52 insertions(+), 33 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index 5f5ccb2..435499a 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -60,7 +60,7 @@ function extractInitialPrompt(args: string[]): string | undefined { return undefined; } -const initialPrompt = extractInitialPrompt(args); +let initialPrompt = extractInitialPrompt(args); const projectRoot = process.cwd(); configureWindowsShell(); @@ -78,11 +78,13 @@ async function main(): Promise { function startApp(): void { let restarting = false; + const appInitialPrompt = initialPrompt; + initialPrompt = undefined; const inkInstance = render( restartRef.current?.()} />, { exitOnCtrlC: false } diff --git a/src/session.ts b/src/session.ts index 095cd3a..5d6ee26 100644 --- a/src/session.ts +++ b/src/session.ts @@ -18,6 +18,7 @@ import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debu const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; +const NEW_PROMPT_REPORT_TIMEOUT_MS = 3000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; @@ -1305,6 +1306,9 @@ ${skillMd} return; } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), NEW_PROMPT_REPORT_TIMEOUT_MS); + void fetch(DEFAULT_NEW_PROMPT_API_URL, { method: "POST", headers: { @@ -1312,19 +1316,10 @@ ${skillMd} Token: machineId, }, body: JSON.stringify({}), + signal: controller.signal, }) - .then(async (response) => { - if (response.ok) { - return; - } - - const body = await response.text().catch(() => ""); - throw new Error(`New prompt API request failed with status ${response.status}${body ? `: ${body}` : ""}`); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.warn(`Failed to report new prompt: ${message}`); - }); + .catch(() => {}) + .finally(() => clearTimeout(timeout)); } interruptActiveSession(): void { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 50d016c..e5ab740 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { SessionManager, type SessionMessage } from "../session"; const originalFetch = globalThis.fetch; +const originalConsoleWarn = console.warn; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; const tempDirs: string[] = []; @@ -20,6 +21,7 @@ function setHomeDir(dir: string): void { afterEach(() => { globalThis.fetch = originalFetch; + console.warn = originalConsoleWarn; if (originalHome === undefined) { delete process.env.HOME; } else { @@ -688,6 +690,7 @@ test("createSession reports a new prompt with the machineId token", async () => assert.equal(fetchCalls.length, 1); assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/new"); assert.equal(fetchCalls[0].init?.method, "POST"); + assert.ok(fetchCalls[0].init?.signal instanceof AbortSignal); assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-123"); }); @@ -719,10 +722,33 @@ test("replySession reports a new prompt with the machineId token", async () => { assert.equal(fetchCalls.length, 1); assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/new"); assert.equal(fetchCalls[0].init?.method, "POST"); + assert.ok(fetchCalls[0].init?.signal instanceof AbortSignal); assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-456"); }); +test("reporting a new prompt does not warn when the background request fails", async () => { + const workspace = createTempDir("deepcode-report-failure-workspace-"); + const home = createTempDir("deepcode-report-failure-home-"); + setHomeDir(home); + + const warnings: unknown[][] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + globalThis.fetch = (async () => { + throw new Error("fetch failed"); + }) as typeof fetch; + + const manager = createSessionManager(workspace, "machine-id-failure"); + (manager as any).activateSession = async () => {}; + + await manager.createSession({ text: "hello world" }); + await flushPromises(); + + assert.deepEqual(warnings, []); +}); + test("replySession continues without appending /continue as a user message", async () => { const workspace = createTempDir("deepcode-continue-workspace-"); const home = createTempDir("deepcode-continue-home-"); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1b60618..b416647 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -53,6 +53,7 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns } = useWindowSize(); + const initialPromptSubmittedRef = useRef(false); const [view, setView] = useState("chat"); const [busy, setBusy] = useState(false); const [skills, setSkills] = useState([]); @@ -296,6 +297,19 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App [handlePrompt] ); + useEffect(() => { + if (initialPromptSubmittedRef.current || !initialPrompt || !initialPrompt.trim()) { + return; + } + + initialPromptSubmittedRef.current = true; + handleSubmit({ + text: initialPrompt, + imageUrls: [], + selectedSkills: undefined, + }); + }, [handleSubmit, initialPrompt]); + const handleSelectSession = useCallback( async (sessionId: string) => { const currentSessionId = sessionManager.getActiveSessionId(); @@ -471,7 +485,6 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App promptHistory={promptHistory} busy={busy} loadingText={loadingText} - initialPrompt={initialPrompt} onSubmit={handleSubmit} onModelConfigChange={handleModelConfigChange} onInterrupt={handleInterrupt} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 353a827..b32d926 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -60,7 +60,6 @@ type Props = { loadingText?: string | null; disabled?: boolean; placeholder?: string; - initialPrompt?: string; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onInterrupt: () => void; @@ -110,16 +109,13 @@ export const PromptInput = React.memo(function PromptInput({ loadingText, disabled, placeholder, - initialPrompt, onSubmit, onModelConfigChange, onInterrupt, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); - const [buffer, setBuffer] = useState(() => - initialPrompt ? { text: initialPrompt, cursor: initialPrompt.length } : EMPTY_BUFFER - ); + const [buffer, setBuffer] = useState(EMPTY_BUFFER); const [imageUrls, setImageUrls] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); const [statusMessage, setStatusMessage] = useState(null); @@ -196,19 +192,6 @@ export const PromptInput = React.memo(function PromptInput({ setDraftBeforeHistory(null); }, [promptHistoryKey]); - // Auto-submit initial prompt provided via -p/--prompt CLI flag - useEffect(() => { - if (!initialPrompt || !initialPrompt.trim()) return; - - onSubmit({ - text: initialPrompt, - imageUrls: [], - selectedSkills: undefined, - }); - setBuffer(EMPTY_BUFFER); - clearPromptUndoRedoState(undoRedoRef.current); - }, []); // Only on mount - useTerminalInput( (input, key) => { if (key.focusIn) { From 65caf610b535b6a230ba9c58ac0ec5cc6b4ca7f0 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sun, 17 May 2026 12:57:24 +0800 Subject: [PATCH 128/217] feat: add test for Bash tool output streaming before command completion --- package-lock.json | 814 ++++++++++++++++++++++++++------ src/tests/tool-handlers.test.ts | 43 ++ 2 files changed, 704 insertions(+), 153 deletions(-) diff --git a/package-lock.json b/package-lock.json index f9caecd..958eed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -846,6 +846,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -856,6 +863,19 @@ "node": ">= 4" } }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", @@ -1040,13 +1060,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.8.0", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.8.0.tgz", - "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "version": "25.6.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~7.19.0" } }, "node_modules/@types/react": { @@ -1066,17 +1086,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", - "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/type-utils": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1089,22 +1109,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.3.tgz", - "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1120,14 +1140,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1142,14 +1162,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1160,9 +1180,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", - "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", "dev": true, "license": "MIT", "engines": { @@ -1177,15 +1197,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", - "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1202,9 +1222,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", - "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -1216,16 +1236,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", - "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.3", - "@typescript-eslint/tsconfig-utils": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1296,16 +1316,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.3.tgz", - "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3" + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1320,13 +1340,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", - "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1430,11 +1450,13 @@ } }, "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } }, "node_modules/auto-bind": { "version": "5.0.1", @@ -1703,7 +1725,7 @@ }, "node_modules/ejs": { "version": "5.0.2", - "resolved": "https://registry.npmmirror.com/ejs/-/ejs-5.0.2.tgz", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", "license": "Apache-2.0", "bin": { @@ -1714,9 +1736,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.356", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", - "integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==", + "version": "1.5.353", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", "dev": true, "license": "ISC" }, @@ -1802,16 +1824,12 @@ } }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/eslint": { @@ -1973,6 +1991,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -2195,9 +2226,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -2206,9 +2237,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", @@ -2239,7 +2283,7 @@ }, "node_modules/glob/node_modules/balanced-match": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", @@ -2249,7 +2293,7 @@ }, "node_modules/glob/node_modules/brace-expansion": { "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", @@ -2262,7 +2306,7 @@ }, "node_modules/glob/node_modules/minimatch": { "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", @@ -2317,28 +2361,6 @@ "node": ">=6.0" } }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", @@ -2431,9 +2453,9 @@ } }, "node_modules/ink": { - "version": "7.0.3", - "resolved": "https://registry.npmmirror.com/ink/-/ink-7.0.3.tgz", - "integrity": "sha512-5kxHkIj9+RuqCU3zyvP4qvYWNOSHP2TW/SHayHGHOmk87KwfVcZwvJGemi9ch+ci2gXUqerK/Eh2DGEDt5q45g==", + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/ink/-/ink-7.0.1.tgz", + "integrity": "sha512-o6LAC268PLawlGVYrXTyaTfke4VtJftEheuwbgkQf7yvSXyWp1nRwBbAyKEkWXFZZsW/la5wrMuNbuBvZK2C1w==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", @@ -2576,13 +2598,13 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -2949,7 +2971,7 @@ }, "node_modules/minipass": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "license": "BlueOak-1.0.0", @@ -2972,9 +2994,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "version": "2.0.38", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, @@ -2994,9 +3016,9 @@ } }, "node_modules/openai": { - "version": "6.37.0", - "resolved": "https://registry.npmmirror.com/openai/-/openai-6.37.0.tgz", - "integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==", + "version": "6.35.0", + "resolved": "https://registry.npmmirror.com/openai/-/openai-6.35.0.tgz", + "integrity": "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -3108,7 +3130,7 @@ }, "node_modules/path-scurry": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", @@ -3125,7 +3147,7 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", @@ -3190,9 +3212,9 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.5", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3223,6 +3245,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -3338,15 +3370,6 @@ "node": ">=10" } }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", @@ -3504,13 +3527,14 @@ } }, "node_modules/tsx": { - "version": "4.22.0", - "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.22.0.tgz", - "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", + "version": "4.21.0", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.28.0" + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" }, "bin": { "tsx": "dist/cli.mjs" @@ -3522,6 +3546,490 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", @@ -3565,16 +4073,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.3", - "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz", - "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.3", - "@typescript-eslint/parser": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3" + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3589,9 +4097,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "7.19.2", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -3695,9 +4203,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3723,9 +4231,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.8.4", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "dev": true, "license": "ISC", "optional": true, diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index 43af7ca..611d012 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { setTimeout as delay } from "node:timers/promises"; import type { ToolExecutionContext } from "../tools/executor"; +import { handleBashTool } from "../tools/bash-handler"; import { handleEditTool } from "../tools/edit-handler"; import { handleReadTool } from "../tools/read-handler"; import { handleWriteTool } from "../tools/write-handler"; @@ -19,6 +21,36 @@ afterEach(() => { } }); +test("Bash streams stdout and stderr before command completion", async () => { + const workspace = createTempWorkspace(); + const chunks: string[] = []; + let completed = false; + + const resultPromise = handleBashTool( + { + command: "printf 'first\\n'; sleep 1; printf 'second\\n'; printf 'err\\n' >&2", + }, + createContext("bash-live-output", workspace, { + onProcessStdout: (_pid, chunk) => { + chunks.push(chunk); + }, + }) + ).finally(() => { + completed = true; + }); + + await waitFor(() => chunks.join("").includes("first"), 1500); + + assert.equal(completed, false); + + const result = await resultPromise; + const streamedOutput = chunks.join(""); + assert.equal(result.ok, true); + assert.match(streamedOutput, /first/); + assert.match(streamedOutput, /second/); + assert.match(streamedOutput, /err/); +}); + test("Read returns snippet metadata and Edit can scope replacements by snippet_id", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "sample.txt"); @@ -573,3 +605,14 @@ function createTempWorkspace(): string { tempDirs.push(dir); return dir; } + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await delay(25); + } + assert.equal(predicate(), true); +} From ac223020cdbca70c145f93608066f21b942f54e2 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 09:33:40 +0800 Subject: [PATCH 129/217] feat: add update plan tool --- docs/SKILL.md | 259 ++++++++++++++++++++++++++++++ docs/SKILL_new.md | 264 +++++++++++++++++++++++++++++++ src/prompt.ts | 24 +++ src/tests/prompt.test.ts | 13 ++ src/tests/tool-handlers.test.ts | 26 +++ src/tools/executor.ts | 2 + src/tools/update-plan-handler.ts | 23 +++ src/ui/MessageView.tsx | 31 ++++ templates/tools/update-plan.md | 38 +++++ 9 files changed, 680 insertions(+) create mode 100644 docs/SKILL.md create mode 100644 docs/SKILL_new.md create mode 100644 src/tools/update-plan-handler.ts create mode 100644 templates/tools/update-plan.md diff --git a/docs/SKILL.md b/docs/SKILL.md new file mode 100644 index 0000000..f6d7149 --- /dev/null +++ b/docs/SKILL.md @@ -0,0 +1,259 @@ +--- +name: plan-and-execute +description: Automatically plan and execute tasks from issue documents. Reads issue requirements, creates a task list at the end of the document, and systematically executes each task while updating progress. Use when working with issue documents, task planning, or when you need to break down and execute complex multi-step requirements. +--- + +# Plan and Execute + +This Skill helps you automatically plan and execute tasks based on issue documents. It reads your requirements, creates a structured task list directly in the document, and systematically works through each task while keeping the document updated with progress. + +## Quick Start + +When you need to work through an issue document: + +1. The Skill will first ask you for the issue document path +2. It reads the document to understand requirements +3. Creates a task list at the end of the document +4. Executes tasks one by one, updating status in real-time + +## Instructions + +### Step 1: Get the issue document path + +Ask the user for the path to their issue document: + +``` +What is the path to your issue document? +``` + +The document can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. + +### Step 2: Read and analyze the issue document + +Use the Read tool to load the document content and analyze: + +- What are the main requirements? +- What tasks need to be completed? +- Are there dependencies between tasks? +- What is the complexity level? + +### Step 3: Create the task list + +Create a structured task list at the END of the issue document using this format: + +```markdown +## Task List + +- [ ] Task 1 description +- [ ] Task 2 description +- [ ] Task 3 description + +### Task Status Legend +- [ ] Pending +- [>] In Progress +- [x] Completed +``` + +Use the Edit tool to append this section to the document. Break down complex requirements into specific, actionable tasks. + +### Step 4: Execute tasks systematically + +For each task in the list: + +1. **Mark as in progress**: Update the task in the document from `[ ]` to `[>]` +2. **Execute the task**: Use appropriate tools to complete the work +3. **Mark as completed**: Update the task from `[>]` to `[x]` when finished +4. **Move to next task**: Only ONE task should be in progress at a time + +Important rules: +- Always update the document BEFORE starting work on a task +- Always update the document IMMEDIATELY after completing a task +- Never work on multiple tasks simultaneously +- If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers + +### Step 5: Handle task breakdown + +If during execution you discover a task is more complex than expected: + +1. Keep the current task as `[>]` +2. Add new sub-tasks below it with indentation: + ```markdown + - [>] Main task + - [ ] Sub-task 1 + - [ ] Sub-task 2 + ``` +3. Complete sub-tasks first, then mark the main task as complete + +### Step 6: Final verification + +After all tasks are completed (`[x]`): + +1. Review the issue requirements to ensure everything is addressed +2. Run any final checks (tests, builds, linting) +3. Add a completion summary at the end of the document + +## Task State Symbols + +- `[ ]` - **Pending**: Not started yet +- `[>]` - **In Progress**: Currently working on this +- `[x]` - **Completed**: Finished successfully +- `[!]` - **Blocked**: Cannot proceed (optional, for blocked tasks) + +## Examples + +### Example 1: Simple feature request + +**Issue document (before):** +```markdown +# Feature: Add dark mode toggle + +Users should be able to switch between light and dark themes. +The toggle should be in the settings page. +``` + +**Issue document (after task list added):** +```markdown +# Feature: Add dark mode toggle + +Users should be able to switch between light and dark themes. +The toggle should be in the settings page. + +## Task List + +- [ ] Create dark mode toggle component in Settings page +- [ ] Add dark mode state management (context/store) +- [ ] Implement CSS-in-JS styles for dark theme +- [ ] Update existing components to support theme switching +- [ ] Run tests and verify functionality + +### Task Status Legend +- [ ] Pending +- [>] In Progress +- [x] Completed +``` + +**During execution:** +```markdown +## Task List + +- [x] Create dark mode toggle component in Settings page +- [>] Add dark mode state management (context/store) +- [ ] Implement CSS-in-JS styles for dark theme +- [ ] Update existing components to support theme switching +- [ ] Run tests and verify functionality +``` + +### Example 2: Bug fix with investigation + +**Issue document:** +```markdown +# Bug: Login form crashes on submit + +When users click submit, the app crashes. +Error message: "Cannot read property 'email' of undefined" +``` + +**Task list created:** +```markdown +## Task List + +- [ ] Reproduce the bug locally +- [ ] Investigate the error in login form component +- [ ] Identify root cause of undefined email property +- [ ] Implement fix +- [ ] Add validation to prevent similar issues +- [ ] Test the fix with various inputs +- [ ] Update error handling + +### Task Status Legend +- [ ] Pending +- [>] In Progress +- [x] Completed +``` + +## When to Use This Skill + +Use this Skill when: + +1. **Complex multi-step tasks** - Issue requires 3+ distinct steps +2. **Feature implementation** - Building new functionality from requirements +3. **Bug fixing** - Need to investigate, fix, and verify +4. **Refactoring** - Multiple files or components need changes +5. **User provides requirements** - Issue document contains specifications +6. **Need progress tracking** - Want visible progress in the document itself + +## When NOT to Use This Skill + +Skip this Skill when: + +1. **Single simple task** - Just one straightforward action needed +2. **Trivial changes** - Quick fixes that don't need planning +3. **Informational requests** - User just wants explanation, not execution +4. **No document provided** - User hasn't created an issue document + +## Best Practices + +1. **Be specific with tasks**: "Add login button to navbar" not "Update UI" +2. **Keep tasks atomic**: Each task should be independently completable +3. **Update immediately**: Don't batch status updates, do them in real-time +4. **One task at a time**: Never mark multiple tasks as `[>]` +5. **Handle blockers**: If stuck, create new tasks to resolve the blocker +6. **Verify completion**: Only mark `[x]` when task is fully done + +## Advanced Usage + +### Handling dependencies + +When tasks have dependencies, order them properly: + +```markdown +- [ ] Create database schema +- [ ] Implement API endpoints (depends on schema) +- [ ] Build frontend forms (depends on API) +``` + +### Using sub-tasks + +For complex tasks, break them down: + +```markdown +- [>] Implement authentication system + - [x] Set up JWT library + - [>] Create login endpoint + - [ ] Create logout endpoint + - [ ] Add token refresh logic +``` + +### Adding notes + +Add implementation notes or findings: + +```markdown +- [x] Investigate performance issue + - Note: Found N+1 query in user loader + - Solution: Added dataloader batching +``` + +## Requirements + +This Skill uses standard Deep Code tools: + +- **Read**: To read the issue document +- **Edit**: To update task status in the document +- **Bash**: To run tests, builds, or other commands +- **Write**: To create new files if needed + +No additional dependencies required. + +## Workflow Summary + +1. Ask user for issue document path +2. Read and analyze the document +3. Append structured task list to document +4. For each task: + - Update to `[>]` in document + - Execute the task + - Update to `[x]` in document +5. Add completion summary when done + +This approach keeps all planning and progress tracking in one place - the issue document itself - making it easy for users to see what's been done and what's remaining. diff --git a/docs/SKILL_new.md b/docs/SKILL_new.md new file mode 100644 index 0000000..cdcc514 --- /dev/null +++ b/docs/SKILL_new.md @@ -0,0 +1,264 @@ +--- +name: plan-and-execute +description: Automatically plan and execute tasks from issue documents. Reads issue requirements, creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with issue documents, task planning, or when you need to break down and execute complex multi-step requirements. +--- + +# Plan and Execute + +This Skill helps you automatically plan and execute tasks based on issue documents. It reads your requirements, creates a structured markdown task list with the UpdatePlan tool, and systematically works through each task while keeping progress visible. + +## Quick Start + +When you need to work through an issue document: + +1. The Skill will first ask you for the issue document path +2. It reads the document to understand requirements +3. Creates a markdown task list by calling the UpdatePlan tool +4. Executes tasks one by one, updating the tool plan in real time + +## Instructions + +### Step 1: Get the issue document path + +Ask the user for the path to their issue document: + +``` +What is the path to your issue document? +``` + +The document can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. + +### Step 2: Read and analyze the issue document + +Use the Read tool to load the document content and analyze: + +- What are the main requirements? +- What tasks need to be completed? +- Are there dependencies between tasks? +- What is the complexity level? + +### Step 3: Create the task list + +Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: + +```json +{ + "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description\n\n### Task Status Legend\n- [ ] Pending\n- [>] In Progress\n- [x] Completed" +} +``` + +Use this markdown format for the `plan` content: + +```markdown +## Task List + +- [ ] Task 1 description +- [ ] Task 2 description +- [ ] Task 3 description + +### Task Status Legend +- [ ] Pending +- [>] In Progress +- [x] Completed +``` + +Do not append the task list to the issue document. Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. + +### Step 4: Execute tasks systematically + +For each task in the list: + +1. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` +2. **Execute the task**: Use appropriate tools to complete the work +3. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished +4. **Move to next task**: Only ONE task should be in progress at a time + +Important rules: +- Always call UpdatePlan BEFORE starting work on a task +- Always call UpdatePlan IMMEDIATELY after completing a task +- Always pass the complete current markdown task list, not a partial diff +- Never work on multiple tasks simultaneously +- If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers + +### Step 5: Handle task breakdown + +If during execution you discover a task is more complex than expected: + +1. Keep the current task as `[>]` +2. Call UpdatePlan with new sub-tasks below it with indentation: + ```markdown + - [>] Main task + - [ ] Sub-task 1 + - [ ] Sub-task 2 + ``` +3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan + +### Step 6: Final verification + +After all tasks are completed (`[x]`): + +1. Review the issue requirements to ensure everything is addressed +2. Run any final checks (tests, builds, linting) +3. Call UpdatePlan with every task marked `[x]` +4. Provide a concise completion summary in the final response + +## Task State Symbols + +- `[ ]` - **Pending**: Not started yet +- `[>]` - **In Progress**: Currently working on this +- `[x]` - **Completed**: Finished successfully +- `[!]` - **Blocked**: Cannot proceed (optional, for blocked tasks) + +## Examples + +### Example 1: Simple feature request + +**Issue document (before):** +```markdown +# Feature: Add dark mode toggle + +Users should be able to switch between light and dark themes. +The toggle should be in the settings page. +``` + +**UpdatePlan call after analysis:** +```markdown +## Task List + +- [ ] Create dark mode toggle component in Settings page +- [ ] Add dark mode state management (context/store) +- [ ] Implement CSS-in-JS styles for dark theme +- [ ] Update existing components to support theme switching +- [ ] Run tests and verify functionality + +### Task Status Legend +- [ ] Pending +- [>] In Progress +- [x] Completed +``` + +**UpdatePlan call during execution:** +```markdown +## Task List + +- [x] Create dark mode toggle component in Settings page +- [>] Add dark mode state management (context/store) +- [ ] Implement CSS-in-JS styles for dark theme +- [ ] Update existing components to support theme switching +- [ ] Run tests and verify functionality +``` + +### Example 2: Bug fix with investigation + +**Issue document:** +```markdown +# Bug: Login form crashes on submit + +When users click submit, the app crashes. +Error message: "Cannot read property 'email' of undefined" +``` + +**UpdatePlan call after analysis:** +```markdown +## Task List + +- [ ] Reproduce the bug locally +- [ ] Investigate the error in login form component +- [ ] Identify root cause of undefined email property +- [ ] Implement fix +- [ ] Add validation to prevent similar issues +- [ ] Test the fix with various inputs +- [ ] Update error handling + +### Task Status Legend +- [ ] Pending +- [>] In Progress +- [x] Completed +``` + +## When to Use This Skill + +Use this Skill when: + +1. **Complex multi-step tasks** - Issue requires 3+ distinct steps +2. **Feature implementation** - Building new functionality from requirements +3. **Bug fixing** - Need to investigate, fix, and verify +4. **Refactoring** - Multiple files or components need changes +5. **User provides requirements** - Issue document contains specifications +6. **Need progress tracking** - Want visible progress without editing the issue document + +## When NOT to Use This Skill + +Skip this Skill when: + +1. **Single simple task** - Just one straightforward action needed +2. **Trivial changes** - Quick fixes that don't need planning +3. **Informational requests** - User just wants explanation, not execution +4. **No document provided** - User hasn't created an issue document + +## Best Practices + +1. **Be specific with tasks**: "Add login button to navbar" not "Update UI" +2. **Keep tasks atomic**: Each task should be independently completable +3. **Update immediately**: Don't batch status updates, do them in real-time +4. **One task at a time**: Never mark multiple tasks as `[>]` +5. **Handle blockers**: If stuck, create new tasks to resolve the blocker +6. **Verify completion**: Only mark `[x]` when task is fully done + +## Advanced Usage + +### Handling dependencies + +When tasks have dependencies, order them properly: + +```markdown +- [ ] Create database schema +- [ ] Implement API endpoints (depends on schema) +- [ ] Build frontend forms (depends on API) +``` + +### Using sub-tasks + +For complex tasks, break them down: + +```markdown +- [>] Implement authentication system + - [x] Set up JWT library + - [>] Create login endpoint + - [ ] Create logout endpoint + - [ ] Add token refresh logic +``` + +### Adding notes + +Add implementation notes or findings: + +```markdown +- [x] Investigate performance issue + - Note: Found N+1 query in user loader + - Solution: Added dataloader batching +``` + +## Requirements + +This Skill uses standard tools: + +- **Read**: To read the issue document +- **UpdatePlan**: To create and update the markdown task list +- **Bash**: To run tests, builds, or other commands +- **Write**: To create new files if needed + +No additional dependencies required. + +## Workflow Summary + +1. Ask user for issue document path +2. Read and analyze the document +3. Call UpdatePlan with the structured markdown task list +4. For each task: + - Update to `[>]` with UpdatePlan + - Execute the task + - Update to `[x]` with UpdatePlan +5. Call UpdatePlan with all tasks completed and summarize the result + +This approach keeps planning and progress tracking in the UpdatePlan display, leaving the issue document unchanged unless the actual task requires editing it. diff --git a/src/prompt.ts b/src/prompt.ts index 4774725..50aa2a3 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -509,6 +509,30 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe }, }, }, + { + type: "function", + function: { + name: "UpdatePlan", + description: + "Update the current task plan. The plan argument must be the complete markdown task list to show as the latest progress state.", + parameters: { + type: "object", + properties: { + plan: { + type: "string", + description: + "The complete markdown task list, including task status markers such as [ ], [>], [x], and optional notes.", + }, + explanation: { + type: "string", + description: "Optional short reason for changing the plan.", + }, + }, + required: ["plan"], + additionalProperties: false, + }, + }, + }, { type: "function", function: { diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index 28c6488..b7c9178 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -12,11 +12,24 @@ test("getTools always includes WebSearch", () => { assert.equal(names.includes("WebSearch"), true); }); +test("getTools includes UpdatePlan with string plan schema", () => { + const tool = getTools().find((candidate) => candidate.function.name === "UpdatePlan"); + assert.ok(tool); + assert.deepEqual(tool.function.parameters.required, ["plan"]); + assert.equal((tool.function.parameters.properties.plan as { type?: unknown }).type, "string"); +}); + test("getSystemPrompt always includes WebSearch docs", () => { const prompt = getSystemPrompt("/tmp/project"); assert.equal(prompt.includes("## WebSearch"), true); }); +test("getSystemPrompt includes UpdatePlan docs", () => { + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes("## UpdatePlan"), true); + assert.equal(prompt.includes("The `plan` argument is a markdown string, not an array of step objects."), true); +}); + test("getSystemPrompt includes current date guidance", () => { const now = new Date(); const expected = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index 58828a2..0b21edd 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -8,6 +8,7 @@ import type { ToolExecutionContext } from "../tools/executor"; import { handleBashTool } from "../tools/bash-handler"; import { handleEditTool } from "../tools/edit-handler"; import { handleReadTool } from "../tools/read-handler"; +import { handleUpdatePlanTool } from "../tools/update-plan-handler"; import { handleWriteTool } from "../tools/write-handler"; const tempDirs: string[] = []; @@ -51,6 +52,31 @@ test("Bash streams stdout and stderr before command completion", async () => { assert.match(streamedOutput, /err/); }); +test("UpdatePlan accepts a markdown task list string", async () => { + const workspace = createTempWorkspace(); + const plan = ["## Task List", "", "- [>] Inspect current behavior", "- [ ] Implement UpdatePlan"].join("\n"); + + const result = await handleUpdatePlanTool({ plan }, createContext("update-plan", workspace)); + + assert.equal(result.ok, true); + assert.equal(result.name, "UpdatePlan"); + assert.equal(result.output, "Plan updated."); + assert.equal(result.metadata?.plan, plan); +}); + +test("UpdatePlan rejects non-string plan payloads", async () => { + const workspace = createTempWorkspace(); + + const result = await handleUpdatePlanTool( + { plan: [{ step: "Inspect current behavior", status: "in_progress" }] }, + createContext("update-plan-invalid", workspace) + ); + + assert.equal(result.ok, false); + assert.equal(result.name, "UpdatePlan"); + assert.match(result.error ?? "", /InputValidationError/); +}); + test("Read returns snippet metadata and Edit can scope replacements by snippet_id", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "sample.txt"); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index e6018d9..70ceab1 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -4,6 +4,7 @@ import { handleAskUserQuestionTool } from "./ask-user-question-handler"; import { handleBashTool } from "./bash-handler"; import { handleEditTool } from "./edit-handler"; import { handleReadTool } from "./read-handler"; +import { handleUpdatePlanTool } from "./update-plan-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; import type { McpManager } from "../mcp/mcp-manager"; @@ -120,6 +121,7 @@ export class ToolExecutor { this.toolHandlers.set("write", handleWriteTool); this.toolHandlers.set("edit", handleEditTool); this.toolHandlers.set("AskUserQuestion", handleAskUserQuestionTool); + this.toolHandlers.set("UpdatePlan", handleUpdatePlanTool); this.toolHandlers.set("WebSearch", handleWebSearchTool); } diff --git a/src/tools/update-plan-handler.ts b/src/tools/update-plan-handler.ts new file mode 100644 index 0000000..7c7198e --- /dev/null +++ b/src/tools/update-plan-handler.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { executeValidatedTool } from "../common/runtime"; + +const updatePlanSchema = z.strictObject({ + plan: z.string().trim().min(1, "plan must not be empty."), + explanation: z.string().trim().optional(), +}); + +export async function handleUpdatePlanTool( + args: Record, + _context: ToolExecutionContext +): Promise { + return executeValidatedTool("UpdatePlan", updatePlanSchema, args, _context, async (input) => ({ + ok: true, + name: "UpdatePlan", + output: "Plan updated.", + metadata: { + plan: input.plan, + ...(input.explanation ? { explanation: input.explanation } : {}), + }, + })); +} diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index c8793fc..6f388d0 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -72,6 +72,7 @@ export function MessageView({ message, collapsed, width = 80 }: Props): React.Re if (message.role === "tool") { const summary = buildToolSummary(message); const diffLines = getToolDiffPreviewLines(summary); + const planLines = getUpdatePlanPreviewLines(summary); return ( {diffLines.length > 0 ? : null} + {planLines.length > 0 ? : null} ); } @@ -272,6 +274,20 @@ function getToolDiffPreviewLines(summary: ToolSummary): DiffPreviewLine[] { return parseDiffPreview(diffPreview); } +function getUpdatePlanPreviewLines(summary: ToolSummary): string[] { + if (!summary.ok || summary.name !== "UpdatePlan") { + return []; + } + const plan = summary.metadata?.plan; + if (typeof plan !== "string" || !plan.trim()) { + return []; + } + return plan + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); +} + export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { return diffPreview .split("\n") @@ -311,6 +327,21 @@ function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElemen ); } +function PlanPreview({ lines }: { lines: string[] }): React.ReactElement { + return ( + + └ Plan + + {lines.map((line, index) => ( + + {line} + + ))} + + + ); +} + function isPlainRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } diff --git a/templates/tools/update-plan.md b/templates/tools/update-plan.md new file mode 100644 index 0000000..c3e08f0 --- /dev/null +++ b/templates/tools/update-plan.md @@ -0,0 +1,38 @@ +## UpdatePlan + +Updates the current task plan and progress display. + +Usage: +- Use this tool for non-trivial multi-step tasks when a task list helps track execution progress. +- Pass the complete current task list every time. The latest call replaces the previous visible plan. +- The `plan` argument is a markdown string, not an array of step objects. +- Keep exactly one task marked `[>]` while work is in progress. +- Update the plan before starting a task, immediately after completing a task, and whenever tasks are split, merged, reordered, blocked, or changed. +- Do not edit issue documents just to maintain task status; use `UpdatePlan` for the task list instead. + +Task markers: +- `[ ]` Pending +- `[>]` In progress +- `[x]` Completed +- `[!]` Blocked + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "plan": { + "description": "The complete markdown task list to display as the latest plan state.", + "type": "string" + }, + "explanation": { + "description": "Optional short reason for changing the plan.", + "type": "string" + } + }, + "required": [ + "plan" + ], + "additionalProperties": false +} +``` From 52dafba25903dc70258d7e59dbe86e283a0f091f Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Mon, 18 May 2026 09:50:38 +0800 Subject: [PATCH 130/217] fix: re-apply dynamic modifier parsing for Shift+Enter after upstream sync Upstream v0.1.21 reverted PR #70. Re-apply: - isShiftReturn() / isReturn() dynamic CSI modifier bit parsing - Kitty progressive enhancement (ESC[>1u) alongside xterm modifyOtherKeys - Clear input when key.return is true (safety net) --- src/tests/promptInputKeys.test.ts | 6 ++--- src/ui/prompt/cursor.ts | 4 +-- src/ui/prompt/useTerminalInput.ts | 43 ++++++++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 69d2075..8952a3d 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -80,7 +80,7 @@ test("parseTerminalInput keeps BS payload for meta+backspace", () => { test("parseTerminalInput recognizes shifted return sequences", () => { const { input, key } = parseTerminalInput("\u001B\r"); - assert.equal(input, "\r"); + assert.equal(input, ""); assert.equal(key.return, true); assert.equal(key.shift, true); assert.equal(key.meta, false); @@ -108,8 +108,8 @@ test("parseTerminalInput recognizes alternate shifted return sequences", () => { }); test("terminal extended key helpers request and restore modifyOtherKeys mode", () => { - assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m"); - assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); + assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m\u001B[>1u"); + assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m\u001B[ { diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 2668470..59b24f2 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -41,11 +41,11 @@ function disableTerminalFocusReporting(): string { } export function enableTerminalExtendedKeys(): string { - return "\u001B[>4;1m"; + return "\u001B[>4;1m\u001B[>1u"; } export function disableTerminalExtendedKeys(): string { - return "\u001B[>4;0m"; + return "\u001B[>4;0m\u001B[ Date: Mon, 18 May 2026 10:22:22 +0800 Subject: [PATCH 131/217] fix: refresh mcpToolDefinitions cache after MCP reconnect After reconnectMcpServer succeeds, SessionManager's cached mcpToolDefinitions was stale, causing "Unknown MCP tool" errors when the model tried to call reconnected tools. --- src/session.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/session.ts b/src/session.ts index eddfe5c..0527ba8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -261,6 +261,7 @@ export class SessionManager { async reconnectMcpServer(name: string, config?: McpServerConfig): Promise { await this.mcpManager.reconnect(name, config); + this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); } dispose(): void { From 63ec2a340192f6e53b8003162320e102e521a49e Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 10:40:39 +0800 Subject: [PATCH 132/217] feat: update update-plan prompt draft --- docs/SKILL.md | 124 +++++++++++++++------------------ docs/SKILL_new.md | 77 +++++++++----------- templates/tools/update-plan.md | 7 -- 3 files changed, 90 insertions(+), 118 deletions(-) diff --git a/docs/SKILL.md b/docs/SKILL.md index f6d7149..8f45c3b 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -1,36 +1,38 @@ --- name: plan-and-execute -description: Automatically plan and execute tasks from issue documents. Reads issue requirements, creates a task list at the end of the document, and systematically executes each task while updating progress. Use when working with issue documents, task planning, or when you need to break down and execute complex multi-step requirements. +description: Automatically plan and execute requirements. Creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with task planning or when you need to break down and execute complex multi-step requirements. --- # Plan and Execute -This Skill helps you automatically plan and execute tasks based on issue documents. It reads your requirements, creates a structured task list directly in the document, and systematically works through each task while keeping the document updated with progress. +This Skill helps you automatically plan and execute requirements. It creates a structured markdown task list with the UpdatePlan tool and systematically works through each task while keeping progress visible. ## Quick Start -When you need to work through an issue document: +When you need to work through a multi-step request: -1. The Skill will first ask you for the issue document path -2. It reads the document to understand requirements -3. Creates a task list at the end of the document -4. Executes tasks one by one, updating status in real-time +1. Understand the requirements +2. Read referenced files only when they are needed for context +3. Create a markdown task list by calling the UpdatePlan tool +4. Execute tasks one by one, updating the tool plan in real time ## Instructions -### Step 1: Get the issue document path +### Step 1: Gather the requirements -Ask the user for the path to their issue document: +Identify the requirements from the available context. Do not require the requirements to be moved into a separate document. + +If a required referenced file path is missing, ask for it: ``` -What is the path to your issue document? +What is the path to the referenced file? ``` -The document can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. +Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. -### Step 2: Read and analyze the issue document +### Step 2: Read and analyze the requirements -Use the Read tool to load the document content and analyze: +Analyze the requirements and read any referenced files needed for context: - What are the main requirements? - What tasks need to be completed? @@ -39,7 +41,15 @@ Use the Read tool to load the document content and analyze: ### Step 3: Create the task list -Create a structured task list at the END of the issue document using this format: +Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: + +```json +{ + "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description" +} +``` + +Use this markdown format for the `plan` content: ```markdown ## Task List @@ -47,27 +57,23 @@ Create a structured task list at the END of the issue document using this format - [ ] Task 1 description - [ ] Task 2 description - [ ] Task 3 description - -### Task Status Legend -- [ ] Pending -- [>] In Progress -- [x] Completed ``` -Use the Edit tool to append this section to the document. Break down complex requirements into specific, actionable tasks. +Do not append the task list to a source file. Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. ### Step 4: Execute tasks systematically For each task in the list: -1. **Mark as in progress**: Update the task in the document from `[ ]` to `[>]` +1. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` 2. **Execute the task**: Use appropriate tools to complete the work -3. **Mark as completed**: Update the task from `[>]` to `[x]` when finished +3. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished 4. **Move to next task**: Only ONE task should be in progress at a time Important rules: -- Always update the document BEFORE starting work on a task -- Always update the document IMMEDIATELY after completing a task +- Always call UpdatePlan BEFORE starting work on a task +- Always call UpdatePlan IMMEDIATELY after completing a task +- Always pass the complete current markdown task list, not a partial diff - Never work on multiple tasks simultaneously - If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers @@ -76,34 +82,35 @@ Important rules: If during execution you discover a task is more complex than expected: 1. Keep the current task as `[>]` -2. Add new sub-tasks below it with indentation: +2. Call UpdatePlan with new sub-tasks below it with indentation: ```markdown - [>] Main task - [ ] Sub-task 1 - [ ] Sub-task 2 ``` -3. Complete sub-tasks first, then mark the main task as complete +3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan ### Step 6: Final verification After all tasks are completed (`[x]`): -1. Review the issue requirements to ensure everything is addressed +1. Review the original requirements to ensure everything is addressed 2. Run any final checks (tests, builds, linting) -3. Add a completion summary at the end of the document +3. Call UpdatePlan with every task marked `[x]` +4. Provide a concise completion summary in the final response ## Task State Symbols -- `[ ]` - **Pending**: Not started yet -- `[>]` - **In Progress**: Currently working on this -- `[x]` - **Completed**: Finished successfully -- `[!]` - **Blocked**: Cannot proceed (optional, for blocked tasks) +- `[ ]` - Pending +- `[>]` - In progress +- `[x]` - Completed +- `[!]` - Blocked ## Examples ### Example 1: Simple feature request -**Issue document (before):** +**Example requirements:** ```markdown # Feature: Add dark mode toggle @@ -111,13 +118,8 @@ Users should be able to switch between light and dark themes. The toggle should be in the settings page. ``` -**Issue document (after task list added):** +**UpdatePlan call after analysis:** ```markdown -# Feature: Add dark mode toggle - -Users should be able to switch between light and dark themes. -The toggle should be in the settings page. - ## Task List - [ ] Create dark mode toggle component in Settings page @@ -125,14 +127,9 @@ The toggle should be in the settings page. - [ ] Implement CSS-in-JS styles for dark theme - [ ] Update existing components to support theme switching - [ ] Run tests and verify functionality - -### Task Status Legend -- [ ] Pending -- [>] In Progress -- [x] Completed ``` -**During execution:** +**UpdatePlan call during execution:** ```markdown ## Task List @@ -145,7 +142,7 @@ The toggle should be in the settings page. ### Example 2: Bug fix with investigation -**Issue document:** +**Example requirements:** ```markdown # Bug: Login form crashes on submit @@ -153,7 +150,7 @@ When users click submit, the app crashes. Error message: "Cannot read property 'email' of undefined" ``` -**Task list created:** +**UpdatePlan call after analysis:** ```markdown ## Task List @@ -164,23 +161,18 @@ Error message: "Cannot read property 'email' of undefined" - [ ] Add validation to prevent similar issues - [ ] Test the fix with various inputs - [ ] Update error handling - -### Task Status Legend -- [ ] Pending -- [>] In Progress -- [x] Completed ``` ## When to Use This Skill Use this Skill when: -1. **Complex multi-step tasks** - Issue requires 3+ distinct steps +1. **Complex multi-step tasks** - Request requires 3+ distinct steps 2. **Feature implementation** - Building new functionality from requirements 3. **Bug fixing** - Need to investigate, fix, and verify 4. **Refactoring** - Multiple files or components need changes -5. **User provides requirements** - Issue document contains specifications -6. **Need progress tracking** - Want visible progress in the document itself +5. **Detailed requirements** - Specifications need to be translated into concrete tasks +6. **Need progress tracking** - Want visible progress without editing source files ## When NOT to Use This Skill @@ -189,7 +181,7 @@ Skip this Skill when: 1. **Single simple task** - Just one straightforward action needed 2. **Trivial changes** - Quick fixes that don't need planning 3. **Informational requests** - User just wants explanation, not execution -4. **No document provided** - User hasn't created an issue document +4. **No execution requested** - User only wants brainstorming or a high-level explanation ## Best Practices @@ -236,10 +228,10 @@ Add implementation notes or findings: ## Requirements -This Skill uses standard Deep Code tools: +This Skill uses standard tools: -- **Read**: To read the issue document -- **Edit**: To update task status in the document +- **Read**: To inspect referenced files when needed +- **UpdatePlan**: To create and update the markdown task list - **Bash**: To run tests, builds, or other commands - **Write**: To create new files if needed @@ -247,13 +239,13 @@ No additional dependencies required. ## Workflow Summary -1. Ask user for issue document path -2. Read and analyze the document -3. Append structured task list to document +1. Analyze the requirements +2. Read referenced files when needed +3. Call UpdatePlan with the structured markdown task list 4. For each task: - - Update to `[>]` in document + - Update to `[>]` with UpdatePlan - Execute the task - - Update to `[x]` in document -5. Add completion summary when done + - Update to `[x]` with UpdatePlan +5. Call UpdatePlan with all tasks completed and summarize the result -This approach keeps all planning and progress tracking in one place - the issue document itself - making it easy for users to see what's been done and what's remaining. +This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. diff --git a/docs/SKILL_new.md b/docs/SKILL_new.md index cdcc514..6efbee9 100644 --- a/docs/SKILL_new.md +++ b/docs/SKILL_new.md @@ -1,36 +1,38 @@ --- name: plan-and-execute -description: Automatically plan and execute tasks from issue documents. Reads issue requirements, creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with issue documents, task planning, or when you need to break down and execute complex multi-step requirements. +description: Automatically plan and execute requirements. Creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with task planning or when you need to break down and execute complex multi-step requirements. --- # Plan and Execute -This Skill helps you automatically plan and execute tasks based on issue documents. It reads your requirements, creates a structured markdown task list with the UpdatePlan tool, and systematically works through each task while keeping progress visible. +This Skill helps you automatically plan and execute requirements. It creates a structured markdown task list with the UpdatePlan tool and systematically works through each task while keeping progress visible. ## Quick Start -When you need to work through an issue document: +When you need to work through a multi-step request: -1. The Skill will first ask you for the issue document path -2. It reads the document to understand requirements -3. Creates a markdown task list by calling the UpdatePlan tool -4. Executes tasks one by one, updating the tool plan in real time +1. Understand the requirements +2. Read referenced files when they are needed for context +3. Create a markdown task list by calling the UpdatePlan tool +4. Execute tasks one by one, updating the tool plan in real time ## Instructions -### Step 1: Get the issue document path +### Step 1: Gather the requirements -Ask the user for the path to their issue document: +Identify the requirements from the available context. Do not require the requirements to be moved into a separate document. + +If a required referenced file path is missing, ask for it: ``` -What is the path to your issue document? +What is the path to the referenced file? ``` -The document can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. +Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. -### Step 2: Read and analyze the issue document +### Step 2: Read and analyze the requirements -Use the Read tool to load the document content and analyze: +Analyze the requirements and read any referenced files needed for context: - What are the main requirements? - What tasks need to be completed? @@ -43,7 +45,7 @@ Create a structured markdown task list and pass it to the UpdatePlan tool as the ```json { - "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description\n\n### Task Status Legend\n- [ ] Pending\n- [>] In Progress\n- [x] Completed" + "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description" } ``` @@ -55,14 +57,9 @@ Use this markdown format for the `plan` content: - [ ] Task 1 description - [ ] Task 2 description - [ ] Task 3 description - -### Task Status Legend -- [ ] Pending -- [>] In Progress -- [x] Completed ``` -Do not append the task list to the issue document. Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. +Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. ### Step 4: Execute tasks systematically @@ -97,23 +94,23 @@ If during execution you discover a task is more complex than expected: After all tasks are completed (`[x]`): -1. Review the issue requirements to ensure everything is addressed +1. Review the original requirements to ensure everything is addressed 2. Run any final checks (tests, builds, linting) 3. Call UpdatePlan with every task marked `[x]` 4. Provide a concise completion summary in the final response ## Task State Symbols -- `[ ]` - **Pending**: Not started yet -- `[>]` - **In Progress**: Currently working on this -- `[x]` - **Completed**: Finished successfully -- `[!]` - **Blocked**: Cannot proceed (optional, for blocked tasks) +- `[ ]` - Pending +- `[>]` - In progress +- `[x]` - Completed +- `[!]` - Blocked ## Examples ### Example 1: Simple feature request -**Issue document (before):** +**Example requirements:** ```markdown # Feature: Add dark mode toggle @@ -130,11 +127,6 @@ The toggle should be in the settings page. - [ ] Implement CSS-in-JS styles for dark theme - [ ] Update existing components to support theme switching - [ ] Run tests and verify functionality - -### Task Status Legend -- [ ] Pending -- [>] In Progress -- [x] Completed ``` **UpdatePlan call during execution:** @@ -150,7 +142,7 @@ The toggle should be in the settings page. ### Example 2: Bug fix with investigation -**Issue document:** +**Example requirements:** ```markdown # Bug: Login form crashes on submit @@ -169,23 +161,18 @@ Error message: "Cannot read property 'email' of undefined" - [ ] Add validation to prevent similar issues - [ ] Test the fix with various inputs - [ ] Update error handling - -### Task Status Legend -- [ ] Pending -- [>] In Progress -- [x] Completed ``` ## When to Use This Skill Use this Skill when: -1. **Complex multi-step tasks** - Issue requires 3+ distinct steps +1. **Complex multi-step tasks** - Request requires 3+ distinct steps 2. **Feature implementation** - Building new functionality from requirements 3. **Bug fixing** - Need to investigate, fix, and verify 4. **Refactoring** - Multiple files or components need changes -5. **User provides requirements** - Issue document contains specifications -6. **Need progress tracking** - Want visible progress without editing the issue document +5. **Detailed requirements** - Specifications need to be translated into concrete tasks +6. **Need progress tracking** - Want visible progress without editing source files ## When NOT to Use This Skill @@ -194,7 +181,7 @@ Skip this Skill when: 1. **Single simple task** - Just one straightforward action needed 2. **Trivial changes** - Quick fixes that don't need planning 3. **Informational requests** - User just wants explanation, not execution -4. **No document provided** - User hasn't created an issue document +4. **No execution requested** - User only wants brainstorming or a high-level explanation ## Best Practices @@ -243,7 +230,7 @@ Add implementation notes or findings: This Skill uses standard tools: -- **Read**: To read the issue document +- **Read**: To inspect referenced files when needed - **UpdatePlan**: To create and update the markdown task list - **Bash**: To run tests, builds, or other commands - **Write**: To create new files if needed @@ -252,8 +239,8 @@ No additional dependencies required. ## Workflow Summary -1. Ask user for issue document path -2. Read and analyze the document +1. Analyze the requirements +2. Read referenced files when needed 3. Call UpdatePlan with the structured markdown task list 4. For each task: - Update to `[>]` with UpdatePlan @@ -261,4 +248,4 @@ No additional dependencies required. - Update to `[x]` with UpdatePlan 5. Call UpdatePlan with all tasks completed and summarize the result -This approach keeps planning and progress tracking in the UpdatePlan display, leaving the issue document unchanged unless the actual task requires editing it. +This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. diff --git a/templates/tools/update-plan.md b/templates/tools/update-plan.md index c3e08f0..28d12f7 100644 --- a/templates/tools/update-plan.md +++ b/templates/tools/update-plan.md @@ -8,13 +8,6 @@ Usage: - The `plan` argument is a markdown string, not an array of step objects. - Keep exactly one task marked `[>]` while work is in progress. - Update the plan before starting a task, immediately after completing a task, and whenever tasks are split, merged, reordered, blocked, or changed. -- Do not edit issue documents just to maintain task status; use `UpdatePlan` for the task list instead. - -Task markers: -- `[ ]` Pending -- `[>]` In progress -- `[x]` Completed -- `[!]` Blocked ```json { From c638114bfc2ab23c502322955403e4c8dc1bff62 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 11:00:13 +0800 Subject: [PATCH 133/217] feat: update update-plan prompt draft --- docs/SKILL.md | 47 ++++++++++++++--------------- docs/SKILL_new.md | 54 ++++++++++++++-------------------- templates/tools/update-plan.md | 2 ++ 3 files changed, 48 insertions(+), 55 deletions(-) diff --git a/docs/SKILL.md b/docs/SKILL.md index 8f45c3b..deb8c5e 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -11,16 +11,16 @@ This Skill helps you automatically plan and execute requirements. It creates a s When you need to work through a multi-step request: -1. Understand the requirements -2. Read referenced files only when they are needed for context -3. Create a markdown task list by calling the UpdatePlan tool -4. Execute tasks one by one, updating the tool plan in real time +1. Analyze the requirements and explore enough project context +2. Create a markdown task list by calling the UpdatePlan tool +3. Execute tasks one by one, updating the tool plan in real time +4. Revise the remaining plan as new context appears ## Instructions -### Step 1: Gather the requirements +### Step 1: Analyze the requirements -Identify the requirements from the available context. Do not require the requirements to be moved into a separate document. +Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. If a required referenced file path is missing, ask for it: @@ -30,16 +30,13 @@ What is the path to the referenced file? Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. -### Step 2: Read and analyze the requirements - -Analyze the requirements and read any referenced files needed for context: - - What are the main requirements? - What tasks need to be completed? - Are there dependencies between tasks? - What is the complexity level? +- Which files, modules, commands, or tests are relevant? -### Step 3: Create the task list +### Step 2: Create the task list Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: @@ -59,25 +56,28 @@ Use this markdown format for the `plan` content: - [ ] Task 3 description ``` -Do not append the task list to a source file. Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. +Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. -### Step 4: Execute tasks systematically +### Step 3: Execute tasks systematically For each task in the list: -1. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` -2. **Execute the task**: Use appropriate tools to complete the work -3. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished -4. **Move to next task**: Only ONE task should be in progress at a time +1. **Refresh the plan**: Before starting the first task and after completing each task, re-evaluate the latest conversation and project context. Update the remaining tasks when scope, order, blockers, or follow-up work changes. +2. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` +3. **Execute the task**: Use appropriate tools to complete the work +4. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished +5. **Move to next task**: Only ONE task should be in progress at a time Important rules: +- Always keep the plan aligned with the latest context before executing the next task - Always call UpdatePlan BEFORE starting work on a task - Always call UpdatePlan IMMEDIATELY after completing a task - Always pass the complete current markdown task list, not a partial diff - Never work on multiple tasks simultaneously +- Remove tasks that are no longer relevant, and add newly discovered tasks before working on them - If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers -### Step 5: Handle task breakdown +### Step 4: Handle task breakdown If during execution you discover a task is more complex than expected: @@ -90,7 +90,7 @@ If during execution you discover a task is more complex than expected: ``` 3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan -### Step 6: Final verification +### Step 5: Final verification After all tasks are completed (`[x]`): @@ -230,7 +230,7 @@ Add implementation notes or findings: This Skill uses standard tools: -- **Read**: To inspect referenced files when needed +- **Read**: To inspect relevant files and explore project context - **UpdatePlan**: To create and update the markdown task list - **Bash**: To run tests, builds, or other commands - **Write**: To create new files if needed @@ -239,13 +239,14 @@ No additional dependencies required. ## Workflow Summary -1. Analyze the requirements -2. Read referenced files when needed -3. Call UpdatePlan with the structured markdown task list +1. Analyze the requirements and relevant project context +2. Call UpdatePlan with the structured markdown task list +3. Refresh the remaining plan before the first task 4. For each task: - Update to `[>]` with UpdatePlan - Execute the task - Update to `[x]` with UpdatePlan + - Re-evaluate and revise remaining tasks before moving on 5. Call UpdatePlan with all tasks completed and summarize the result This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. diff --git a/docs/SKILL_new.md b/docs/SKILL_new.md index 6efbee9..41ed251 100644 --- a/docs/SKILL_new.md +++ b/docs/SKILL_new.md @@ -11,16 +11,16 @@ This Skill helps you automatically plan and execute requirements. It creates a s When you need to work through a multi-step request: -1. Understand the requirements -2. Read referenced files when they are needed for context -3. Create a markdown task list by calling the UpdatePlan tool -4. Execute tasks one by one, updating the tool plan in real time +1. Analyze the requirements and explore enough project context +2. Create a markdown task list by calling the UpdatePlan tool +3. Execute tasks one by one, updating the tool plan in real time +4. Revise the remaining plan as new context appears ## Instructions -### Step 1: Gather the requirements +### Step 1: Analyze the requirements -Identify the requirements from the available context. Do not require the requirements to be moved into a separate document. +Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. If a required referenced file path is missing, ask for it: @@ -30,16 +30,13 @@ What is the path to the referenced file? Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. -### Step 2: Read and analyze the requirements - -Analyze the requirements and read any referenced files needed for context: - - What are the main requirements? - What tasks need to be completed? - Are there dependencies between tasks? - What is the complexity level? +- Which files, modules, commands, or tests are relevant? -### Step 3: Create the task list +### Step 2: Create the task list Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: @@ -61,23 +58,26 @@ Use this markdown format for the `plan` content: Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. -### Step 4: Execute tasks systematically +### Step 3: Execute tasks systematically For each task in the list: -1. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` -2. **Execute the task**: Use appropriate tools to complete the work -3. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished -4. **Move to next task**: Only ONE task should be in progress at a time +1. **Refresh the plan**: Before starting the first task and after completing each task, re-evaluate the latest conversation and project context. Update the remaining tasks when scope, order, blockers, or follow-up work changes. +2. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` +3. **Execute the task**: Use appropriate tools to complete the work +4. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished +5. **Move to next task**: Only ONE task should be in progress at a time Important rules: +- Always keep the plan aligned with the latest context before executing the next task - Always call UpdatePlan BEFORE starting work on a task - Always call UpdatePlan IMMEDIATELY after completing a task - Always pass the complete current markdown task list, not a partial diff - Never work on multiple tasks simultaneously +- Remove tasks that are no longer relevant, and add newly discovered tasks before working on them - If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers -### Step 5: Handle task breakdown +### Step 4: Handle task breakdown If during execution you discover a task is more complex than expected: @@ -90,7 +90,7 @@ If during execution you discover a task is more complex than expected: ``` 3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan -### Step 6: Final verification +### Step 5: Final verification After all tasks are completed (`[x]`): @@ -226,26 +226,16 @@ Add implementation notes or findings: - Solution: Added dataloader batching ``` -## Requirements - -This Skill uses standard tools: - -- **Read**: To inspect referenced files when needed -- **UpdatePlan**: To create and update the markdown task list -- **Bash**: To run tests, builds, or other commands -- **Write**: To create new files if needed - -No additional dependencies required. - ## Workflow Summary -1. Analyze the requirements -2. Read referenced files when needed -3. Call UpdatePlan with the structured markdown task list +1. Analyze the requirements and relevant project context +2. Call UpdatePlan with the structured markdown task list +3. Refresh the remaining plan before the first task 4. For each task: - Update to `[>]` with UpdatePlan - Execute the task - Update to `[x]` with UpdatePlan + - Re-evaluate and revise remaining tasks before moving on 5. Call UpdatePlan with all tasks completed and summarize the result This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. diff --git a/templates/tools/update-plan.md b/templates/tools/update-plan.md index 28d12f7..a0f2fd6 100644 --- a/templates/tools/update-plan.md +++ b/templates/tools/update-plan.md @@ -8,6 +8,8 @@ Usage: - The `plan` argument is a markdown string, not an array of step objects. - Keep exactly one task marked `[>]` while work is in progress. - Update the plan before starting a task, immediately after completing a task, and whenever tasks are split, merged, reordered, blocked, or changed. +- Before executing the first task and after completing each task, re-evaluate the latest conversation and project context, then revise the remaining plan if needed. +- Remove tasks that are no longer relevant, and add newly discovered follow-up tasks before working on them. ```json { From 33bcd484aad094b45a48d6808d017d3868b5fe78 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 11:06:06 +0800 Subject: [PATCH 134/217] feat: update update-plan prompt draft --- docs/SKILL.md | 252 ---------------------------------------------- docs/SKILL_new.md | 21 ++-- 2 files changed, 13 insertions(+), 260 deletions(-) delete mode 100644 docs/SKILL.md diff --git a/docs/SKILL.md b/docs/SKILL.md deleted file mode 100644 index deb8c5e..0000000 --- a/docs/SKILL.md +++ /dev/null @@ -1,252 +0,0 @@ ---- -name: plan-and-execute -description: Automatically plan and execute requirements. Creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with task planning or when you need to break down and execute complex multi-step requirements. ---- - -# Plan and Execute - -This Skill helps you automatically plan and execute requirements. It creates a structured markdown task list with the UpdatePlan tool and systematically works through each task while keeping progress visible. - -## Quick Start - -When you need to work through a multi-step request: - -1. Analyze the requirements and explore enough project context -2. Create a markdown task list by calling the UpdatePlan tool -3. Execute tasks one by one, updating the tool plan in real time -4. Revise the remaining plan as new context appears - -## Instructions - -### Step 1: Analyze the requirements - -Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. - -If a required referenced file path is missing, ask for it: - -``` -What is the path to the referenced file? -``` - -Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. - -- What are the main requirements? -- What tasks need to be completed? -- Are there dependencies between tasks? -- What is the complexity level? -- Which files, modules, commands, or tests are relevant? - -### Step 2: Create the task list - -Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: - -```json -{ - "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description" -} -``` - -Use this markdown format for the `plan` content: - -```markdown -## Task List - -- [ ] Task 1 description -- [ ] Task 2 description -- [ ] Task 3 description -``` - -Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. - -### Step 3: Execute tasks systematically - -For each task in the list: - -1. **Refresh the plan**: Before starting the first task and after completing each task, re-evaluate the latest conversation and project context. Update the remaining tasks when scope, order, blockers, or follow-up work changes. -2. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` -3. **Execute the task**: Use appropriate tools to complete the work -4. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished -5. **Move to next task**: Only ONE task should be in progress at a time - -Important rules: -- Always keep the plan aligned with the latest context before executing the next task -- Always call UpdatePlan BEFORE starting work on a task -- Always call UpdatePlan IMMEDIATELY after completing a task -- Always pass the complete current markdown task list, not a partial diff -- Never work on multiple tasks simultaneously -- Remove tasks that are no longer relevant, and add newly discovered tasks before working on them -- If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers - -### Step 4: Handle task breakdown - -If during execution you discover a task is more complex than expected: - -1. Keep the current task as `[>]` -2. Call UpdatePlan with new sub-tasks below it with indentation: - ```markdown - - [>] Main task - - [ ] Sub-task 1 - - [ ] Sub-task 2 - ``` -3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan - -### Step 5: Final verification - -After all tasks are completed (`[x]`): - -1. Review the original requirements to ensure everything is addressed -2. Run any final checks (tests, builds, linting) -3. Call UpdatePlan with every task marked `[x]` -4. Provide a concise completion summary in the final response - -## Task State Symbols - -- `[ ]` - Pending -- `[>]` - In progress -- `[x]` - Completed -- `[!]` - Blocked - -## Examples - -### Example 1: Simple feature request - -**Example requirements:** -```markdown -# Feature: Add dark mode toggle - -Users should be able to switch between light and dark themes. -The toggle should be in the settings page. -``` - -**UpdatePlan call after analysis:** -```markdown -## Task List - -- [ ] Create dark mode toggle component in Settings page -- [ ] Add dark mode state management (context/store) -- [ ] Implement CSS-in-JS styles for dark theme -- [ ] Update existing components to support theme switching -- [ ] Run tests and verify functionality -``` - -**UpdatePlan call during execution:** -```markdown -## Task List - -- [x] Create dark mode toggle component in Settings page -- [>] Add dark mode state management (context/store) -- [ ] Implement CSS-in-JS styles for dark theme -- [ ] Update existing components to support theme switching -- [ ] Run tests and verify functionality -``` - -### Example 2: Bug fix with investigation - -**Example requirements:** -```markdown -# Bug: Login form crashes on submit - -When users click submit, the app crashes. -Error message: "Cannot read property 'email' of undefined" -``` - -**UpdatePlan call after analysis:** -```markdown -## Task List - -- [ ] Reproduce the bug locally -- [ ] Investigate the error in login form component -- [ ] Identify root cause of undefined email property -- [ ] Implement fix -- [ ] Add validation to prevent similar issues -- [ ] Test the fix with various inputs -- [ ] Update error handling -``` - -## When to Use This Skill - -Use this Skill when: - -1. **Complex multi-step tasks** - Request requires 3+ distinct steps -2. **Feature implementation** - Building new functionality from requirements -3. **Bug fixing** - Need to investigate, fix, and verify -4. **Refactoring** - Multiple files or components need changes -5. **Detailed requirements** - Specifications need to be translated into concrete tasks -6. **Need progress tracking** - Want visible progress without editing source files - -## When NOT to Use This Skill - -Skip this Skill when: - -1. **Single simple task** - Just one straightforward action needed -2. **Trivial changes** - Quick fixes that don't need planning -3. **Informational requests** - User just wants explanation, not execution -4. **No execution requested** - User only wants brainstorming or a high-level explanation - -## Best Practices - -1. **Be specific with tasks**: "Add login button to navbar" not "Update UI" -2. **Keep tasks atomic**: Each task should be independently completable -3. **Update immediately**: Don't batch status updates, do them in real-time -4. **One task at a time**: Never mark multiple tasks as `[>]` -5. **Handle blockers**: If stuck, create new tasks to resolve the blocker -6. **Verify completion**: Only mark `[x]` when task is fully done - -## Advanced Usage - -### Handling dependencies - -When tasks have dependencies, order them properly: - -```markdown -- [ ] Create database schema -- [ ] Implement API endpoints (depends on schema) -- [ ] Build frontend forms (depends on API) -``` - -### Using sub-tasks - -For complex tasks, break them down: - -```markdown -- [>] Implement authentication system - - [x] Set up JWT library - - [>] Create login endpoint - - [ ] Create logout endpoint - - [ ] Add token refresh logic -``` - -### Adding notes - -Add implementation notes or findings: - -```markdown -- [x] Investigate performance issue - - Note: Found N+1 query in user loader - - Solution: Added dataloader batching -``` - -## Requirements - -This Skill uses standard tools: - -- **Read**: To inspect relevant files and explore project context -- **UpdatePlan**: To create and update the markdown task list -- **Bash**: To run tests, builds, or other commands -- **Write**: To create new files if needed - -No additional dependencies required. - -## Workflow Summary - -1. Analyze the requirements and relevant project context -2. Call UpdatePlan with the structured markdown task list -3. Refresh the remaining plan before the first task -4. For each task: - - Update to `[>]` with UpdatePlan - - Execute the task - - Update to `[x]` with UpdatePlan - - Re-evaluate and revise remaining tasks before moving on -5. Call UpdatePlan with all tasks completed and summarize the result - -This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. diff --git a/docs/SKILL_new.md b/docs/SKILL_new.md index 41ed251..9037a00 100644 --- a/docs/SKILL_new.md +++ b/docs/SKILL_new.md @@ -12,9 +12,10 @@ This Skill helps you automatically plan and execute requirements. It creates a s When you need to work through a multi-step request: 1. Analyze the requirements and explore enough project context -2. Create a markdown task list by calling the UpdatePlan tool -3. Execute tasks one by one, updating the tool plan in real time -4. Revise the remaining plan as new context appears +2. Clarify unclear or ambiguous requirements with AskUserQuestion +3. Create a markdown task list by calling the UpdatePlan tool +4. Execute tasks one by one, updating the tool plan in real time +5. Revise the remaining plan as new context appears ## Instructions @@ -22,7 +23,9 @@ When you need to work through a multi-step request: Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. -If a required referenced file path is missing, ask for it: +If the original requirements are unclear, incomplete, or ambiguous, call the AskUserQuestion tool before creating the task list. Ask only the questions needed to avoid implementing the wrong behavior, and keep each question specific to the decision that affects the plan or acceptance criteria. + +If a required referenced file path is missing, ask for it with AskUserQuestion: ``` What is the path to the referenced file? @@ -35,6 +38,7 @@ Referenced files can be in any text format (.md, .txt, etc.) that contains task - Are there dependencies between tasks? - What is the complexity level? - Which files, modules, commands, or tests are relevant? +- What ambiguity would change the implementation or acceptance criteria? ### Step 2: Create the task list @@ -229,13 +233,14 @@ Add implementation notes or findings: ## Workflow Summary 1. Analyze the requirements and relevant project context -2. Call UpdatePlan with the structured markdown task list -3. Refresh the remaining plan before the first task -4. For each task: +2. Call AskUserQuestion if the original requirements are unclear or ambiguous +3. Call UpdatePlan with the structured markdown task list +4. Refresh the remaining plan before the first task +5. For each task: - Update to `[>]` with UpdatePlan - Execute the task - Update to `[x]` with UpdatePlan - Re-evaluate and revise remaining tasks before moving on -5. Call UpdatePlan with all tasks completed and summarize the result +6. Call UpdatePlan with all tasks completed and summarize the result This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. From 32e5796c9e188defdb69c8f3db75a539f5c9dc0c Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 11:57:39 +0800 Subject: [PATCH 135/217] feat: update update-plan prompt draft --- docs/SKILL_new.md | 48 +++++++++++++++++----------------- templates/tools/update-plan.md | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/SKILL_new.md b/docs/SKILL_new.md index 9037a00..9fc8bd2 100644 --- a/docs/SKILL_new.md +++ b/docs/SKILL_new.md @@ -116,55 +116,55 @@ After all tasks are completed (`[x]`): **Example requirements:** ```markdown -# Feature: Add dark mode toggle +# 新功能:添加深色模式切换 -Users should be able to switch between light and dark themes. -The toggle should be in the settings page. +用户应该能够在浅色和深色主题之间切换。 +切换开关应放在设置页面中。 ``` -**UpdatePlan call after analysis:** +**分析后的 UpdatePlan 调用:** ```markdown ## Task List -- [ ] Create dark mode toggle component in Settings page -- [ ] Add dark mode state management (context/store) -- [ ] Implement CSS-in-JS styles for dark theme -- [ ] Update existing components to support theme switching -- [ ] Run tests and verify functionality +- [ ] 在设置页面创建深色模式切换组件 +- [ ] 添加深色模式状态管理(context/store) +- [ ] 实现深色主题的 CSS-in-JS 样式 +- [ ] 更新现有组件以支持主题切换 +- [ ] 运行测试并验证功能 ``` **UpdatePlan call during execution:** ```markdown ## Task List -- [x] Create dark mode toggle component in Settings page -- [>] Add dark mode state management (context/store) -- [ ] Implement CSS-in-JS styles for dark theme -- [ ] Update existing components to support theme switching -- [ ] Run tests and verify functionality +- [x] 在设置页面创建深色模式切换组件 +- [>] 添加深色模式状态管理(context/store) +- [ ] 实现深色主题的 CSS-in-JS 样式 +- [ ] 更新现有组件以支持主题切换 +- [ ] 运行测试并验证功能 ``` ### Example 2: Bug fix with investigation **Example requirements:** ```markdown -# Bug: Login form crashes on submit +# Fix bug:登录表单提交时崩溃 -When users click submit, the app crashes. -Error message: "Cannot read property 'email' of undefined" +当用户点击提交时,应用崩溃。 +错误信息:"Cannot read property 'email' of undefined" ``` **UpdatePlan call after analysis:** ```markdown ## Task List -- [ ] Reproduce the bug locally -- [ ] Investigate the error in login form component -- [ ] Identify root cause of undefined email property -- [ ] Implement fix -- [ ] Add validation to prevent similar issues -- [ ] Test the fix with various inputs -- [ ] Update error handling +- [ ] 在本地复现缺陷 +- [ ] 调查登录表单组件中的错误 +- [ ] 定位 undefined email 属性的根本原因 +- [ ] 实施修复 +- [ ] 添加验证以防止类似问题 +- [ ] 使用各种输入测试修复 +- [ ] 更新错误处理 ``` ## When to Use This Skill diff --git a/templates/tools/update-plan.md b/templates/tools/update-plan.md index a0f2fd6..0c74b36 100644 --- a/templates/tools/update-plan.md +++ b/templates/tools/update-plan.md @@ -5,7 +5,7 @@ Updates the current task plan and progress display. Usage: - Use this tool for non-trivial multi-step tasks when a task list helps track execution progress. - Pass the complete current task list every time. The latest call replaces the previous visible plan. -- The `plan` argument is a markdown string, not an array of step objects. +- The `plan` argument is a markdown string, not an array of step objects. If the requirement is in Chinese, then use Chinese for the markdown as well. - Keep exactly one task marked `[>]` while work is in progress. - Update the plan before starting a task, immediately after completing a task, and whenever tasks are split, merged, reordered, blocked, or changed. - Before executing the first task and after completing each task, re-evaluate the latest conversation and project context, then revise the remaining plan if needed. From 77a779fe341aaa81a726dea319bd2b85c7fb2ab9 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 14:29:34 +0800 Subject: [PATCH 136/217] feat: change the UpdatePlan display --- src/session.ts | 2 ++ src/tests/session.test.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/session.ts b/src/session.ts index 431eb40..dde29b3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2032,6 +2032,8 @@ ${skillMd} if (description) { return description; } + } else if (toolName === "UpdatePlan") { + return typeof args.explanation === "string" ? args.explanation.trim() : ""; } const firstKey = Object.keys(args)[0]; diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index e5ab740..7bc98f9 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1160,6 +1160,27 @@ test("buildOpenAIMessages preserves a real failed tool result", () => { assert.doesNotMatch(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); }); +test("UpdatePlan tool params only show explanation when provided", () => { + const manager = createSessionManager(process.cwd(), "machine-id-update-plan-params"); + const plan = "## Task List\n\n- [ ] Inspect project"; + + const withExplanation = (manager as any).buildToolMessage( + "session-1", + "call-plan-1", + JSON.stringify({ ok: true, name: "UpdatePlan", output: "Plan updated." }), + { name: "UpdatePlan", arguments: JSON.stringify({ plan, explanation: "Start planning" }) } + ) as SessionMessage; + const withoutExplanation = (manager as any).buildToolMessage( + "session-1", + "call-plan-2", + JSON.stringify({ ok: true, name: "UpdatePlan", output: "Plan updated." }), + { name: "UpdatePlan", arguments: JSON.stringify({ plan }) } + ) as SessionMessage; + + assert.equal(withExplanation.meta?.paramsMd, "Start planning"); + assert.equal(withoutExplanation.meta?.paramsMd, ""); +}); + test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messages", () => { const manager = createSessionManager(process.cwd(), "machine-id-mixed-tool-badcase"); const assistantMessage = (manager as any).buildAssistantMessage( From e691b3efcf32e24d4521a29e17b0a512960d2a79 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 15:39:26 +0800 Subject: [PATCH 137/217] feat: add default skill templates and update session management to include skill prompts --- package.json | 1 + src/prompt.ts | 198 ++++----------------- src/session.ts | 28 ++- src/tests/prompt.test.ts | 36 +++- src/tests/session.test.ts | 35 ++++ templates/skills/agent-drift-guard.md | 152 ++++++++++++++++ templates/skills/plan-and-execute.md | 246 ++++++++++++++++++++++++++ 7 files changed, 528 insertions(+), 168 deletions(-) create mode 100644 templates/skills/agent-drift-guard.md create mode 100644 templates/skills/plan-and-execute.md diff --git a/package.json b/package.json index 90c2b51..e61d81d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dist/cli.js", "templates/tools/**", "templates/prompts/**", + "templates/skills/**", "README.md", "LICENSE" ], diff --git a/src/prompt.ts b/src/prompt.ts index 50aa2a3..717991b 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -8,161 +8,6 @@ import type { SessionMessage } from "./session"; import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; import { supportsMultimodal } from "./common/model-capabilities"; -export const AGENT_DRIFT_GUARD_SKILL = ` ---- -name: agent-drift-guard -description: Detect and correct execution drift while working on user requests. Use when you are actively implementing, debugging, reviewing, or investigating and there is a risk of wandering beyond the user's goal, adding unrequested work, touching live systems, over-exploring, or ignoring repeated user boundary corrections. Especially useful during multi-step coding tasks, production-adjacent requests, ambiguous scopes, and anytime you should self-check whether it is still solving the requested problem. ---- - -# Agent Drift Guard - -Keep execution tightly aligned with the user's actual request. - -## Quick Start - -Run this mental check before substantial work and again whenever the plan expands: - -1. State the user's requested outcome in one sentence. -2. List explicit non-goals or boundaries the user has set. -3. Ask whether the next action directly advances the requested outcome. -4. If not, either cut it or pause to confirm. - -## Drift Signals - -Treat these as warning signs that execution may be drifting: - -- Exploring broadly before opening the most relevant file, command, or artifact. -- Solving adjacent operational issues when the user asked only for code changes. -- Adding extra safeguards, scripts, docs, refactors, or cleanup that the user did not ask for. -- Reframing the task around what seems "better" instead of what was requested. -- Continuing with a broader plan after the user narrows the scope. -- Repeating searches or tool calls without increasing certainty. -- Mixing diagnosis, remediation, and feature work when the user asked for only one of them. -- Touching production-like state, external systems, or live data without explicit permission. - -## Severity Levels - -### Level 1: Mild Drift - -Examples: -- One or two extra exploratory commands. -- Considering a broader solution but not acting on it yet. -- Briefly over-explaining instead of moving the task forward. - -Response: -- Auto-correct silently. -- Narrow to the smallest next action. -- Do not interrupt the user. - -### Level 2: Material Drift - -Examples: -- Planning additional deliverables not requested. -- Writing helper scripts, migrations, docs, or tests outside the asked scope. -- Expanding from code changes into operational fixes. -- Continuing after the user has already corrected the scope once. - -Response: -- Stop and realign internally first. -- If the broader action is avoidable, drop it and continue on scope. -- If the broader action has non-obvious tradeoffs, ask a brief confirmation question. - -### Level 3: Boundary or Risk Violation - -Examples: -- Modifying live systems, production data, external services, or user-owned state without being asked. -- Taking destructive or hard-to-reverse actions outside the requested scope. -- Ignoring repeated user instructions about what not to do. - -Response: -- Pause before acting. -- Surface the exact boundary and ask for confirmation. -- Offer the smallest on-scope option first. - -## Self-Check Loop - -Use this loop during execution: - -### Before the first meaningful action - -Write down mentally: -- Requested outcome -- Allowed scope -- Forbidden scope -- Smallest useful next step - -### After each non-trivial step - -Ask: -- Did this step directly help deliver the requested outcome? -- Did I learn something that changes scope, or only implementation? -- Am I about to do more than the user asked? - -### After a user correction - -Treat the correction as a hard boundary update. - -Then: -- Remove the old broader plan. -- Do not defend the discarded work. -- Continue from the narrowed scope. -- If needed, acknowledge briefly and move on. - -## Decision Rules - -Use these rules in order: - -1. Prefer the most direct artifact first. - - Open the relevant file before scanning the whole repo. - - Inspect the specific failing path before designing a general framework. - -2. Prefer the smallest complete fix. - - Solve the asked problem before improving related systems. - - Avoid bonus work unless it is required for correctness. - -3. Prefer internal correction over user interruption. - - If you can shrink back to scope confidently, do it. - - Ask only when the next step changes deliverables, risk, or ownership. - -4. Treat repeated user constraints as priority signals. - - A repeated instruction means your execution style is currently misaligned. - - Tighten scope immediately. - -5. Separate categories of work. - - Code change, investigation, production remediation, cleanup, and documentation are distinct tasks unless the user explicitly combines them. - -## Good Intervention Style - -When you must pause, keep it short and specific: - -- State the potential drift in one sentence. -- Name the tradeoff or boundary. -- Offer the smallest on-scope option first. - -Example: - -"Quick alignment check: I can keep this to the code fix only, or also add an ops cleanup step. I'll stick to the code fix unless you want both." - -## Anti-Patterns - -Do not: - -- Create cleanup scripts, docs, or side tools just because they seem useful. -- Broaden the task after discovering a neighboring problem. -- Continue with a plan the user has already rejected. -- Justify drift with "best practice" when the user asked for a narrower deliverable. -- Hide extra work inside a larger patch. - -## Final Check Before Responding - -Before sending the final answer, verify: - -- The delivered work matches the requested outcome. -- No extra deliverables were added without confirmation. -- Any assumptions are stated briefly. -- Suggested next steps are optional, not bundled into the completed work. -`; - const COMPACT_PROMPT_BASE = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. @@ -254,6 +99,8 @@ type PromptToolOptions = { webSearchEnabled?: boolean; }; +const DEFAULT_SKILL_TEMPLATES = ["agent-drift-guard.md", "plan-and-execute.md"]; + function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): string { const toolsDir = path.join(extensionRoot, "templates", "tools"); if (!fs.existsSync(toolsDir)) { @@ -281,6 +128,35 @@ function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): s return docs.join("\n\n"); } +function readDefaultSkillDocs(extensionRoot: string): Array<{ name: string; content: string }> { + const skillsDir = path.join(extensionRoot, "templates", "skills"); + return DEFAULT_SKILL_TEMPLATES.map((entry) => { + const fullPath = path.join(skillsDir, entry); + try { + return { + name: path.basename(entry, ".md"), + content: fs.readFileSync(fullPath, "utf8").trim(), + }; + } catch { + return null; + } + }).filter((skill): skill is { name: string; content: string } => Boolean(skill?.content)); +} + +export function getDefaultSkillPrompt(): string { + const skillDocs = readDefaultSkillDocs(getExtensionRoot()); + if (skillDocs.length === 0) { + return ""; + } + + const blocks = skillDocs.map( + (skill) => `<${skill.name}-skill> +${skill.content} +` + ); + return `Use the skill documents below to assist the user:\n${blocks.join("\n\n")}`; +} + function getCurrentDateAndModelPrompt(model?: string): string { const date = new Date(); let prompt = `今天是${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日。随着对话的进行,时间在流逝。`; @@ -288,10 +164,10 @@ function getCurrentDateAndModelPrompt(model?: string): string { return prompt; } -export function getSystemPrompt(projectRoot: string, options: PromptToolOptions = {}): string { +export function getSystemPrompt(_projectRoot: string, options: PromptToolOptions = {}): string { const toolDocs = readToolDocs(getExtensionRoot(), options); const basePrompt = toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; - return `${basePrompt}\n\n${getCurrentDateAndModelPrompt(options.model)}\n\n${getRuntimeContext(projectRoot)}`; + return basePrompt; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { @@ -310,7 +186,7 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { return `${COMPACT_PROMPT_BASE}\n\nconversation below:\n\n\`\`\`jsonl\n${jsonl}\n\`\`\``; } -function getRuntimeContext(projectRoot: string): string { +export function getRuntimeContext(projectRoot: string, model?: string): string { const uname = getUnameInfo(); const shellPath = getShellPathInfo(); const shellModeOpts = process.platform === "win32" ? { "shell mode": "git-bash" } : {}; @@ -328,7 +204,11 @@ function getRuntimeContext(projectRoot: string): string { jq: checkToolInstalled("jq"), }, }; - return `# Local Workspace Environment\n\n\`\`\`json + return `${getCurrentDateAndModelPrompt(model)} + +# Local Workspace Environment + +\`\`\`json ${JSON.stringify(env, null, 2)} \`\`\``; } diff --git a/src/session.ts b/src/session.ts index dde29b3..1c9d3b6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -9,7 +9,14 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open import { launchNotifyScript } from "./common/notify"; import { buildThinkingRequestOptions } from "./common/openai-thinking"; import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities"; -import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt"; +import { + getCompactPrompt, + getDefaultSkillPrompt, + getRuntimeContext, + getSystemPrompt, + getTools, + type ToolDefinition, +} from "./prompt"; import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig } from "./settings"; @@ -907,20 +914,29 @@ The candidate skills are as follows:\n\n`; this.saveSessionsIndex(index); this.removeSessionMessages(droppedEntries.map((item) => item.id)); - const systemPrompt = getSystemPrompt(this.projectRoot, this.getPromptToolOptions()); + const promptToolOptions = this.getPromptToolOptions(); + const systemPrompt = getSystemPrompt(this.projectRoot, promptToolOptions); const systemMessage = this.buildSystemMessage(sessionId, systemPrompt); this.appendSessionMessage(sessionId, systemMessage); + const defaultSkillPrompt = getDefaultSkillPrompt(); + if (defaultSkillPrompt) { + const defaultSkillMessage = this.buildSystemMessage(sessionId, defaultSkillPrompt); + this.appendSessionMessage(sessionId, defaultSkillMessage); + } + + const runtimeContextMessage = this.buildSystemMessage( + sessionId, + getRuntimeContext(this.projectRoot, promptToolOptions.model) + ); + this.appendSessionMessage(sessionId, runtimeContextMessage); + const agentInstructions = this.loadAgentInstructions(); if (agentInstructions) { const instructionsMessage = this.buildSystemMessage(sessionId, agentInstructions); this.appendSessionMessage(sessionId, instructionsMessage); } - const defaultSkillPrompt = `Use the skill document below to assist the user:\n${AGENT_DRIFT_GUARD_SKILL}`; - const defaultSkillMessage = this.buildSystemMessage(sessionId, defaultSkillPrompt); - this.appendSessionMessage(sessionId, defaultSkillMessage); - const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index b7c9178..cc86712 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import * as fs from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; -import { getSystemPrompt, getTools } from "../prompt"; +import { getDefaultSkillPrompt, getRuntimeContext, getSystemPrompt, getTools } from "../prompt"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -30,11 +30,39 @@ test("getSystemPrompt includes UpdatePlan docs", () => { assert.equal(prompt.includes("The `plan` argument is a markdown string, not an array of step objects."), true); }); -test("getSystemPrompt includes current date guidance", () => { +test("getSystemPrompt does not include runtime context", () => { + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes("# Local Workspace Environment"), false); + assert.equal(prompt.includes('"root path": "/tmp/project"'), false); +}); + +test("getDefaultSkillPrompt loads default skill templates in order", () => { + const prompt = getDefaultSkillPrompt(); + const agentDriftIndex = prompt.indexOf(""); + const planIndex = prompt.indexOf(""); + + assert.notEqual(agentDriftIndex, -1); + assert.notEqual(planIndex, -1); + assert.equal(agentDriftIndex < planIndex, true); + assert.equal(prompt.includes("Use the skill documents below to assist the user:"), true); + assert.equal(prompt.includes('path="templates/skills/'), false); +}); + +test("getSystemPrompt does not include current date guidance", () => { const now = new Date(); const expected = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; const prompt = getSystemPrompt("/tmp/project"); - assert.equal(prompt.includes(expected), true); + assert.equal(prompt.includes(expected), false); +}); + +test("getRuntimeContext includes current date and model guidance", () => { + const now = new Date(); + const expectedDate = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; + const prompt = getRuntimeContext("/tmp/project", "deepseek-v4-pro"); + assert.equal(prompt.includes(expectedDate), true); + assert.equal(prompt.includes("当前LLM模型为deepseek-v4-pro,对话中可通过/model命令切换模型。"), true); + assert.equal(prompt.includes("# Local Workspace Environment"), true); + assert.equal(prompt.includes('"root path": "/tmp/project"'), true); }); test("getSystemPrompt renders Read docs for non-multimodal models", () => { @@ -47,6 +75,8 @@ test("runtime prompt assets live under templates", () => { assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "web-search.md")), true); assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md.ejs")), true); assert.equal(fs.existsSync(path.join(repoRoot, "templates", "prompts", "init_command.md.ejs")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "skills", "agent-drift-guard.md")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "skills", "plan-and-execute.md")), true); assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md")), false); assert.equal(fs.existsSync(path.join(repoRoot, "docs", "tools")), false); assert.equal(fs.existsSync(path.join(repoRoot, "docs", "prompts")), false); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 7bc98f9..10e3b2c 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -609,6 +609,37 @@ test("createSession stores /init and sends the active .deepcode project AGENTS p assert.ok(!systemContents.includes("root project instructions")); }); +test("createSession appends default system prompts in prefix-cache-friendly order", async () => { + const workspace = createTempDir("deepcode-system-order-workspace-"); + const home = createTempDir("deepcode-system-order-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-system-order"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "hello" }); + const systemContents = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.equal(systemContents.length >= 4, true); + assert.match(systemContents[0] ?? "", /# Available Tools/); + assert.doesNotMatch(systemContents[0] ?? "", /# Local Workspace Environment/); + assert.doesNotMatch(systemContents[0] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[1] ?? "", //); + assert.match(systemContents[1] ?? "", //); + assert.doesNotMatch(systemContents[1] ?? "", /path="templates\/skills\//); + assert.doesNotMatch(systemContents[1] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[2] ?? "", /# Local Workspace Environment/); + assert.match(systemContents[2] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[2] ?? "", new RegExp(escapeRegExp(`"root path": "${workspace}"`))); + assert.equal(systemContents[3], "root project instructions"); +}); + test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-root-workspace-"); const home = createTempDir("deepcode-init-root-home-"); @@ -1686,6 +1717,10 @@ function createTempDir(prefix: string): string { return dir; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + async function flushPromises(): Promise { await new Promise((resolve) => setImmediate(resolve)); } diff --git a/templates/skills/agent-drift-guard.md b/templates/skills/agent-drift-guard.md new file mode 100644 index 0000000..c6711b1 --- /dev/null +++ b/templates/skills/agent-drift-guard.md @@ -0,0 +1,152 @@ +--- +name: agent-drift-guard +description: Detect and correct execution drift while working on user requests. Use when you are actively implementing, debugging, reviewing, or investigating and there is a risk of wandering beyond the user's goal, adding unrequested work, touching live systems, over-exploring, or ignoring repeated user boundary corrections. Especially useful during multi-step coding tasks, production-adjacent requests, ambiguous scopes, and anytime you should self-check whether it is still solving the requested problem. +--- + +# Agent Drift Guard + +Keep execution tightly aligned with the user's actual request. + +## Quick Start + +Run this mental check before substantial work and again whenever the plan expands: + +1. State the user's requested outcome in one sentence. +2. List explicit non-goals or boundaries the user has set. +3. Ask whether the next action directly advances the requested outcome. +4. If not, either cut it or pause to confirm. + +## Drift Signals + +Treat these as warning signs that execution may be drifting: + +- Exploring broadly before opening the most relevant file, command, or artifact. +- Solving adjacent operational issues when the user asked only for code changes. +- Adding extra safeguards, scripts, docs, refactors, or cleanup that the user did not ask for. +- Reframing the task around what seems "better" instead of what was requested. +- Continuing with a broader plan after the user narrows the scope. +- Repeating searches or tool calls without increasing certainty. +- Mixing diagnosis, remediation, and feature work when the user asked for only one of them. +- Touching production-like state, external systems, or live data without explicit permission. + +## Severity Levels + +### Level 1: Mild Drift + +Examples: +- One or two extra exploratory commands. +- Considering a broader solution but not acting on it yet. +- Briefly over-explaining instead of moving the task forward. + +Response: +- Auto-correct silently. +- Narrow to the smallest next action. +- Do not interrupt the user. + +### Level 2: Material Drift + +Examples: +- Planning additional deliverables not requested. +- Writing helper scripts, migrations, docs, or tests outside the asked scope. +- Expanding from code changes into operational fixes. +- Continuing after the user has already corrected the scope once. + +Response: +- Stop and realign internally first. +- If the broader action is avoidable, drop it and continue on scope. +- If the broader action has non-obvious tradeoffs, ask a brief confirmation question. + +### Level 3: Boundary or Risk Violation + +Examples: +- Modifying live systems, production data, external services, or user-owned state without being asked. +- Taking destructive or hard-to-reverse actions outside the requested scope. +- Ignoring repeated user instructions about what not to do. + +Response: +- Pause before acting. +- Surface the exact boundary and ask for confirmation. +- Offer the smallest on-scope option first. + +## Self-Check Loop + +Use this loop during execution: + +### Before the first meaningful action + +Write down mentally: +- Requested outcome +- Allowed scope +- Forbidden scope +- Smallest useful next step + +### After each non-trivial step + +Ask: +- Did this step directly help deliver the requested outcome? +- Did I learn something that changes scope, or only implementation? +- Am I about to do more than the user asked? + +### After a user correction + +Treat the correction as a hard boundary update. + +Then: +- Remove the old broader plan. +- Do not defend the discarded work. +- Continue from the narrowed scope. +- If needed, acknowledge briefly and move on. + +## Decision Rules + +Use these rules in order: + +1. Prefer the most direct artifact first. + - Open the relevant file before scanning the whole repo. + - Inspect the specific failing path before designing a general framework. + +2. Prefer the smallest complete fix. + - Solve the asked problem before improving related systems. + - Avoid bonus work unless it is required for correctness. + +3. Prefer internal correction over user interruption. + - If you can shrink back to scope confidently, do it. + - Ask only when the next step changes deliverables, risk, or ownership. + +4. Treat repeated user constraints as priority signals. + - A repeated instruction means your execution style is currently misaligned. + - Tighten scope immediately. + +5. Separate categories of work. + - Code change, investigation, production remediation, cleanup, and documentation are distinct tasks unless the user explicitly combines them. + +## Good Intervention Style + +When you must pause, keep it short and specific: + +- State the potential drift in one sentence. +- Name the tradeoff or boundary. +- Offer the smallest on-scope option first. + +Example: + +"Quick alignment check: I can keep this to the code fix only, or also add an ops cleanup step. I'll stick to the code fix unless you want both." + +## Anti-Patterns + +Do not: + +- Create cleanup scripts, docs, or side tools just because they seem useful. +- Broaden the task after discovering a neighboring problem. +- Continue with a plan the user has already rejected. +- Justify drift with "best practice" when the user asked for a narrower deliverable. +- Hide extra work inside a larger patch. + +## Final Check Before Responding + +Before sending the final answer, verify: + +- The delivered work matches the requested outcome. +- No extra deliverables were added without confirmation. +- Any assumptions are stated briefly. +- Suggested next steps are optional, not bundled into the completed work. diff --git a/templates/skills/plan-and-execute.md b/templates/skills/plan-and-execute.md new file mode 100644 index 0000000..9fc8bd2 --- /dev/null +++ b/templates/skills/plan-and-execute.md @@ -0,0 +1,246 @@ +--- +name: plan-and-execute +description: Automatically plan and execute requirements. Creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with task planning or when you need to break down and execute complex multi-step requirements. +--- + +# Plan and Execute + +This Skill helps you automatically plan and execute requirements. It creates a structured markdown task list with the UpdatePlan tool and systematically works through each task while keeping progress visible. + +## Quick Start + +When you need to work through a multi-step request: + +1. Analyze the requirements and explore enough project context +2. Clarify unclear or ambiguous requirements with AskUserQuestion +3. Create a markdown task list by calling the UpdatePlan tool +4. Execute tasks one by one, updating the tool plan in real time +5. Revise the remaining plan as new context appears + +## Instructions + +### Step 1: Analyze the requirements + +Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. + +If the original requirements are unclear, incomplete, or ambiguous, call the AskUserQuestion tool before creating the task list. Ask only the questions needed to avoid implementing the wrong behavior, and keep each question specific to the decision that affects the plan or acceptance criteria. + +If a required referenced file path is missing, ask for it with AskUserQuestion: + +``` +What is the path to the referenced file? +``` + +Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. + +- What are the main requirements? +- What tasks need to be completed? +- Are there dependencies between tasks? +- What is the complexity level? +- Which files, modules, commands, or tests are relevant? +- What ambiguity would change the implementation or acceptance criteria? + +### Step 2: Create the task list + +Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: + +```json +{ + "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description" +} +``` + +Use this markdown format for the `plan` content: + +```markdown +## Task List + +- [ ] Task 1 description +- [ ] Task 2 description +- [ ] Task 3 description +``` + +Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. + +### Step 3: Execute tasks systematically + +For each task in the list: + +1. **Refresh the plan**: Before starting the first task and after completing each task, re-evaluate the latest conversation and project context. Update the remaining tasks when scope, order, blockers, or follow-up work changes. +2. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` +3. **Execute the task**: Use appropriate tools to complete the work +4. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished +5. **Move to next task**: Only ONE task should be in progress at a time + +Important rules: +- Always keep the plan aligned with the latest context before executing the next task +- Always call UpdatePlan BEFORE starting work on a task +- Always call UpdatePlan IMMEDIATELY after completing a task +- Always pass the complete current markdown task list, not a partial diff +- Never work on multiple tasks simultaneously +- Remove tasks that are no longer relevant, and add newly discovered tasks before working on them +- If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers + +### Step 4: Handle task breakdown + +If during execution you discover a task is more complex than expected: + +1. Keep the current task as `[>]` +2. Call UpdatePlan with new sub-tasks below it with indentation: + ```markdown + - [>] Main task + - [ ] Sub-task 1 + - [ ] Sub-task 2 + ``` +3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan + +### Step 5: Final verification + +After all tasks are completed (`[x]`): + +1. Review the original requirements to ensure everything is addressed +2. Run any final checks (tests, builds, linting) +3. Call UpdatePlan with every task marked `[x]` +4. Provide a concise completion summary in the final response + +## Task State Symbols + +- `[ ]` - Pending +- `[>]` - In progress +- `[x]` - Completed +- `[!]` - Blocked + +## Examples + +### Example 1: Simple feature request + +**Example requirements:** +```markdown +# 新功能:添加深色模式切换 + +用户应该能够在浅色和深色主题之间切换。 +切换开关应放在设置页面中。 +``` + +**分析后的 UpdatePlan 调用:** +```markdown +## Task List + +- [ ] 在设置页面创建深色模式切换组件 +- [ ] 添加深色模式状态管理(context/store) +- [ ] 实现深色主题的 CSS-in-JS 样式 +- [ ] 更新现有组件以支持主题切换 +- [ ] 运行测试并验证功能 +``` + +**UpdatePlan call during execution:** +```markdown +## Task List + +- [x] 在设置页面创建深色模式切换组件 +- [>] 添加深色模式状态管理(context/store) +- [ ] 实现深色主题的 CSS-in-JS 样式 +- [ ] 更新现有组件以支持主题切换 +- [ ] 运行测试并验证功能 +``` + +### Example 2: Bug fix with investigation + +**Example requirements:** +```markdown +# Fix bug:登录表单提交时崩溃 + +当用户点击提交时,应用崩溃。 +错误信息:"Cannot read property 'email' of undefined" +``` + +**UpdatePlan call after analysis:** +```markdown +## Task List + +- [ ] 在本地复现缺陷 +- [ ] 调查登录表单组件中的错误 +- [ ] 定位 undefined email 属性的根本原因 +- [ ] 实施修复 +- [ ] 添加验证以防止类似问题 +- [ ] 使用各种输入测试修复 +- [ ] 更新错误处理 +``` + +## When to Use This Skill + +Use this Skill when: + +1. **Complex multi-step tasks** - Request requires 3+ distinct steps +2. **Feature implementation** - Building new functionality from requirements +3. **Bug fixing** - Need to investigate, fix, and verify +4. **Refactoring** - Multiple files or components need changes +5. **Detailed requirements** - Specifications need to be translated into concrete tasks +6. **Need progress tracking** - Want visible progress without editing source files + +## When NOT to Use This Skill + +Skip this Skill when: + +1. **Single simple task** - Just one straightforward action needed +2. **Trivial changes** - Quick fixes that don't need planning +3. **Informational requests** - User just wants explanation, not execution +4. **No execution requested** - User only wants brainstorming or a high-level explanation + +## Best Practices + +1. **Be specific with tasks**: "Add login button to navbar" not "Update UI" +2. **Keep tasks atomic**: Each task should be independently completable +3. **Update immediately**: Don't batch status updates, do them in real-time +4. **One task at a time**: Never mark multiple tasks as `[>]` +5. **Handle blockers**: If stuck, create new tasks to resolve the blocker +6. **Verify completion**: Only mark `[x]` when task is fully done + +## Advanced Usage + +### Handling dependencies + +When tasks have dependencies, order them properly: + +```markdown +- [ ] Create database schema +- [ ] Implement API endpoints (depends on schema) +- [ ] Build frontend forms (depends on API) +``` + +### Using sub-tasks + +For complex tasks, break them down: + +```markdown +- [>] Implement authentication system + - [x] Set up JWT library + - [>] Create login endpoint + - [ ] Create logout endpoint + - [ ] Add token refresh logic +``` + +### Adding notes + +Add implementation notes or findings: + +```markdown +- [x] Investigate performance issue + - Note: Found N+1 query in user loader + - Solution: Added dataloader batching +``` + +## Workflow Summary + +1. Analyze the requirements and relevant project context +2. Call AskUserQuestion if the original requirements are unclear or ambiguous +3. Call UpdatePlan with the structured markdown task list +4. Refresh the remaining plan before the first task +5. For each task: + - Update to `[>]` with UpdatePlan + - Execute the task + - Update to `[x]` with UpdatePlan + - Re-evaluate and revise remaining tasks before moving on +6. Call UpdatePlan with all tasks completed and summarize the result + +This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. From 486e649adbe93f4dd7db62a0b433bc54351d254a Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 16:29:20 +0800 Subject: [PATCH 138/217] feat: update createSession test to validate environment JSON structure and root path --- src/tests/session.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 10e3b2c..a178726 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -636,7 +636,10 @@ test("createSession appends default system prompts in prefix-cache-friendly orde assert.doesNotMatch(systemContents[1] ?? "", /当前LLM模型为test-model/); assert.match(systemContents[2] ?? "", /# Local Workspace Environment/); assert.match(systemContents[2] ?? "", /当前LLM模型为test-model/); - assert.match(systemContents[2] ?? "", new RegExp(escapeRegExp(`"root path": "${workspace}"`))); + const environmentJsonMatch = (systemContents[2] ?? "").match(/```json\n([\s\S]+?)\n```/); + assert.ok(environmentJsonMatch); + const environmentInfo = JSON.parse(environmentJsonMatch[1] ?? "{}") as { "root path"?: string }; + assert.equal(environmentInfo["root path"], workspace); assert.equal(systemContents[3], "root project instructions"); }); From 08ba8d34b298f045f0fd908317e543dcfa6644e6 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 18:05:10 +0800 Subject: [PATCH 139/217] feat: add file mention functionality with scanning and filtering capabilities --- src/tests/fileMentions.test.ts | 157 +++++++++++++ src/ui/App.tsx | 1 + src/ui/PromptInput.tsx | 110 ++++++++- src/ui/fileMentions.ts | 410 +++++++++++++++++++++++++++++++++ src/ui/index.ts | 9 + 5 files changed, 682 insertions(+), 5 deletions(-) create mode 100644 src/tests/fileMentions.test.ts create mode 100644 src/ui/fileMentions.ts diff --git a/src/tests/fileMentions.test.ts b/src/tests/fileMentions.test.ts new file mode 100644 index 0000000..57f078e --- /dev/null +++ b/src/tests/fileMentions.test.ts @@ -0,0 +1,157 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + filterFileMentionItems, + formatFileMentionPath, + getCurrentFileMentionToken, + replaceCurrentFileMentionToken, + scanFileMentionItems, + type FileMentionItem, +} from "../ui/fileMentions"; + +test("getCurrentFileMentionToken detects bare @file tokens under the cursor", () => { + assert.deepEqual(getCurrentFileMentionToken({ text: "review @src/app.ts please", cursor: 10 }), { + query: "src/app.ts", + start: 7, + end: 18, + quoted: false, + }); + assert.deepEqual(getCurrentFileMentionToken({ text: "@", cursor: 1 }), { + query: "", + start: 0, + end: 1, + quoted: false, + }); + assert.equal(getCurrentFileMentionToken({ text: "foo@bar", cursor: 7 }), null); +}); + +test("getCurrentFileMentionToken supports quoted paths with spaces", () => { + assert.deepEqual(getCurrentFileMentionToken({ text: 'open @"docs/my file.md"', cursor: 22 }), { + query: "docs/my file.md", + start: 5, + end: 23, + quoted: true, + }); + assert.deepEqual(getCurrentFileMentionToken({ text: 'open @"docs/my', cursor: 14 }), { + query: "docs/my", + start: 5, + end: 14, + quoted: true, + }); + assert.equal(getCurrentFileMentionToken({ text: 'open @"docs/my file.md" now', cursor: 24 }), null); +}); + +test("formatFileMentionPath quotes only paths that need it", () => { + assert.equal(formatFileMentionPath("src/App.tsx"), "@src/App.tsx"); + assert.equal(formatFileMentionPath("docs/my file.md"), '@"docs/my file.md"'); + assert.equal(formatFileMentionPath('docs/a"b.md'), '@"docs/a\\"b.md"'); +}); + +test("replaceCurrentFileMentionToken inserts a trailing-space mention", () => { + const state = { text: "read @sr then", cursor: 8 }; + const token = getCurrentFileMentionToken(state); + assert.ok(token); + assert.deepEqual(replaceCurrentFileMentionToken(state, token, "src/index.ts"), { + text: "read @src/index.ts then", + cursor: 19, + }); + + const quotedState = { text: 'read @"doc', cursor: 10 }; + const quotedToken = getCurrentFileMentionToken(quotedState); + assert.ok(quotedToken); + assert.deepEqual(replaceCurrentFileMentionToken(quotedState, quotedToken, "docs/my file.md"), { + text: 'read @"docs/my file.md" ', + cursor: 24, + }); +}); + +test("filterFileMentionItems prioritizes prefix and basename matches", () => { + const items: FileMentionItem[] = [ + { path: "src/PromptInput.tsx", type: "file" }, + { path: "docs/prompt guide.md", type: "file" }, + { path: "templates/prompts/init.md", type: "file" }, + ]; + + assert.deepEqual( + filterFileMentionItems(items, "prompt").map((item) => item.path), + ["docs/prompt guide.md", "src/PromptInput.tsx", "templates/prompts/init.md"] + ); +}); + +test("scanFileMentionItems returns relative slash-separated files and directories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "src", "index.ts"), ""); + fs.mkdirSync(path.join(root, "node_modules")); + fs.writeFileSync(path.join(root, "node_modules", "ignored.js"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["node_modules/", "node_modules/ignored.js", "src/", "src/index.ts"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems respects project gitignore patterns inside git repositories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git")); + fs.mkdirSync(path.join(root, ".mypy_cache"), { recursive: true }); + fs.writeFileSync(path.join(root, ".mypy_cache", "ignored.json"), ""); + fs.mkdirSync(path.join(root, "tmp")); + fs.writeFileSync(path.join(root, "tmp", "ignored.txt"), ""); + fs.mkdirSync(path.join(root, "docs")); + fs.writeFileSync(path.join(root, "docs", "guide.md"), ""); + fs.writeFileSync(path.join(root, ".gitignore"), ".mypy_cache/\ntmp/\n"); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["docs/", "docs/guide.md", ".gitignore"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems ignores gitignore files outside git repositories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, "tmp")); + fs.writeFileSync(path.join(root, "tmp", "visible.txt"), ""); + fs.writeFileSync(path.join(root, ".gitignore"), "tmp/\n"); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["tmp/", "tmp/visible.txt", ".gitignore"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems applies parent and nested ignore files", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, ".gitignore"), "ignored-from-parent/\n"); + fs.mkdirSync(path.join(root, "sub", "ignored-from-parent"), { recursive: true }); + fs.writeFileSync(path.join(root, "sub", "ignored-from-parent", "hidden.txt"), ""); + fs.mkdirSync(path.join(root, "sub", "nested", "ignored-from-nested"), { recursive: true }); + fs.writeFileSync(path.join(root, "sub", "nested", ".gitignore"), "ignored-from-nested/\n"); + fs.writeFileSync(path.join(root, "sub", "nested", "ignored-from-nested", "hidden.txt"), ""); + fs.writeFileSync(path.join(root, "sub", "nested", "visible.txt"), ""); + + assert.deepEqual( + scanFileMentionItems(path.join(root, "sub")).map((item) => item.path), + ["nested/", "nested/.gitignore", "nested/visible.txt"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e56111f..8d8dca1 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -512,6 +512,7 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App /> ) : isExiting ? null : ( (null); const [modelDropdownIndex, setModelDropdownIndex] = useState(0); const [pendingModel, setPendingModel] = useState(null); + const [fileMentionIndex, setFileMentionIndex] = useState(0); + const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); const [historyCursor, setHistoryCursor] = useState(-1); const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); const lastCtrlDAt = React.useRef(0); const undoRedoRef = React.useRef(createPromptUndoRedoState()); + const fileMentionItems = React.useMemo(() => scanFileMentionItems(projectRoot), [projectRoot]); + const fileMentionToken = getCurrentFileMentionToken(buffer); + const fileMentionKey = fileMentionToken ? `${fileMentionToken.start}:${fileMentionToken.query}` : null; + const fileMentionMatches = React.useMemo( + () => (fileMentionToken ? filterFileMentionItems(fileMentionItems, fileMentionToken.query) : []), + [fileMentionItems, fileMentionToken] + ); + const showFileMentionMenu = + !showSkillsDropdown && + !modelDropdownStep && + fileMentionToken !== null && + fileMentionKey !== dismissedFileMentionKey; const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); const slashMenu = React.useMemo( () => - showSkillsDropdown || modelDropdownStep ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : [], - [showSkillsDropdown, modelDropdownStep, slashToken, slashItems] + showSkillsDropdown || modelDropdownStep || showFileMentionMenu + ? [] + : slashToken + ? filterSlashCommands(slashItems, slashToken) + : [], + [showSkillsDropdown, modelDropdownStep, showFileMentionMenu, slashToken, slashItems] ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); @@ -153,7 +180,7 @@ export const PromptInput = React.memo(function PromptInput({ ? loadingText && loadingText.trim() ? `${loadingText}${processHint}` : `esc to interrupt · ctrl+c to cancel input${processHint}` - : `enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit${processHint}`; + : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processHint}`; useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); @@ -168,6 +195,22 @@ export const PromptInput = React.memo(function PromptInput({ } }, [slashMenu, showMenu, menuIndex]); + useEffect(() => { + if (!fileMentionKey) { + setDismissedFileMentionKey(null); + } + }, [fileMentionKey]); + + useEffect(() => { + if (!showFileMentionMenu) { + setFileMentionIndex(0); + return; + } + if (fileMentionIndex >= fileMentionMatches.length) { + setFileMentionIndex(Math.max(0, fileMentionMatches.length - 1)); + } + }, [fileMentionMatches.length, fileMentionIndex, showFileMentionMenu]); + useEffect(() => { if (skillsDropdownIndex >= skills.length) { setSkillsDropdownIndex(Math.max(0, skills.length - 1)); @@ -222,6 +265,10 @@ export const PromptInput = React.memo(function PromptInput({ setShowSkillsDropdown(false); return; } + if (showFileMentionMenu && fileMentionKey) { + setDismissedFileMentionKey(fileMentionKey); + return; + } if (busy) { onInterrupt(); setStatusMessage("Interrupting…"); @@ -353,6 +400,35 @@ export const PromptInput = React.memo(function PromptInput({ const returnAction = getPromptReturnKeyAction(key); const isPlainReturn = returnAction === "submit"; + if (showFileMentionMenu) { + if (key.upArrow) { + if (fileMentionMatches.length > 0) { + setFileMentionIndex((idx) => (idx - 1 + fileMentionMatches.length) % fileMentionMatches.length); + } + return; + } + if (key.downArrow) { + if (fileMentionMatches.length > 0) { + setFileMentionIndex((idx) => (idx + 1) % fileMentionMatches.length); + } + return; + } + if (key.tab || returnAction === "submit") { + const selected = fileMentionMatches[fileMentionIndex]; + if (selected && fileMentionToken) { + insertFileMentionSelection(selected); + return; + } + if (key.tab) { + setDismissedFileMentionKey(fileMentionKey); + return; + } + if (fileMentionKey) { + setDismissedFileMentionKey(fileMentionKey); + } + } + } + if (showMenu) { if (key.upArrow) { setMenuIndex((idx) => (idx - 1 + slashMenu.length) % slashMenu.length); @@ -585,6 +661,14 @@ export const PromptInput = React.memo(function PromptInput({ setHistoryCursor(nextCursor); } + function insertFileMentionSelection(item: FileMentionItem): void { + if (!fileMentionToken) { + return; + } + updateBuffer((state) => replaceCurrentFileMentionToken(state, fileMentionToken, item.path)); + setDismissedFileMentionKey(null); + } + function handleSlashSelection(item: SlashCommandItem): void { if (busy && item.kind !== "exit") { setStatusMessage("wait for the current response or press esc to interrupt"); @@ -760,8 +844,8 @@ export const PromptInput = React.memo(function PromptInput({ })); const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || modelDropdownStep !== null, - [showMenu, showSkillsDropdown, modelDropdownStep] + () => showMenu || showSkillsDropdown || modelDropdownStep !== null || showFileMentionMenu, + [showMenu, showSkillsDropdown, modelDropdownStep, showFileMentionMenu] ); return ( @@ -830,6 +914,22 @@ export const PromptInput = React.memo(function PromptInput({ maxVisible={6} /> ) : null} + {showFileMentionMenu ? ( + ({ + key: item.path, + label: item.path, + description: item.type === "directory" ? "directory" : "file", + }))} + activeIndex={fileMentionIndex} + activeColor="#229ac3" + maxVisible={8} + /> + ) : null} {!showFooterText && ( diff --git a/src/ui/fileMentions.ts b/src/ui/fileMentions.ts new file mode 100644 index 0000000..cbacbe6 --- /dev/null +++ b/src/ui/fileMentions.ts @@ -0,0 +1,410 @@ +import * as fs from "fs"; +import * as path from "path"; +import ignore from "ignore"; +import type { PromptBufferState } from "./promptBuffer"; + +export type FileMentionItem = { + path: string; + type: "file" | "directory"; +}; + +export type FileMentionToken = { + query: string; + start: number; + end: number; + quoted: boolean; +}; + +const DEFAULT_MAX_ITEMS = 2000; +const DEFAULT_MAX_DEPTH = 8; + +type IgnoreMatcher = { + base: string; + matcher: ignore.Ignore; +}; + +export function scanFileMentionItems(root: string, maxItems = DEFAULT_MAX_ITEMS): FileMentionItem[] { + const items: FileMentionItem[] = []; + const seen = new Set(); + const gitRoot = findGitRoot(root); + const visitedDirectories = new Set(); + + function addItem(item: FileMentionItem): void { + if (items.length >= maxItems || seen.has(item.path)) { + return; + } + seen.add(item.path); + items.push(item); + } + + function visit(directory: string, depth: number, matchers: IgnoreMatcher[]): void { + if (items.length >= maxItems || depth > DEFAULT_MAX_DEPTH) { + return; + } + + const currentMatchers = [...matchers, ...loadDirectoryIgnoreMatchers(directory, gitRoot)]; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + + entries.sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) { + return a.isDirectory() ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + for (const entry of entries) { + if (items.length >= maxItems) { + return; + } + if (entry.name === "." || entry.name === ".." || entry.name === ".git") { + continue; + } + + const absolute = path.join(directory, entry.name); + const relative = toMentionPath(path.relative(root, absolute)); + if (!relative) { + continue; + } + + const entryType = getMentionEntryType(entry, absolute); + if (!entryType) { + continue; + } + + if (matchesAnyIgnore(absolute, entryType === "directory", currentMatchers)) { + continue; + } + + if (entryType === "directory") { + const realPath = safeRealpath(absolute); + if (realPath) { + if (visitedDirectories.has(realPath)) { + continue; + } + visitedDirectories.add(realPath); + } + addItem({ path: `${relative}/`, type: "directory" }); + visit(absolute, depth + 1, currentMatchers); + continue; + } + + if (entryType === "file") { + addItem({ path: relative, type: "file" }); + } + } + } + + const rootRealPath = safeRealpath(root); + if (rootRealPath) { + visitedDirectories.add(rootRealPath); + } + visit(root, 0, loadAncestorIgnoreMatchers(root, gitRoot)); + return items; +} + +function getMentionEntryType(entry: fs.Dirent, absolute: string): FileMentionItem["type"] | null { + if (entry.isDirectory()) { + return "directory"; + } + if (entry.isFile()) { + return "file"; + } + if (!entry.isSymbolicLink()) { + return null; + } + try { + const stat = fs.statSync(absolute); + if (stat.isDirectory()) { + return "directory"; + } + if (stat.isFile()) { + return "file"; + } + } catch { + return null; + } + return null; +} + +function safeRealpath(absolute: string): string | null { + try { + return fs.realpathSync(absolute); + } catch { + return null; + } +} + +function loadDirectoryIgnoreMatchers(directory: string, gitRoot: string | null): IgnoreMatcher[] { + const matchers: IgnoreMatcher[] = []; + if (gitRoot && isPathInsideOrEqual(directory, gitRoot)) { + const gitignoreMatcher = loadIgnoreFileMatcher(directory, path.join(directory, ".gitignore")); + if (gitignoreMatcher) { + matchers.push(gitignoreMatcher); + } + if (path.resolve(directory) === path.resolve(gitRoot)) { + const gitExcludeMatcher = loadIgnoreFileMatcher(directory, path.join(directory, ".git", "info", "exclude")); + if (gitExcludeMatcher) { + matchers.push(gitExcludeMatcher); + } + } + } + + const ignoreMatcher = loadIgnoreFileMatcher(directory, path.join(directory, ".ignore")); + if (ignoreMatcher) { + matchers.push(ignoreMatcher); + } + return matchers; +} + +function loadAncestorIgnoreMatchers(root: string, gitRoot: string | null): IgnoreMatcher[] { + const resolvedRoot = path.resolve(root); + const ancestors: string[] = []; + let current = path.dirname(resolvedRoot); + while (gitRoot && isPathInsideOrEqual(current, gitRoot)) { + ancestors.push(current); + if (path.resolve(current) === path.resolve(gitRoot)) { + break; + } + current = path.dirname(current); + } + return ancestors.reverse().flatMap((directory) => loadDirectoryIgnoreMatchers(directory, gitRoot)); +} + +function loadIgnoreFileMatcher(base: string, ignoreFilePath: string): IgnoreMatcher | null { + try { + if (!fs.existsSync(ignoreFilePath)) { + return null; + } + const content = fs.readFileSync(ignoreFilePath, "utf8"); + if (!content.trim()) { + return null; + } + return { base, matcher: ignore().add(content) }; + } catch { + return null; + } +} + +function matchesAnyIgnore(absolute: string, isDir: boolean, matchers: IgnoreMatcher[]): boolean { + let ignored = false; + for (const { base, matcher } of matchers) { + const relative = toMentionPath(path.relative(base, absolute)); + if (!relative || relative.startsWith("../")) { + continue; + } + const result = matcher.test(isDir ? `${relative}/` : relative); + if (result.ignored) { + ignored = true; + } + if (result.unignored) { + ignored = false; + } + } + return ignored; +} + +function findGitRoot(start: string): string | null { + let current = path.resolve(start); + while (true) { + if (fs.existsSync(path.join(current, ".git"))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function isPathInsideOrEqual(candidate: string, parent: string): boolean { + const relative = path.relative(parent, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function filterFileMentionItems(items: FileMentionItem[], query: string, maxResults = 12): FileMentionItem[] { + const normalizedQuery = normalizeForSearch(query); + const scored = items + .map((item, index) => ({ item, index, score: scoreFileMention(item.path, normalizedQuery) })) + .filter((entry) => entry.score !== Number.POSITIVE_INFINITY) + .sort((a, b) => a.score - b.score || a.item.path.length - b.item.path.length || a.index - b.index); + + return scored.slice(0, maxResults).map((entry) => entry.item); +} + +export function getCurrentFileMentionToken(state: PromptBufferState): FileMentionToken | null { + const text = state.text; + const cursor = clampCursorToBoundary(text, state.cursor); + const quoted = getCurrentQuotedFileMentionToken(text, cursor); + if (quoted) { + return quoted; + } + return getCurrentBareFileMentionToken(text, cursor); +} + +export function replaceCurrentFileMentionToken( + state: PromptBufferState, + token: FileMentionToken, + selectedPath: string +): PromptBufferState { + const inserted = `${formatFileMentionPath(selectedPath)} `; + const end = token.end < state.text.length && isWhitespace(state.text[token.end] ?? "") ? token.end + 1 : token.end; + const text = `${state.text.slice(0, token.start)}${inserted}${state.text.slice(end)}`; + return { text, cursor: token.start + inserted.length }; +} + +export function formatFileMentionPath(filePath: string): string { + if (!/[\s"]/.test(filePath)) { + return `@${filePath}`; + } + return `@"${filePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function getCurrentBareFileMentionToken(text: string, cursor: number): FileMentionToken | null { + const beforeCursor = text.slice(0, cursor); + const afterCursor = text.slice(cursor); + const start = findTokenStart(beforeCursor); + const end = cursor + findTokenEnd(afterCursor); + const token = text.slice(start, end); + + if (!token.startsWith("@") || token.startsWith('@"')) { + return null; + } + if (start > 0 && !isWhitespace(text[start - 1] ?? "")) { + return null; + } + return { query: token.slice(1), start, end, quoted: false }; +} + +function getCurrentQuotedFileMentionToken(text: string, cursor: number): FileMentionToken | null { + for (let index = cursor; index >= 0; index--) { + if (text[index] !== "@" || text[index + 1] !== '"') { + continue; + } + if (index > 0 && !isWhitespace(text[index - 1] ?? "")) { + continue; + } + + const closeQuote = findClosingQuote(text, index + 2); + if (closeQuote !== -1 && cursor > closeQuote) { + continue; + } + + const end = closeQuote === -1 ? cursor : closeQuote + 1; + return { + query: unescapeQuotedMentionQuery( + text.slice(index + 2, Math.min(cursor, closeQuote === -1 ? cursor : closeQuote)) + ), + start: index, + end, + quoted: true, + }; + } + return null; +} + +function findTokenStart(beforeCursor: string): number { + const whitespaceIndex = findLastWhitespaceIndex(beforeCursor); + return whitespaceIndex === -1 ? 0 : whitespaceIndex + 1; +} + +function findTokenEnd(afterCursor: string): number { + const whitespaceIndex = afterCursor.search(/\s/); + return whitespaceIndex === -1 ? afterCursor.length : whitespaceIndex; +} + +function findLastWhitespaceIndex(value: string): number { + for (let index = value.length - 1; index >= 0; index--) { + if (isWhitespace(value[index] ?? "")) { + return index; + } + } + return -1; +} + +function findClosingQuote(text: string, start: number): number { + let escaped = false; + for (let index = start; index < text.length; index++) { + const char = text[index]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + return index; + } + } + return -1; +} + +function unescapeQuotedMentionQuery(query: string): string { + return query.replace(/\\(["\\])/g, "$1"); +} + +function clampCursorToBoundary(text: string, cursor: number): number { + return Math.max(0, Math.min(cursor, text.length)); +} + +function scoreFileMention(itemPath: string, normalizedQuery: string): number { + if (!normalizedQuery) { + return itemPath.endsWith("/") ? 5 : 10; + } + + const normalizedPath = normalizeForSearch(itemPath); + const normalizedBase = normalizeForSearch(path.posix.basename(itemPath.replace(/\/$/, ""))); + if (normalizedPath === normalizedQuery) { + return 0; + } + if (normalizedPath.startsWith(normalizedQuery)) { + return 1; + } + if (normalizedBase.startsWith(normalizedQuery)) { + return isQueryBoundary(normalizedBase[normalizedQuery.length] ?? "") ? 2 : 3; + } + const pathIndex = normalizedPath.indexOf(normalizedQuery); + if (pathIndex !== -1) { + return 20 + pathIndex; + } + const fuzzyScore = fuzzyMatchScore(normalizedPath, normalizedQuery); + return fuzzyScore === null ? Number.POSITIVE_INFINITY : 100 + fuzzyScore; +} + +function fuzzyMatchScore(value: string, query: string): number | null { + let valueIndex = 0; + let score = 0; + for (const char of query) { + const nextIndex = value.indexOf(char, valueIndex); + if (nextIndex === -1) { + return null; + } + score += nextIndex - valueIndex; + valueIndex = nextIndex + 1; + } + return score; +} + +function normalizeForSearch(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function isQueryBoundary(value: string): boolean { + return value === "" || /[\s._/-]/.test(value); +} + +function toMentionPath(value: string): string { + return value.split(path.sep).join("/"); +} + +function isWhitespace(value: string): boolean { + return /\s/.test(value); +} diff --git a/src/ui/index.ts b/src/ui/index.ts index 5b4ff8f..5bcde40 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -79,5 +79,14 @@ export { type SlashCommandKind, type SlashCommandItem, } from "./slashCommands"; +export { + filterFileMentionItems, + formatFileMentionPath, + getCurrentFileMentionToken, + replaceCurrentFileMentionToken, + scanFileMentionItems, + type FileMentionItem, + type FileMentionToken, +} from "./fileMentions"; export { findExpandedThinkingId } from "./thinkingState"; export { buildExitSummaryText } from "./exitSummary"; From 47d3c21abe3c3582d24e7c1109bdf19e0818c90d Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 18 May 2026 18:13:34 +0800 Subject: [PATCH 140/217] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20/raw?= =?UTF-8?q?=20=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=E5=8F=8A=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=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 f69ef03394cccf42f019fa22b0bf19bb4a42a09e Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 18:45:17 +0800 Subject: [PATCH 141/217] feat: enhance file mention handling in PromptInput with dynamic updates --- src/tests/fileMentions.test.ts | 115 +++++++++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 42 +++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/tests/fileMentions.test.ts b/src/tests/fileMentions.test.ts index 57f078e..b382eee 100644 --- a/src/tests/fileMentions.test.ts +++ b/src/tests/fileMentions.test.ts @@ -155,3 +155,118 @@ test("scanFileMentionItems applies parent and nested ignore files", () => { fs.rmSync(root, { recursive: true, force: true }); } }); + +test("scanFileMentionItems applies git info exclude at the repository root", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git", "info"), { recursive: true }); + fs.writeFileSync(path.join(root, ".git", "info", "exclude"), "secret.txt\n"); + fs.writeFileSync(path.join(root, "secret.txt"), ""); + fs.writeFileSync(path.join(root, "visible.txt"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["visible.txt"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems applies .ignore files outside git repositories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.writeFileSync(path.join(root, ".ignore"), "ignored.txt\n"); + fs.writeFileSync(path.join(root, "ignored.txt"), ""); + fs.writeFileSync(path.join(root, "visible.txt"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + [".ignore", "visible.txt"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems honors gitignore negation patterns", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, ".gitignore"), "*.log\n!important.log\n"); + fs.writeFileSync(path.join(root, "debug.log"), ""); + fs.writeFileSync(path.join(root, "important.log"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + [".gitignore", "important.log"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems includes hidden entries except the .git directory", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, ".env"), ""); + fs.mkdirSync(path.join(root, ".config")); + fs.writeFileSync(path.join(root, ".config", "settings.json"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + [".config/", ".config/settings.json", ".env"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems sees files created after an earlier scan", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + assert.deepEqual(scanFileMentionItems(root), []); + + fs.writeFileSync(path.join(root, "index.html"), ""); + + assert.deepEqual(scanFileMentionItems(root), [{ path: "index.html", type: "file" }]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems follows symlinked files", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.writeFileSync(path.join(root, "source.txt"), ""); + try { + fs.symlinkSync(path.join(root, "source.txt"), path.join(root, "alias.txt")); + } catch { + t.skip("symlink creation is not available in this environment"); + return; + } + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["alias.txt", "source.txt"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("filterFileMentionItems returns newly scanned files for @ mention queries", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.writeFileSync(path.join(root, "index.html"), ""); + const items = scanFileMentionItems(root); + + assert.deepEqual( + filterFileMentionItems(items, "index").map((item) => item.path), + ["index.html"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index c9e4c47..0387ceb 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -139,6 +139,7 @@ export const PromptInput = React.memo(function PromptInput({ const [modelDropdownStep, setModelDropdownStep] = useState(null); const [modelDropdownIndex, setModelDropdownIndex] = useState(0); const [pendingModel, setPendingModel] = useState(null); + const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot)); const [fileMentionIndex, setFileMentionIndex] = useState(0); const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); const [historyCursor, setHistoryCursor] = useState(-1); @@ -146,9 +147,11 @@ export const PromptInput = React.memo(function PromptInput({ const [hasTerminalFocus, setHasTerminalFocus] = useState(true); const lastCtrlDAt = React.useRef(0); const undoRedoRef = React.useRef(createPromptUndoRedoState()); + const wasBusyRef = React.useRef(busy); + const hadFileMentionTokenRef = React.useRef(false); - const fileMentionItems = React.useMemo(() => scanFileMentionItems(projectRoot), [projectRoot]); const fileMentionToken = getCurrentFileMentionToken(buffer); + const hasFileMentionToken = fileMentionToken !== null; const fileMentionKey = fileMentionToken ? `${fileMentionToken.start}:${fileMentionToken.query}` : null; const fileMentionMatches = React.useMemo( () => (fileMentionToken ? filterFileMentionItems(fileMentionItems, fileMentionToken.query) : []), @@ -185,6 +188,28 @@ export const PromptInput = React.memo(function PromptInput({ useTerminalExtendedKeys(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); + const refreshFileMentionItems = React.useCallback(() => { + setFileMentionItems(scanFileMentionItems(projectRoot)); + }, [projectRoot]); + + useEffect(() => { + refreshFileMentionItems(); + }, [refreshFileMentionItems]); + + useEffect(() => { + if (wasBusyRef.current && !busy) { + refreshFileMentionItems(); + } + wasBusyRef.current = busy; + }, [busy, refreshFileMentionItems]); + + useEffect(() => { + if (hasFileMentionToken && !hadFileMentionTokenRef.current) { + refreshFileMentionItems(); + } + hadFileMentionTokenRef.current = hasFileMentionToken; + }, [hasFileMentionToken, refreshFileMentionItems]); + useEffect(() => { if (!showMenu) { setMenuIndex(0); @@ -928,6 +953,21 @@ export const PromptInput = React.memo(function PromptInput({ activeIndex={fileMentionIndex} activeColor="#229ac3" maxVisible={8} + renderItem={(item, isActive) => ( + + {isActive ? "> " : " "} + + + {item.label} + + + {item.description ? ( + + {item.description} + + ) : null} + + )} /> ) : null} From 67e2066b73f7e2d1b172e1e053d44009bcb7be0a Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 18 May 2026 20:20:26 +0800 Subject: [PATCH 142/217] =?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 d87dcfccf86496ad3e9b69a5f194b7779c82f6c1 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 20:51:03 +0800 Subject: [PATCH 143/217] feat: update AGENTS.md --- .deepcode/AGENTS.md | 53 ++++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index 9cdf64e..7f9cf35 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -4,40 +4,47 @@ ``` src/ -├── cli.tsx # Entry point — parses args, renders Ink App +├── cli.tsx # Entry point — parses args (-p, -v), renders Ink App ├── session.ts # SessionManager — LLM loop, compaction, tool orchestration ├── settings.ts # Settings resolution from ~/.deepcode/settings.json -├── prompt.ts # System prompt builder, tool definitions, agent-drift-guard skill +├── prompt.ts # System prompt builder, tool definitions, built-in skills ├── common/ │ ├── model-capabilities.ts # Model detection and thinking-mode defaults +│ ├── openai-thinking.ts # OpenAI thinking request options builder │ ├── file-utils.ts # File read/write with encoding and diff preview │ ├── shell-utils.ts # Shell path resolution (Git Bash, zsh, bash) │ ├── state.ts # In-memory file state and snippet tracking -│ └── runtime.ts # Tool validation runtime helpers +│ ├── runtime.ts # Tool validation runtime helpers +│ ├── notify.ts # Desktop notification after LLM turn completion +│ ├── debug-logger.ts # Debug logging for OpenAI API calls +│ └── error-logger.ts # API error logging ├── ui/ │ ├── App.tsx # Root Ink component — state, routing, session orchestration -│ ├── PromptInput.tsx # Multi-line input with slash commands, image paste, skills +│ ├── PromptInput.tsx # Multi-line input with file mentions (@), slash commands, image paste, skills │ ├── MessageView.tsx # Renders assistant/tool messages with markdown -│ ├── DropdownMenu.tsx # Reusable dropdown for skill/model selection -│ ├── SessionList.tsx # Session picker for /resume -│ ├── promptUndoRedo.ts # Ctrl+- undo / Ctrl+Shift+- redo for prompt input +│ ├── McpStatusList.tsx # MCP server connection status and available tools +│ ├── ProcessStdoutView.tsx # Ctrl+O fullscreen overlay for live process stdout +│ ├── UpdatePrompt.tsx # UpdatePlan task list progress display +│ ├── fileMentions.ts # @-mention file scanning, filtering, and insertion │ └── ... ├── mcp/ │ ├── mcp-client.ts # MCP client — JSON-RPC communication with MCP servers -│ └── mcp-manager.ts # MCP manager — lifecycle, tool registration, execution +│ └── mcp-manager.ts # MCP manager — lifecycle, tool registration, execution, status ├── tools/ -│ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers -│ ├── bash-handler.ts # Executes shell commands -│ ├── read-handler.ts # Reads files and images +│ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers (7 built-in) +│ ├── bash-handler.ts # Executes shell commands with live stdout streaming +│ ├── read-handler.ts # Reads files, images, PDFs, and notebooks │ ├── write-handler.ts # Creates/overwrites files -│ ├── edit-handler.ts # Scoped string replacements in files -│ ├── web-search-handler.ts # Web search tool -│ └── ask-user-question-handler.ts # Interactive user prompts -├── tests/ # Test suite — one *.test.ts per module +│ ├── edit-handler.ts # Scoped string replacements with snippet tracking +│ ├── update-plan-handler.ts # Updates the task plan progress display +│ ├── web-search-handler.ts # Web search via natural language queries +│ └── ask-user-question-handler.ts # Interactive user prompts with options +├── tests/ # One *.test.ts per source module, plus run-tests.mjs templates/ ├── tools/ # Tool descriptions fed to the LLM +├── skills/ # Built-in skill definitions (agent-drift-guard, plan-and-execute) ├── prompts/ # EJS templates (e.g., init_command.md.ejs) -docs/ # User-facing documentation +docs/ # User-facing documentation (configuration, MCP, skills) dist/ # Bundled CLI output (gitignored) ``` @@ -80,7 +87,7 @@ Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundl - **Coverage**: Target meaningful unit tests for core logic (session management, tool handlers, settings resolution, prompt buffer). Test files are in `src/tests/` matching the source module name. - **Test naming**: `describe`/`test` blocks with descriptive names. Example: `test("SessionManager preserves structured system content when building OpenAI messages", ...)` - **Relaxed lint rules**: Test files allow `any` and unused vars. -- Run all tests with `npm test` before submitting a PR. +- Run all tests with `npm test` before submitting a PR. A cross-platform test runner is available at `src/tests/run-tests.mjs`. ## Commit & Pull Request Guidelines @@ -102,12 +109,18 @@ Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundl ## Architecture Overview -The CLI renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). +The CLI (`@vegamo/deepcode-cli`) renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). -Six tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `templates/tools/`. +Seven built-in tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, `UpdatePlan`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `templates/tools/`. The `UpdatePlan` tool enables the LLM to display and update a structured task list in the terminal. + +**Slash commands**: `/model`, `/new`, `/init`, `/resume`, `/continue`, `/mcp`, `/exit`, plus dynamic `/skill-name` for each loaded skill. + +**Key UI features**: `@` file mentions in the prompt input (scans project files), `Ctrl+O` to view live process stdout in fullscreen, `Ctrl+V` to paste images, MCP server status display. + +**CLI flags**: `-p ` / `--prompt` to auto-submit a prompt on launch, `-v` / `--version`, `-h` / `--help`. ## Agent-Specific Instructions - **AGENTS.md loading**: The CLI loads agent instructions from `./AGENTS.md`, `./.deepcode/AGENTS.md`, or `~/.deepcode/AGENTS.md` (first found wins). Write project-specific guidance for the LLM in any of these. - **Skills**: Place skill definitions in `~/.agents/skills//SKILL.md` (user-level) or `./.agents/skills//SKILL.md` (project-level). Legacy path `./.deepcode/skills/` is also supported. Each SKILL.md uses YAML frontmatter with `name` and `description` fields. -- The built-in `agent-drift-guard` skill is always injected into every session. +- **Built-in skills**: `agent-drift-guard` (detects and corrects execution drift) and `plan-and-execute` (structured task planning with progress tracking). Both are defined in `templates/skills/` and always injected into every session. From d80a82354c36ea993a911fd8eadf21132f0d7e25 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 18 May 2026 20:51:33 +0800 Subject: [PATCH 144/217] 0.1.22 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 51b7a95..800d75a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.21", + "version": "0.1.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.21", + "version": "0.1.22", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index e61d81d..c438d68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.21", + "version": "0.1.22", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From a42d5de1c1c6fbf935e53d0f470cf17c15361d63 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 08:52:57 +0800 Subject: [PATCH 145/217] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8DMessageVie?= =?UTF-8?q?w=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 146/217] =?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 cc59ae14ce9f1f497334efa97a942c400ea0943d Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 19 May 2026 09:20:52 +0800 Subject: [PATCH 147/217] feat: add support for file_path in write tool parameters --- src/session.ts | 2 ++ src/tests/session.test.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/session.ts b/src/session.ts index 1c9d3b6..96a9adb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2050,6 +2050,8 @@ ${skillMd} } } else if (toolName === "UpdatePlan") { return typeof args.explanation === "string" ? args.explanation.trim() : ""; + } else if (toolName === "write") { + return typeof args.file_path === "string" ? args.file_path.trim() : ""; } const firstKey = Object.keys(args)[0]; diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index a178726..b7eadae 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1215,6 +1215,26 @@ test("UpdatePlan tool params only show explanation when provided", () => { assert.equal(withoutExplanation.meta?.paramsMd, ""); }); +test("Write tool params prefer file_path even when content appears first", () => { + const manager = createSessionManager(process.cwd(), "machine-id-write-params"); + const filePath = path.join(process.cwd(), "index.html"); + + const toolMessage = (manager as any).buildToolMessage( + "session-1", + "call-write-1", + JSON.stringify({ ok: true, name: "write", output: "Created file." }), + { + name: "write", + arguments: JSON.stringify({ + content: "// === entry ===\nconsole.log('demo');\n", + file_path: filePath, + }), + } + ) as SessionMessage; + + assert.equal(toolMessage.meta?.paramsMd, filePath); +}); + test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messages", () => { const manager = createSessionManager(process.cwd(), "machine-id-mixed-tool-badcase"); const assistantMessage = (manager as any).buildAssistantMessage( From 4680a30481cf64352d67099c136d604e00b06022 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 19 May 2026 10:09:03 +0800 Subject: [PATCH 148/217] feat: implement killProcessTree function for process termination management --- src/common/process-tree.ts | 61 ++++++++++++++ src/mcp/mcp-client.ts | 7 +- src/session.ts | 23 +---- src/tests/process-tree.test.ts | 148 +++++++++++++++++++++++++++++++++ src/updateCheck.ts | 7 +- 5 files changed, 224 insertions(+), 22 deletions(-) create mode 100644 src/common/process-tree.ts create mode 100644 src/tests/process-tree.test.ts diff --git a/src/common/process-tree.ts b/src/common/process-tree.ts new file mode 100644 index 0000000..40a0d1e --- /dev/null +++ b/src/common/process-tree.ts @@ -0,0 +1,61 @@ +import { spawnSync } from "child_process"; + +type TaskkillSpawnSync = ( + command: string, + args: string[], + options: { stdio: "ignore"; windowsHide: true } +) => { status: number | null; error?: Error }; + +export type KillProcessTreeDeps = { + platform?: NodeJS.Platform; + killPid?: (pid: number, signal: NodeJS.Signals) => void; + runTaskkill?: (pid: number) => boolean; + killGroupOnNonWindows?: boolean; +}; + +export function killProcessTree( + pid: number, + signal: NodeJS.Signals = "SIGKILL", + deps: KillProcessTreeDeps = {} +): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + const platform = deps.platform ?? process.platform; + const killPid = deps.killPid ?? ((targetPid, targetSignal) => process.kill(targetPid, targetSignal)); + + if (platform === "win32") { + const runTaskkill = deps.runTaskkill ?? runWindowsTaskkill; + if (runTaskkill(pid)) { + return true; + } + return killDirectProcess(pid, signal, killPid); + } + + if (deps.killGroupOnNonWindows !== false && killDirectProcess(-pid, signal, killPid)) { + return true; + } + return killDirectProcess(pid, signal, killPid); +} + +export function runWindowsTaskkill(pid: number, spawnSyncImpl: TaskkillSpawnSync = spawnSync): boolean { + const result = spawnSyncImpl("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + return !result.error && result.status === 0; +} + +function killDirectProcess( + pid: number, + signal: NodeJS.Signals, + killPid: (pid: number, signal: NodeJS.Signals) => void +): boolean { + try { + killPid(pid, signal); + return true; + } catch { + return false; + } +} diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index 9636732..2755755 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "child_process"; import { createInterface, type Interface } from "readline"; import * as os from "os"; import * as path from "path"; +import { killProcessTree } from "../common/process-tree"; type JsonRpcRequest = { jsonrpc: "2.0"; @@ -268,7 +269,11 @@ export class McpClient { this.reader = null; } if (this.process) { - this.process.kill(); + if (typeof this.process.pid === "number") { + killProcessTree(this.process.pid, "SIGTERM", { killGroupOnNonWindows: false }); + } else { + this.process.kill(); + } this.process = null; } } diff --git a/src/session.ts b/src/session.ts index 96a9adb..3b6b67a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -22,6 +22,7 @@ import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; +import { killProcessTree } from "./common/process-tree"; const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; @@ -1359,17 +1360,11 @@ ${skillMd} const killedPids: number[] = []; const failedPids: number[] = []; for (const pid of processIds) { - const killedGroup = this.killProcessGroup(pid); - if (killedGroup) { + if (killProcessTree(pid, "SIGKILL")) { killedPids.push(pid); continue; } - try { - process.kill(pid, "SIGKILL"); - killedPids.push(pid); - } catch { - failedPids.push(pid); - } + failedPids.push(pid); } const controller = this.sessionControllers.get(sessionId); @@ -2186,18 +2181,6 @@ ${skillMd} ); } - private killProcessGroup(pid: number): boolean { - if (process.platform === "win32") { - return false; - } - try { - process.kill(-pid, "SIGKILL"); - return true; - } catch { - return false; - } - } - private normalizeSessionEntry(entry: unknown): SessionEntry { const value = entry && typeof entry === "object" ? (entry as Record) : {}; return { diff --git a/src/tests/process-tree.test.ts b/src/tests/process-tree.test.ts new file mode 100644 index 0000000..1dd08a1 --- /dev/null +++ b/src/tests/process-tree.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { killProcessTree, runWindowsTaskkill } from "../common/process-tree"; + +test("runWindowsTaskkill invokes taskkill for the full process tree", () => { + const calls: Array<{ command: string; args: string[]; options: { stdio: "ignore"; windowsHide: true } }> = []; + + const ok = runWindowsTaskkill(1234, (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0 }; + }); + + assert.equal(ok, true); + assert.deepEqual(calls, [ + { + command: "taskkill", + args: ["/PID", "1234", "/T", "/F"], + options: { stdio: "ignore", windowsHide: true }, + }, + ]); +}); + +test("runWindowsTaskkill reports failure for non-zero exits and spawn errors", () => { + assert.equal( + runWindowsTaskkill(1234, () => ({ + status: 1, + })), + false + ); + assert.equal( + runWindowsTaskkill(1234, () => ({ + status: null, + error: new Error("taskkill missing"), + })), + false + ); +}); + +test("killProcessTree uses taskkill on Windows", () => { + const killed: number[] = []; + + const ok = killProcessTree(1234, "SIGKILL", { + platform: "win32", + runTaskkill: (pid) => { + killed.push(pid); + return true; + }, + killPid: () => { + throw new Error("direct kill should not be used"); + }, + }); + + assert.equal(ok, true); + assert.deepEqual(killed, [1234]); +}); + +test("killProcessTree falls back to direct kill on Windows taskkill failure", () => { + const directKills: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const ok = killProcessTree(1234, "SIGTERM", { + platform: "win32", + runTaskkill: () => false, + killPid: (pid, signal) => { + directKills.push({ pid, signal }); + }, + }); + + assert.equal(ok, true); + assert.deepEqual(directKills, [{ pid: 1234, signal: "SIGTERM" }]); +}); + +test("killProcessTree returns false on Windows when all kill attempts fail", () => { + const ok = killProcessTree(1234, "SIGKILL", { + platform: "win32", + runTaskkill: () => false, + killPid: () => { + throw new Error("missing process"); + }, + }); + + assert.equal(ok, false); +}); + +test("killProcessTree kills a process group before direct PID on non-Windows platforms", () => { + const kills: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const ok = killProcessTree(1234, "SIGKILL", { + platform: "darwin", + killPid: (pid, signal) => { + kills.push({ pid, signal }); + }, + }); + + assert.equal(ok, true); + assert.deepEqual(kills, [{ pid: -1234, signal: "SIGKILL" }]); +}); + +test("killProcessTree falls back to direct PID on non-Windows group failure", () => { + const kills: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const ok = killProcessTree(1234, "SIGTERM", { + platform: "linux", + killPid: (pid, signal) => { + kills.push({ pid, signal }); + if (pid < 0) { + throw new Error("no process group"); + } + }, + }); + + assert.equal(ok, true); + assert.deepEqual(kills, [ + { pid: -1234, signal: "SIGTERM" }, + { pid: 1234, signal: "SIGTERM" }, + ]); +}); + +test("killProcessTree can skip non-Windows process group killing", () => { + const kills: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const ok = killProcessTree(1234, "SIGTERM", { + platform: "linux", + killGroupOnNonWindows: false, + killPid: (pid, signal) => { + kills.push({ pid, signal }); + }, + }); + + assert.equal(ok, true); + assert.deepEqual(kills, [{ pid: 1234, signal: "SIGTERM" }]); +}); + +test("killProcessTree ignores invalid PIDs", () => { + for (const pid of [0, -1, 1.5, Number.NaN]) { + assert.equal( + killProcessTree(pid, "SIGKILL", { + platform: "win32", + runTaskkill: () => { + throw new Error("taskkill should not be used"); + }, + killPid: () => { + throw new Error("direct kill should not be used"); + }, + }), + false + ); + } +}); diff --git a/src/updateCheck.ts b/src/updateCheck.ts index 626e529..81f1ff1 100644 --- a/src/updateCheck.ts +++ b/src/updateCheck.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { render, type Instance } from "ink"; import chalk from "chalk"; import { UpdatePrompt, type UpdatePromptChoice } from "./ui"; +import { killProcessTree } from "./common/process-tree"; export type PackageInfo = { name: string; @@ -222,7 +223,11 @@ function runNpmViewLatestVersion( }; const timer = setTimeout(() => { - child.kill(); + if (typeof child.pid === "number") { + killProcessTree(child.pid, "SIGTERM", { killGroupOnNonWindows: false }); + } else { + child.kill(); + } finish({ ok: false }); }, timeoutMs); From 418294dfd315402e7942a42960239185fa44ef0a Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 10:28:48 +0800 Subject: [PATCH 149/217] =?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 8e6e13f3d762395e58e9d74aa375c71e561c6ca8 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 19 May 2026 10:33:29 +0800 Subject: [PATCH 150/217] feat: improve npm command execution for cross-platform compatibility --- src/updateCheck.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/updateCheck.ts b/src/updateCheck.ts index 81f1ff1..fcd9bfb 100644 --- a/src/updateCheck.ts +++ b/src/updateCheck.ts @@ -1,4 +1,4 @@ -import { spawn } from "child_process"; +import { spawn, type ChildProcess, type SpawnOptions } from "child_process"; import React from "react"; import * as fs from "fs"; import * as os from "os"; @@ -162,9 +162,8 @@ async function promptUpdateChoice({ async function runNpmInstallGlobal(installSpec: string): Promise { return new Promise((resolve) => { - const child = spawn("npm", ["install", "-g", installSpec], { + const child = spawnNpm(["install", "-g", installSpec], { stdio: "inherit", - shell: process.platform === "win32", }); child.on("error", (error) => { process.stderr.write(`Failed to start npm install: ${error.message}\n`); @@ -206,9 +205,8 @@ function runNpmViewLatestVersion( if (registry) { args.push("--registry", registry); } - const child = spawn("npm", args, { + const child = spawnNpm(args, { stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", }); let stdout = ""; @@ -246,6 +244,24 @@ function runNpmViewLatestVersion( }); } +function spawnNpm(args: string[], options: SpawnOptions): ChildProcess { + if (process.platform === "win32") { + return spawn(["npm", ...args.map(quoteCmdArg)].join(" "), [], { + ...options, + shell: true, + }); + } + + return spawn("npm", args, { + ...options, + shell: false, + }); +} + +function quoteCmdArg(arg: string): string { + return `"${String(arg).replace(/"/g, '\\"')}"`; +} + export function parseNpmViewVersion(output: string): string | null { const trimmed = output.trim(); if (!trimmed) { From 5bfce46d6d17ea88e7f02b56b5f7282b37f9ebd9 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 11:11:40 +0800 Subject: [PATCH 151/217] =?UTF-8?q?docs(readme):=20=E5=A2=9E=E5=BC=BA=20RE?= =?UTF-8?q?ADME.md=20=E6=96=87=E4=BB=B6=E5=86=85=E5=AE=B9=E5=92=8C?= =?UTF-8?q?=E7=BB=93=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 152/217] =?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 153/217] =?UTF-8?q?docs(readme):=20=E6=81=A2=E5=A4=8D=20RE?= =?UTF-8?q?ADME-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 3fef0fc5137af49f218237fa0e919159fb231122 Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 10:09:38 +0800 Subject: [PATCH 154/217] feat(notify): pass STATUS, FAIL_REASON, BODY as env vars to notify hook - Add NotifyContext type with status, failReason, body fields - buildNotifyEnv injects STATUS, FAIL_REASON, BODY when provided - maybeNotifyTaskCompletion extracts last assistant message as BODY - launchNotifyScript accepts optional context parameter - Add unit tests for new context env var injection - Update docs with env variable table and iTerm2/macOS notify examples --- docs/configuration.md | 34 ++++++ docs/configuration_en.md | 34 ++++++ src/common/notify.ts | 38 ++++++- src/session.ts | 18 +++- src/tests/session.test.ts | 144 ++++++++++++++++++++++++++ src/tests/settings-and-notify.test.ts | 65 +++++++++++- 6 files changed, 324 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f8e52c3..45aaab0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,12 +67,46 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 +通知脚本执行时,会通过环境变量注入以下上下文信息: + +| 环境变量 | 说明 | +|----------|------| +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + ```json { "notify": "/path/to/slack-notify.sh" } ``` +**iTerm2 终端通知示例**: + +如果你的终端是 iTerm2,可以直接通过 OSC 9 转义序列弹出通知,无需额外脚本。创建以下脚本(如 `~/.deepcode/notify.sh`): + +```bash +#!/bin/bash +# iTerm2 OSC 9 通知 +echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +**macOS 系统通知示例**: + +```bash +#!/bin/bash +# macOS 系统通知 +osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\"" +``` + #### `webSearchTool` — 自定义联网搜索 Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 369f8e4..606fcab 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -67,12 +67,46 @@ When thinking mode is enabled, controls the depth of the model’s reasoning: Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). +The following context is injected as environment variables when the notify script runs: + +| Variable | Description | +|----------|-------------| +| `DURATION` | Session duration in seconds (integer) | +| `STATUS` | Session status: `"completed"` or `"failed"` | +| `FAIL_REASON` | Failure reason (only set on failure) | +| `BODY` | The text content of the last AI assistant reply | +| `TITLE` | Session title (matches the resume list title) | + ```json { "notify": "/path/to/slack-notify.sh" } ``` +**iTerm2 Notification Example**: + +On iTerm2 you can use the OSC 9 escape sequence for native notifications. Create a script (e.g., `~/.deepcode/notify.sh`): + +```bash +#!/bin/bash +# iTerm2 OSC 9 notification +echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +**macOS System Notification Example**: + +```bash +#!/bin/bash +# macOS system notification +osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\"" +``` + #### `webSearchTool` — Custom Web Search Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script: diff --git a/src/common/notify.ts b/src/common/notify.ts index 8878c50..d1b541b 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -16,11 +16,40 @@ export function formatDurationSeconds(durationMs: number): string { return String(Math.floor(safeMs / 1000)); } -export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return { +export type NotifyContext = { + status?: string; + failReason?: string; + body?: string; + title?: string; +}; + +export function buildNotifyEnv( + durationMs: number, + baseEnv: NodeJS.ProcessEnv = process.env, + context: NotifyContext = {} +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...baseEnv, DURATION: formatDurationSeconds(durationMs), }; + delete env.STATUS; + delete env.FAIL_REASON; + delete env.BODY; + delete env.TITLE; + + if (context.status) { + env.STATUS = context.status; + } + if (context.failReason) { + env.FAIL_REASON = context.failReason; + } + if (context.body) { + env.BODY = context.body; + } + if (context.title) { + env.TITLE = context.title; + } + return env; } export function launchNotifyScript( @@ -28,7 +57,8 @@ export function launchNotifyScript( durationMs: number, workingDirectory?: string, spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn, - configuredEnv: Record = {} + configuredEnv: Record = {}, + context: NotifyContext = {} ): void { const commandPath = notifyPath?.trim(); if (!commandPath) { @@ -38,7 +68,7 @@ export function launchNotifyScript( const options = { cwd: workingDirectory, detached: process.platform !== "win32", - env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }), + env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context), stdio: "ignore" as const, }; diff --git a/src/session.ts b/src/session.ts index 96a9adb..3a6e13b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2124,7 +2124,23 @@ ${skillMd} return; } - launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv); + // Find the last assistant message body for the BODY env variable. + let body: string | undefined; + const messages = this.listSessionMessages(sessionId); + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg && msg.role === "assistant" && msg.content) { + body = msg.content; + break; + } + } + + launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, { + status: session.status, + failReason: session.failReason ?? undefined, + body, + title: session.summary ?? undefined, + }); } private addSessionProcess(sessionId: string, processId: string | number, command: string): void { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index b7eadae..d079949 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -783,6 +783,68 @@ test("reporting a new prompt does not warn when the background request fails", a assert.deepEqual(warnings, []); }); +test( + "SessionManager notifies successful completion with session context", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-notify-success-workspace-"); + const home = createTempDir("deepcode-notify-success-home-"); + setHomeDir(home); + + const notifyOutput = path.join(workspace, "notify.jsonl"); + const notifyScript = createNotifyRecorderScript(workspace); + const manager = createNotifyingSessionManager( + workspace, + [createChatResponse("final answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + notifyScript, + notifyOutput + ); + + await manager.createSession({ text: "notify success" }); + + const records = await waitForNotifyRecords(notifyOutput, 1); + assert.equal(records[0]?.STATUS, "completed"); + assert.equal(records[0]?.FAIL_REASON, null); + assert.equal(records[0]?.BODY, "final answer"); + assert.equal(records[0]?.TITLE, "notify success"); + assert.match(String(records[0]?.DURATION), /^\d+$/); + } +); + +test( + "SessionManager notifies failed completion with failure context", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-notify-failure-workspace-"); + const home = createTempDir("deepcode-notify-failure-home-"); + setHomeDir(home); + + const notifyOutput = path.join(workspace, "notify.jsonl"); + const notifyScript = createNotifyRecorderScript(workspace); + const manager = createNotifyingSessionManager( + workspace, + [ + createChatResponse("first answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + new Error("second request failed"), + ], + notifyScript, + notifyOutput + ); + + const sessionId = await manager.createSession({ text: "notify failure" }); + await waitForNotifyRecords(notifyOutput, 1); + await manager.replySession(sessionId, { text: "second prompt" }); + + const records = await waitForNotifyRecords(notifyOutput, 2); + const failedRecord = records[1]; + assert.equal(failedRecord?.STATUS, "failed"); + assert.equal(failedRecord?.FAIL_REASON, "second request failed"); + assert.equal(failedRecord?.BODY, "first answer"); + assert.notEqual(failedRecord?.BODY, "stale-body"); + assert.equal(failedRecord?.TITLE, "notify failure"); + } +); + test("replySession continues without appending /continue as a user message", async () => { const workspace = createTempDir("deepcode-continue-workspace-"); const home = createTempDir("deepcode-continue-home-"); @@ -1657,6 +1719,49 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa }); } +function createNotifyingSessionManager( + projectRoot: string, + responses: unknown[], + notifyPath: string, + notifyOutput: string +): SessionManager { + const client = { + chat: { + completions: { + create: async () => { + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + if (response instanceof Error) { + throw response; + } + return response; + }, + }, + }, + }; + + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + notify: notifyPath, + env: { + NOTIFY_OUTPUT: notifyOutput, + STATUS: "stale-status", + FAIL_REASON: "stale-failure", + BODY: "stale-body", + TITLE: "stale-title", + }, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + function createMockedClientSessionManager(projectRoot: string, responses: unknown[]): SessionManager { const client = { chat: { @@ -1740,6 +1845,45 @@ function createTempDir(prefix: string): string { return dir; } +function createNotifyRecorderScript(dir: string): string { + const scriptPath = path.join(dir, "notify-recorder.cjs"); + fs.writeFileSync( + scriptPath, + `#!/usr/bin/env node +const fs = require("fs"); +const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"]; +const record = {}; +for (const key of keys) { + record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null; +} +fs.appendFileSync(process.env.NOTIFY_OUTPUT, JSON.stringify(record) + "\\n", "utf8"); +`, + "utf8" + ); + fs.chmodSync(scriptPath, 0o755); + return scriptPath; +} + +async function waitForNotifyRecords( + outputPath: string, + expectedCount: number +): Promise>> { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (fs.existsSync(outputPath)) { + const records = fs + .readFileSync(outputPath, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + if (records.length >= expectedCount) { + return records; + } + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`expected ${expectedCount} notify records in ${outputPath}`); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 6990288..202f849 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -1,6 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../common/notify"; +import { + buildNotifyEnv, + formatDurationSeconds, + launchNotifyScript, + type NotifyContext, + type NotifySpawn, +} from "../common/notify"; import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; const TEST_PROCESS_ENV = {}; @@ -358,14 +364,52 @@ test("formatDurationSeconds preserves sub-second precision and trims trailing ze assert.equal(formatDurationSeconds(4000), "4"); }); -test("buildNotifyEnv injects DURATION", () => { +test("buildNotifyEnv injects DURATION without context", () => { const env = buildNotifyEnv(2750, { HOME: "/tmp/home" }); assert.equal(env.HOME, "/tmp/home"); assert.equal(env.DURATION, "2"); + assert.equal(env.STATUS, undefined); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); + +test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", () => { + const context: NotifyContext = { + status: "failed", + failReason: "API key not found", + body: "Hello, this is the last assistant message.", + title: "Fix login bug", + }; + const env = buildNotifyEnv(5000, { HOME: "/tmp/home" }, context); + assert.equal(env.HOME, "/tmp/home"); + assert.equal(env.DURATION, "5"); + assert.equal(env.STATUS, "failed"); + assert.equal(env.FAIL_REASON, "API key not found"); + assert.equal(env.BODY, "Hello, this is the last assistant message."); + assert.equal(env.TITLE, "Fix login bug"); +}); + +test("buildNotifyEnv omits optional context fields when not provided", () => { + const env = buildNotifyEnv( + 1000, + { + HOME: "/tmp/home", + STATUS: "stale-status", + FAIL_REASON: "stale-failure", + BODY: "stale-body", + TITLE: "stale-title", + }, + { status: "completed" } + ); + assert.equal(env.STATUS, "completed"); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); }); test( - "launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts", + "launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts", { skip: process.platform === "win32" }, () => { const calls: Array<{ @@ -390,7 +434,13 @@ test( }; }; - launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }); + const context: NotifyContext = { + status: "completed", + body: "Task finished successfully.", + title: "Fix login bug", + }; + + launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }, context); assert.equal(calls.length, 2); assert.equal(calls[0]?.command, "/tmp/notify.sh"); @@ -398,9 +448,16 @@ test( assert.equal(calls[0]?.options.cwd, "/tmp/project"); assert.equal(calls[0]?.options.env?.DURATION, "2"); assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); + assert.equal(calls[0]?.options.env?.STATUS, "completed"); + assert.equal(calls[0]?.options.env?.FAIL_REASON, undefined); + assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully."); + assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug"); assert.equal(calls[1]?.command, "/bin/sh"); assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); assert.equal(calls[1]?.options.cwd, "/tmp/project"); assert.equal(calls[1]?.options.env?.DURATION, "2"); + assert.equal(calls[1]?.options.env?.STATUS, "completed"); + assert.equal(calls[1]?.options.env?.BODY, "Task finished successfully."); + assert.equal(calls[1]?.options.env?.TITLE, "Fix login bug"); } ); From a3ff70e82d548a8c1273ea377844f078cbd0ae00 Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 10:43:55 +0800 Subject: [PATCH 155/217] docs(notify): add Windows Terminal, Linux, and msg popup notification examples; add edge-case tests - Expand OSC 9 example to cover both iTerm2 and Windows Terminal - Add .bat example for Windows Terminal users - Add Linux notify-send example - Add Windows msg popup notification example - Add tests for empty-string rejection and special character preservation --- docs/configuration.md | 32 +++++++++++++++++++++++---- docs/configuration_en.md | 32 +++++++++++++++++++++++---- src/tests/settings-and-notify.test.ts | 27 ++++++++++++++++++++++ 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 45aaab0..7c2880c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,14 +83,14 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 } ``` -**iTerm2 终端通知示例**: +**终端内通知示例(支持 iTerm2 / Windows Terminal)**: -如果你的终端是 iTerm2,可以直接通过 OSC 9 转义序列弹出通知,无需额外脚本。创建以下脚本(如 `~/.deepcode/notify.sh`): +如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。创建以下脚本(如 `~/.deepcode/notify.sh`): ```bash #!/bin/bash -# iTerm2 OSC 9 通知 -echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07" +# iTerm2 / Windows Terminal OSC 9 通知 +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" ``` ```json @@ -99,6 +99,14 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07" } ``` +Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本: + +```batch +@echo off +REM Windows Terminal OSC 9 通知 +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + **macOS 系统通知示例**: ```bash @@ -107,6 +115,22 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07" osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\"" ``` +**Linux 系统通知示例**(需安装 `libnotify-bin`): + +```bash +#!/bin/bash +# Linux notify-send 通知 +notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s" +``` + +**Windows msg 弹窗通知示例**: + +```batch +@echo off +REM Windows msg 弹窗通知 +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + #### `webSearchTool` — 自定义联网搜索 Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 606fcab..5d931f4 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -83,14 +83,14 @@ The following context is injected as environment variables when the notify scrip } ``` -**iTerm2 Notification Example**: +**Terminal Notification Example (iTerm2 / Windows Terminal)**: -On iTerm2 you can use the OSC 9 escape sequence for native notifications. Create a script (e.g., `~/.deepcode/notify.sh`): +On iTerm2 or Windows Terminal you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. Create a script (e.g., `~/.deepcode/notify.sh`): ```bash #!/bin/bash -# iTerm2 OSC 9 notification -echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07" +# iTerm2 / Windows Terminal OSC 9 notification +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" ``` ```json @@ -99,6 +99,14 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07" } ``` +Windows users on Git Bash can use the same script; alternatively create a `.bat` script: + +```batch +@echo off +REM Windows Terminal OSC 9 notification +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + **macOS System Notification Example**: ```bash @@ -107,6 +115,22 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07" osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\"" ``` +**Linux System Notification Example** (requires `libnotify-bin`): + +```bash +#!/bin/bash +# Linux notify-send notification +notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s" +``` + +**Windows msg Popup Notification Example**: + +```batch +@echo off +REM Windows msg popup notification +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + #### `webSearchTool` — Custom Web Search Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script: diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 202f849..1707aff 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -408,6 +408,33 @@ test("buildNotifyEnv omits optional context fields when not provided", () => { assert.equal(env.TITLE, undefined); }); +test("buildNotifyEnv ignores empty strings in context", () => { + const env = buildNotifyEnv( + 1000, + { HOME: "/tmp/home" }, + { + status: "", + failReason: "", + body: "", + title: "", + } + ); + assert.equal(env.STATUS, undefined); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); + +test("buildNotifyEnv preserves special characters in body and title", () => { + const context: NotifyContext = { + body: 'Line 1\nLine 2\tindented "quoted"', + title: "Fix: login & signup (urgent)", + }; + const env = buildNotifyEnv(1000, {}, context); + assert.equal(env.BODY, 'Line 1\nLine 2\tindented "quoted"'); + assert.equal(env.TITLE, "Fix: login & signup (urgent)"); +}); + test( "launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts", { skip: process.platform === "win32" }, From 479606f6a7087398302334996e95cb8eb2d841b3 Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 13:33:28 +0800 Subject: [PATCH 156/217] docs(notify): replace terminal notification examples with Feishu webhook example - Remove iTerm2/Windows Terminal OSC 9, macOS osascript, Linux notify-send, and Windows msg examples (OSC 9 is not compatible with current spawn+stdio:ignore architecture) - Add Feishu (Lark) webhook notification example in both Chinese and English docs - Keep the env variable table (DURATION, STATUS, FAIL_REASON, BODY, TITLE) unchanged --- docs/configuration.md | 62 ++++++++++++++-------------------------- docs/configuration_en.md | 62 ++++++++++++++-------------------------- 2 files changed, 44 insertions(+), 80 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 7c2880c..b05a44f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,53 +83,35 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 } ``` -**终端内通知示例(支持 iTerm2 / Windows Terminal)**: +**飞书 Webhook 通知示例**: -如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。创建以下脚本(如 `~/.deepcode/notify.sh`): +`node` 构建 JSON(自动转义特殊字符),`curl` 发送: ```bash #!/bin/bash -# iTerm2 / Windows Terminal OSC 9 通知 -printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" -``` - -```json -{ - "notify": "/Users/you/.deepcode/notify.sh" -} -``` - -Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本: - -```batch -@echo off -REM Windows Terminal OSC 9 通知 -echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 -``` - -**macOS 系统通知示例**: - -```bash -#!/bin/bash -# macOS 系统通知 -osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\"" -``` - -**Linux 系统通知示例**(需安装 `libnotify-bin`): +WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") -```bash -#!/bin/bash -# Linux notify-send 通知 -notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s" +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" ``` -**Windows msg 弹窗通知示例**: - -```batch -@echo off -REM Windows msg 弹窗通知 -msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" -``` +将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。更多变量参考上表。同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。 #### `webSearchTool` — 自定义联网搜索 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 5d931f4..4f2f94d 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -83,53 +83,35 @@ The following context is injected as environment variables when the notify scrip } ``` -**Terminal Notification Example (iTerm2 / Windows Terminal)**: +**Feishu (Lark) Webhook Notification Example**: -On iTerm2 or Windows Terminal you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. Create a script (e.g., `~/.deepcode/notify.sh`): +`node` builds the JSON (auto-escapes special characters), `curl` sends it: ```bash #!/bin/bash -# iTerm2 / Windows Terminal OSC 9 notification -printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" -``` - -```json -{ - "notify": "/Users/you/.deepcode/notify.sh" -} -``` - -Windows users on Git Bash can use the same script; alternatively create a `.bat` script: - -```batch -@echo off -REM Windows Terminal OSC 9 notification -echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 -``` - -**macOS System Notification Example**: - -```bash -#!/bin/bash -# macOS system notification -osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\"" -``` - -**Linux System Notification Example** (requires `libnotify-bin`): +WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") -```bash -#!/bin/bash -# Linux notify-send notification -notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s" +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" ``` -**Windows msg Popup Notification Example**: - -```batch -@echo off -REM Windows msg popup notification -msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" -``` +Replace `WEBHOOK_URL` with your Feishu bot webhook URL. See the table above for all available variables. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format. #### `webSearchTool` — Custom Web Search From 7e5eeda26829b14eb3ed503b550db06c1145acf6 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 19 May 2026 15:05:00 +0800 Subject: [PATCH 157/217] =?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 158/217] =?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 159/217] =?UTF-8?q?feat(rawmode):=20=E6=B7=BB=E5=8A=A0=20R?= =?UTF-8?q?awMode=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; From f9d2737f2737d195ab94e587d378c322f13b411a Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 19 May 2026 16:04:03 +0800 Subject: [PATCH 160/217] docs(notify): extract notification examples to standalone notify.md - Add docs/notify.md and docs/notify_en.md with Slack, Feishu, terminal, macOS, Linux, Windows msg, and custom notification examples - Simplify notify section in configuration.md / configuration_en.md to field description + env table + reference to notify docs - Replace external binfer.net link with docs/notify.md in README FAQ across README.md, README-zh_CN.md, README-en.md --- README-en.md | 2 +- README-zh_CN.md | 2 +- README.md | 2 +- docs/configuration.md | 32 +----- docs/configuration_en.md | 32 +----- docs/notify.md | 211 +++++++++++++++++++++++++++++++++++++++ docs/notify_en.md | 211 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 429 insertions(+), 63 deletions(-) create mode 100644 docs/notify.md create mode 100644 docs/notify_en.md diff --git a/README-en.md b/README-en.md index 4c78cbd..55d0cf6 100644 --- a/README-en.md +++ b/README-en.md @@ -99,7 +99,7 @@ Deep Code supports multimodal input — you can paste images from the clipboard ### How to automatically send a Slack message after a task completes? -Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, refer to: https://binfer.net/share/jby5xnc-so6g +Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, see [docs/notify_en.md](docs/notify_en.md). ### How do I enable web search? diff --git a/README-zh_CN.md b/README-zh_CN.md index 98346b6..8a427de 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -99,7 +99,7 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 ### 怎样在任务完成后自动给 Slack 发消息? -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 ### 怎样启用联网搜索功能? diff --git a/README.md b/README.md index 98346b6..8a427de 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 ### 怎样在任务完成后自动给 Slack 发消息? -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 ### 怎样启用联网搜索功能? diff --git a/docs/configuration.md b/docs/configuration.md index b05a44f..1cce9a1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,39 +79,11 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 ```json { - "notify": "/path/to/slack-notify.sh" + "notify": "/path/to/notify-script.sh" } ``` -**飞书 Webhook 通知示例**: - -`node` 构建 JSON(自动转义特殊字符),`curl` 发送: - -```bash -#!/bin/bash -WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" - -STATUS="${STATUS:-completed}" -TITLE="${TITLE:-Untitled}" -DURATION="${DURATION:-0}" -BODY="${BODY:-(no output)}" - -PAYLOAD=$(node -e " -process.stdout.write(JSON.stringify({ - msg_type: 'interactive', - card: { - header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, - elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] - } -})) -") - -curl -s -X POST "$WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" -``` - -将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。更多变量参考上表。同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。 +> 详细的 Slack、飞书、终端通知、系统通知等配置示例,请参阅 [notify.md](notify.md)。 #### `webSearchTool` — 自定义联网搜索 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 4f2f94d..fa396f9 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -79,39 +79,11 @@ The following context is injected as environment variables when the notify scrip ```json { - "notify": "/path/to/slack-notify.sh" + "notify": "/path/to/notify-script.sh" } ``` -**Feishu (Lark) Webhook Notification Example**: - -`node` builds the JSON (auto-escapes special characters), `curl` sends it: - -```bash -#!/bin/bash -WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" - -STATUS="${STATUS:-completed}" -TITLE="${TITLE:-Untitled}" -DURATION="${DURATION:-0}" -BODY="${BODY:-(no output)}" - -PAYLOAD=$(node -e " -process.stdout.write(JSON.stringify({ - msg_type: 'interactive', - card: { - header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, - elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] - } -})) -") - -curl -s -X POST "$WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" -``` - -Replace `WEBHOOK_URL` with your Feishu bot webhook URL. See the table above for all available variables. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format. +> For detailed configuration examples (Slack, Feishu, terminal notifications, system notifications, etc.), see [notify_en.md](notify_en.md). #### `webSearchTool` — Custom Web Search diff --git a/docs/notify.md b/docs/notify.md new file mode 100644 index 0000000..d73eef4 --- /dev/null +++ b/docs/notify.md @@ -0,0 +1,211 @@ +# Deep Code 任务完成通知 + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +## 工作原理 + +在 `settings.json` 中配置 `notify` 字段,指向一个可执行脚本的完整路径。每次 AI 助手完成任务应答后,Deep Code 会执行该脚本,并通过环境变量注入上下文信息。 + +## 注入的环境变量 + +| 环境变量 | 说明 | +|----------|------| +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + +## 配置方法 + +编辑 `~/.deepcode/settings.json`,添加 `notify` 字段: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "notify": "/path/to/your-notify-script.sh" +} +``` + +你也可以在 `env` 中配置通知脚本所需的自定义环境变量,例如 Slack Webhook URL: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-...", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +这些 `env` 中的变量会被注入到脚本的执行环境中。 + +## Slack 通知 + +### 1. 获取 Slack Webhook URL + +1. 创建 [Slack App](https://api.slack.com/apps) +2. 在 App 页面点击 **Incoming Webhooks** → **Add New Webhook to Workspace**,生成 Webhook URL + +### 2. 创建通知脚本 + +创建 `~/.deepcode/notify-slack.sh`: + +```bash +#!/usr/bin/env bash +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" +CURRENT_DIR=$(pwd) +BRANCH=$(git branch --show-current 2>/dev/null) +curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-type: application/json" \ + --data "{ + \"text\": \"✅ Deep Code 任务已完成\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION 秒\" + }" +``` + +给脚本添加可执行权限: + +```bash +chmod +x ~/.deepcode/notify-slack.sh +``` + +### 3. 配置 settings.json + +```json +{ + "env": { + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +> Python 版本的脚本同样支持,你可以在 `env` 中传入并引用任意自定义环境变量。 + +## 飞书 / 企业微信等 Webhook 通知 + +以下示例使用 `node` 构建 JSON(自动转义特殊字符),`curl` 发送。通过 `env` 传入 `WEBHOOK_URL`: + +```bash +#!/bin/bash +WEBHOOK_URL="${WEBHOOK_URL:-}" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") + +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +```json +{ + "env": { + "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + }, + "notify": "/Users/you/.deepcode/notify-feishu.sh" +} +``` + +将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。此模式同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。 + +## 终端通知(iTerm2 / Windows Terminal) + +如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。 + +创建 `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# iTerm2 / Windows Terminal OSC 9 通知 +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本: + +```batch +@echo off +REM Windows Terminal OSC 9 通知 +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + +## macOS 系统通知 + +```bash +#!/bin/bash +# macOS 系统通知 +osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\"" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +## Linux 系统通知 + +需要安装 `libnotify-bin`: + +```bash +sudo apt install libnotify-bin # Debian/Ubuntu +``` + +创建 `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# Linux notify-send 通知 +notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s" +``` + +```json +{ + "notify": "/home/you/.deepcode/notify.sh" +} +``` + +## Windows msg 弹窗通知 + +```batch +@echo off +REM Windows msg 弹窗通知 +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + +```json +{ + "notify": "C:\\Users\\you\\.deepcode\\notify.bat" +} +``` + +## 自定义通知脚本 + +你可以根据通知脚本注入的环境变量自行编写任意逻辑的通知脚本(Python、Node.js、Ruby 等均可),只要脚本可执行即可。脚本中可通过 `env` 字段传入额外需要的配置变量。 diff --git a/docs/notify_en.md b/docs/notify_en.md new file mode 100644 index 0000000..b949161 --- /dev/null +++ b/docs/notify_en.md @@ -0,0 +1,211 @@ +# Deep Code Task Completion Notification + +When the AI assistant finishes a round of tasks, Deep Code can automatically execute a notification script to send task results to your chosen channel (Slack, system notifications, etc.). + +## How It Works + +Configure the `notify` field in `settings.json` with the full path to an executable script. Every time the AI assistant completes a task response, Deep Code executes that script and injects context as environment variables. + +## Injected Environment Variables + +| Variable | Description | +|----------|-------------| +| `DURATION` | Session duration in seconds (integer) | +| `STATUS` | Session status: `"completed"` or `"failed"` | +| `FAIL_REASON` | Failure reason (only set on failure) | +| `BODY` | The text content of the last AI assistant reply | +| `TITLE` | Session title (matches the resume list title) | + +## Configuration + +Edit `~/.deepcode/settings.json` and add the `notify` field: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "notify": "/path/to/your-notify-script.sh" +} +``` + +You can also configure custom environment variables for the notify script in `env`, such as a Slack Webhook URL: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-...", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +These `env` variables are injected into the script's execution environment. + +## Slack Notification + +### 1. Get a Slack Webhook URL + +1. Create a [Slack App](https://api.slack.com/apps) +2. In the App page, go to **Incoming Webhooks** → **Add New Webhook to Workspace** to generate a Webhook URL + +### 2. Create the Notification Script + +Create `~/.deepcode/notify-slack.sh`: + +```bash +#!/usr/bin/env bash +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" +CURRENT_DIR=$(pwd) +BRANCH=$(git branch --show-current 2>/dev/null) +curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-type: application/json" \ + --data "{ + \"text\": \"✅ Deep Code task completed\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION s\" + }" +``` + +Make the script executable: + +```bash +chmod +x ~/.deepcode/notify-slack.sh +``` + +### 3. Configure settings.json + +```json +{ + "env": { + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +> A Python version is also supported; you can pass and reference any custom environment variables via `env`. + +## Feishu / WeCom Webhook Notification + +Use `node` to build JSON (auto-escapes special characters) and `curl` to send. Pass `WEBHOOK_URL` via `env`: + +```bash +#!/bin/bash +WEBHOOK_URL="${WEBHOOK_URL:-}" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") + +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +```json +{ + "env": { + "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + }, + "notify": "/Users/you/.deepcode/notify-feishu.sh" +} +``` + +Replace `WEBHOOK_URL` with your Feishu bot webhook URL. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format. + +## Terminal Notification (iTerm2 / Windows Terminal) + +On iTerm2 or Windows Terminal, you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. + +Create `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# iTerm2 / Windows Terminal OSC 9 notification +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +Windows users on Git Bash can use the same script; alternatively, create a `.bat` script: + +```batch +@echo off +REM Windows Terminal OSC 9 notification +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + +## macOS System Notification + +```bash +#!/bin/bash +# macOS system notification +osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\"" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +## Linux System Notification + +Requires `libnotify-bin`: + +```bash +sudo apt install libnotify-bin # Debian/Ubuntu +``` + +Create `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# Linux notify-send notification +notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s" +``` + +```json +{ + "notify": "/home/you/.deepcode/notify.sh" +} +``` + +## Windows msg Popup Notification + +```batch +@echo off +REM Windows msg popup notification +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + +```json +{ + "notify": "C:\\Users\\you\\.deepcode\\notify.bat" +} +``` + +## Custom Notification Scripts + +You can write your own notification scripts in any language (Python, Node.js, Ruby, etc.) using the injected environment variables and any additional variables passed via `env`. From 1bd7e6a38c3f0f58a9b6971ba31d8c888d9f975d Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 16:24:40 +0800 Subject: [PATCH 161/217] perf: reuse OpenAI client instance and add connection warmup Cache the OpenAI client at module level keyed by (apiKey, baseURL) to avoid creating a fresh HTTP connection pool on every LLM turn. The client is a stateless fetch wrapper so sharing across calls is safe. Model, thinking-mode and other settings are still read fresh from config files each time. Also add a mount-time warmup effect that eagerly creates the client so the TCP+TLS connection is established while the user composes their first prompt. --- .gitignore | 1 + src/ui/App.tsx | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 11b67ce..dd972a7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .vscode/ *.tgz *.log +scripts/ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 582abaf..e82e5f3 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -162,6 +162,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. void refreshSkills(); }, [refreshSessionsList, refreshSkills]); + // Eagerly create the OpenAI client on mount so the TCP+TLS connection + // warmup (fire-and-forget inside createOpenAIClient) starts before the + // user sends their first prompt. + useEffect(() => { + createOpenAIClient(projectRoot); + }, [projectRoot]); + useLayoutEffect(() => { const settings = resolveCurrentSettings(projectRoot); void sessionManager.initMcpServers(settings.mcpServers); @@ -721,6 +728,13 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res ); } +// Module-level cache for the OpenAI client instance. The client itself is +// a stateless fetch wrapper, so it is safe to share across calls as long as +// the apiKey + baseURL stay the same. Model, thinking-mode and other +// settings are always read fresh from the project / user config files. +let _cachedOpenAI: OpenAI | null = null; +let _cachedOpenAIKey = ""; + export function createOpenAIClient(projectRoot: string = process.cwd()): { client: OpenAI | null; model: string; @@ -749,12 +763,35 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { }; } - const client = new OpenAI({ + const cacheKey = `${settings.apiKey}::${settings.baseURL}`; + if (_cachedOpenAI && _cachedOpenAIKey === cacheKey) { + return { + client: _cachedOpenAI, + model: settings.model, + baseURL: settings.baseURL, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; + } + + _cachedOpenAI = new OpenAI({ apiKey: settings.apiKey, baseURL: settings.baseURL || undefined, }); + _cachedOpenAIKey = cacheKey; + + // Fire-and-forget warmup: pre-establish TCP+TLS connection to the API + // server while the user is composing their first prompt. Errors are + // silently ignored — the real request will retry on its own if needed. + void _cachedOpenAI.models.list().catch(() => {}); + return { - client, + client: _cachedOpenAI, model: settings.model, baseURL: settings.baseURL, thinkingEnabled: settings.thinkingEnabled, From 2e5b2ed2a4eed8c463546385ecbf374002a8d6c6 Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 16:54:30 +0800 Subject: [PATCH 162/217] perf: replace undici fetch with custom https.Agent for long keepAlive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default undici-based global fetch only keeps connections alive for 4 seconds, which is too short for a CLI where the user may spend 10–30 seconds reading output before typing the next prompt. Add a custom fetch implementation backed by node:https.Agent with keepAlive: true and a 60-second idle timeout. The custom fetch is passed to the OpenAI SDK constructor so every LLM API request benefits from persistent connections across conversational turns. Also handles streaming request bodies (ReadableStream) for SDK features like file uploads. --- src/ui/App.tsx | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e82e5f3..1cdd855 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; import chalk from "chalk"; import * as fs from "fs"; +import https from "node:https"; import * as os from "os"; import * as path from "path"; import OpenAI from "openai"; @@ -728,6 +729,99 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res ); } +// Custom fetch implementation that uses node:https.Agent with a configurable +// keepAlive timeout. The default undici-based global fetch only keeps +// connections alive for 4 seconds, which is too short for a CLI where the +// user may spend 10–30 seconds reading output before typing the next prompt. +// With this custom Agent we get full control over idle connection lifetime. +const KEEP_ALIVE_MSEC = 60_000; // 1 minute + +function createCustomFetch(keepAliveMsecs: number = KEEP_ALIVE_MSEC) { + const agent = new https.Agent({ keepAlive: true, keepAliveMsecs }); + + return async function customFetch(url: string | URL | Request, init?: RequestInit): Promise { + const urlObj = typeof url === "string" ? new URL(url) : url instanceof URL ? url : new URL(url.url); + const { method = "GET", headers = {}, body: reqBody, signal } = init ?? {}; + + // Normalize Headers to a plain Record + const plainHeaders: Record = {}; + if (headers instanceof Headers) { + for (const [k, v] of headers) plainHeaders[k] = v; + } else if (Array.isArray(headers)) { + for (const [k, v] of headers) plainHeaders[k] = v; + } else { + Object.assign(plainHeaders, headers); + } + + const port = urlObj.port ? Number(urlObj.port) : 443; + + return new Promise((resolve, reject) => { + const req = https.request( + { + hostname: urlObj.hostname, + port, + path: urlObj.pathname + urlObj.search, + method, + headers: plainHeaders, + agent, + signal: signal ?? undefined, + }, + (res) => { + const resHeaders = new Headers(); + for (const [k, v] of Object.entries(res.headers)) { + if (v) (Array.isArray(v) ? v : [v]).forEach((val) => resHeaders.append(k, val)); + } + + const body = new ReadableStream({ + start(controller) { + res.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))); + res.on("end", () => controller.close()); + res.on("error", (err) => controller.error(err)); + }, + cancel() { + res.destroy(); + }, + }); + + resolve( + new Response(body, { + status: res.statusCode, + statusText: res.statusMessage, + headers: resHeaders, + }) + ); + } + ); + + req.on("error", reject); + + if (reqBody) { + if (typeof reqBody === "string" || reqBody instanceof Uint8Array || ArrayBuffer.isView(reqBody)) { + req.write(reqBody as Parameters[0]); + } else if (reqBody instanceof ReadableStream) { + // Pipe streaming request body (used for file uploads by the SDK) + const reader = (reqBody as ReadableStream).getReader(); + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) req.write(value); + } + req.end(); + } catch (err) { + req.destroy(err instanceof Error ? err : new Error(String(err))); + } + })(); + return; // req.end() is called inside the async IIFE + } + } + + req.end(); + }); + }; +} + // Module-level cache for the OpenAI client instance. The client itself is // a stateless fetch wrapper, so it is safe to share across calls as long as // the apiKey + baseURL stay the same. Model, thinking-mode and other @@ -782,6 +876,7 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { _cachedOpenAI = new OpenAI({ apiKey: settings.apiKey, baseURL: settings.baseURL || undefined, + fetch: createCustomFetch(), }); _cachedOpenAIKey = cacheKey; From 6f8d2e228d853f8014741c8108f6936e7d037c82 Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 17:04:09 +0800 Subject: [PATCH 163/217] refactor: replace custom fetch wrapper with undici Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use npm undici's Agent with keepAliveTimeout: 60s instead of the 90-line custom https.Agent-based fetch wrapper. The approach is the same but much simpler — just pass undiciFetch with a configured Agent dispatcher to the OpenAI SDK. --- src/ui/App.tsx | 103 +++++-------------------------------------------- 1 file changed, 9 insertions(+), 94 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1cdd855..42397a5 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2,10 +2,10 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; import chalk from "chalk"; import * as fs from "fs"; -import https from "node:https"; import * as os from "os"; import * as path from "path"; import OpenAI from "openai"; +import { Agent, fetch as undiciFetch } from "undici"; import { type LlmStreamProgress, type MessageMeta, @@ -729,98 +729,12 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res ); } -// Custom fetch implementation that uses node:https.Agent with a configurable -// keepAlive timeout. The default undici-based global fetch only keeps -// connections alive for 4 seconds, which is too short for a CLI where the -// user may spend 10–30 seconds reading output before typing the next prompt. -// With this custom Agent we get full control over idle connection lifetime. -const KEEP_ALIVE_MSEC = 60_000; // 1 minute - -function createCustomFetch(keepAliveMsecs: number = KEEP_ALIVE_MSEC) { - const agent = new https.Agent({ keepAlive: true, keepAliveMsecs }); - - return async function customFetch(url: string | URL | Request, init?: RequestInit): Promise { - const urlObj = typeof url === "string" ? new URL(url) : url instanceof URL ? url : new URL(url.url); - const { method = "GET", headers = {}, body: reqBody, signal } = init ?? {}; - - // Normalize Headers to a plain Record - const plainHeaders: Record = {}; - if (headers instanceof Headers) { - for (const [k, v] of headers) plainHeaders[k] = v; - } else if (Array.isArray(headers)) { - for (const [k, v] of headers) plainHeaders[k] = v; - } else { - Object.assign(plainHeaders, headers); - } - - const port = urlObj.port ? Number(urlObj.port) : 443; - - return new Promise((resolve, reject) => { - const req = https.request( - { - hostname: urlObj.hostname, - port, - path: urlObj.pathname + urlObj.search, - method, - headers: plainHeaders, - agent, - signal: signal ?? undefined, - }, - (res) => { - const resHeaders = new Headers(); - for (const [k, v] of Object.entries(res.headers)) { - if (v) (Array.isArray(v) ? v : [v]).forEach((val) => resHeaders.append(k, val)); - } - - const body = new ReadableStream({ - start(controller) { - res.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))); - res.on("end", () => controller.close()); - res.on("error", (err) => controller.error(err)); - }, - cancel() { - res.destroy(); - }, - }); - - resolve( - new Response(body, { - status: res.statusCode, - statusText: res.statusMessage, - headers: resHeaders, - }) - ); - } - ); - - req.on("error", reject); - - if (reqBody) { - if (typeof reqBody === "string" || reqBody instanceof Uint8Array || ArrayBuffer.isView(reqBody)) { - req.write(reqBody as Parameters[0]); - } else if (reqBody instanceof ReadableStream) { - // Pipe streaming request body (used for file uploads by the SDK) - const reader = (reqBody as ReadableStream).getReader(); - (async () => { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) req.write(value); - } - req.end(); - } catch (err) { - req.destroy(err instanceof Error ? err : new Error(String(err))); - } - })(); - return; // req.end() is called inside the async IIFE - } - } - - req.end(); - }); - }; -} +// Custom undici Agent with a 60-second keepAlive timeout. The default +// global fetch (undici) only keeps connections alive for 4 seconds, which +// is too short for a CLI where the user may spend 10–30 seconds reading +// output between prompts. By passing a dedicated Agent to undiciFetch we +// keep connections reusable for a full minute after the last request. +const _keepAliveAgent = new Agent({ keepAliveTimeout: 60_000 }); // Module-level cache for the OpenAI client instance. The client itself is // a stateless fetch wrapper, so it is safe to share across calls as long as @@ -876,7 +790,8 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { _cachedOpenAI = new OpenAI({ apiKey: settings.apiKey, baseURL: settings.baseURL || undefined, - fetch: createCustomFetch(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: _keepAliveAgent }), }); _cachedOpenAIKey = cacheKey; From 5d48d41b0c46813478f1b055671e9e32181c840f Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 19 May 2026 17:31:52 +0800 Subject: [PATCH 164/217] feat(bash): Add Bash timeout control feature and related adjustments --- src/common/bash-timeout.ts | 12 +++ src/session.ts | 138 +++++++++++++++++++++++++++++--- src/tests/session.test.ts | 34 ++++++++ src/tests/tool-handlers.test.ts | 54 ++++++++++++- src/tools/bash-handler.ts | 106 ++++++++++++++++++++++-- src/tools/executor.ts | 17 ++++ src/ui/App.tsx | 10 ++- src/ui/ProcessStdoutView.tsx | 111 +++++++++++++++++++++---- src/ui/PromptInput.tsx | 4 +- 9 files changed, 450 insertions(+), 36 deletions(-) create mode 100644 src/common/bash-timeout.ts diff --git a/src/common/bash-timeout.ts b/src/common/bash-timeout.ts new file mode 100644 index 0000000..0a76d21 --- /dev/null +++ b/src/common/bash-timeout.ts @@ -0,0 +1,12 @@ +export const DEFAULT_BASH_TIMEOUT_MS = 10 * 60 * 1000; +export const MIN_BASH_TIMEOUT_MS = 60 * 1000; +export const BASH_TIMEOUT_INCREMENT_MS = 5 * 60 * 1000; +export const BASH_TIMEOUT_DECREMENT_MS = 60 * 1000; + +export function clampBashTimeoutMs(timeoutMs: number, minTimeoutMs: number = MIN_BASH_TIMEOUT_MS): number { + if (!Number.isFinite(timeoutMs)) { + return DEFAULT_BASH_TIMEOUT_MS; + } + const minimum = Number.isFinite(minTimeoutMs) ? Math.max(1, Math.round(minTimeoutMs)) : MIN_BASH_TIMEOUT_MS; + return Math.max(minimum, Math.round(timeoutMs)); +} diff --git a/src/session.ts b/src/session.ts index 3b6b67a..10e0782 100644 --- a/src/session.ts +++ b/src/session.ts @@ -17,7 +17,12 @@ import { getTools, type ToolDefinition, } from "./prompt"; -import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; +import { + ToolExecutor, + type CreateOpenAIClient, + type ProcessTimeoutControl, + type ProcessTimeoutInfo, +} from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig } from "./settings"; import { logApiError } from "./common/error-logger"; @@ -134,6 +139,21 @@ export type ModelUsage = { total_reqs?: number; }; +export type SessionProcessEntry = { + startTime: string; + command: string; + timeoutMs?: number; + deadlineAt?: string; + timedOut?: boolean; +}; + +export type BashTimeoutAdjustment = { + processId: string; + timeoutMs: number; + deadlineAt: string; + timedOut: boolean; +}; + export type SessionEntry = { id: string; summary: string | null; @@ -148,7 +168,7 @@ export type SessionEntry = { activeTokens: number; createTime: string; updateTime: string; - processes: Map | null; // {pid: {startTime, command}} + processes: Map | null; // {pid: process info} }; export type SessionsIndex = { @@ -234,6 +254,7 @@ export class SessionManager { private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); + private readonly processTimeoutControls = new Map(); private readonly toolExecutor: ToolExecutor; private readonly mcpManager = new McpManager(); private mcpToolDefinitions: ToolDefinition[] = []; @@ -1360,6 +1381,7 @@ ${skillMd} const killedPids: number[] = []; const failedPids: number[] = []; for (const pid of processIds) { + this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, pid)); if (killProcessTree(pid, "SIGKILL")) { killedPids.push(pid); continue; @@ -1397,6 +1419,37 @@ ${skillMd} return !this.sessionControllers.has(sessionId); } + adjustActiveBashTimeout(deltaMs: number): BashTimeoutAdjustment | null { + const sessionId = this.activeSessionId; + if (!sessionId || !Number.isFinite(deltaMs)) { + return null; + } + const session = this.getSession(sessionId); + if (!session?.processes) { + return null; + } + + let selectedPid: string | null = null; + for (const pid of session.processes.keys()) { + if (this.processTimeoutControls.has(this.getProcessControlKey(sessionId, pid))) { + selectedPid = pid; + } + } + if (!selectedPid) { + return null; + } + + const control = this.processTimeoutControls.get(this.getProcessControlKey(sessionId, selectedPid)); + if (!control) { + return null; + } + + const current = control.getInfo(); + const next = control.setTimeoutMs(current.timeoutMs + deltaMs); + this.updateSessionProcessTimeout(sessionId, selectedPid, next); + return this.buildBashTimeoutAdjustment(selectedPid, next); + } + listSessions(): SessionEntry[] { const index = this.loadSessionsIndex(); return index.entries; @@ -1741,6 +1794,7 @@ ${skillMd} onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), + onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control), shouldStop: () => this.isInterrupted(sessionId), }); if (this.isInterrupted(sessionId)) { @@ -2137,6 +2191,7 @@ ${skillMd} private removeSessionProcess(sessionId: string, processId: string | number): void { const now = new Date().toISOString(); + this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId)); this.updateSessionEntry(sessionId, (entry) => { const processes = new Map(entry.processes ?? []); processes.delete(String(processId)); @@ -2148,7 +2203,58 @@ ${skillMd} }); } - private getProcessIds(processes: Map | null): number[] { + private setSessionProcessTimeoutControl( + sessionId: string, + processId: string | number, + control: ProcessTimeoutControl | null + ): void { + const key = this.getProcessControlKey(sessionId, processId); + if (!control) { + this.processTimeoutControls.delete(key); + return; + } + + this.processTimeoutControls.set(key, control); + this.updateSessionProcessTimeout(sessionId, processId, control.getInfo()); + } + + private updateSessionProcessTimeout(sessionId: string, processId: string | number, info: ProcessTimeoutInfo): void { + const now = new Date().toISOString(); + this.updateSessionEntry(sessionId, (entry) => { + const processes = new Map(entry.processes ?? []); + const pid = String(processId); + const processInfo = processes.get(pid); + if (!processInfo) { + return entry; + } + processes.set(pid, { + ...processInfo, + timeoutMs: info.timeoutMs, + deadlineAt: new Date(info.deadlineAtMs).toISOString(), + timedOut: info.timedOut, + }); + return { + ...entry, + processes, + updateTime: now, + }; + }); + } + + private buildBashTimeoutAdjustment(processId: string, info: ProcessTimeoutInfo): BashTimeoutAdjustment { + return { + processId, + timeoutMs: info.timeoutMs, + deadlineAt: new Date(info.deadlineAtMs).toISOString(), + timedOut: info.timedOut, + }; + } + + private getProcessControlKey(sessionId: string, processId: string | number): string { + return `${sessionId}:${String(processId)}`; + } + + private getProcessIds(processes: Map | null): number[] { if (!processes) { return []; } @@ -2232,11 +2338,11 @@ ${skillMd} return usagePerModel; } - private deserializeProcesses(value: unknown): Map | null { + private deserializeProcesses(value: unknown): Map | null { if (!value || typeof value !== "object") { return null; } - const processes = new Map(); + const processes = new Map(); for (const [pid, entry] of Object.entries(value as Record)) { if (!pid) { continue; @@ -2245,22 +2351,34 @@ ${skillMd} // Backward compatibility for old format where just stored start time processes.set(pid, { startTime: entry, command: "Running process..." }); } else if (typeof entry === "object" && entry !== null) { - const obj = entry as { startTime?: unknown; command?: unknown }; + const obj = entry as { + startTime?: unknown; + command?: unknown; + timeoutMs?: unknown; + deadlineAt?: unknown; + timedOut?: unknown; + }; const startTime = typeof obj.startTime === "string" ? obj.startTime : new Date().toISOString(); const command = typeof obj.command === "string" ? obj.command : "Running process..."; - processes.set(pid, { startTime, command }); + processes.set(pid, { + startTime, + command, + timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : undefined, + deadlineAt: typeof obj.deadlineAt === "string" ? obj.deadlineAt : undefined, + timedOut: typeof obj.timedOut === "boolean" ? obj.timedOut : undefined, + }); } } return processes.size > 0 ? processes : null; } private serializeProcesses( - processes: Map | null - ): Record | null { + processes: Map | null + ): Record | null { if (!processes || processes.size === 0) { return null; } - const serialized: Record = {}; + const serialized: Record = {}; for (const [pid, entry] of processes.entries()) { serialized[pid] = entry; } diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index b7eadae..2ab4fe9 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1641,6 +1641,40 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = assert.equal(session?.failReason, "interrupted"); }); +test("SessionManager adjusts the active Bash timeout control and session metadata", async () => { + const workspace = createTempDir("deepcode-bash-timeout-session-"); + const manager = createSessionManager(workspace, ""); + const sessionId = await manager.createSession({ text: "hello" }); + + (manager as any).addSessionProcess(sessionId, 123, "sleep 10"); + + let timeoutInfo = { + timeoutMs: 10 * 60 * 1000, + startedAtMs: 1000, + deadlineAtMs: 1000 + 10 * 60 * 1000, + timedOut: false, + }; + (manager as any).setSessionProcessTimeoutControl(sessionId, 123, { + getInfo: () => timeoutInfo, + setTimeoutMs: (timeoutMs: number) => { + timeoutInfo = { + ...timeoutInfo, + timeoutMs, + deadlineAtMs: timeoutInfo.startedAtMs + timeoutMs, + }; + return timeoutInfo; + }, + }); + + const adjustment = manager.adjustActiveBashTimeout(5 * 60 * 1000); + const processInfo = manager.getSession(sessionId)?.processes?.get("123"); + + assert.equal(adjustment?.processId, "123"); + assert.equal(adjustment?.timeoutMs, 15 * 60 * 1000); + assert.equal(processInfo?.timeoutMs, 15 * 60 * 1000); + assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString()); +}); + function createSessionManager(projectRoot: string, machineId: string): SessionManager { return new SessionManager({ projectRoot, diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index 0b21edd..f66153c 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -4,7 +4,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { setTimeout as delay } from "node:timers/promises"; -import type { ToolExecutionContext } from "../tools/executor"; +import type { ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor"; import { handleBashTool } from "../tools/bash-handler"; import { handleEditTool } from "../tools/edit-handler"; import { handleReadTool } from "../tools/read-handler"; @@ -52,6 +52,58 @@ test("Bash streams stdout and stderr before command completion", async () => { assert.match(streamedOutput, /err/); }); +test("Bash terminates commands that exceed the configured timeout", async () => { + const workspace = createTempWorkspace(); + const exitedPids: Array = []; + + const result = await handleBashTool( + { + command: "printf 'start\\n'; sleep 5; printf 'done\\n'", + }, + createContext("bash-timeout", workspace, { + bashTimeoutMs: 100, + bashMinTimeoutMs: 1, + onProcessExit: (pid) => { + exitedPids.push(pid); + }, + }) + ); + + assert.equal(result.ok, false); + assert.equal(result.error, "Command timed out."); + assert.equal(result.metadata?.timedOut, true); + assert.equal(result.metadata?.timeoutMs, 100); + assert.doesNotMatch(result.output ?? "", /done/); + assert.equal(exitedPids.length, 1); +}); + +test("Bash timeout control can extend the active command deadline", async () => { + const workspace = createTempWorkspace(); + let timeoutControl: ProcessTimeoutControl | null = null; + + const result = await handleBashTool( + { + command: "sleep 0.2; printf 'done\\n'", + }, + createContext("bash-timeout-extend", workspace, { + bashTimeoutMs: 100, + bashMinTimeoutMs: 1, + onProcessTimeoutControl: (_pid, control) => { + if (control) { + timeoutControl = control; + control.setTimeoutMs(1000); + } + }, + }) + ); + + assert.ok(timeoutControl); + assert.equal(result.ok, true); + assert.match(result.output ?? "", /done/); + assert.equal(result.metadata?.timedOut, false); + assert.equal(result.metadata?.timeoutMs, 1000); +}); + test("UpdatePlan accepts a markdown task list string", async () => { const workspace = createTempWorkspace(); const plan = ["## Task List", "", "- [>] Inspect current behavior", "- [ ] Implement UpdatePlan"].join("\n"); diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index 071da53..4272271 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -1,5 +1,7 @@ import { spawn } from "child_process"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { DEFAULT_BASH_TIMEOUT_MS, clampBashTimeoutMs } from "../common/bash-timeout"; +import { killProcessTree } from "../common/process-tree"; +import type { ProcessTimeoutControl, ProcessTimeoutInfo, ToolExecutionContext, ToolExecutionResult } from "./executor"; import { buildDisableExtglobCommand, buildShellEnv, @@ -22,6 +24,9 @@ type ToolCommandResult = { truncated: boolean; shellPath?: string; startCwd?: string; + timedOut?: boolean; + timeoutMs?: number; + deadlineAt?: string; }; export async function handleBashTool( @@ -48,12 +53,15 @@ export async function handleBashTool( execution.exitCode, execution.signal, shellPath, - startCwd + startCwd, + execution.timedOut, + execution.timeoutMs, + execution.deadlineAtMs ); updateSessionCwd(context.sessionId, startCwd, result.cwd); if (execution.error || result.exitCode !== 0 || result.signal !== null) { - const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error); + const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error, execution.timedOut); return formatResult({ ...result, ok: false }, "bash", errorMessage); } @@ -102,10 +110,27 @@ async function executeShellCommand( cwd: string, command: string, context: ToolExecutionContext -): Promise<{ stdout: string; stderr: string; exitCode: number | null; signal: string | null; error?: string }> { +): Promise<{ + stdout: string; + stderr: string; + exitCode: number | null; + signal: string | null; + error?: string; + timedOut: boolean; + timeoutMs: number; + deadlineAtMs: number; +}> { return new Promise((resolve) => { const detached = process.platform !== "win32"; const configuredEnv = context.createOpenAIClient?.().env ?? {}; + const minTimeoutMs = context.bashMinTimeoutMs; + const initialTimeoutMs = clampBashTimeoutMs(context.bashTimeoutMs ?? DEFAULT_BASH_TIMEOUT_MS, minTimeoutMs); + const startedAtMs = Date.now(); + let timeoutMs = initialTimeoutMs; + let deadlineAtMs = startedAtMs + timeoutMs; + let timedOut = false; + let settled = false; + let timeoutTimer: ReturnType | null = null; const child = spawn(shellPath, shellArgs, { cwd, env: buildShellEnv(shellPath, configuredEnv), @@ -114,8 +139,53 @@ async function executeShellCommand( stdio: ["ignore", "pipe", "pipe"], }); const pid = child.pid; + + const getTimeoutInfo = (): ProcessTimeoutInfo => ({ + timeoutMs, + startedAtMs, + deadlineAtMs, + timedOut, + }); + const stopTimeoutTimer = () => { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + }; + const triggerTimeout = () => { + if (settled || timedOut || typeof pid !== "number") { + return; + } + timedOut = true; + stopTimeoutTimer(); + killProcessTree(pid, "SIGKILL"); + }; + const scheduleTimeout = () => { + stopTimeoutTimer(); + if (settled) { + return; + } + const remainingMs = Math.max(0, deadlineAtMs - Date.now()); + timeoutTimer = setTimeout(triggerTimeout, remainingMs); + }; + const timeoutControl: ProcessTimeoutControl = { + getInfo: getTimeoutInfo, + setTimeoutMs: (nextTimeoutMs) => { + timeoutMs = clampBashTimeoutMs(nextTimeoutMs, minTimeoutMs); + deadlineAtMs = startedAtMs + timeoutMs; + if (deadlineAtMs <= Date.now()) { + triggerTimeout(); + } else { + scheduleTimeout(); + } + return getTimeoutInfo(); + }, + }; + if (typeof pid === "number") { context.onProcessStart?.(pid, command); + context.onProcessTimeoutControl?.(pid, timeoutControl); + scheduleTimeout(); } let stdout = ""; @@ -138,7 +208,10 @@ async function executeShellCommand( }); child.on("close", (code, signal) => { + settled = true; + stopTimeoutTimer(); if (typeof pid === "number") { + context.onProcessTimeoutControl?.(pid, null); context.onProcessExit?.(pid); } resolve({ @@ -147,6 +220,9 @@ async function executeShellCommand( exitCode: typeof code === "number" ? code : null, signal: signal ?? null, error, + timedOut, + timeoutMs, + deadlineAtMs, }); }); }); @@ -173,7 +249,10 @@ function buildToolCommandResult( exitCode: number | null, signal: string | null, shellPath: string, - startCwd: string + startCwd: string, + timedOut: boolean = false, + timeoutMs?: number, + deadlineAtMs?: number ): ToolCommandResult { const { output: cleanedStdout, cwd } = stripMarker(stdout, marker); const combined = joinOutput(cleanedStdout, stderr); @@ -187,6 +266,9 @@ function buildToolCommandResult( truncated, shellPath, startCwd, + timedOut, + timeoutMs, + deadlineAt: typeof deadlineAtMs === "number" ? new Date(deadlineAtMs).toISOString() : undefined, }; } @@ -231,10 +313,13 @@ function truncateOutput(output: string): { text: string; truncated: boolean } { return { text: output.slice(0, MAX_OUTPUT_CHARS), truncated: true }; } -function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string): string { +function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string, timedOut = false): string { if (error) { return error; } + if (timedOut) { + return "Command timed out."; + } if (signal) { return `Command terminated by signal ${signal}.`; } @@ -253,6 +338,15 @@ function formatResult(result: ToolCommandResult, name: string, errorMessage?: st shellPath: result.shellPath, startCwd: result.startCwd, }; + if (typeof result.timedOut === "boolean") { + metadata.timedOut = result.timedOut; + } + if (typeof result.timeoutMs === "number") { + metadata.timeoutMs = result.timeoutMs; + } + if (result.deadlineAt) { + metadata.deadlineAt = result.deadlineAt; + } const outputValue = result.output ? result.output : undefined; diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 70ceab1..093e9f3 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -39,15 +39,31 @@ export type ToolExecutionContext = { onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; onProcessStdout?: (processId: string | number, chunk: string) => void; + onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void; + bashTimeoutMs?: number; + bashMinTimeoutMs?: number; }; export type ToolExecutionHooks = { onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; onProcessStdout?: (processId: string | number, chunk: string) => void; + onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void; shouldStop?: () => boolean; }; +export type ProcessTimeoutInfo = { + timeoutMs: number; + startedAtMs: number; + deadlineAtMs: number; + timedOut: boolean; +}; + +export type ProcessTimeoutControl = { + getInfo: () => ProcessTimeoutInfo; + setTimeoutMs: (timeoutMs: number) => ProcessTimeoutInfo; +}; + export type ToolExecutionResult = { ok: boolean; name: string; @@ -200,6 +216,7 @@ export class ToolExecutor { onProcessStart: hooks?.onProcessStart, onProcessExit: hooks?.onProcessExit, onProcessStdout: hooks?.onProcessStdout, + onProcessTimeoutControl: hooks?.onProcessTimeoutControl, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 582abaf..c729dc0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -54,7 +54,7 @@ type AppProps = { export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); - const { columns } = useWindowSize(); + const { columns, rows } = useWindowSize(); const { mode, setMode } = useRawModeContext(); const initialPromptSubmittedRef = useRef(false); const processStdoutRef = useRef>(new Map()); @@ -281,6 +281,11 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setShowProcessStdout(false); }, []); + const handleAdjustBashTimeout = useCallback( + (deltaMs: number) => sessionManager.adjustActiveBashTimeout(deltaMs), + [sessionManager] + ); + const handleModelConfigChange = useCallback( (selection: ModelConfigSelection): string => { const current = resolveCurrentSettings(projectRoot); @@ -467,6 +472,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. }, [busy, mode, sessionManager, columns, stdout]); const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]); + const screenHeight = useMemo(() => rows ?? stdout?.rows ?? 24, [rows, stdout]); const promptHistory = useMemo(() => { return messages .filter((message) => message.role === "user" && typeof message.content === "string") @@ -568,7 +574,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. processStdoutRef={processStdoutRef} runningProcesses={runningProcesses} onDismiss={handleDismissProcessStdout} + onAdjustTimeout={handleAdjustBashTimeout} screenWidth={screenWidth} + screenHeight={screenHeight} /> ) : view === "session-list" ? ( >; runningProcesses: RunningProcesses; onDismiss: () => void; + onAdjustTimeout: (deltaMs: number) => BashTimeoutAdjustment | null; screenWidth: number; + screenHeight: number; }; const REFRESH_INTERVAL_MS = 150; -const MAX_VISIBLE_LINES = 100; +const MAX_PANEL_HEIGHT = 30; +const MIN_PANEL_HEIGHT = 5; export const ProcessStdoutView = React.memo(function ProcessStdoutView({ processStdoutRef, runningProcesses, onDismiss, + onAdjustTimeout, screenWidth, + screenHeight, }: ProcessStdoutViewProps): React.ReactElement { const [stdoutText, setStdoutText] = useState(""); const [scrollOffset, setScrollOffset] = useState(0); - const containerRef = useRef<{ lineCount: number }>({ lineCount: 0 }); + const [statusMessage, setStatusMessage] = useState(""); + const statusTimerRef = useRef | null>(null); + + const panelHeight = Math.max(MIN_PANEL_HEIGHT, Math.min(screenHeight - 1, MAX_PANEL_HEIGHT)); + const reservedRows = statusMessage ? 2 : 1; + const visibleLineLimit = Math.max(1, panelHeight - reservedRows); useEffect(() => { const updateStdout = () => { @@ -51,21 +62,37 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({ return () => clearInterval(interval); }, [processStdoutRef, runningProcesses]); - // Update container line count for scroll awareness + useEffect(() => { + return () => { + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current); + } + }; + }, []); + const lines = useMemo(() => stdoutText.split("\n"), [stdoutText]); - containerRef.current.lineCount = lines.length; + const timeoutProcess = useMemo(() => getLatestTimeoutProcess(runningProcesses), [runningProcesses]); const visibleLines = useMemo(() => { - if (lines.length <= MAX_VISIBLE_LINES) { + if (lines.length <= visibleLineLimit) { return lines; } - const start = Math.max(0, lines.length - MAX_VISIBLE_LINES - scrollOffset); - const slice = lines.slice(start, start + MAX_VISIBLE_LINES); - if (lines.length > MAX_VISIBLE_LINES) { + const outputLineLimit = Math.max(1, visibleLineLimit - 1); + const start = Math.max(0, lines.length - outputLineLimit - scrollOffset); + const slice = lines.slice(start, start + outputLineLimit); + if (lines.length > visibleLineLimit) { slice.unshift(`... (${start} lines above · ↑/↓ to scroll · ${lines.length} total lines) ...`); } return slice; - }, [lines, scrollOffset]); + }, [lines, scrollOffset, visibleLineLimit]); + + const setTemporaryStatus = (message: string) => { + setStatusMessage(message); + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current); + } + statusTimerRef.current = setTimeout(() => setStatusMessage(""), 2000); + }; useTerminalInput( (input, key) => { @@ -73,8 +100,18 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({ onDismiss(); return; } + if (input === "+") { + const adjustment = onAdjustTimeout(BASH_TIMEOUT_INCREMENT_MS); + setTemporaryStatus(formatAdjustmentStatus(adjustment)); + return; + } + if (input === "-") { + const adjustment = onAdjustTimeout(-BASH_TIMEOUT_DECREMENT_MS); + setTemporaryStatus(formatAdjustmentStatus(adjustment)); + return; + } if (key.upArrow) { - setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - MAX_VISIBLE_LINES))); + setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - visibleLineLimit))); return; } if (key.downArrow) { @@ -82,11 +119,11 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({ return; } if (key.pageUp) { - setScrollOffset((s) => Math.min(s + MAX_VISIBLE_LINES, Math.max(0, lines.length - MAX_VISIBLE_LINES))); + setScrollOffset((s) => Math.min(s + visibleLineLimit, Math.max(0, lines.length - visibleLineLimit))); return; } if (key.pageDown) { - setScrollOffset((s) => Math.max(s - MAX_VISIBLE_LINES, 0)); + setScrollOffset((s) => Math.max(s - visibleLineLimit, 0)); return; } }, @@ -94,16 +131,58 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({ ); return ( - + 📟 Process Output - (Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll) + {` (${formatTimeoutHint( + timeoutProcess?.entry + )} · +/- adjust · Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll)`} - + {visibleLines.map((line, index) => ( {line} ))} + {statusMessage ? ( + + {statusMessage} + + ) : null} ); }); + +function getLatestTimeoutProcess( + runningProcesses: RunningProcesses +): { pid: string; entry: SessionProcessEntry } | null { + if (!runningProcesses) { + return null; + } + let latest: { pid: string; entry: SessionProcessEntry } | null = null; + for (const [pid, entry] of runningProcesses.entries()) { + if (typeof entry.timeoutMs !== "number") { + continue; + } + latest = { pid, entry }; + } + return latest; +} + +function formatTimeoutHint(entry?: SessionProcessEntry): string { + if (!entry || typeof entry.timeoutMs !== "number") { + return "timeout unavailable"; + } + return `timeout ${formatDuration(entry.timeoutMs)}`; +} + +function formatAdjustmentStatus(adjustment: BashTimeoutAdjustment | null): string { + if (!adjustment) { + return "No adjustable Bash timeout"; + } + return `Timeout set to ${formatDuration(adjustment.timeoutMs)}`; +} + +function formatDuration(ms: number): string { + const totalMinutes = Math.max(1, Math.round(ms / 60000)); + return `${totalMinutes}m`; +} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 1096a93..b35f72e 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -39,7 +39,7 @@ import { } from "./fileMentions"; import type { FileMentionItem } from "./fileMentions"; import { readClipboardImageAsync } from "./clipboard"; -import type { SkillInfo } from "../session"; +import type { SessionEntry, SkillInfo } from "../session"; // Re-exported from prompt modules for backward compatibility export { useTerminalInput, parseTerminalInput } from "./prompt"; @@ -70,7 +70,7 @@ type Props = { loadingText?: string | null; disabled?: boolean; placeholder?: string; - runningProcesses?: Map | null; + runningProcesses?: SessionEntry["processes"]; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onRawModeChange?: (mode: string) => void; From c081efd169a7c2c47a503eb540b538640ac8810a Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Tue, 19 May 2026 18:16:19 +0800 Subject: [PATCH 165/217] Revert "fix: re-apply dynamic modifier parsing for Shift+Enter after upstream sync" This reverts commit 52dafba25903dc70258d7e59dbe86e283a0f091f. --- src/tests/promptInputKeys.test.ts | 6 ++--- src/ui/prompt/cursor.ts | 4 +-- src/ui/prompt/useTerminalInput.ts | 43 +++---------------------------- 3 files changed, 9 insertions(+), 44 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 8952a3d..69d2075 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -80,7 +80,7 @@ test("parseTerminalInput keeps BS payload for meta+backspace", () => { test("parseTerminalInput recognizes shifted return sequences", () => { const { input, key } = parseTerminalInput("\u001B\r"); - assert.equal(input, ""); + assert.equal(input, "\r"); assert.equal(key.return, true); assert.equal(key.shift, true); assert.equal(key.meta, false); @@ -108,8 +108,8 @@ test("parseTerminalInput recognizes alternate shifted return sequences", () => { }); test("terminal extended key helpers request and restore modifyOtherKeys mode", () => { - assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m\u001B[>1u"); - assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m\u001B[4;1m"); + assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); }); test("parseTerminalInput recognizes terminal focus events", () => { diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 59b24f2..2668470 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -41,11 +41,11 @@ function disableTerminalFocusReporting(): string { } export function enableTerminalExtendedKeys(): string { - return "\u001B[>4;1m\u001B[>1u"; + return "\u001B[>4;1m"; } export function disableTerminalExtendedKeys(): string { - return "\u001B[>4;0m\u001B[4;0m"; } export function getPromptCursorPlacement( diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index f448d4f..8013ff6 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -26,42 +26,7 @@ const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]); const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]); const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]); -const SHIFT_RETURN_SEQUENCES = new Set([ - "\u001B\r", - "\u001B[13;2u", - "\u001B[13;1u", - "\u001B[13;2~", - "\u001B[13;1~", - "\u001B[27;2;13~", - "\u001B[27;1;13~", -]); - -const CSI_SHIFT_RETURN_RE = /^\u001B\[13;(\d+)[u~]$/; -const CSI_EXTENDED_SHIFT_RETURN_RE = /^\u001B\[27;(\d+);13~$/; - -function isShiftReturn(raw: string): boolean { - if (SHIFT_RETURN_SEQUENCES.has(raw)) return true; - let m: RegExpMatchArray | null; - if ((m = raw.match(CSI_SHIFT_RETURN_RE)) !== null) { - const mod = parseInt(m[1], 10); - return (mod & 2) !== 0 || (mod & 1) !== 0; - } - if ((m = raw.match(CSI_EXTENDED_SHIFT_RETURN_RE)) !== null) { - const mod = parseInt(m[1], 10); - return (mod & 2) !== 0 || (mod & 1) !== 0; - } - return false; -} - -const CSI_RETURN_RE = /^\u001B\[13;(\d+)[u~]$/; -const CSI_EXTENDED_RETURN_RE = /^\u001B\[27;(\d+);13~$/; - -function isReturn(raw: string): boolean { - if (raw === "\r") return true; - if (SHIFT_RETURN_SEQUENCES.has(raw)) return true; - if (META_RETURN_SEQUENCES.has(raw)) return true; - return CSI_RETURN_RE.test(raw) || CSI_EXTENDED_RETURN_RE.test(raw); -} +const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u", "\u001B[13;2~", "\u001B[27;2;13~"]); const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]); const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]); const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]); @@ -148,10 +113,10 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: end: END_SEQUENCES.has(raw), pageDown: raw === "\u001B[6~", pageUp: raw === "\u001B[5~", - return: isReturn(raw), + return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), escape: raw === "\u001B", ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw), - shift: isShiftReturn(raw), + shift: SHIFT_RETURN_SEQUENCES.has(raw), tab: raw === "\t" || raw === "\u001B[Z", backspace: BACKSPACE_BYTES.has(raw), delete: FORWARD_DELETE_SEQUENCES.has(raw), @@ -197,7 +162,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: key.shift = true; } - if (key.tab || key.backspace || key.delete || key.return) { + if (key.tab || key.backspace || key.delete) { input = ""; } From 255226a3c9bdd7254dd8b5728a0e1bff7de28707 Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 18:33:10 +0800 Subject: [PATCH 166/217] chore: add undici devDependency for custom keepAlive Agent Required by the custom fetch wrapper that replaces the default 4s keepAlive undici global dispatcher with a custom Agent (60s). --- package-lock.json | 13 ++++++++++++- package.json | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 800d75a..0b43587 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,8 @@ "prettier": "^3.8.3", "tsx": "^4.21.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2" + "typescript-eslint": "^8.59.2", + "undici": "^8.3.0" }, "engines": { "node": ">=22" @@ -4096,6 +4097,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", diff --git a/package.json b/package.json index c438d68..b805d18 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "prettier": "^3.8.3", "tsx": "^4.21.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2" + "typescript-eslint": "^8.59.2", + "undici": "^8.3.0" } } From 5b74c00db5bf16e1519c6aaafb233c4c2b78bf1a Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 18:33:59 +0800 Subject: [PATCH 167/217] fix: move undici from devDependencies to dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit undici is imported at runtime in App.tsx for the custom keepAlive Agent. When bundled with --packages=external, end users need the package installed — it cannot be a devDependency. --- package-lock.json | 5 ++--- package.json | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b43587..7d68f74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "ink-gradient": "^4.0.0", "openai": "^6.35.0", "react": "^19.2.5", + "undici": "^8.3.0", "zod": "^4.4.3" }, "bin": { @@ -38,8 +39,7 @@ "prettier": "^3.8.3", "tsx": "^4.21.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2", - "undici": "^8.3.0" + "typescript-eslint": "^8.59.2" }, "engines": { "node": ">=22" @@ -4101,7 +4101,6 @@ "version": "8.3.0", "resolved": "https://registry.npmmirror.com/undici/-/undici-8.3.0.tgz", "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index b805d18..6d58864 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "ink-gradient": "^4.0.0", "openai": "^6.35.0", "react": "^19.2.5", + "undici": "^8.3.0", "zod": "^4.4.3" }, "devDependencies": { @@ -65,7 +66,6 @@ "prettier": "^3.8.3", "tsx": "^4.21.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2", - "undici": "^8.3.0" + "typescript-eslint": "^8.59.2" } } From db78e2b1756e2e9e2f9eea008e30e2f4638e0856 Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 18:47:23 +0800 Subject: [PATCH 168/217] fix: downgrade undici to v7 for Node 20 compatibility undici v8 requires Node >=22, but the CI matrix includes Node 20 which the project intentionally supports. v7 works on >=20.18.1. --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d68f74..82db9af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "ink-gradient": "^4.0.0", "openai": "^6.35.0", "react": "^19.2.5", - "undici": "^8.3.0", + "undici": "^7.25.0", "zod": "^4.4.3" }, "bin": { @@ -4098,12 +4098,12 @@ } }, "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmmirror.com/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "7.25.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "license": "MIT", "engines": { - "node": ">=22.19.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 6d58864..b2826c2 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "ink-gradient": "^4.0.0", "openai": "^6.35.0", "react": "^19.2.5", - "undici": "^8.3.0", + "undici": "^7.25.0", "zod": "^4.4.3" }, "devDependencies": { From 87d52ade53833a18118e23a595f511a4823b0a6c Mon Sep 17 00:00:00 2001 From: lellansin Date: Tue, 19 May 2026 19:00:28 +0800 Subject: [PATCH 169/217] fix: add 3s timeout to warmup request to prevent exit hang Codex review found that the fire-and-forget warmup models.list() had no timeout. The OpenAI client defaults to a 10-minute timeout, so an unreachable API could keep the Node process alive long after the user exits. --- src/ui/App.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 42397a5..515d5e6 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -796,9 +796,17 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { _cachedOpenAIKey = cacheKey; // Fire-and-forget warmup: pre-establish TCP+TLS connection to the API - // server while the user is composing their first prompt. Errors are - // silently ignored — the real request will retry on its own if needed. - void _cachedOpenAI.models.list().catch(() => {}); + // server while the user is composing their first prompt. Bounded by a + // short timeout so a slow / unreachable API never blocks process exit. + void (async () => { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + try { + await _cachedOpenAI.models.list({ signal: ac.signal }).catch(() => {}); + } finally { + clearTimeout(timer); + } + })(); return { client: _cachedOpenAI, From 38246a0192a6c20f7eec6e1d6d904cd5ab335925 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 19 May 2026 19:40:37 +0800 Subject: [PATCH 170/217] 0.1.23 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 800d75a..17a77ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.22", + "version": "0.1.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.22", + "version": "0.1.23", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index c438d68..b72fd96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.22", + "version": "0.1.23", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From f28cbce383fe342e4815f54db084ffec359a8006 Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 09:02:30 +0800 Subject: [PATCH 171/217] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E6=AD=A3=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E8=B7=AF=E5=BE=84=E6=8B=BC=E5=86=99=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将所有导入路径中的 "compoments" 修正为 "components" - 更新多个文件中相关的导入语句,包括 App.tsx、index.ts、messageView.test.ts 和 PromptInput.tsx - 保证组件引用路径正确,避免运行时找不到模块错误 - 提升代码的可维护性和一致性 --- src/tests/messageView.test.ts | 4 ++-- src/ui/App.tsx | 4 ++-- src/ui/PromptInput.tsx | 2 +- src/ui/{compoments => components}/MessageView/index.tsx | 0 src/ui/{compoments => components}/MessageView/markdown.ts | 0 src/ui/{compoments => components}/MessageView/types.ts | 0 src/ui/{compoments => components}/MessageView/utils.ts | 0 .../{compoments => components}/RawModeExitPrompt/index.tsx | 0 .../{compoments => components}/RawModelDropdown/index.tsx | 0 src/ui/{compoments => components}/index.ts | 0 src/ui/index.ts | 6 +++--- 11 files changed, 8 insertions(+), 8 deletions(-) rename src/ui/{compoments => components}/MessageView/index.tsx (100%) rename src/ui/{compoments => components}/MessageView/markdown.ts (100%) rename src/ui/{compoments => components}/MessageView/types.ts (100%) rename src/ui/{compoments => components}/MessageView/utils.ts (100%) rename src/ui/{compoments => components}/RawModeExitPrompt/index.tsx (100%) rename src/ui/{compoments => components}/RawModelDropdown/index.tsx (100%) rename src/ui/{compoments => components}/index.ts (100%) diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts index 990c8ff..b806dbd 100644 --- a/src/tests/messageView.test.ts +++ b/src/tests/messageView.test.ts @@ -6,10 +6,10 @@ import { renderMessageToStdout, getUpdatePlanPreviewLines, parseToolPayload, -} from "../ui/compoments/MessageView/utils"; +} from "../ui/components/MessageView/utils"; import { RawMode } from "../ui/contexts"; import type { SessionMessage } from "../session"; -import type { ToolSummary } from "../ui/compoments/MessageView/types"; +import type { ToolSummary } from "../ui/components/MessageView/types"; test("parseDiffPreview removes headers and classifies lines", () => { const lines = parseDiffPreview( diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c729dc0..8ba842a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -23,7 +23,7 @@ import { resolveSettingsSources, } from "../settings"; import { PromptInput, type PromptSubmission } from "./PromptInput"; -import { MessageView, RawModeExitPrompt } from "./compoments"; +import { MessageView, RawModeExitPrompt } from "./components"; import { SessionList } from "./SessionList"; import { buildLoadingText } from "./loadingText"; import { findExpandedThinkingId } from "./thinkingState"; @@ -38,7 +38,7 @@ import { } from "./askUserQuestion"; import { buildExitSummaryText } from "./exitSummary"; import { RawMode, useRawModeContext } from "./contexts"; -import { renderMessageToStdout } from "./compoments/MessageView/utils"; +import { renderMessageToStdout } from "./components/MessageView/utils"; const DEFAULT_MODEL = "deepseek-v4-pro"; const DEFAULT_BASE_URL = "https://api.deepseek.com"; diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index b35f72e..b9b1f8e 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -51,7 +51,7 @@ import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusRepor import SlashCommandMenu from "./SlashCommandMenu"; import type { ModelConfigSelection, ReasoningEffort } from "../settings"; import DropdownMenu from "./DropdownMenu"; -import { RawModelDropdown } from "./compoments"; +import { RawModelDropdown } from "./components"; export type PromptSubmission = { text: string; diff --git a/src/ui/compoments/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx similarity index 100% rename from src/ui/compoments/MessageView/index.tsx rename to src/ui/components/MessageView/index.tsx diff --git a/src/ui/compoments/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts similarity index 100% rename from src/ui/compoments/MessageView/markdown.ts rename to src/ui/components/MessageView/markdown.ts diff --git a/src/ui/compoments/MessageView/types.ts b/src/ui/components/MessageView/types.ts similarity index 100% rename from src/ui/compoments/MessageView/types.ts rename to src/ui/components/MessageView/types.ts diff --git a/src/ui/compoments/MessageView/utils.ts b/src/ui/components/MessageView/utils.ts similarity index 100% rename from src/ui/compoments/MessageView/utils.ts rename to src/ui/components/MessageView/utils.ts diff --git a/src/ui/compoments/RawModeExitPrompt/index.tsx b/src/ui/components/RawModeExitPrompt/index.tsx similarity index 100% rename from src/ui/compoments/RawModeExitPrompt/index.tsx rename to src/ui/components/RawModeExitPrompt/index.tsx diff --git a/src/ui/compoments/RawModelDropdown/index.tsx b/src/ui/components/RawModelDropdown/index.tsx similarity index 100% rename from src/ui/compoments/RawModelDropdown/index.tsx rename to src/ui/components/RawModelDropdown/index.tsx diff --git a/src/ui/compoments/index.ts b/src/ui/components/index.ts similarity index 100% rename from src/ui/compoments/index.ts rename to src/ui/components/index.ts diff --git a/src/ui/index.ts b/src/ui/index.ts index f2e698c..aa757f9 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -9,8 +9,8 @@ export { } from "./App"; export { default as AppContainer } from "./AppContainer"; export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; -export { MessageView } from "./compoments"; -export { parseDiffPreview } from "./compoments/MessageView/utils"; +export { MessageView } from "./components"; +export { parseDiffPreview } from "./components/MessageView/utils"; export { PromptInput, IMAGE_ATTACHMENT_CLEAR_HINT, @@ -48,7 +48,7 @@ export { } from "./askUserQuestion"; export { readClipboardImage, type ClipboardImage } from "./clipboard"; export { buildLoadingText, type LoadingTextInput } from "./loadingText"; -export { renderMarkdown } from "./compoments/MessageView/markdown"; +export { renderMarkdown } from "./components/MessageView/markdown"; export { EMPTY_BUFFER, insertText, From bbf810d1a55bfa2c45cdf576ed186f7d0c606715 Mon Sep 17 00:00:00 2001 From: Seunghoon Shin Date: Wed, 20 May 2026 12:13:43 +0900 Subject: [PATCH 172/217] fix: resolve CJK composition bug on iOS terminals (backspace packet splitting) --- src/ui/prompt/useTerminalInput.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index 8013ff6..8fe0d60 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -193,6 +193,30 @@ export function useTerminalInput( return; } const handleData = (data: Buffer | string) => { + const raw = String(data); + + // Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.). + // iOS keyboards send composed characters as a single packet like: + // "가\x7f나" (character + backspace + new character) + // Without splitting, parseTerminalInput treats the whole packet as + // one input and drops the composition backspaces, corrupting the text. + if (raw.includes("\x7f") && raw.length > 1) { + const parts = raw.split("\x7f"); + if (parts[0]) { + const { input, key } = parseTerminalInput(parts[0]); + handlerRef.current(input, key); + } + for (let i = 1; i < parts.length; i++) { + const bs = parseTerminalInput("\x7f"); + handlerRef.current(bs.input, bs.key); + if (parts[i]) { + const { input, key } = parseTerminalInput(parts[i]); + handlerRef.current(input, key); + } + } + return; + } + const { input, key } = parseTerminalInput(data); handlerRef.current(input, key); }; From 4605be4e3ccffa9a48d9203a32a1f5db5f0a0516 Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 11:18:23 +0800 Subject: [PATCH 173/217] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=92=8C=E6=8A=80=E8=83=BD=E9=80=89=E6=8B=A9=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E8=8F=9C=E5=8D=95=E7=BB=84=E4=BB=B6=E5=B9=B6=E9=9B=86?= =?UTF-8?q?=E6=88=90=E5=88=B0PromptInput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 ModelsDropdown 组件支持选择模型及思考模式 - 创建 SkillsDropdown 组件支持选择和切换技能 - 在 ui/components/index.ts 中导出新增组件 - 在 ui/index.ts 中导出 ModelsDropdown 相关辅助方法 - 在 PromptInput 组件中替换旧模型选择逻辑,改用新增下拉组件 - 优化 PromptInput 的快捷键处理,实现技能和模型菜单切换 - 移除 PromptInput 内部的模型选择状态及逻辑,简化代码结构 - 保持现有功能一致,增加用户界面交互的灵活性与可用性 --- src/ui/PromptInput.tsx | 231 +++------------------ src/ui/components/ModelsDropdown/index.tsx | 165 +++++++++++++++ src/ui/components/SkillsDropdown/index.tsx | 74 +++++++ src/ui/components/index.ts | 2 + src/ui/index.ts | 10 +- 5 files changed, 275 insertions(+), 207 deletions(-) create mode 100644 src/ui/components/ModelsDropdown/index.tsx create mode 100644 src/ui/components/SkillsDropdown/index.tsx diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index b9b1f8e..a79fe30 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -49,9 +49,9 @@ import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; -import type { ModelConfigSelection, ReasoningEffort } from "../settings"; +import type { ModelConfigSelection } from "../settings"; import DropdownMenu from "./DropdownMenu"; -import { RawModelDropdown } from "./components"; +import { ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components"; export type PromptSubmission = { text: string; @@ -79,21 +79,6 @@ type Props = { }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; - -type ThinkingModeOption = { - label: string; - thinkingEnabled: boolean; - reasoningEffort?: ReasoningEffort; -}; - -export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ - { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, - { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, - { label: "No thinking", thinkingEnabled: false }, -]; - -type ModelDropdownStep = "model" | "thinking"; const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { const [spinnerIndex, setSpinnerIndex] = useState(0); @@ -140,10 +125,7 @@ export const PromptInput = React.memo(function PromptInput({ const [menuIndex, setMenuIndex] = useState(0); const [showSkillsDropdown, setShowSkillsDropdown] = useState(false); const [openRawModelDropdown, setOpenRawModelDropdown] = useState(false); - const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); - const [modelDropdownStep, setModelDropdownStep] = useState(null); - const [modelDropdownIndex, setModelDropdownIndex] = useState(0); - const [pendingModel, setPendingModel] = useState(null); + const [showModelDropdown, setShowModelDropdown] = useState(false); const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot)); const [fileMentionIndex, setFileMentionIndex] = useState(0); const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); @@ -164,19 +146,19 @@ export const PromptInput = React.memo(function PromptInput({ ); const showFileMentionMenu = !showSkillsDropdown && - !modelDropdownStep && + !showModelDropdown && fileMentionToken !== null && fileMentionKey !== dismissedFileMentionKey; const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); const slashMenu = React.useMemo( () => - showSkillsDropdown || modelDropdownStep || showFileMentionMenu + showSkillsDropdown || showModelDropdown || showFileMentionMenu ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : [], - [showSkillsDropdown, modelDropdownStep, showFileMentionMenu, slashToken, slashItems] + [showSkillsDropdown, showModelDropdown, showFileMentionMenu, slashToken, slashItems] ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); @@ -241,23 +223,6 @@ export const PromptInput = React.memo(function PromptInput({ } }, [fileMentionMatches.length, fileMentionIndex, showFileMentionMenu]); - useEffect(() => { - if (skillsDropdownIndex >= skills.length) { - setSkillsDropdownIndex(Math.max(0, skills.length - 1)); - } - }, [skills.length, skillsDropdownIndex]); - - useEffect(() => { - if (!modelDropdownStep) { - return; - } - const optionCount = - modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; - if (modelDropdownIndex >= optionCount) { - setModelDropdownIndex(Math.max(0, optionCount - 1)); - } - }, [modelDropdownIndex, modelDropdownStep]); - useEffect(() => { if (!statusMessage) { return; @@ -287,14 +252,6 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.escape) { - if (modelDropdownStep) { - closeModelDropdown(); - return; - } - if (showSkillsDropdown) { - setShowSkillsDropdown(false); - return; - } if (showFileMentionMenu && fileMentionKey) { setDismissedFileMentionKey(fileMentionKey); return; @@ -348,7 +305,7 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } - if (openRawModelDropdown) { + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { return; } @@ -356,53 +313,6 @@ export const PromptInput = React.memo(function PromptInput({ exitHistoryBrowsing(); } - if (showSkillsDropdown) { - if (skills.length === 0) { - setShowSkillsDropdown(false); - } else { - if (key.upArrow) { - setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); - return; - } - if (key.downArrow) { - setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); - return; - } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - const skill = skills[skillsDropdownIndex]; - if (skill) { - toggleSelectedSkill(skill); - } - return; - } - if (key.tab) { - setShowSkillsDropdown(false); - return; - } - } - } - - if (modelDropdownStep) { - const optionCount = - modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; - if (key.upArrow) { - setModelDropdownIndex((idx) => (idx - 1 + optionCount) % optionCount); - return; - } - if (key.downArrow) { - setModelDropdownIndex((idx) => (idx + 1) % optionCount); - return; - } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - selectModelDropdownItem(); - return; - } - if (key.tab) { - closeModelDropdown(); - return; - } - } - if (key.ctrl && (input === "v" || input === "V")) { setStatusMessage("Reading clipboard..."); readClipboardImageAsync() @@ -722,7 +632,8 @@ export const PromptInput = React.memo(function PromptInput({ } if (item.kind === "model") { clearSlashToken(); - openModelDropdown(); + setShowSkillsDropdown(false); + setShowModelDropdown(true); return; } if (item.kind === "raw") { @@ -828,63 +739,9 @@ export const PromptInput = React.memo(function PromptInput({ clearUndoRedoStacks(); } - function openModelDropdown(): void { - const currentModelIndex = MODEL_COMMAND_MODELS.findIndex((model) => model === modelConfig.model); - setPendingModel(null); - setModelDropdownStep("model"); - setModelDropdownIndex(currentModelIndex >= 0 ? currentModelIndex : 0); - setShowSkillsDropdown(false); - } - - function closeModelDropdown(): void { - setModelDropdownStep(null); - setPendingModel(null); - } - - function selectModelDropdownItem(): void { - if (modelDropdownStep === "model") { - const model = MODEL_COMMAND_MODELS[modelDropdownIndex] ?? modelConfig.model; - setPendingModel(model); - setModelDropdownStep("thinking"); - setModelDropdownIndex(getThinkingOptionIndex(modelConfig)); - return; - } - - const option = MODEL_COMMAND_THINKING_OPTIONS[modelDropdownIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]; - const selection: ModelConfigSelection = { - model: pendingModel ?? modelConfig.model, - thinkingEnabled: option.thinkingEnabled, - reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, - }; - closeModelDropdown(); - Promise.resolve(onModelConfigChange(selection)) - .then((message) => { - if (message) { - setStatusMessage(message); - } - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setStatusMessage(`Failed to update model settings: ${message}`); - }); - } - - const modelDropdownItems = - modelDropdownStep === "model" - ? MODEL_COMMAND_MODELS.map((model) => ({ - label: model, - selected: model === (pendingModel ?? modelConfig.model), - description: model === modelConfig.model ? "current model" : "", - })) - : MODEL_COMMAND_THINKING_OPTIONS.map((option) => ({ - label: option.label, - selected: getThinkingOptionIndex(modelConfig) === MODEL_COMMAND_THINKING_OPTIONS.indexOf(option), - description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", - })); - const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || openRawModelDropdown || modelDropdownStep !== null || showFileMentionMenu, - [showMenu, showSkillsDropdown, modelDropdownStep, openRawModelDropdown, showFileMentionMenu] + () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, + [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] ); const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; @@ -925,44 +782,22 @@ export const PromptInput = React.memo(function PromptInput({ onSelect={(mode) => onRawModeChange?.(mode)} screenWidth={screenWidth} /> - {showSkillsDropdown ? ( - ({ - key: skill.path || skill.name, - label: skill.name, - description: skill.path, - selected: isSkillSelected(selectedSkills, skill), - statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, - }))} - activeIndex={skillsDropdownIndex} - activeColor="#229ac3" - maxVisible={6} - /> - ) : null} - {modelDropdownStep ? ( - ({ - key: item.label, - label: item.label, - description: item.description, - selected: item.selected, - }))} - activeIndex={modelDropdownIndex} - activeColor="#229ac3" - maxVisible={6} - /> - ) : null} + + setShowModelDropdown(false)} + onModelConfigChange={onModelConfigChange} + onStatusMessage={setStatusMessage} + /> {showFileMentionMenu ? ( -): number { - const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { - if (!config.thinkingEnabled) { - return !option.thinkingEnabled; - } - return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort; - }); - return index >= 0 ? index : 0; -} - export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { let start = state.cursor; while (start > 0 && !/\s/.test(state.text[start - 1] ?? "")) { diff --git a/src/ui/components/ModelsDropdown/index.tsx b/src/ui/components/ModelsDropdown/index.tsx new file mode 100644 index 0000000..bdd68ab --- /dev/null +++ b/src/ui/components/ModelsDropdown/index.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from "react"; +import { useInput } from "ink"; +import DropdownMenu from "../../DropdownMenu"; +import type { ModelConfigSelection, ReasoningEffort } from "../../../settings"; + +type ModelStep = "model" | "thinking"; + +type ThinkingModeOption = { + label: string; + thinkingEnabled: boolean; + reasoningEffort?: ReasoningEffort; +}; + +export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; + +export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ + { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, + { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, + { label: "No thinking", thinkingEnabled: false }, +]; + +function getThinkingOptionIndex(config: Pick): number { + const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { + if (!config.thinkingEnabled) { + return !option.thinkingEnabled; + } + return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort; + }); + return index >= 0 ? index : 0; +} + +type Props = { + open: boolean; + modelConfig: ModelConfigSelection; + width: number; + onClose: () => void; + onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + onStatusMessage?: (message: string | null) => void; +}; + +const ModelsDropdown: React.FC = ({ + open, + modelConfig, + width, + onClose, + onModelConfigChange, + onStatusMessage, +}) => { + const [step, setStep] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const [pendingModel, setPendingModel] = useState(null); + + // Initialize state when opened + useEffect(() => { + if (open) { + const currentIndex = MODEL_COMMAND_MODELS.findIndex((m) => m === modelConfig.model); + setPendingModel(null); + setStep("model"); + setActiveIndex(currentIndex >= 0 ? currentIndex : 0); + } else { + setStep(null); + } + }, [open, modelConfig.model]); + + // Validate activeIndex bounds + useEffect(() => { + if (!step) { + return; + } + const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + if (activeIndex >= optionCount) { + setActiveIndex(Math.max(0, optionCount - 1)); + } + }, [activeIndex, step]); + + function selectItem(): void { + if (step === "model") { + const model = MODEL_COMMAND_MODELS[activeIndex] ?? modelConfig.model; + setPendingModel(model); + setStep("thinking"); + setActiveIndex(getThinkingOptionIndex(modelConfig)); + return; + } + + const option = MODEL_COMMAND_THINKING_OPTIONS[activeIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]!; + const selection: ModelConfigSelection = { + model: pendingModel ?? modelConfig.model, + thinkingEnabled: option.thinkingEnabled, + reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, + }; + onClose(); + Promise.resolve(onModelConfigChange(selection)) + .then((message) => { + if (message) { + onStatusMessage?.(message); + } + }) + .catch((error) => { + const msg = error instanceof Error ? error.message : String(error); + onStatusMessage?.(`Failed to update model settings: ${msg}`); + }); + } + + useInput( + (input, key) => { + if (!step) { + return; + } + + const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + + if (key.upArrow) { + setActiveIndex((idx) => (idx - 1 + optionCount) % optionCount); + return; + } + if (key.downArrow) { + setActiveIndex((idx) => (idx + 1) % optionCount); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + selectItem(); + return; + } + if (key.tab || key.escape) { + onClose(); + return; + } + }, + { isActive: open } + ); + + if (!open || !step) { + return null; + } + + const items = + step === "model" + ? MODEL_COMMAND_MODELS.map((model) => ({ + key: model, + label: model, + description: model === modelConfig.model ? "current model" : "", + selected: model === (pendingModel ?? modelConfig.model), + })) + : MODEL_COMMAND_THINKING_OPTIONS.map((option, i) => ({ + key: option.label, + label: option.label, + description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", + selected: getThinkingOptionIndex(modelConfig) === i, + })); + + return ( + + ); +}; + +export { getThinkingOptionIndex }; +export default ModelsDropdown; diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx new file mode 100644 index 0000000..545e2ab --- /dev/null +++ b/src/ui/components/SkillsDropdown/index.tsx @@ -0,0 +1,74 @@ +import DropdownMenu from "../../DropdownMenu"; +import React, { useEffect, useState } from "react"; +import { isSkillSelected } from "../../PromptInput"; +import type { SkillInfo } from "../../../session"; +import { useInput } from "ink"; + +const SkillsDropdown: React.FC<{ + open: boolean; + onClose?: (value: boolean) => void; + width: number; + skills: SkillInfo[]; + selectedSkills: SkillInfo[]; + onSelect?: (skill: SkillInfo) => void; +}> = ({ open, width, skills, selectedSkills, onSelect, onClose }) => { + const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); + useInput( + (input, key) => { + if (key.upArrow) { + setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); + return; + } + if (key.downArrow) { + setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + const skill = skills[skillsDropdownIndex]; + if (skill) { + onSelect?.(skill); + } + return; + } + if (key.tab) { + onClose?.(false); + return; + } + if (key.escape) { + onClose?.(false); + } + }, + { isActive: open } + ); + + useEffect(() => { + if (skillsDropdownIndex >= skills.length) { + setSkillsDropdownIndex(Math.max(0, skills.length - 1)); + } + }, [skills.length, skillsDropdownIndex]); + + if (!open) { + return null; + } + + return ( + ({ + key: skill.path || skill.name, + label: skill.name, + description: skill.path, + selected: isSkillSelected(selectedSkills, skill), + statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, + }))} + activeIndex={skillsDropdownIndex} + activeColor="#229ac3" + maxVisible={6} + /> + ); +}; + +export default SkillsDropdown; diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index 942d3ed..1d929f3 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -1,3 +1,5 @@ export { default as RawModelDropdown } from "./RawModelDropdown"; export { MessageView } from "./MessageView"; export { RawModeExitPrompt } from "./RawModeExitPrompt"; +export { default as SkillsDropdown } from "./SkillsDropdown"; +export { default as ModelsDropdown } from "./ModelsDropdown"; diff --git a/src/ui/index.ts b/src/ui/index.ts index aa757f9..efb4edd 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,3 +1,9 @@ +import { + getThinkingOptionIndex, + MODEL_COMMAND_MODELS, + MODEL_COMMAND_THINKING_OPTIONS, +} from "./components/ModelsDropdown"; + export { readSettings, readProjectSettings, @@ -24,14 +30,12 @@ export { getPromptReturnKeyAction, renderBufferWithCursor, buildInitPromptSubmission, - getThinkingOptionIndex, - MODEL_COMMAND_MODELS, - MODEL_COMMAND_THINKING_OPTIONS, useTerminalInput, parseTerminalInput, type PromptSubmission, type InputKey, } from "./PromptInput"; +export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; export { SessionList, formatSessionTitle } from "./SessionList"; export { ThemedGradient } from "./ThemedGradient"; From 02865704effba4ee50201fd6389f48744293e108 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 20 May 2026 11:35:20 +0800 Subject: [PATCH 174/217] feat: add undo functionality and enhance session management --- src/session.ts | 302 ++++++++++++++++++++++++++++++ src/tests/promptInputKeys.test.ts | 28 ++- src/tests/session.test.ts | 296 +++++++++++++++++++++++++++++ src/tests/slashCommands.test.ts | 20 +- src/tools/edit-handler.ts | 2 + src/tools/executor.ts | 6 + src/tools/write-handler.ts | 2 + src/ui/App.tsx | 106 ++++++++++- src/ui/PromptInput.tsx | 36 +++- src/ui/UndoSelector.tsx | 195 +++++++++++++++++++ src/ui/index.ts | 2 + src/ui/slashCommands.ts | 7 + 12 files changed, 997 insertions(+), 5 deletions(-) create mode 100644 src/ui/UndoSelector.tsx diff --git a/src/session.ts b/src/session.ts index 3f79481..6b2ceee 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; +import * as childProcess from "child_process"; import { fileURLToPath } from "url"; import matter from "gray-matter"; import ejs from "ejs"; @@ -34,6 +35,8 @@ const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; const NEW_PROMPT_REPORT_TIMEOUT_MS = 3000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; +const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint"; +const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost"; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -202,6 +205,13 @@ export type SessionMessage = { updateTime: string; meta?: MessageMeta; html?: string; + checkpointHash?: string; +}; + +export type UndoTarget = { + message: SessionMessage; + index: number; + canRestoreCode: boolean; }; export type UserPromptContent = { @@ -902,6 +912,7 @@ The candidate skills are as follows:\n\n`; userPrompt.skills = await this.normalizeSkills(userPrompt.skills); this.throwIfAborted(signal); const sessionId = crypto.randomUUID(); + this.ensureFileHistorySession(sessionId); const now = new Date().toISOString(); const index = this.loadSessionsIndex(); const entry: SessionEntry = { @@ -1022,6 +1033,7 @@ ${skillMd} userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); this.throwIfAborted(signal); + this.ensureFileHistorySession(sessionId); const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); @@ -1480,6 +1492,61 @@ ${skillMd} return messages; } + listUndoTargets(sessionId: string): UndoTarget[] { + return this.listSessionMessages(sessionId) + .map((message, index) => ({ message, index })) + .filter(({ message }) => this.isUndoTargetMessage(message)) + .map(({ message, index }) => ({ + message, + index, + canRestoreCode: Boolean( + message.checkpointHash && this.canRestoreCheckpointHash(sessionId, message.checkpointHash) + ), + })); + } + + restoreSessionConversation(sessionId: string, messageId: string): SessionMessage[] { + const messages = this.listSessionMessages(sessionId); + const targetIndex = messages.findIndex((message) => message.id === messageId); + if (targetIndex === -1) { + throw new Error("Selected message was not found in this session."); + } + + const keptMessages = messages.slice(0, targetIndex); + this.saveSessionMessages(sessionId, keptMessages); + const now = new Date().toISOString(); + const latestAssistant = [...keptMessages].reverse().find((message) => message.role === "assistant"); + const latestAssistantParams = latestAssistant?.messageParams as + | { tool_calls?: unknown[]; reasoning_content?: string } + | null + | undefined; + + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + assistantReply: latestAssistant?.content ?? null, + assistantThinking: + typeof latestAssistantParams?.reasoning_content === "string" ? latestAssistantParams.reasoning_content : null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + processes: null, + updateTime: now, + })); + return keptMessages; + } + + restoreSessionCode(sessionId: string, messageId: string): void { + const message = this.listSessionMessages(sessionId).find((item) => item.id === messageId); + if (!message) { + throw new Error("Selected message was not found in this session."); + } + if (!message.checkpointHash) { + throw new Error("Selected message has no code checkpoint."); + } + this.restoreCheckpointHash(sessionId, message.checkpointHash); + } + private normalizeSessionMessage(message: SessionMessage): SessionMessage { if (message.role !== "tool") { return message; @@ -1518,6 +1585,238 @@ ${skillMd} return { projectCode, projectDir, sessionsIndexPath }; } + private getFileHistoryGitDir(): string { + const { projectDir } = this.getProjectStorage(); + return path.join(projectDir, "file-history", ".git"); + } + + private getSessionBranchRef(sessionId: string): string | null { + if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) { + return null; + } + return `refs/heads/${sessionId}`; + } + + private ensureFileHistorySession(sessionId: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef) { + return undefined; + } + + try { + const gitDir = this.getFileHistoryGitDir(); + if (!fs.existsSync(gitDir)) { + fs.mkdirSync(path.dirname(gitDir), { recursive: true }); + this.runFileHistoryGit(["init"], { includeWorkTree: true }); + } + + const current = this.getCurrentCheckpointHash(sessionId); + if (current) { + return current; + } + + const emptyTree = this.runFileHistoryGit(["mktree"], { includeWorkTree: false, input: "" }).trim(); + const commitHash = this.createFileHistoryCommit(emptyTree, null, "Initial checkpoint"); + this.runFileHistoryGit(["update-ref", branchRef, commitHash], { includeWorkTree: false }); + return commitHash; + } catch { + return undefined; + } + } + + private getCurrentCheckpointHash(sessionId: string): string | undefined { + const gitDir = this.getFileHistoryGitDir(); + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(gitDir)) { + return undefined; + } + + try { + const hash = this.runFileHistoryGit(["rev-parse", "--verify", `${branchRef}^{commit}`], { + includeWorkTree: false, + }).trim(); + return this.isCommitHash(hash) ? hash : undefined; + } catch { + return undefined; + } + } + + private prepareFileMutationCheckpoint(sessionId: string, filePath: string): void { + const previousHash = this.ensureFileHistorySession(sessionId); + if (!previousHash) { + return; + } + this.updateLatestUserCheckpointHash(sessionId, undefined, previousHash); + const nextHash = this.recordFileHistoryCheckpoint(sessionId, [filePath], "Pre-mutation checkpoint"); + if (nextHash && nextHash !== previousHash) { + this.updateLatestUserCheckpointHash(sessionId, previousHash, nextHash); + } + } + + private recordFileMutationCheckpoint(sessionId: string, filePath: string): void { + this.ensureFileHistorySession(sessionId); + this.recordFileHistoryCheckpoint(sessionId, [filePath], "File mutation checkpoint"); + } + + private recordFileHistoryCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef) { + return undefined; + } + + const relativePaths = filePaths + .map((filePath) => this.toProjectRelativeGitPath(filePath)) + .filter((filePath): filePath is string => Boolean(filePath)); + if (relativePaths.length === 0) { + return this.getCurrentCheckpointHash(sessionId); + } + + try { + const parentHash = this.ensureFileHistorySession(sessionId); + if (!parentHash) { + return undefined; + } + this.runFileHistoryGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); + this.runFileHistoryGit(["add", "-f", "-A", "--", ...relativePaths], { includeWorkTree: true }); + const treeHash = this.runFileHistoryGit(["write-tree"], { includeWorkTree: false }).trim(); + const parentTreeHash = this.runFileHistoryGit(["rev-parse", `${parentHash}^{tree}`], { + includeWorkTree: false, + }).trim(); + if (treeHash === parentTreeHash) { + return parentHash; + } + + const commitHash = this.createFileHistoryCommit(treeHash, parentHash, message); + this.runFileHistoryGit(["update-ref", branchRef, commitHash, parentHash], { includeWorkTree: false }); + return commitHash; + } catch { + return undefined; + } + } + + private updateLatestUserCheckpointHash(sessionId: string, previousHash: string | undefined, nextHash: string): void { + const messages = this.listSessionMessages(sessionId); + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!message || !this.isUndoTargetMessage(message)) { + continue; + } + if (message.checkpointHash && message.checkpointHash !== previousHash) { + return; + } + messages[index] = { + ...message, + checkpointHash: nextHash, + updateTime: new Date().toISOString(), + }; + this.saveSessionMessages(sessionId, messages); + return; + } + } + + private createFileHistoryCommit(treeHash: string, parentHash: string | null, message: string): string { + const args = ["commit-tree", treeHash]; + if (parentHash) { + args.push("-p", parentHash); + } + args.push("-m", message); + return this.runFileHistoryGit(args, { + includeWorkTree: false, + env: this.getFileHistoryGitEnv(), + }).trim(); + } + + private getFileHistoryGitEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || FILE_HISTORY_AUTHOR_NAME, + GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, + GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || FILE_HISTORY_AUTHOR_NAME, + GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, + }; + } + + private toProjectRelativeGitPath(filePath: string): string | null { + const absolutePath = path.resolve(filePath); + const relativePath = path.relative(this.projectRoot, absolutePath); + if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return null; + } + return relativePath.split(path.sep).join("/"); + } + + private canRestoreCheckpointHash(sessionId: string, checkpointHash: string): boolean { + if (!this.isCommitHash(checkpointHash)) { + return false; + } + if (!this.getSessionBranchRef(sessionId)) { + return false; + } + const gitDir = this.getFileHistoryGitDir(); + if (!fs.existsSync(gitDir)) { + return false; + } + + try { + this.runFileHistoryGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); + return true; + } catch { + return false; + } + } + + private restoreCheckpointHash(sessionId: string, checkpointHash: string): void { + if (!this.isCommitHash(checkpointHash)) { + throw new Error("Invalid checkpoint hash."); + } + const gitDir = this.getFileHistoryGitDir(); + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(gitDir)) { + throw new Error("File history Git repository was not found for this project."); + } + this.runFileHistoryGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); + + try { + this.runFileHistoryGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); + } catch { + // If the session branch is missing, fall back to the target tree only. + // The target checkpoint has already been validated above. + } + this.runFileHistoryGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true }); + this.runFileHistoryGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false }); + } + + private runFileHistoryGit( + args: string[], + options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv } + ): string { + const gitDir = this.getFileHistoryGitDir(); + const gitArgs = [`--git-dir=${gitDir}`]; + if (options.includeWorkTree) { + gitArgs.push(`--work-tree=${this.projectRoot}`); + } + gitArgs.push(...args); + const result = childProcess.spawnSync("git", gitArgs, { + encoding: "utf8", + input: options.input, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + throw new Error(detail || `git ${args.join(" ")} failed`); + } + return result.stdout ?? ""; + } + + private isCommitHash(value: string): boolean { + return /^[0-9a-f]{40}$/i.test(value); + } + + private isUndoTargetMessage(message: SessionMessage): boolean { + return message.role === "user" && message.visible && !message.compacted; + } + private ensureProjectDir(): string { const { projectDir } = this.getProjectStorage(); fs.mkdirSync(projectDir, { recursive: true }); @@ -1628,6 +1927,7 @@ ${skillMd} visible: true, createTime: now, updateTime: now, + checkpointHash: this.getCurrentCheckpointHash(sessionId), }; } @@ -1795,6 +2095,8 @@ ${skillMd} onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control), + onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath), + onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath), shouldStop: () => this.isInterrupted(sessionId), }); if (this.isInterrupted(sessionId)) { diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 69d2075..54213a1 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -19,10 +19,11 @@ import { toggleSkillSelection, renderBufferWithCursor, buildInitPromptSubmission, + buildPromptDraftFromSessionMessage, disableTerminalExtendedKeys, enableTerminalExtendedKeys, } from "../ui"; -import type { SkillInfo } from "../session"; +import type { SessionMessage, SkillInfo } from "../session"; test("parseTerminalInput treats DEL bytes as backspace", () => { const { input, key } = parseTerminalInput("\u007F"); @@ -112,6 +113,31 @@ test("terminal extended key helpers request and restore modifyOtherKeys mode", ( assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); }); +test("buildPromptDraftFromSessionMessage restores text and image urls", () => { + const message: SessionMessage = { + id: "user-with-images", + sessionId: "session-1", + role: "user", + content: "revise this prompt", + contentParams: [ + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + { type: "text", text: "ignored" }, + { type: "image_url", image_url: { url: "data:image/jpeg;base64,def" } }, + ], + messageParams: null, + compacted: false, + visible: true, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }; + + assert.deepEqual(buildPromptDraftFromSessionMessage(message, 7), { + nonce: 7, + text: "revise this prompt", + imageUrls: ["data:image/png;base64,abc", "data:image/jpeg;base64,def"], + }); +}); + test("parseTerminalInput recognizes terminal focus events", () => { const focusIn = parseTerminalInput("\u001B[I"); const focusOut = parseTerminalInput("\u001B[O"); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index bfe5bad..c02c0fa 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1,5 +1,6 @@ import { afterEach, test } from "node:test"; import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -887,6 +888,219 @@ test("replySession continues without appending /continue as a user message", asy assert.equal(fetchCalls.length, 0); }); +test("replySession records the current file-history branch head as checkpointHash", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-checkpoint-hash-workspace-"); + const home = createTempDir("deepcode-checkpoint-hash-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-checkpoint-hash"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "note.txt": "checkpoint\n" }); + + await manager.replySession(sessionId, { text: "second prompt" }); + + const userMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "user"); + assert.equal(userMessages[userMessages.length - 1]?.checkpointHash, checkpointHash); +}); + +test("createSession initializes file-history repo and session branch", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-file-history-init-workspace-"); + const home = createTempDir("deepcode-file-history-init-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-file-history-init"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + const gitDir = path.join( + home, + ".deepcode", + "projects", + workspace.replace(/[\\/]/g, "-").replace(/:/g, ""), + "file-history", + ".git" + ); + + assert.ok(fs.existsSync(gitDir)); + assert.ok(userMessage?.checkpointHash); + assert.equal( + runFileHistoryGit(gitDir, workspace, ["rev-parse", "--verify", `refs/heads/${sessionId}^{commit}`]).trim(), + userMessage.checkpointHash + ); +}); + +test("Write tool advances file-history while preserving the user prompt checkpoint", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-write-checkpoint-workspace-"); + const home = createTempDir("deepcode-write-checkpoint-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "index.html"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-index", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: filePath, content: "

Hello

\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an index page" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + assert.ok(userMessage?.checkpointHash); + assert.equal(fs.existsSync(filePath), true); + + manager.restoreSessionCode(sessionId, userMessage.id); + + assert.equal(fs.existsSync(filePath), false); +}); + +test("missing git executable does not block sessions or Write tool calls", async () => { + const workspace = createTempDir("deepcode-no-git-write-workspace-"); + const home = createTempDir("deepcode-no-git-write-home-"); + setHomeDir(home); + + const originalPath = process.env.PATH; + process.env.PATH = ""; + try { + const filePath = path.join(workspace, "index.html"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-no-git", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: filePath, content: "

No Git

\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an index page" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + + assert.equal(fs.readFileSync(filePath, "utf8"), "

No Git

\n"); + assert.equal(userMessage?.checkpointHash, undefined); + assert.equal(manager.getSession(sessionId)?.status, "completed"); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } +}); + +test("restoreSessionConversation truncates messages before the selected user prompt", async () => { + const workspace = createTempDir("deepcode-undo-conversation-workspace-"); + const home = createTempDir("deepcode-undo-conversation-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-undo-conversation"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const firstAssistant = (manager as any).buildAssistantMessage( + sessionId, + "first answer", + null, + null + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, firstAssistant); + await manager.replySession(sessionId, { text: "second prompt" }); + const secondUserMessage = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "user") + .at(-1); + assert.ok(secondUserMessage); + const secondAssistant = (manager as any).buildAssistantMessage( + sessionId, + "second answer", + null, + null + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, secondAssistant); + + manager.restoreSessionConversation(sessionId, secondUserMessage.id); + + const contents = manager.listSessionMessages(sessionId).map((message) => message.content); + assert.ok(contents.includes("first prompt")); + assert.ok(contents.includes("first answer")); + assert.ok(!contents.includes("second prompt")); + assert.ok(!contents.includes("second answer")); + assert.equal(manager.getSession(sessionId)?.assistantReply, "first answer"); +}); + +test("restoreSessionCode restores project files from the recorded Git checkpoint", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-undo-code-workspace-"); + const home = createTempDir("deepcode-undo-code-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-undo-code"); + const sessionId = "session-code-restore"; + const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "before\n" }); + createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "after\n", "new.txt": "remove me\n" }); + fs.writeFileSync(path.join(workspace, "tracked.txt"), "after\n", "utf8"); + fs.writeFileSync(path.join(workspace, "new.txt"), "remove me\n", "utf8"); + + (manager as any).appendSessionMessage(sessionId, { + ...buildTestMessage("user-with-checkpoint", sessionId, "user", "restore here"), + checkpointHash, + }); + + manager.restoreSessionCode(sessionId, "user-with-checkpoint"); + + assert.equal(fs.readFileSync(path.join(workspace, "tracked.txt"), "utf8"), "before\n"); + assert.equal(fs.existsSync(path.join(workspace, "new.txt")), false); +}); + test("replySession /continue runs trailing pending tool calls before requesting another response", async () => { const workspace = createTempDir("deepcode-continue-tool-workspace-"); const home = createTempDir("deepcode-continue-tool-home-"); @@ -1737,6 +1951,88 @@ test("SessionManager adjusts the active Bash timeout control and session metadat assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString()); }); +function hasGit(): boolean { + try { + execFileSync("git", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function createFileHistoryCommit( + home: string, + workspace: string, + sessionId: string, + files: Record +): string { + const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); + const gitDir = path.join(home, ".deepcode", "projects", projectCode, "file-history", ".git"); + const branchRef = `refs/heads/${sessionId}`; + fs.mkdirSync(path.dirname(gitDir), { recursive: true }); + if (!fs.existsSync(gitDir)) { + runFileHistoryGit(gitDir, workspace, ["init"]); + } + + let parentHash = ""; + try { + parentHash = runFileHistoryGit(gitDir, workspace, ["rev-parse", "--verify", `${branchRef}^{commit}`]).trim(); + } catch { + const emptyTree = runFileHistoryGit(gitDir, workspace, ["mktree"], ""); + parentHash = runFileHistoryGit( + gitDir, + workspace, + ["commit-tree", emptyTree.trim(), "-m", "initial checkpoint"], + "", + fileHistoryCommitEnv() + ).trim(); + runFileHistoryGit(gitDir, workspace, ["update-ref", branchRef, parentHash]); + } + runFileHistoryGit(gitDir, workspace, ["read-tree", "--reset", branchRef]); + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(workspace, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, "utf8"); + } + runFileHistoryGit(gitDir, workspace, ["add", "-f", "-A", "--", ...Object.keys(files)]); + const treeHash = runFileHistoryGit(gitDir, workspace, ["write-tree"]).trim(); + const commitHash = runFileHistoryGit( + gitDir, + workspace, + ["commit-tree", treeHash, "-p", parentHash, "-m", "checkpoint"], + "", + fileHistoryCommitEnv() + ).trim(); + runFileHistoryGit(gitDir, workspace, ["update-ref", branchRef, commitHash, parentHash]); + return commitHash; +} + +function runFileHistoryGit( + gitDir: string, + workspace: string, + args: string[], + input = "", + env: NodeJS.ProcessEnv = process.env +): string { + return execFileSync("git", [`--git-dir=${gitDir}`, `--work-tree=${workspace}`, ...args], { + encoding: "utf8", + input, + env, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +function fileHistoryCommitEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_AUTHOR_NAME: "DeepCode Test", + GIT_AUTHOR_EMAIL: "deepcode-test@example.com", + GIT_COMMITTER_NAME: "DeepCode Test", + GIT_COMMITTER_EMAIL: "deepcode-test@example.com", + }; +} + function createSessionManager(projectRoot: string, machineId: string): SessionManager { return new SessionManager({ projectRoot, diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 34b48d0..30d77ee 100644 --- a/src/tests/slashCommands.test.ts +++ b/src/tests/slashCommands.test.ts @@ -19,7 +19,18 @@ 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", "raw", "exit"]); + assert.deepEqual(builtinNames, [ + "skills", + "model", + "new", + "init", + "resume", + "continue", + "undo", + "mcp", + "raw", + "exit", + ]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -66,6 +77,13 @@ test("findExactSlashCommand returns built-in /continue", () => { assert.equal(item?.kind, "continue"); }); +test("findExactSlashCommand returns built-in /undo", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/undo"); + assert.ok(item); + assert.equal(item?.kind, "undo"); +}); + test("findExactSlashCommand returns built-in /skills", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/skills"); diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 29108e5..454a673 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -321,7 +321,9 @@ export async function handleEditTool( const updated = applyReplacement(raw, replacementOldString, replacementNewString, matches, replaceAll); const diffPreview = buildDiffPreview(filePath, raw, updated); + context.onBeforeFileMutation?.(filePath); writeTextFile(filePath, updated, metadata.encoding, metadata.lineEndings); + context.onAfterFileMutation?.(filePath); const freshMetadata = readTextFileWithMetadata(filePath); recordFileState( context.sessionId, diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 093e9f3..73e31f5 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -40,6 +40,8 @@ export type ToolExecutionContext = { onProcessExit?: (processId: string | number) => void; onProcessStdout?: (processId: string | number, chunk: string) => void; onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void; + onBeforeFileMutation?: (filePath: string) => void; + onAfterFileMutation?: (filePath: string) => void; bashTimeoutMs?: number; bashMinTimeoutMs?: number; }; @@ -49,6 +51,8 @@ export type ToolExecutionHooks = { onProcessExit?: (processId: string | number) => void; onProcessStdout?: (processId: string | number, chunk: string) => void; onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void; + onBeforeFileMutation?: (filePath: string) => void; + onAfterFileMutation?: (filePath: string) => void; shouldStop?: () => boolean; }; @@ -217,6 +221,8 @@ export class ToolExecutor { onProcessExit: hooks?.onProcessExit, onProcessStdout: hooks?.onProcessStdout, onProcessTimeoutControl: hooks?.onProcessTimeoutControl, + onBeforeFileMutation: hooks?.onBeforeFileMutation, + onAfterFileMutation: hooks?.onAfterFileMutation, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts index 153c1c6..a4c81bf 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -97,7 +97,9 @@ export async function handleWriteTool( const encoding = existingMetadata?.encoding ?? "utf8"; const lineEndings = existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : "LF"); const diffPreview = buildDiffPreview(filePath, existingMetadata?.content ?? null, normalizedContent); + context.onBeforeFileMutation?.(filePath); const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings); + context.onAfterFileMutation?.(filePath); const freshMetadata = readTextFileWithMetadata(filePath); recordFileState( diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c729dc0..70f9755 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -13,6 +13,7 @@ import { type SessionMessage, type SessionStatus, type SkillInfo, + type UndoTarget, type UserPromptContent, } from "../session"; import { @@ -22,9 +23,10 @@ import { type ResolvedDeepcodingSettings, resolveSettingsSources, } from "../settings"; -import { PromptInput, type PromptSubmission } from "./PromptInput"; +import { PromptInput, type PromptDraft, type PromptSubmission } from "./PromptInput"; import { MessageView, RawModeExitPrompt } from "./compoments"; import { SessionList } from "./SessionList"; +import { UndoSelector, type UndoRestoreMode } from "./UndoSelector"; import { buildLoadingText } from "./loadingText"; import { findExpandedThinkingId } from "./thinkingState"; import { WelcomeScreen } from "./WelcomeScreen"; @@ -43,7 +45,7 @@ import { renderMessageToStdout } from "./compoments/MessageView/utils"; const DEFAULT_MODEL = "deepseek-v4-pro"; const DEFAULT_BASE_URL = "https://api.deepseek.com"; -type View = "chat" | "session-list" | "mcp-status"; +type View = "chat" | "session-list" | "undo" | "mcp-status"; type AppProps = { projectRoot: string; @@ -67,6 +69,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const [skills, setSkills] = useState([]); const [messages, setMessages] = useState([]); const [sessions, setSessions] = useState([]); + const [undoTargets, setUndoTargets] = useState([]); + const [promptDraft, setPromptDraft] = useState(null); const [statusLine, setStatusLine] = useState(""); const [errorLine, setErrorLine] = useState(null); const [streamProgress, setStreamProgress] = useState(null); @@ -223,6 +227,17 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setView("session-list"); return; } + if (submission.command === "undo") { + const activeSessionId = sessionManager.getActiveSessionId(); + if (!activeSessionId) { + setErrorLine("No active session to undo."); + return; + } + setShowWelcome(false); + setUndoTargets(sessionManager.listUndoTargets(activeSessionId)); + setView("undo"); + return; + } if (submission.command === "mcp") { setShowWelcome(false); setMcpStatuses(sessionManager.getMcpStatus()); @@ -337,6 +352,20 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. [handlePrompt] ); + 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); + }, + [sessionManager] + ); + useEffect(() => { if (initialPromptSubmittedRef.current || !initialPrompt || !initialPrompt.trim()) { return; @@ -376,6 +405,45 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. [sessionManager, refreshSkills] ); + const handleUndoRestore = useCallback( + async (target: UndoTarget, restoreMode: UndoRestoreMode): Promise => { + const sessionId = sessionManager.getActiveSessionId(); + if (!sessionId) { + setErrorLine("No active session to undo."); + setView("chat"); + setShowWelcome(true); + return; + } + + const errors: string[] = []; + if (restoreMode === "code-and-conversation") { + try { + sessionManager.restoreSessionCode(sessionId, target.message.id); + } catch (error) { + errors.push(`Code restore failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + let conversationRestored = false; + try { + sessionManager.restoreSessionConversation(sessionId, target.message.id); + conversationRestored = true; + } catch (error) { + errors.push(`Conversation restore failed: ${error instanceof Error ? error.message : String(error)}`); + } + + refreshSessionsList(); + await refreshSkills(sessionId); + setView("chat"); + setErrorLine(errors.length > 0 ? errors.join(" ") : null); + if (conversationRestored) { + setPromptDraft(buildPromptDraftFromSessionMessage(target.message, Date.now())); + } + reloadActiveSessionView(sessionId); + }, + [reloadActiveSessionView, refreshSessionsList, refreshSkills, sessionManager] + ); + const handleRawModeChange = useCallback( (nextMode: string) => { const activeSessionId = sessionManager.getActiveSessionId(); @@ -584,6 +652,15 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. onSelect={(id) => void handleSelectSession(id)} onCancel={() => setView("chat")} /> + ) : view === "undo" ? ( + void handleUndoRestore(target, restoreMode)} + onCancel={() => { + setView("chat"); + setShowWelcome(true); + }} + /> ) : view === "mcp-status" ? ( setView("chat")} /> ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( @@ -602,6 +679,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. busy={busy} loadingText={loadingText} runningProcesses={runningProcesses} + promptDraft={promptDraft} onSubmit={handleSubmit} onModelConfigChange={handleModelConfigChange} onRawModeChange={handleRawModeChange} @@ -646,6 +724,30 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session }; } +export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft { + return { + nonce, + text: typeof message.content === "string" ? message.content : "", + imageUrls: extractImageUrlsFromContentParams(message.contentParams), + }; +} + +function extractImageUrlsFromContentParams(contentParams: unknown): string[] { + const params = Array.isArray(contentParams) ? contentParams : contentParams ? [contentParams] : []; + const imageUrls: string[] = []; + for (const param of params) { + if (!param || typeof param !== "object") { + continue; + } + const record = param as { type?: unknown; image_url?: { url?: unknown } }; + const url = record.image_url?.url; + if (record.type === "image_url" && typeof url === "string" && url) { + imageUrls.push(url); + } + } + return imageUrls; +} + function isCurrentSessionEmpty(sessionManager: SessionManager): boolean { const activeSessionId = sessionManager.getActiveSessionId(); return !activeSessionId || !sessionManager.getSession(activeSessionId); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index b35f72e..ffb614d 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -57,7 +57,13 @@ export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; - command?: "new" | "resume" | "continue" | "mcp" | "exit"; + command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; +}; + +export type PromptDraft = { + nonce: number; + text: string; + imageUrls: string[]; }; type Props = { @@ -71,6 +77,7 @@ type Props = { disabled?: boolean; placeholder?: string; runningProcesses?: SessionEntry["processes"]; + promptDraft?: PromptDraft | null; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onRawModeChange?: (mode: string) => void; @@ -124,6 +131,7 @@ export const PromptInput = React.memo(function PromptInput({ disabled, placeholder, runningProcesses, + promptDraft, onSubmit, onModelConfigChange, onInterrupt, @@ -154,6 +162,7 @@ export const PromptInput = React.memo(function PromptInput({ const undoRedoRef = React.useRef(createPromptUndoRedoState()); const wasBusyRef = React.useRef(busy); const hadFileMentionTokenRef = React.useRef(false); + const appliedDraftNonceRef = React.useRef(null); const fileMentionToken = getCurrentFileMentionToken(buffer); const hasFileMentionToken = fileMentionToken !== null; @@ -266,6 +275,22 @@ export const PromptInput = React.memo(function PromptInput({ return () => clearTimeout(timer); }, [statusMessage]); + useEffect(() => { + if (!promptDraft || appliedDraftNonceRef.current === promptDraft.nonce) { + return; + } + appliedDraftNonceRef.current = promptDraft.nonce; + setBuffer({ text: promptDraft.text, cursor: promptDraft.text.length }); + setImageUrls(promptDraft.imageUrls); + setSelectedSkills([]); + setShowSkillsDropdown(false); + setOpenRawModelDropdown(false); + setModelDropdownStep(null); + setHistoryCursor(-1); + setDraftBeforeHistory(null); + clearPromptUndoRedoState(undoRedoRef.current); + }, [promptDraft]); + useEffect(() => { setHistoryCursor(-1); setDraftBeforeHistory(null); @@ -766,6 +791,15 @@ export const PromptInput = React.memo(function PromptInput({ setShowSkillsDropdown(false); return; } + if (item.kind === "undo") { + onSubmit({ text: "/undo", imageUrls: [], command: "undo" }); + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + return; + } if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); setBuffer(EMPTY_BUFFER); diff --git a/src/ui/UndoSelector.tsx b/src/ui/UndoSelector.tsx new file mode 100644 index 0000000..fad3e17 --- /dev/null +++ b/src/ui/UndoSelector.tsx @@ -0,0 +1,195 @@ +import React, { useMemo, useState } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { UndoTarget } from "../session"; + +export type UndoRestoreMode = "code-and-conversation" | "conversation"; + +type Props = { + targets: UndoTarget[]; + onSelect: (target: UndoTarget, mode: UndoRestoreMode) => void; + onCancel: () => void; +}; + +type Phase = "message" | "mode"; + +const MAX_VISIBLE_TARGETS = 7; + +export function UndoSelector({ targets, onSelect, onCancel }: Props): React.ReactElement { + const [phase, setPhase] = useState("message"); + const [targetIndex, setTargetIndex] = useState(Math.max(0, targets.length - 1)); + const [modeIndex, setModeIndex] = useState(0); + const { columns, rows } = useWindowSize(); + + const safeTargetIndex = useMemo(() => { + if (targets.length === 0) { + return 0; + } + return Math.max(0, Math.min(targetIndex, targets.length - 1)); + }, [targetIndex, targets.length]); + + const selectedTarget = targets[safeTargetIndex] ?? null; + const maxVisible = Math.max(1, Math.min(MAX_VISIBLE_TARGETS, rows - 8)); + const scrollOffset = Math.max(0, Math.min(safeTargetIndex - Math.floor(maxVisible / 2), targets.length - maxVisible)); + const visibleTargets = targets.slice(scrollOffset, scrollOffset + maxVisible); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + if (phase === "mode") { + setPhase("message"); + return; + } + onCancel(); + return; + } + + if (targets.length === 0) { + return; + } + + if (phase === "message") { + if (key.upArrow) { + setTargetIndex((index) => Math.max(0, index - 1)); + return; + } + if (key.downArrow) { + setTargetIndex((index) => Math.min(targets.length - 1, index + 1)); + return; + } + if (key.home) { + setTargetIndex(0); + return; + } + if (key.end) { + setTargetIndex(targets.length - 1); + return; + } + if (key.return) { + setModeIndex(selectedTarget?.canRestoreCode ? 0 : 1); + setPhase("mode"); + } + return; + } + + if (key.upArrow || key.downArrow) { + setModeIndex((index) => (index === 0 ? 1 : 0)); + return; + } + if (key.return && selectedTarget) { + onSelect(selectedTarget, modeIndex === 0 ? "code-and-conversation" : "conversation"); + } + }); + + if (targets.length === 0) { + return ( + + Nothing to undo yet. + Press Esc to go back. + + ); + } + + return ( + + + + + Undo + + restore to the point before a prompt + + {phase === "message" ? ( + + {visibleTargets.map((target, visibleIndex) => { + const actualIndex = scrollOffset + visibleIndex; + const isActive = actualIndex === safeTargetIndex; + return ( + + {isActive ? "> " : " "} + + + {formatUndoMessage(target.message.content)} + + + {formatTimestamp(target.message.createTime)} + {target.canRestoreCode ? " · code checkpoint available" : " · conversation only"} + + + + ); + })} + + ) : ( + + Selected prompt: + {formatUndoMessage(selectedTarget?.message.content ?? "")} + + + {modeIndex === 0 ? "> " : " "}Restore code and conversation + + + {" "} + {selectedTarget?.canRestoreCode + ? "Restore files from the recorded Git checkpoint, then fork the conversation." + : "No code checkpoint is recorded for this prompt."} + + + {modeIndex === 1 ? "> " : " "}Restore conversation + + {" "}Fork the conversation without changing files. + + + )} + + + {phase === "message" + ? "↑/↓ navigate · Enter choose · Esc cancel" + : "↑/↓ choose restore mode · Enter restore · Esc back"} + + + + + ); +} + +function formatUndoMessage(content: unknown): string { + const text = typeof content === "string" && content.trim() ? content.trim() : "(empty message)"; + const singleLine = text.replace(/\r?\n/g, " ").replace(/\s+/g, " "); + return singleLine.length > 90 ? `${singleLine.slice(0, 89)}…` : singleLine; +} + +function formatTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.valueOf())) { + return value; + } + return date.toLocaleString(); +} diff --git a/src/ui/index.ts b/src/ui/index.ts index f2e698c..656b582 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -6,6 +6,7 @@ export { writeModelConfigSelection, resolveCurrentSettings, createOpenAIClient, + buildPromptDraftFromSessionMessage, } from "./App"; export { default as AppContainer } from "./AppContainer"; export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; @@ -30,6 +31,7 @@ export { useTerminalInput, parseTerminalInput, type PromptSubmission, + type PromptDraft, type InputKey, } from "./PromptInput"; export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index 948a7ab..6d9b7cc 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -8,6 +8,7 @@ export type SlashCommandKind = | "init" | "resume" | "continue" + | "undo" | "mcp" | "raw" | "exit"; @@ -58,6 +59,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/continue", description: "Continue the active conversation or pick one to resume", }, + { + kind: "undo", + name: "undo", + label: "/undo", + description: "Restore code and/or conversation to a previous point", + }, { kind: "mcp", name: "mcp", From 883f1fd051a648b67b457288580565b6f7c7aca0 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 20 May 2026 11:46:52 +0800 Subject: [PATCH 175/217] refactor: extract the file-history checkpoint logic out of SessionManager --- src/common/file-history.ts | 194 ++++++++++++++++++++++++++++++++++++ src/session.ts | 196 +++---------------------------------- 2 files changed, 209 insertions(+), 181 deletions(-) create mode 100644 src/common/file-history.ts diff --git a/src/common/file-history.ts b/src/common/file-history.ts new file mode 100644 index 0000000..5194e6e --- /dev/null +++ b/src/common/file-history.ts @@ -0,0 +1,194 @@ +import * as childProcess from "child_process"; +import * as fs from "fs"; +import * as path from "path"; + +const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint"; +const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost"; + +export class GitFileHistory { + constructor( + private readonly projectRoot: string, + private readonly gitDir: string + ) {} + + ensureSession(sessionId: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef) { + return undefined; + } + + try { + if (!fs.existsSync(this.gitDir)) { + fs.mkdirSync(path.dirname(this.gitDir), { recursive: true }); + this.runGit(["init"], { includeWorkTree: true }); + } + + const current = this.getCurrentCheckpointHash(sessionId); + if (current) { + return current; + } + + const emptyTree = this.runGit(["mktree"], { includeWorkTree: false, input: "" }).trim(); + const commitHash = this.createCommit(emptyTree, null, "Initial checkpoint"); + this.runGit(["update-ref", branchRef, commitHash], { includeWorkTree: false }); + return commitHash; + } catch { + return undefined; + } + } + + getCurrentCheckpointHash(sessionId: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(this.gitDir)) { + return undefined; + } + + try { + const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`], { + includeWorkTree: false, + }).trim(); + return isCommitHash(hash) ? hash : undefined; + } catch { + return undefined; + } + } + + recordCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef) { + return undefined; + } + + const relativePaths = filePaths + .map((filePath) => this.toProjectRelativeGitPath(filePath)) + .filter((filePath): filePath is string => Boolean(filePath)); + if (relativePaths.length === 0) { + return this.getCurrentCheckpointHash(sessionId); + } + + try { + const parentHash = this.ensureSession(sessionId); + if (!parentHash) { + return undefined; + } + this.runGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); + this.runGit(["add", "-f", "-A", "--", ...relativePaths], { includeWorkTree: true }); + const treeHash = this.runGit(["write-tree"], { includeWorkTree: false }).trim(); + const parentTreeHash = this.runGit(["rev-parse", `${parentHash}^{tree}`], { + includeWorkTree: false, + }).trim(); + if (treeHash === parentTreeHash) { + return parentHash; + } + + const commitHash = this.createCommit(treeHash, parentHash, message); + this.runGit(["update-ref", branchRef, commitHash, parentHash], { includeWorkTree: false }); + return commitHash; + } catch { + return undefined; + } + } + + canRestore(sessionId: string, checkpointHash: string): boolean { + if (!isCommitHash(checkpointHash)) { + return false; + } + if (!this.getSessionBranchRef(sessionId)) { + return false; + } + if (!fs.existsSync(this.gitDir)) { + return false; + } + + try { + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); + return true; + } catch { + return false; + } + } + + restore(sessionId: string, checkpointHash: string): void { + if (!isCommitHash(checkpointHash)) { + throw new Error("Invalid checkpoint hash."); + } + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(this.gitDir)) { + throw new Error("File history Git repository was not found for this project."); + } + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); + + try { + this.runGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); + } catch { + // If the session branch is missing, fall back to the target tree only. + // The target checkpoint has already been validated above. + } + this.runGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true }); + this.runGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false }); + } + + private getSessionBranchRef(sessionId: string): string | null { + if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) { + return null; + } + return `refs/heads/${sessionId}`; + } + + private createCommit(treeHash: string, parentHash: string | null, message: string): string { + const args = ["commit-tree", treeHash]; + if (parentHash) { + args.push("-p", parentHash); + } + args.push("-m", message); + return this.runGit(args, { + includeWorkTree: false, + env: getFileHistoryGitEnv(), + }).trim(); + } + + private toProjectRelativeGitPath(filePath: string): string | null { + const absolutePath = path.resolve(filePath); + const relativePath = path.relative(this.projectRoot, absolutePath); + if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return null; + } + return relativePath.split(path.sep).join("/"); + } + + private runGit( + args: string[], + options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv } + ): string { + const gitArgs = [`--git-dir=${this.gitDir}`]; + if (options.includeWorkTree) { + gitArgs.push(`--work-tree=${this.projectRoot}`); + } + gitArgs.push(...args); + const result = childProcess.spawnSync("git", gitArgs, { + encoding: "utf8", + input: options.input, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + throw new Error(detail || `git ${args.join(" ")} failed`); + } + return result.stdout ?? ""; + } +} + +function getFileHistoryGitEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || FILE_HISTORY_AUTHOR_NAME, + GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, + GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || FILE_HISTORY_AUTHOR_NAME, + GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, + }; +} + +function isCommitHash(value: string): boolean { + return /^[0-9a-f]{40}$/i.test(value); +} diff --git a/src/session.ts b/src/session.ts index 6b2ceee..88e85b6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,7 +2,6 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; -import * as childProcess from "child_process"; import { fileURLToPath } from "url"; import matter from "gray-matter"; import ejs from "ejs"; @@ -29,14 +28,13 @@ import type { McpServerConfig } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; import { killProcessTree } from "./common/process-tree"; +import { GitFileHistory } from "./common/file-history"; const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; const NEW_PROMPT_REPORT_TIMEOUT_MS = 3000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; -const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint"; -const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost"; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -1585,113 +1583,40 @@ ${skillMd} return { projectCode, projectDir, sessionsIndexPath }; } + private getFileHistory(): GitFileHistory { + return new GitFileHistory(this.projectRoot, this.getFileHistoryGitDir()); + } + private getFileHistoryGitDir(): string { const { projectDir } = this.getProjectStorage(); return path.join(projectDir, "file-history", ".git"); } - private getSessionBranchRef(sessionId: string): string | null { - if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) { - return null; - } - return `refs/heads/${sessionId}`; - } - private ensureFileHistorySession(sessionId: string): string | undefined { - const branchRef = this.getSessionBranchRef(sessionId); - if (!branchRef) { - return undefined; - } - - try { - const gitDir = this.getFileHistoryGitDir(); - if (!fs.existsSync(gitDir)) { - fs.mkdirSync(path.dirname(gitDir), { recursive: true }); - this.runFileHistoryGit(["init"], { includeWorkTree: true }); - } - - const current = this.getCurrentCheckpointHash(sessionId); - if (current) { - return current; - } - - const emptyTree = this.runFileHistoryGit(["mktree"], { includeWorkTree: false, input: "" }).trim(); - const commitHash = this.createFileHistoryCommit(emptyTree, null, "Initial checkpoint"); - this.runFileHistoryGit(["update-ref", branchRef, commitHash], { includeWorkTree: false }); - return commitHash; - } catch { - return undefined; - } + return this.getFileHistory().ensureSession(sessionId); } private getCurrentCheckpointHash(sessionId: string): string | undefined { - const gitDir = this.getFileHistoryGitDir(); - const branchRef = this.getSessionBranchRef(sessionId); - if (!branchRef || !fs.existsSync(gitDir)) { - return undefined; - } - - try { - const hash = this.runFileHistoryGit(["rev-parse", "--verify", `${branchRef}^{commit}`], { - includeWorkTree: false, - }).trim(); - return this.isCommitHash(hash) ? hash : undefined; - } catch { - return undefined; - } + return this.getFileHistory().getCurrentCheckpointHash(sessionId); } private prepareFileMutationCheckpoint(sessionId: string, filePath: string): void { - const previousHash = this.ensureFileHistorySession(sessionId); + const fileHistory = this.getFileHistory(); + const previousHash = fileHistory.ensureSession(sessionId); if (!previousHash) { return; } this.updateLatestUserCheckpointHash(sessionId, undefined, previousHash); - const nextHash = this.recordFileHistoryCheckpoint(sessionId, [filePath], "Pre-mutation checkpoint"); + const nextHash = fileHistory.recordCheckpoint(sessionId, [filePath], "Pre-mutation checkpoint"); if (nextHash && nextHash !== previousHash) { this.updateLatestUserCheckpointHash(sessionId, previousHash, nextHash); } } private recordFileMutationCheckpoint(sessionId: string, filePath: string): void { - this.ensureFileHistorySession(sessionId); - this.recordFileHistoryCheckpoint(sessionId, [filePath], "File mutation checkpoint"); - } - - private recordFileHistoryCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined { - const branchRef = this.getSessionBranchRef(sessionId); - if (!branchRef) { - return undefined; - } - - const relativePaths = filePaths - .map((filePath) => this.toProjectRelativeGitPath(filePath)) - .filter((filePath): filePath is string => Boolean(filePath)); - if (relativePaths.length === 0) { - return this.getCurrentCheckpointHash(sessionId); - } - - try { - const parentHash = this.ensureFileHistorySession(sessionId); - if (!parentHash) { - return undefined; - } - this.runFileHistoryGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); - this.runFileHistoryGit(["add", "-f", "-A", "--", ...relativePaths], { includeWorkTree: true }); - const treeHash = this.runFileHistoryGit(["write-tree"], { includeWorkTree: false }).trim(); - const parentTreeHash = this.runFileHistoryGit(["rev-parse", `${parentHash}^{tree}`], { - includeWorkTree: false, - }).trim(); - if (treeHash === parentTreeHash) { - return parentHash; - } - - const commitHash = this.createFileHistoryCommit(treeHash, parentHash, message); - this.runFileHistoryGit(["update-ref", branchRef, commitHash, parentHash], { includeWorkTree: false }); - return commitHash; - } catch { - return undefined; - } + const fileHistory = this.getFileHistory(); + fileHistory.ensureSession(sessionId); + fileHistory.recordCheckpoint(sessionId, [filePath], "File mutation checkpoint"); } private updateLatestUserCheckpointHash(sessionId: string, previousHash: string | undefined, nextHash: string): void { @@ -1714,103 +1639,12 @@ ${skillMd} } } - private createFileHistoryCommit(treeHash: string, parentHash: string | null, message: string): string { - const args = ["commit-tree", treeHash]; - if (parentHash) { - args.push("-p", parentHash); - } - args.push("-m", message); - return this.runFileHistoryGit(args, { - includeWorkTree: false, - env: this.getFileHistoryGitEnv(), - }).trim(); - } - - private getFileHistoryGitEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || FILE_HISTORY_AUTHOR_NAME, - GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, - GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || FILE_HISTORY_AUTHOR_NAME, - GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, - }; - } - - private toProjectRelativeGitPath(filePath: string): string | null { - const absolutePath = path.resolve(filePath); - const relativePath = path.relative(this.projectRoot, absolutePath); - if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - return null; - } - return relativePath.split(path.sep).join("/"); - } - private canRestoreCheckpointHash(sessionId: string, checkpointHash: string): boolean { - if (!this.isCommitHash(checkpointHash)) { - return false; - } - if (!this.getSessionBranchRef(sessionId)) { - return false; - } - const gitDir = this.getFileHistoryGitDir(); - if (!fs.existsSync(gitDir)) { - return false; - } - - try { - this.runFileHistoryGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); - return true; - } catch { - return false; - } + return this.getFileHistory().canRestore(sessionId, checkpointHash); } private restoreCheckpointHash(sessionId: string, checkpointHash: string): void { - if (!this.isCommitHash(checkpointHash)) { - throw new Error("Invalid checkpoint hash."); - } - const gitDir = this.getFileHistoryGitDir(); - const branchRef = this.getSessionBranchRef(sessionId); - if (!branchRef || !fs.existsSync(gitDir)) { - throw new Error("File history Git repository was not found for this project."); - } - this.runFileHistoryGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); - - try { - this.runFileHistoryGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); - } catch { - // If the session branch is missing, fall back to the target tree only. - // The target checkpoint has already been validated above. - } - this.runFileHistoryGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true }); - this.runFileHistoryGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false }); - } - - private runFileHistoryGit( - args: string[], - options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv } - ): string { - const gitDir = this.getFileHistoryGitDir(); - const gitArgs = [`--git-dir=${gitDir}`]; - if (options.includeWorkTree) { - gitArgs.push(`--work-tree=${this.projectRoot}`); - } - gitArgs.push(...args); - const result = childProcess.spawnSync("git", gitArgs, { - encoding: "utf8", - input: options.input, - env: options.env, - stdio: ["pipe", "pipe", "pipe"], - }); - if (result.status !== 0) { - const detail = (result.stderr || result.stdout || "").trim(); - throw new Error(detail || `git ${args.join(" ")} failed`); - } - return result.stdout ?? ""; - } - - private isCommitHash(value: string): boolean { - return /^[0-9a-f]{40}$/i.test(value); + this.getFileHistory().restore(sessionId, checkpointHash); } private isUndoTargetMessage(message: SessionMessage): boolean { From 057e3538b286ca5f67a4968674014baf0dbdc808 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 20 May 2026 12:03:31 +0800 Subject: [PATCH 176/217] fix: Bash timeout session test now uses a temporary HOME --- src/tests/session.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index c02c0fa..3658e1c 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1919,6 +1919,9 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = test("SessionManager adjusts the active Bash timeout control and session metadata", async () => { const workspace = createTempDir("deepcode-bash-timeout-session-"); + const home = createTempDir("deepcode-bash-timeout-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, ""); const sessionId = await manager.createSession({ text: "hello" }); From f37ee2b71c1320820bd0e9b970a45fea4815556f Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 14:00:28 +0800 Subject: [PATCH 177/217] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8F=90=E5=8F=8A=E8=8F=9C=E5=8D=95=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96PromptInput=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 FileMentionMenu 组件用于文件提及功能显示与交互 - 在 components/index.ts 中导出 FileMentionMenu 组件 - PromptInput 集成 FileMentionMenu 替换原 DropdownMenu 实现 - 重构 PromptInput 相关状态管理及事件处理,移除冗余索引状态 - 提取 resetPromptInput 函数简化重置输入框逻辑 - 优化键盘事件处理逻辑,简化文件提及菜单快捷键支持 - 使用 FileMentionMenu 替换内联菜单渲染,提高代码复用性与可维护性 --- src/ui/PromptInput.tsx | 129 +++++--------------- src/ui/components/FileMentionMenu/index.tsx | 117 ++++++++++++++++++ src/ui/components/index.ts | 1 + 3 files changed, 147 insertions(+), 100 deletions(-) create mode 100644 src/ui/components/FileMentionMenu/index.tsx diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index a79fe30..44e1754 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -50,8 +50,7 @@ import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; import type { ModelConfigSelection } from "../settings"; -import DropdownMenu from "./DropdownMenu"; -import { ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components"; +import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components"; export type PromptSubmission = { text: string; @@ -127,7 +126,6 @@ export const PromptInput = React.memo(function PromptInput({ const [openRawModelDropdown, setOpenRawModelDropdown] = useState(false); const [showModelDropdown, setShowModelDropdown] = useState(false); const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot)); - const [fileMentionIndex, setFileMentionIndex] = useState(0); const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); const [historyCursor, setHistoryCursor] = useState(-1); const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); @@ -213,16 +211,6 @@ export const PromptInput = React.memo(function PromptInput({ } }, [fileMentionKey]); - useEffect(() => { - if (!showFileMentionMenu) { - setFileMentionIndex(0); - return; - } - if (fileMentionIndex >= fileMentionMatches.length) { - setFileMentionIndex(Math.max(0, fileMentionMatches.length - 1)); - } - }, [fileMentionMatches.length, fileMentionIndex, showFileMentionMenu]); - useEffect(() => { if (!statusMessage) { return; @@ -252,8 +240,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.escape) { - if (showFileMentionMenu && fileMentionKey) { - setDismissedFileMentionKey(fileMentionKey); + if (showFileMentionMenu) { return; } if (busy) { @@ -345,32 +332,9 @@ export const PromptInput = React.memo(function PromptInput({ const isPlainReturn = returnAction === "submit"; if (showFileMentionMenu) { - if (key.upArrow) { - if (fileMentionMatches.length > 0) { - setFileMentionIndex((idx) => (idx - 1 + fileMentionMatches.length) % fileMentionMatches.length); - } + if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") { return; } - if (key.downArrow) { - if (fileMentionMatches.length > 0) { - setFileMentionIndex((idx) => (idx + 1) % fileMentionMatches.length); - } - return; - } - if (key.tab || returnAction === "submit") { - const selected = fileMentionMatches[fileMentionIndex]; - if (selected && fileMentionToken) { - insertFileMentionSelection(selected); - return; - } - if (key.tab) { - setDismissedFileMentionKey(fileMentionKey); - return; - } - if (fileMentionKey) { - setDismissedFileMentionKey(fileMentionKey); - } - } } if (showMenu) { @@ -613,6 +577,14 @@ export const PromptInput = React.memo(function PromptInput({ setDismissedFileMentionKey(null); } + function resetPromptInput(): void { + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + } + function handleSlashSelection(item: SlashCommandItem): void { if (busy && item.kind !== "exit") { setStatusMessage("wait for the current response or press esc to interrupt"); @@ -643,47 +615,27 @@ export const PromptInput = React.memo(function PromptInput({ } if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "init") { onSubmit(buildInitPromptSubmission(selectedSkills)); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "resume") { onSubmit({ text: "", imageUrls: [], command: "resume" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "continue") { onSubmit({ text: "/continue", imageUrls: [], command: "continue" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "exit") { @@ -718,11 +670,7 @@ export const PromptInput = React.memo(function PromptInput({ imageUrls, selectedSkills, }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); } function addSelectedSkill(skill: SkillInfo): void { @@ -798,37 +746,18 @@ export const PromptInput = React.memo(function PromptInput({ onModelConfigChange={onModelConfigChange} onStatusMessage={setStatusMessage} /> - {showFileMentionMenu ? ( - ({ - key: item.path, - label: item.path, - description: item.type === "directory" ? "directory" : "file", - }))} - activeIndex={fileMentionIndex} - activeColor="#229ac3" - maxVisible={8} - renderItem={(item, isActive) => ( - - {isActive ? "> " : " "} - - - {item.label} - - - {item.description ? ( - - {item.description} - - ) : null} - - )} - /> - ) : null} + { + if (fileMentionKey) { + setDismissedFileMentionKey(fileMentionKey); + } + }} + onSelect={insertFileMentionSelection} + /> {!showFooterText && ( diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx new file mode 100644 index 0000000..ce9a8ee --- /dev/null +++ b/src/ui/components/FileMentionMenu/index.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import { Box, Text } from "ink"; +import { useInput } from "ink"; +import DropdownMenu from "../../DropdownMenu"; +import type { FileMentionItem, FileMentionToken } from "../../fileMentions"; + +type Props = { + open: boolean; + width: number; + token: FileMentionToken | null; + items: FileMentionItem[]; + onClose: () => void; + onSelect: (item: FileMentionItem) => void; +}; + +const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, onSelect }) => { + const [activeIndex, setActiveIndex] = useState(0); + + // Reset index when opened + useEffect(() => { + if (open) { + setActiveIndex(0); + } + }, [open]); + + // Validate activeIndex bounds + useEffect(() => { + if (!open) { + return; + } + if (items.length === 0) { + setActiveIndex(0); + return; + } + if (activeIndex >= items.length) { + setActiveIndex(Math.max(0, items.length - 1)); + } + }, [activeIndex, items.length, open]); + + useInput( + (input, key) => { + if (!open) { + return; + } + + if (key.escape) { + onClose(); + return; + } + + if (key.upArrow) { + if (items.length > 0) { + setActiveIndex((idx) => (idx - 1 + items.length) % items.length); + } + return; + } + + if (key.downArrow) { + if (items.length > 0) { + setActiveIndex((idx) => (idx + 1) % items.length); + } + return; + } + + if (key.tab || (key.return && !key.shift && !key.meta)) { + const selected = items[activeIndex]; + if (selected) { + onSelect(selected); + return; + } + if (key.tab) { + onClose(); + } + return; + } + }, + { isActive: open } + ); + + if (!open) { + return null; + } + + return ( + ({ + key: item.path, + label: item.path, + description: item.type === "directory" ? "directory" : "file", + }))} + activeIndex={activeIndex} + activeColor="#229ac3" + maxVisible={8} + renderItem={(item, isActive) => ( + + {isActive ? "> " : " "} + + + {item.label} + + + {item.description ? ( + + {item.description} + + ) : null} + + )} + /> + ); +}; + +export default FileMentionMenu; diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index 1d929f3..635f733 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -3,3 +3,4 @@ export { MessageView } from "./MessageView"; export { RawModeExitPrompt } from "./RawModeExitPrompt"; export { default as SkillsDropdown } from "./SkillsDropdown"; export { default as ModelsDropdown } from "./ModelsDropdown"; +export { default as FileMentionMenu } from "./FileMentionMenu"; From f611c948fd117c105a7610ca0b8700e181c95140 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 20 May 2026 14:15:30 +0800 Subject: [PATCH 178/217] fix: update git command options to handle line endings consistently --- src/common/file-history.ts | 2 +- src/tests/session.test.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/common/file-history.ts b/src/common/file-history.ts index 5194e6e..d5966d9 100644 --- a/src/common/file-history.ts +++ b/src/common/file-history.ts @@ -160,7 +160,7 @@ export class GitFileHistory { args: string[], options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv } ): string { - const gitArgs = [`--git-dir=${this.gitDir}`]; + const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`]; if (options.includeWorkTree) { gitArgs.push(`--work-tree=${this.projectRoot}`); } diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 3658e1c..d5191fa 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -2018,12 +2018,16 @@ function runFileHistoryGit( input = "", env: NodeJS.ProcessEnv = process.env ): string { - return execFileSync("git", [`--git-dir=${gitDir}`, `--work-tree=${workspace}`, ...args], { - encoding: "utf8", - input, - env, - stdio: ["pipe", "pipe", "pipe"], - }); + return execFileSync( + "git", + ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${gitDir}`, `--work-tree=${workspace}`, ...args], + { + encoding: "utf8", + input, + env, + stdio: ["pipe", "pipe", "pipe"], + } + ); } function fileHistoryCommitEnv(): NodeJS.ProcessEnv { From db6f0c6991095b194f224988e1a72d769f860483 Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 14:24:59 +0800 Subject: [PATCH 179/217] =?UTF-8?q?feat(SessionList):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E8=BF=87=E6=BB=A4=E5=8A=9F=E8=83=BD=E5=8F=8A?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增filterSessions函数支持根据关键词过滤会话,包括摘要、状态、失败原因和助手回复 - 支持搜索查询框,动态过滤并显示匹配的会话列表 - 实现格式化会话状态显示,如"completed"显示为"done" - 搜索过程中支持编辑和清除查询内容,按Esc键清除搜索或退出 - 会话列表根据搜索结果自动调整高亮和滚动 - 增强交互提示,根据搜索状态显示不同的快捷键说明 - 针对新增功能添加了大量单元测试覆盖各种匹配和边界情况 - 导出filterSessions和formatSessionStatus以供外部使用 --- src/tests/sessionList.test.ts | 108 +++++++++++++++- src/ui/SessionList.tsx | 227 ++++++++++++++++++++++++++-------- src/ui/index.ts | 2 +- 3 files changed, 280 insertions(+), 57 deletions(-) diff --git a/src/tests/sessionList.test.ts b/src/tests/sessionList.test.ts index e3bf51c..3dfda33 100644 --- a/src/tests/sessionList.test.ts +++ b/src/tests/sessionList.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { formatSessionTitle } from "../ui"; +import { formatSessionTitle, filterSessions, formatSessionStatus } from "../ui"; +import type { SessionEntry } from "../session"; test("formatSessionTitle replaces newlines with spaces", () => { assert.equal(formatSessionTitle("first line\nsecond line\r\nthird"), "first line second line third"); @@ -9,3 +10,108 @@ test("formatSessionTitle replaces newlines with spaces", () => { test("formatSessionTitle truncates after normalizing whitespace", () => { assert.equal(formatSessionTitle("one\n two three", 10), "one two th…"); }); + +test("formatSessionStatus maps status values to display labels", () => { + assert.equal(formatSessionStatus("completed"), "done"); + assert.equal(formatSessionStatus("processing"), "running"); + assert.equal(formatSessionStatus("pending"), "pending"); + assert.equal(formatSessionStatus("waiting_for_user"), "waiting"); + assert.equal(formatSessionStatus("failed"), "failed"); + assert.equal(formatSessionStatus("interrupted"), "stopped"); + assert.equal(formatSessionStatus("unknown_status" as any), "unknown_status"); +}); + +test("filterSessions returns all sessions when query is empty", () => { + const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]); + assert.equal(filterSessions(sessions, "").length, 2); + assert.equal(filterSessions(sessions, " ").length, 2); +}); + +test("filterSessions matches by summary (case-insensitive)", () => { + const sessions = buildSessions([ + { summary: "Fix login bug" }, + { summary: "Add dark mode" }, + { summary: "Refactor auth module" }, + ]); + + assert.equal(filterSessions(sessions, "login").length, 1); + assert.equal(filterSessions(sessions, "LOGIN").length, 1); + assert.equal(filterSessions(sessions, "Login").length, 1); +}); + +test("filterSessions matches by status (case-insensitive)", () => { + const sessions = buildSessions([ + { summary: "Task 1", status: "completed" }, + { summary: "Task 2", status: "failed" }, + { summary: "Task 3", status: "completed" }, + ]); + + assert.equal(filterSessions(sessions, "failed").length, 1); + assert.equal(filterSessions(sessions, "completed").length, 2); +}); + +test("filterSessions matches by failReason", () => { + const sessions = buildSessions([ + { summary: "Task 1", status: "failed", failReason: "API key not found" }, + { summary: "Task 2", status: "completed" }, + ]); + + assert.equal(filterSessions(sessions, "API key").length, 1); + assert.equal(filterSessions(sessions, "not found").length, 1); +}); + +test("filterSessions matches by assistantReply", () => { + const sessions = buildSessions([ + { summary: "Task 1", assistantReply: "The bug was fixed by updating the config." }, + { summary: "Task 2", assistantReply: "Dark mode has been added successfully." }, + ]); + + assert.equal(filterSessions(sessions, "dark mode").length, 1); + assert.equal(filterSessions(sessions, "config").length, 1); +}); + +test("filterSessions returns empty array when no match", () => { + const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]); + + assert.equal(filterSessions(sessions, "nonexistent").length, 0); +}); + +test("filterSessions matches across multiple fields on same session", () => { + const sessions = buildSessions([ + { summary: "Fix login bug", status: "failed", failReason: "Timeout error" }, + { summary: "Add dark mode", status: "completed" }, + ]); + + // Should match the first session via status + assert.equal(filterSessions(sessions, "failed").length, 1); + // Should match the first session via failReason + assert.equal(filterSessions(sessions, "timeout").length, 1); + // Partial summary match + assert.equal(filterSessions(sessions, "login").length, 1); +}); + +test("filterSessions handles sessions with null fields", () => { + const sessions = buildSessions([{ summary: null }, { summary: "Valid summary" }]); + + assert.equal(filterSessions(sessions, "valid").length, 1); + assert.equal(filterSessions(sessions, "summary").length, 1); +}); + +function buildSessions(overrides: Array>): SessionEntry[] { + return overrides.map((override, i) => ({ + id: `session-${i}`, + summary: override.summary ?? null, + assistantReply: override.assistantReply ?? null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: override.status ?? "completed", + failReason: override.failReason ?? null, + usage: null, + usagePerModel: null, + activeTokens: 0, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + processes: null, + })); +} diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index fdbd1fe..ab3bf75 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,6 +1,6 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; -import type { SessionEntry } from "../session"; +import type { SessionEntry, SessionStatus } from "../session"; type Props = { sessions: SessionEntry[]; @@ -8,25 +8,57 @@ type Props = { onCancel: () => void; }; +/** + * Filter sessions by a search query. + * Matches against summary, status, and failReason fields (case-insensitive). + * Returns all sessions when query is empty. + */ +export function filterSessions(sessions: SessionEntry[], query: string): SessionEntry[] { + if (!query.trim()) { + return sessions; + } + + const lowerQuery = query.toLowerCase().trim(); + return sessions.filter((session) => { + if (session.summary && session.summary.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.status.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.failReason && session.failReason.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.assistantReply && session.assistantReply.toLowerCase().includes(lowerQuery)) { + return true; + } + return false; + }); +} + export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { const [index, setIndex] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); const { columns, rows } = useWindowSize(); + // Filter sessions by search query + const filteredSessions = useMemo(() => filterSessions(sessions, searchQuery), [sessions, searchQuery]); + + // Reset index when filtered list changes (e.g., query changes) + const safeIndex = useMemo(() => { + if (filteredSessions.length === 0) return 0; + return Math.max(0, Math.min(index, filteredSessions.length - 1)); + }, [index, filteredSessions.length]); + // Dynamically calculate the number of visible sessions based on terminal height const maxVisibleSessions = useMemo(() => { - // Subtract space used by borders, header, footer, scroll indicator, etc. - // Outer container height=rows-1, outer border 2 + header 1 + inner border 2 + footer 1 + scroll indicator 1 = 8 - const reservedLines = 8; + // Subtract space used by borders, header (2 lines with search bar), footer, scroll indicator, etc. + // Outer container height=rows-1, outer border 2 + header 2 + search bar 1 + inner border 2 + footer 1 + scroll indicator 1 = 9 + const reservedLines = searchQuery ? 12 : 9; const linesPerSession = 3; // height=2 + marginBottom=1 const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); return Math.max(1, Math.floor(availableLines / linesPerSession)); - }, [rows]); - - // Ensure index stays within valid range - const safeIndex = useMemo(() => { - if (sessions.length === 0) return 0; - return Math.max(0, Math.min(index, sessions.length - 1)); - }, [index, sessions.length]); + }, [rows, searchQuery]); // Calculate scroll offset to keep the selected item visible const scrollOffset = useMemo(() => { @@ -36,23 +68,63 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac // Get the currently visible session list const visibleSessions = useMemo(() => { - return sessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); - }, [sessions, scrollOffset, maxVisibleSessions]); + return filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); + }, [filteredSessions, scrollOffset, maxVisibleSessions]); + + // Handle backspace for search query + const handleBackspace = useCallback(() => { + setSearchQuery((prev) => prev.slice(0, -1)); + setIndex(0); + }, []); useInput((input, key) => { - if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + // ESC: clear search first, then cancel + if (key.escape) { + if (searchQuery) { + setSearchQuery(""); + setIndex(0); + return; + } onCancel(); return; } - if (sessions.length === 0) { + + // Ctrl+C also cancels + if (key.ctrl && (input === "c" || input === "C")) { + onCancel(); return; } + + // Backspace / Delete: remove last search character + if (key.backspace || key.delete) { + if (searchQuery) { + handleBackspace(); + return; + } + // If no search query, navigation keys below handle the rest + } + + // Printable character: append to search query + if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab) { + // Ignore if it's a named key that happens to have input (safety check) + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { + return; + } + setSearchQuery((prev) => prev + input); + setIndex(0); + return; + } + + if (filteredSessions.length === 0) { + return; + } + if (key.upArrow) { setIndex((i) => Math.max(0, i - 1)); return; } if (key.downArrow) { - setIndex((i) => Math.min(sessions.length - 1, i + 1)); + setIndex((i) => Math.min(filteredSessions.length - 1, i + 1)); return; } if (key.pageUp) { @@ -60,7 +132,7 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return; } if (key.pageDown) { - setIndex((i) => Math.min(sessions.length - 1, i + maxVisibleSessions)); + setIndex((i) => Math.min(filteredSessions.length - 1, i + maxVisibleSessions)); return; } if (key.home) { @@ -68,17 +140,19 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return; } if (key.end) { - setIndex(sessions.length - 1); + setIndex(filteredSessions.length - 1); return; } if (key.return) { - const session = sessions[safeIndex]; + const session = filteredSessions[safeIndex]; if (session) { onSelect(session.id); } } }); + const hasActiveSearch = searchQuery.trim().length > 0; + if (sessions.length === 0) { return ( @@ -99,15 +173,24 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac > {/* Header row */} - - - Resume a session - - - {" "} - ({sessions.length} total) - + + + + Resume a session + + + {" "} + ({sessions.length} total + {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""}) + + + {/* Search bar */} + + {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"} + {searchQuery ? | : null} + + {/* Session list */} - {visibleSessions.map((session, i) => { - const actualIndex = scrollOffset + i; - return ( - - - {actualIndex === safeIndex ? "> " : " "} - - - - - {formatSessionTitle(session.summary || "Untitled")} - - ({session.status}) + {filteredSessions.length === 0 ? ( + + No sessions match "{searchQuery}". + + ) : ( + visibleSessions.map((session, i) => { + const actualIndex = scrollOffset + i; + return ( + + + {actualIndex === safeIndex ? "> " : " "} - - {formatTimestamp(session.updateTime)} + + + + {formatSessionTitle(session.summary || "Untitled")} + + ({formatSessionStatus(session.status)}) + + + {formatTimestamp(session.updateTime)} + - - ); - })} - {scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length ? ( + ); + }) + )} + {scrollOffset > 0 || scrollOffset + maxVisibleSessions < filteredSessions.length ? ( - {scrollOffset > 0 ? … {scrollOffset} newer sessions above. : null} - {scrollOffset + maxVisibleSessions < sessions.length ? ( - … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below. + {scrollOffset > 0 ? … {scrollOffset} sessions above. : null} + {scrollOffset + maxVisibleSessions < filteredSessions.length ? ( + … {filteredSessions.length - scrollOffset - maxVisibleSessions} sessions below. ) : null} ) : null} {/* Footer */} - - ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel + + {hasActiveSearch ? ( + + Esc clear search · + ↑/↓ navigate · Enter select · Esc again to cancel + + ) : ( + + Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel + + )} @@ -179,6 +277,25 @@ export function formatSessionTitle(value: string, max = 70): string { return truncate(value.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim(), max); } +export function formatSessionStatus(status: SessionStatus): string { + switch (status) { + case "completed": + return "done"; + case "processing": + return "running"; + case "pending": + return "pending"; + case "waiting_for_user": + return "waiting"; + case "failed": + return "failed"; + case "interrupted": + return "stopped"; + default: + return status; + } +} + function truncate(value: string, max: number): string { if (value.length <= max) { return value; diff --git a/src/ui/index.ts b/src/ui/index.ts index efb4edd..f639c65 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -37,7 +37,7 @@ export { } from "./PromptInput"; export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; -export { SessionList, formatSessionTitle } from "./SessionList"; +export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./SessionList"; export { ThemedGradient } from "./ThemedGradient"; export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./WelcomeScreen"; From 208a9886196c404dd00c052a4bcc45e4a1ed4a0c Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 14:38:00 +0800 Subject: [PATCH 180/217] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=9F=A5=E8=AF=A2=E4=B8=AD=E5=9B=9E=E8=BD=A6=E9=94=AE?= =?UTF-8?q?=E7=9A=84=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在处理搜索输入时忽略回车键,避免错误触发搜索操作 - 增加对回车键的判断,提升输入处理的准确性 - 防止命名键在输入查询时误触发逻辑分支 --- src/ui/SessionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index ab3bf75..5f186bd 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -105,7 +105,7 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac } // Printable character: append to search query - if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab) { + if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab && !key.return) { // Ignore if it's a named key that happens to have input (safety check) if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { return; From 585fe5ff442b0dcbfeb6a68a959c68badb386fcd Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 14:48:05 +0800 Subject: [PATCH 181/217] merge(branch): 'main' into refactor/extract-dropdown-components --- src/ui/PromptInput.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 24aa076..7008cba 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -238,7 +238,6 @@ export const PromptInput = React.memo(function PromptInput({ setSelectedSkills([]); setShowSkillsDropdown(false); setOpenRawModelDropdown(false); - setModelDropdownStep(null); setHistoryCursor(-1); setDraftBeforeHistory(null); clearPromptUndoRedoState(undoRedoRef.current); From 3c15003454ce743a2577fbaa278579c88e675fb6 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 20 May 2026 14:53:10 +0800 Subject: [PATCH 182/217] refactor: extracted terminal data dispatch into `dispatchTerminalInput()` --- src/tests/promptInputKeys.test.ts | 51 +++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 2 +- src/ui/index.ts | 1 + src/ui/prompt/index.ts | 2 +- src/ui/prompt/useTerminalInput.ts | 58 +++++++++++++++++-------------- 5 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 54213a1..4f8b4d9 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -20,11 +20,23 @@ import { renderBufferWithCursor, buildInitPromptSubmission, buildPromptDraftFromSessionMessage, + dispatchTerminalInput, disableTerminalExtendedKeys, enableTerminalExtendedKeys, + EMPTY_BUFFER, + insertText, + backspace, } from "../ui"; import type { SessionMessage, SkillInfo } from "../session"; +function collectDispatchedInput(data: string) { + const events: ReturnType[] = []; + dispatchTerminalInput(data, (input, key) => { + events.push({ input, key }); + }); + return events; +} + test("parseTerminalInput treats DEL bytes as backspace", () => { const { input, key } = parseTerminalInput("\u007F"); assert.equal(input, ""); @@ -72,6 +84,45 @@ test("parseTerminalInput keeps DEL payload for meta+backspace", () => { assert.equal(key.backspace, false); }); +test("dispatchTerminalInput splits iOS CJK composition packets", () => { + const events = collectDispatchedInput("가\u007F나"); + assert.equal(events.length, 3); + assert.equal(events[0]?.input, "가"); + assert.equal(events[1]?.input, ""); + assert.equal(events[1]?.key.backspace, true); + assert.equal(events[2]?.input, "나"); +}); + +test("dispatchTerminalInput applies multi-step CJK composition to the prompt buffer", () => { + let state = EMPTY_BUFFER; + dispatchTerminalInput("ㄱ\u007F가\u007F각", (input, key) => { + if (key.backspace) { + state = backspace(state); + return; + } + state = insertText(state, input); + }); + + assert.equal(state.text, "각"); + assert.equal(state.cursor, 1); +}); + +test("dispatchTerminalInput preserves meta+backspace as one event", () => { + const events = collectDispatchedInput("\u001B\u007F"); + assert.equal(events.length, 1); + assert.equal(events[0]?.input, "\u007F"); + assert.equal(events[0]?.key.meta, true); + assert.equal(events[0]?.key.backspace, false); + assert.equal(events[0]?.key.escape, false); +}); + +test("dispatchTerminalInput emits consecutive backspaces from one packet", () => { + const events = collectDispatchedInput("\u007F\u007F"); + assert.equal(events.length, 2); + assert.equal(events[0]?.key.backspace, true); + assert.equal(events[1]?.key.backspace, true); +}); + test("parseTerminalInput keeps BS payload for meta+backspace", () => { const { input, key } = parseTerminalInput("\u001B\b"); assert.equal(input, "\b"); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 6767560..3cb51f2 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -42,7 +42,7 @@ import { readClipboardImageAsync } from "./clipboard"; import type { SessionEntry, SkillInfo } from "../session"; // Re-exported from prompt modules for backward compatibility -export { useTerminalInput, parseTerminalInput } from "./prompt"; +export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./prompt"; export type { InputKey } from "./prompt"; import { useTerminalInput } from "./prompt"; diff --git a/src/ui/index.ts b/src/ui/index.ts index 7c0ed15..681d77c 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -30,6 +30,7 @@ export { MODEL_COMMAND_THINKING_OPTIONS, useTerminalInput, parseTerminalInput, + dispatchTerminalInput, type PromptSubmission, type PromptDraft, type InputKey, diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index a33172c..5907558 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -1,4 +1,4 @@ -export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; +export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./useTerminalInput"; export type { InputKey } from "./useTerminalInput"; export { diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index 8fe0d60..9ce6976 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -169,6 +169,37 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: return { input, key }; } +export function dispatchTerminalInput( + data: Buffer | string, + inputHandler: (input: string, key: InputKey) => void +): void { + const raw = String(data); + + // Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.). + // iOS keyboards can send composed characters as a single packet like: + // "가\x7f나" (character + backspace + replacement character) + // Do not split escape-prefixed sequences such as Alt+Backspace. + if (!raw.startsWith("\u001B") && raw.includes("\x7f") && raw.length > 1) { + const parts = raw.split("\x7f"); + if (parts[0]) { + const { input, key } = parseTerminalInput(parts[0]); + inputHandler(input, key); + } + for (let i = 1; i < parts.length; i++) { + const bs = parseTerminalInput("\x7f"); + inputHandler(bs.input, bs.key); + if (parts[i]) { + const { input, key } = parseTerminalInput(parts[i]); + inputHandler(input, key); + } + } + return; + } + + const { input, key } = parseTerminalInput(data); + inputHandler(input, key); +} + export function useTerminalInput( inputHandler: (input: string, key: InputKey) => void, options: { isActive?: boolean } = {} @@ -193,32 +224,7 @@ export function useTerminalInput( return; } const handleData = (data: Buffer | string) => { - const raw = String(data); - - // Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.). - // iOS keyboards send composed characters as a single packet like: - // "가\x7f나" (character + backspace + new character) - // Without splitting, parseTerminalInput treats the whole packet as - // one input and drops the composition backspaces, corrupting the text. - if (raw.includes("\x7f") && raw.length > 1) { - const parts = raw.split("\x7f"); - if (parts[0]) { - const { input, key } = parseTerminalInput(parts[0]); - handlerRef.current(input, key); - } - for (let i = 1; i < parts.length; i++) { - const bs = parseTerminalInput("\x7f"); - handlerRef.current(bs.input, bs.key); - if (parts[i]) { - const { input, key } = parseTerminalInput(parts[i]); - handlerRef.current(input, key); - } - } - return; - } - - const { input, key } = parseTerminalInput(data); - handlerRef.current(input, key); + dispatchTerminalInput(data, handlerRef.current); }; stdin?.on("data", handleData); From abde38cc76be4cd1836d04713d2f51cca294d11b Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 20 May 2026 15:40:35 +0800 Subject: [PATCH 183/217] feat: refresh cached MCP tool definitions after server crash --- src/mcp/mcp-manager.ts | 1 + src/tests/session.test.ts | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts index 217e3fc..fe8066b 100644 --- a/src/mcp/mcp-manager.ts +++ b/src/mcp/mcp-manager.ts @@ -236,6 +236,7 @@ export class McpManager { this.tools = this.tools.filter((t) => t.serverName !== name); this.prompts = this.prompts.filter((p) => p.serverName !== name); this.resources = this.resources.filter((r) => r.serverName !== name); + this.onToolsListChanged?.(); this.setStatus({ name, status: "failed", diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 27b504e..9f3c7fb 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -482,6 +482,60 @@ rl.on("line", (line) => { assert.deepEqual(manager.getMcpStatus(), []); }); +test("SessionManager refreshes cached MCP tool definitions after server crash", async () => { + const workspace = createTempDir("deepcode-mcp-crash-cache-workspace-"); + const serverPath = path.join(workspace, "mcp-server-crash.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "echo", inputSchema: { type: "object", properties: {} } } + ] } }); + return; + } + if (request.method === "prompts/list") { + send({ jsonrpc: "2.0", id: request.id, result: { prompts: [] } }); + return; + } + if (request.method === "resources/list") { + send({ jsonrpc: "2.0", id: request.id, result: { resources: [] } }); + setTimeout(() => process.exit(9), 10); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-crash-cache"); + await manager.initMcpServers({ crashy: { command: process.execPath, args: [serverPath] } }); + + assert.equal(manager.getMcpStatus()[0]?.status, "ready"); + assert.equal((manager as any).mcpToolDefinitions.length, 1); + + await waitForMcpStatus(manager, "failed"); + + assert.equal((manager as any).mcpToolDefinitions.length, 0); + + manager.dispose(); +}); + test("SessionManager reports configured MCP servers as starting before initialization", () => { const workspace = createTempDir("deepcode-mcp-configured-workspace-"); const manager = new SessionManager({ @@ -2276,6 +2330,16 @@ async function waitForNotifyRecords( assert.fail(`expected ${expectedCount} notify records in ${outputPath}`); } +async function waitForMcpStatus(manager: SessionManager, expectedStatus: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (manager.getMcpStatus()[0]?.status === expectedStatus) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`expected MCP status ${expectedStatus}`); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } From b757ea191993207f2fe7761bf58730e111eec01a Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 16:51:07 +0800 Subject: [PATCH 184/217] =?UTF-8?q?refactor(ui):=20=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=20isSkillSelected=20=E5=87=BD?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 isSkillSelected 函数从 PromptInput.tsx 移动到 SlashCommandMenu.tsx - 在 PromptInput.tsx 中从 SlashCommandMenu 导入 isSkillSelected - 简化代码结构,避免函数重复定义 - 统一技能选择判断逻辑以提高代码复用性 --- src/ui/PromptInput.tsx | 6 +----- src/ui/SlashCommandMenu.tsx | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 5e2c2b5..074cab6 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -48,7 +48,7 @@ export type { InputKey } from "./prompt"; import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; -import SlashCommandMenu from "./SlashCommandMenu"; +import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; import type { ModelConfigSelection } from "../settings"; import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components"; @@ -818,10 +818,6 @@ export function formatSelectedSkillsStatus(skills: SkillInfo[]): string { return `⚡ ${names.join(", ")}`; } -export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean { - return skills.some((item) => item.name === skill.name); -} - export function addUniqueSkill(skills: SkillInfo[], skill: SkillInfo): SkillInfo[] { if (isSkillSelected(skills, skill)) { return skills; diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index 02ff308..df599b5 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -3,6 +3,7 @@ import type { SlashCommandItem } from "./slashCommands"; import { ARGS_SEPARATOR } from "./constants"; import React from "react"; import { Box, Text } from "ink"; +import type { SkillInfo } from "../session"; type SlashCommandMenuProps = { items: SlashCommandItem[]; @@ -10,7 +11,9 @@ type SlashCommandMenuProps = { width: number; maxVisible?: number; }; - +export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean { + return skills.some((item) => item.name === skill.name); +} const SlashCommandMenu = React.memo(function SlashCommandMenu({ items, activeIndex, From e0bde604fb220b63b8b43b17a72cf3b5ea0934fd Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 17:00:45 +0800 Subject: [PATCH 185/217] =?UTF-8?q?refactor(ui):=20=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=20isSkillSelected=20=E5=87=BD?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 isSkillSelected 函数从 PromptInput.tsx 移动到 SlashCommandMenu.tsx - 在 PromptInput.tsx 中从 SlashCommandMenu 导入 isSkillSelected - 简化代码结构,避免函数重复定义 - 统一技能选择判断逻辑以提高代码复用性 --- src/ui/components/SkillsDropdown/index.tsx | 2 +- src/ui/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx index 545e2ab..b320d24 100644 --- a/src/ui/components/SkillsDropdown/index.tsx +++ b/src/ui/components/SkillsDropdown/index.tsx @@ -1,8 +1,8 @@ import DropdownMenu from "../../DropdownMenu"; import React, { useEffect, useState } from "react"; -import { isSkillSelected } from "../../PromptInput"; import type { SkillInfo } from "../../../session"; import { useInput } from "ink"; +import { isSkillSelected } from "../../SlashCommandMenu"; const SkillsDropdown: React.FC<{ open: boolean; diff --git a/src/ui/index.ts b/src/ui/index.ts index d634ed6..26e7eaa 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -23,7 +23,6 @@ export { IMAGE_ATTACHMENT_CLEAR_HINT, formatImageAttachmentStatus, formatSelectedSkillsStatus, - isSkillSelected, addUniqueSkill, toggleSkillSelection, removeCurrentSlashToken, From b2544b831252c5d58f15cbfa6b5c7c04e1a1aa8f Mon Sep 17 00:00:00 2001 From: lellansin Date: Wed, 20 May 2026 17:36:41 +0800 Subject: [PATCH 186/217] perf: reuse OpenAI client and add undici keep-alive Agent with connection warmup Extract OpenAI client creation logic into src/common/openai-client.ts: - Custom undici Agent with 60s keepAlive timeout (default is 4s) - Module-level client instance cache (reuse across calls) - Fire-and-forget connection warmup on first creation (3s timeout) - getMachineId() helper The App.tsx now simply imports and re-exports createOpenAIClient from the new common module, keeping UI concerns separate from HTTP/client lifecycle management. --- package-lock.json | 10 +++ package.json | 1 + src/common/openai-client.ts | 117 ++++++++++++++++++++++++++++++++++++ src/ui/App.tsx | 73 +++------------------- src/ui/index.ts | 2 +- 5 files changed, 138 insertions(+), 65 deletions(-) create mode 100644 src/common/openai-client.ts diff --git a/package-lock.json b/package-lock.json index 17a77ca..cdb85de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "ink-gradient": "^4.0.0", "openai": "^6.35.0", "react": "^19.2.5", + "undici": "^7.25.0", "zod": "^4.4.3" }, "bin": { @@ -4096,6 +4097,15 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", diff --git a/package.json b/package.json index b72fd96..bf8d167 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "ink-gradient": "^4.0.0", "openai": "^6.35.0", "react": "^19.2.5", + "undici": "^7.25.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/common/openai-client.ts b/src/common/openai-client.ts new file mode 100644 index 0000000..7f9634c --- /dev/null +++ b/src/common/openai-client.ts @@ -0,0 +1,117 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import OpenAI from "openai"; +import { Agent, fetch as undiciFetch } from "undici"; +import { resolveCurrentSettings } from "../ui/App"; + +// Custom undici Agent with a 60-second keepAlive timeout. The default +// global fetch (undici) only keeps connections alive for 4 seconds, which +// is too short for a CLI where the user may spend 10–30 seconds reading +// output between prompts. By passing a dedicated Agent to undiciFetch we +// keep connections reusable for a full minute after the last request. +const keepAliveAgent = new Agent({ keepAliveTimeout: 60_000 }); + +// Module-level cache for the OpenAI client instance. The client itself is +// a stateless fetch wrapper, so it is safe to share across calls as long as +// the apiKey + baseURL stay the same. Model, thinking-mode and other +// settings are always read fresh from the project / user config files. +let cachedOpenAI: OpenAI | null = null; +let cachedOpenAIKey = ""; + +export function createOpenAIClient(projectRoot: string = process.cwd()): { + client: OpenAI | null; + model: string; + baseURL: string; + thinkingEnabled: boolean; + reasoningEffort: "high" | "max"; + debugLogEnabled: boolean; + notify?: string; + webSearchTool?: string; + env: Record; + machineId?: string; +} { + const settings = resolveCurrentSettings(projectRoot); + if (!settings.apiKey) { + return { + client: null, + model: settings.model, + baseURL: settings.baseURL, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; + } + + const cacheKey = `${settings.apiKey}::${settings.baseURL}`; + if (cachedOpenAI && cachedOpenAIKey === cacheKey) { + return { + client: cachedOpenAI, + model: settings.model, + baseURL: settings.baseURL, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; + } + + cachedOpenAI = new OpenAI({ + apiKey: settings.apiKey, + baseURL: settings.baseURL || undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: keepAliveAgent }), + }); + cachedOpenAIKey = cacheKey; + + // Fire-and-forget warmup: pre-establish TCP+TLS connection to the API + // server while the user is composing their first prompt. Bounded by a + // short timeout so a slow / unreachable API never blocks process exit. + void (async () => { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + try { + await cachedOpenAI.models.list({ signal: ac.signal }).catch(() => {}); + } finally { + clearTimeout(timer); + } + })(); + + return { + client: cachedOpenAI, + model: settings.model, + baseURL: settings.baseURL, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; +} + +function getMachineId(): string | undefined { + try { + const idPath = path.join(os.homedir(), ".deepcode", "machine-id"); + if (fs.existsSync(idPath)) { + const raw = fs.readFileSync(idPath, "utf8").trim(); + if (raw) { + return raw; + } + } + const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`; + fs.mkdirSync(path.dirname(idPath), { recursive: true }); + fs.writeFileSync(idPath, generated, "utf8"); + return generated; + } catch { + return undefined; + } +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 75d6689..5419a2a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -4,7 +4,7 @@ import chalk from "chalk"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import OpenAI from "openai"; +import { createOpenAIClient } from "../common/openai-client"; import { type LlmStreamProgress, type MessageMeta, @@ -166,6 +166,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. void refreshSkills(); }, [refreshSessionsList, refreshSkills]); + // Eagerly create the OpenAI client on mount so the TCP+TLS connection + // warmup (fire-and-forget inside createOpenAIClient) starts before the + // user sends their first prompt. + useEffect(() => { + createOpenAIClient(projectRoot); + }, [projectRoot]); + useLayoutEffect(() => { const settings = resolveCurrentSettings(projectRoot); void sessionManager.initMcpServers(settings.mcpServers); @@ -838,69 +845,7 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res ); } -export function createOpenAIClient(projectRoot: string = process.cwd()): { - client: OpenAI | null; - model: string; - baseURL: string; - thinkingEnabled: boolean; - reasoningEffort: "high" | "max"; - debugLogEnabled: boolean; - notify?: string; - webSearchTool?: string; - env: Record; - machineId?: string; -} { - const settings = resolveCurrentSettings(projectRoot); - if (!settings.apiKey) { - return { - client: null, - model: settings.model, - baseURL: settings.baseURL, - thinkingEnabled: settings.thinkingEnabled, - reasoningEffort: settings.reasoningEffort, - debugLogEnabled: settings.debugLogEnabled, - notify: settings.notify, - webSearchTool: settings.webSearchTool, - env: settings.env, - machineId: getMachineId(), - }; - } - - const client = new OpenAI({ - apiKey: settings.apiKey, - baseURL: settings.baseURL || undefined, - }); - return { - client, - model: settings.model, - baseURL: settings.baseURL, - thinkingEnabled: settings.thinkingEnabled, - reasoningEffort: settings.reasoningEffort, - debugLogEnabled: settings.debugLogEnabled, - notify: settings.notify, - webSearchTool: settings.webSearchTool, - env: settings.env, - machineId: getMachineId(), - }; -} - -function getMachineId(): string | undefined { - try { - const idPath = path.join(os.homedir(), ".deepcode", "machine-id"); - if (fs.existsSync(idPath)) { - const raw = fs.readFileSync(idPath, "utf8").trim(); - if (raw) { - return raw; - } - } - const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`; - fs.mkdirSync(path.dirname(idPath), { recursive: true }); - fs.writeFileSync(idPath, generated, "utf8"); - return generated; - } catch { - return undefined; - } -} +export { createOpenAIClient } from "../common/openai-client"; function getUserSettingsPath(): string { return path.join(os.homedir(), ".deepcode", "settings.json"); diff --git a/src/ui/index.ts b/src/ui/index.ts index 26e7eaa..d899d4b 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -11,9 +11,9 @@ export { writeProjectSettings, writeModelConfigSelection, resolveCurrentSettings, - createOpenAIClient, buildPromptDraftFromSessionMessage, } from "./App"; +export { createOpenAIClient } from "../common/openai-client"; export { default as AppContainer } from "./AppContainer"; export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; export { MessageView } from "./components"; From 7578c324639fccdccee32870b193111aaeff3183 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 20 May 2026 18:12:54 +0800 Subject: [PATCH 187/217] feat: add built-in tool alias mapping --- src/tests/tool-executor.test.ts | 41 +++++++++++++++++++++++++++++++++ src/tools/executor.ts | 10 +++++++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/tests/tool-executor.test.ts diff --git a/src/tests/tool-executor.test.ts b/src/tests/tool-executor.test.ts new file mode 100644 index 0000000..f7def2f --- /dev/null +++ b/src/tests/tool-executor.test.ts @@ -0,0 +1,41 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ToolExecutor } from "../tools/executor"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("ToolExecutor accepts title-case built-in tool aliases", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-tool-executor-")); + tempDirs.push(workspace); + const filePath = path.join(workspace, "sample.txt"); + fs.writeFileSync(filePath, "alpha\nbeta\n", "utf8"); + + const executor = new ToolExecutor(workspace); + const executions = await executor.executeToolCalls("alias-session", [ + { + id: "call-read", + type: "function", + function: { + name: "Read", + arguments: JSON.stringify({ file_path: filePath }) + } + } + ]); + + assert.equal(executions.length, 1); + assert.equal(executions[0]?.result.ok, true); + assert.equal(executions[0]?.result.name, "read"); + assert.match(executions[0]?.result.output ?? "", /alpha/); +}); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 73e31f5..edfca6f 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -89,6 +89,13 @@ export type ToolHandler = ( context: ToolExecutionContext ) => Promise; +const BUILT_IN_TOOL_NAME_ALIASES = new Map([ + ["Bash", "bash"], + ["Read", "read"], + ["Write", "write"], + ["Edit", "edit"] +]); + export type ToolCallExecution = { toolCallId: string; content: string; @@ -187,7 +194,8 @@ export class ToolExecutor { hooks?: ToolExecutionHooks ): Promise { const toolName = toolCall.function.name; - const handler = this.toolHandlers.get(toolName); + const handlerName = BUILT_IN_TOOL_NAME_ALIASES.get(toolName) ?? toolName; + const handler = this.toolHandlers.get(handlerName); if (!handler) { // Try MCP tools if (this.mcpManager?.isMcpTool(toolName)) { From e424e187cfb1ecaa07f2673138464e303fa7e699 Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 21:30:49 +0800 Subject: [PATCH 188/217] =?UTF-8?q?refactor(ui):=20=E4=BD=BF=E7=94=A8=20re?= =?UTF-8?q?setPromptInput=20=E7=AE=80=E5=8C=96=E6=92=A4=E9=94=80=E5=92=8C?= =?UTF-8?q?=E5=9B=9E=E7=BB=95=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换手动状态重置为调用 resetPromptInput 函数 - 简化代码提高可读性和维护性 - 保持撤销(undo)和回绕(rewind)命令处理逻辑一致 - 移除重复的状态重置代码块 --- src/ui/PromptInput.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 074cab6..d2af534 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -659,11 +659,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (item.kind === "undo") { onSubmit({ text: "/undo", imageUrls: [], command: "undo" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "mcp") { From a858684cf1d246d597552f0f16dc65bdedde422d Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 20 May 2026 21:36:50 +0800 Subject: [PATCH 189/217] =?UTF-8?q?fix(executor):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F=E4=B8=8E=E8=AF=AD=E6=B3=95?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在工具类型列表的最后一项添加缺失的逗号 - 修正测试用例中 JSON 对象的格式错误 - 确保工具调用参数语法规范合理 - 修复断言前的代码缩进问题 --- src/tests/tool-executor.test.ts | 6 +++--- src/tools/executor.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tests/tool-executor.test.ts b/src/tests/tool-executor.test.ts index f7def2f..f36def2 100644 --- a/src/tests/tool-executor.test.ts +++ b/src/tests/tool-executor.test.ts @@ -29,9 +29,9 @@ test("ToolExecutor accepts title-case built-in tool aliases", async () => { type: "function", function: { name: "Read", - arguments: JSON.stringify({ file_path: filePath }) - } - } + arguments: JSON.stringify({ file_path: filePath }), + }, + }, ]); assert.equal(executions.length, 1); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index edfca6f..220fc89 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -93,7 +93,7 @@ const BUILT_IN_TOOL_NAME_ALIASES = new Map([ ["Bash", "bash"], ["Read", "read"], ["Write", "write"], - ["Edit", "edit"] + ["Edit", "edit"], ]); export type ToolCallExecution = { From 3a8041faa173f09fac437318f15cfefb4d9c2f78 Mon Sep 17 00:00:00 2001 From: Kayro Date: Wed, 20 May 2026 21:30:27 +0800 Subject: [PATCH 190/217] feat(ui): add bracketed paste detection with large-paste marker collapsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect bracketed paste (ESC[200~ / ESC[201~) and dispatch as atomic paste event - Large pastes (>10 lines or >1000 chars) are stored and replaced with a compact marker [paste #N] - Ctrl+O toggles expand/collapse, backspace/delete atomically remove the entire marker - Markers are highlighted with chalk.yellow and expanded back on submit - Follows existing terminal hook patterns (useBracketedPaste alongside useTerminalExtendedKeys) - Array-based chunk buffering to avoid O(n²) string concatenation on multi-chunk pastes - Lazy text cleaning deferred to expand/submit time Known limitation: expand/collapse briefly clears Ink content above the prompt (React render pipeline constraint). Reference: PR #45 (closed), inspired by pi project's paste marker approach. --- src/ui/PromptInput.tsx | 234 ++++++++++++++++++++++++++++-- src/ui/prompt/cursor.ts | 21 +++ src/ui/prompt/index.ts | 1 + src/ui/prompt/useTerminalInput.ts | 110 ++++++++++++++ src/ui/promptBuffer.ts | 114 +++++++++++++++ 5 files changed, 465 insertions(+), 15 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 074cab6..ad73d83 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -4,11 +4,18 @@ import chalk from "chalk"; import { ARGS_SEPARATOR } from "./constants"; import { EMPTY_BUFFER, + PASTE_MARKER_REGEX, backspace, + cleanPasteContent, deleteForward, + deletePasteMarkerBackward, + deletePasteMarkerForward, deleteWordBefore, deleteWordAfter, + expandPasteMarkers, + findPasteMarkerContaining, getCurrentSlashToken, + hasActivePasteMarkers, insertText, isEmpty, killLine, @@ -47,7 +54,12 @@ export type { InputKey } from "./prompt"; import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; -import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; +import { + useHiddenTerminalCursor, + useTerminalExtendedKeys, + useBracketedPaste, + useTerminalFocusReporting, +} from "./prompt"; import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; import type { ModelConfigSelection } from "../settings"; import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components"; @@ -143,6 +155,12 @@ export const PromptInput = React.memo(function PromptInput({ const wasBusyRef = React.useRef(busy); const hadFileMentionTokenRef = React.useRef(false); const appliedDraftNonceRef = React.useRef(null); + const pastesRef = React.useRef>(new Map()); + const pasteCounterRef = React.useRef(0); + // Track expanded paste regions for toggle (Ctrl+O expand / collapse). + const expandedRegionsRef = React.useRef>( + new Map() + ); const fileMentionToken = getCurrentFileMentionToken(buffer); const hasFileMentionToken = fileMentionToken !== null; @@ -170,16 +188,25 @@ export const PromptInput = React.memo(function PromptInput({ const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); const hasRunningProcess = runningProcesses && runningProcesses.size > 0; - const processHint = hasRunningProcess ? " · ctrl+o view output" : ""; + const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text); + const hasExpandedRegions = expandedRegionsRef.current.size > 0; + const processOrPasteHint = hasRunningProcess + ? " · ctrl+o view output" + : hasCollapsedMarkers + ? " · ctrl+o expand" + : hasExpandedRegions + ? " · ctrl+o collapse" + : ""; const footerText = statusMessage ? statusMessage : busy ? loadingText && loadingText.trim() - ? `${loadingText}${processHint}` - : `esc to interrupt · ctrl+c to cancel input${processHint}` - : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processHint}`; + ? `${loadingText}${processOrPasteHint}` + : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}` + : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`; useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); + useBracketedPaste(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); const refreshFileMentionItems = React.useCallback(() => { @@ -241,6 +268,8 @@ export const PromptInput = React.memo(function PromptInput({ setHistoryCursor(-1); setDraftBeforeHistory(null); clearPromptUndoRedoState(undoRedoRef.current); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); }, [promptDraft]); useEffect(() => { @@ -278,7 +307,7 @@ export const PromptInput = React.memo(function PromptInput({ if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) { onToggleProcessStdout(); } else { - setStatusMessage("No running process to inspect"); + expandPasteMarkerAtCursor(); } return; } @@ -306,6 +335,8 @@ export const PromptInput = React.memo(function PromptInput({ } else if (!isEmpty(buffer)) { setBuffer(EMPTY_BUFFER); clearUndoRedoStacks(); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); } else { setStatusMessage("press ctrl+d to exit"); } @@ -324,6 +355,11 @@ export const PromptInput = React.memo(function PromptInput({ exitHistoryBrowsing(); } + if (key.paste) { + handlePaste(input); + return; + } + if (key.ctrl && (input === "v" || input === "V")) { setStatusMessage("Reading clipboard..."); readClipboardImageAsync() @@ -395,12 +431,12 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.delete) { - updateBuffer((s) => deleteForward(s)); + updateBuffer((s) => deletePasteMarkerForward(s) ?? deleteForward(s)); return; } if (key.backspace) { - updateBuffer((s) => backspace(s)); + updateBuffer((s) => deletePasteMarkerBackward(s) ?? backspace(s)); return; } @@ -490,6 +526,8 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.ctrl && (input === "u" || input === "U")) { updateBuffer(() => EMPTY_BUFFER); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); return; } if (key.ctrl && (input === "w" || input === "W")) { @@ -567,6 +605,81 @@ export const PromptInput = React.memo(function PromptInput({ }); } + function handlePaste(pastedText: string): void { + const totalChars = pastedText.length; + + if (totalChars <= 1000) { + const newlineCount = (pastedText.match(/\n/g) ?? []).length; + if (newlineCount <= 9) { + const clean = pastedText + .replace(/\r\n|\r/g, "\n") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/\t/g, " "); + updateBuffer((s) => insertText(s, clean)); + return; + } + } + + // Large paste: store raw text, insert marker with line/char count. + const lineCount = (pastedText.match(/\n/g) ?? []).length + 1; + pasteCounterRef.current += 1; + const pasteId = pasteCounterRef.current; + pastesRef.current.set(pasteId, pastedText); + + const marker = + lineCount > 10 ? `[paste #${pasteId} +${lineCount} lines]` : `[paste #${pasteId} ${totalChars} chars]`; + + updateBuffer((s) => insertText(s, marker)); + } + + function expandPasteMarkerAtCursor(): void { + // First, try to collapse an already-expanded region at the cursor. + for (const [id, region] of expandedRegionsRef.current) { + if (buffer.cursor >= region.start && buffer.cursor <= region.end) { + // Collapse back to marker. + expandedRegionsRef.current.delete(id); + pastesRef.current.set(id, region.content); + setTimeout(() => { + updateBuffer((s) => { + const text = s.text.slice(0, region.start) + region.marker + s.text.slice(region.end); + return { text, cursor: region.start + region.marker.length }; + }); + }, 0); + return; + } + } + + // No expanded region at cursor — try to expand a paste marker. + const marker = findPasteMarkerContaining(buffer); + if (!marker) { + setStatusMessage("No paste marker at cursor"); + return; + } + const content = pastesRef.current.get(marker.id); + if (!content) { + setStatusMessage("Paste content not found"); + return; + } + + const pasteId = marker.id; + const originalMarker = buffer.text.slice(marker.start, marker.end); + pastesRef.current.delete(pasteId); + + setTimeout(() => { + updateBuffer((s) => { + const text = s.text.slice(0, marker.start) + cleanPasteContent(content) + s.text.slice(marker.end); + const newEnd = marker.start + content.length; + expandedRegionsRef.current.set(pasteId, { + start: marker.start, + end: newEnd, + content, + marker: originalMarker, + }); + return { text, cursor: marker.start }; + }); + }, 0); + } + function navigateHistory(direction: -1 | 1): void { if (promptHistory.length === 0) { return; @@ -607,6 +720,9 @@ export const PromptInput = React.memo(function PromptInput({ setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); + pasteCounterRef.current = 0; } function handleSlashSelection(item: SlashCommandItem): void { @@ -664,6 +780,8 @@ export const PromptInput = React.memo(function PromptInput({ setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); return; } if (item.kind === "mcp") { @@ -699,7 +817,7 @@ export const PromptInput = React.memo(function PromptInput({ } onSubmit({ - text: buffer.text, + text: expandPasteMarkers(buffer.text, pastesRef.current), imageUrls, selectedSkills, }); @@ -871,9 +989,6 @@ export function getPromptReturnKeyAction(key: Pick= end) return ""; + + const segText = text.slice(start, end); + const cursorRel = cursor - start; // relative cursor position inside this segment + + // Cursor not in this segment – just return the text. + if (cursorRel < 0 || cursorRel > segText.length) { + return highlighted ? chalk.yellow(segText) : segText; } - if (typeof at === "undefined") { - return before + renderCursorCell(" "); + // Cursor is exactly at `end` (which equals `segText.length`). + if (cursorRel === segText.length) { + return highlighted ? chalk.yellow(segText) + renderCursorCell(" ") : segText + renderCursorCell(" "); } + + // Cursor is somewhere inside the segment. + const at = segText[cursorRel]; + if (at === "\n") { + // Render newline as a space in the cursor cell, then output the actual newline. + const before = segText.slice(0, cursorRel); + const after = segText.slice(cursorRel + 1); return before + renderCursorCell(" ") + "\n" + after; } + + const before = segText.slice(0, cursorRel); + const after = segText.slice(cursorRel + 1); + if (highlighted) { + return chalk.yellow(before) + renderCursorCell(at) + chalk.yellow(after); + } return before + renderCursorCell(at) + after; } diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 2668470..aefea34 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,6 +40,14 @@ function disableTerminalFocusReporting(): string { return "\u001B[?1004l"; } +function enableBracketedPaste(): string { + return "\u001B[?2004h"; +} + +function disableBracketedPaste(): string { + return "\u001B[?2004l"; +} + export function enableTerminalExtendedKeys(): string { return "\u001B[>4;1m"; } @@ -260,3 +268,16 @@ export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined, }; }, [isActive, stdout]); } + +export function useBracketedPaste(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableBracketedPaste()); + return () => { + stdout.write(disableBracketedPaste()); + }; + }, [isActive, stdout]); +} diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index 5907558..6435f62 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -4,6 +4,7 @@ export type { InputKey } from "./useTerminalInput"; export { useHiddenTerminalCursor, useTerminalExtendedKeys, + useBracketedPaste, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index 9ce6976..e3d6349 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -20,6 +20,8 @@ export type InputKey = { meta: boolean; focusIn: boolean; focusOut: boolean; + /** True when the input came from a bracketed paste (ESC[200~ ... ESC[201~). */ + paste: boolean; }; const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); @@ -35,6 +37,13 @@ const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); const TERMINAL_FOCUS_IN = "\u001B[I"; const TERMINAL_FOCUS_OUT = "\u001B[O"; +// Bracketed paste mode markers (xterm-style). +// When the terminal supports bracketed paste, pasted text is wrapped with: +// ESC[200~ ...pasted content... ESC[201~ +const PASTE_START = "\u001B[200~"; +const PASTE_END = "\u001B[201~"; +const PASTE_END_LENGTH = 6; // length of PASTE_END + // Ctrl+- (minus) sequences in modifyOtherKeys mode. // \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl) // \u001B[27;5;45~ — extended format for function-like reporting @@ -73,6 +82,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: false, focusIn: false, focusOut: false, + paste: false, }; return { input, key }; } @@ -100,6 +110,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: false, focusIn: false, focusOut: false, + paste: false, }; return { input, key }; } @@ -123,6 +134,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), focusIn: raw === TERMINAL_FOCUS_IN, focusOut: raw === TERMINAL_FOCUS_OUT, + paste: false, }; if (input <= "\u001A" && !key.return) { @@ -200,6 +212,29 @@ export function dispatchTerminalInput( inputHandler(input, key); } +/** An InputKey with all fields false (including paste). Used when dispatching paste events. */ +const EMPTY_KEY: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + paste: false, +}; + export function useTerminalInput( inputHandler: (input: string, key: InputKey) => void, options: { isActive?: boolean } = {} @@ -209,8 +244,15 @@ export function useTerminalInput( const handlerRef = useRef(inputHandler); handlerRef.current = inputHandler; + // Mutable paste-bracketing state shared across data events. + // Uses an array of chunks instead of string concatenation to avoid + // O(n²) copying when the terminal splits a large paste across many events. + const pasteRef = useRef({ active: false, chunks: [] as string[] }); + useEffect(() => { if (!isActive) { + pasteRef.current.active = false; + pasteRef.current.chunks = []; return; } setRawMode(true); @@ -223,7 +265,75 @@ export function useTerminalInput( if (!isActive) { return; } + const handleData = (data: Buffer | string) => { + const raw = String(data); + + // ----- Bracketed paste handling ----- + // Most terminals send the start/end markers in the same chunk as + // the content. We handle both inline and multi-chunk scenarios. + + if (raw.includes(PASTE_START)) { + pasteRef.current.active = true; + pasteRef.current.chunks = []; + + // Extract content after the start marker. + const startIdx = raw.indexOf(PASTE_START); + const afterStart = raw.slice(startIdx + PASTE_START.length); + + // Check if the end marker is also in this same chunk. + const endIdx = afterStart.indexOf(PASTE_END); + if (endIdx !== -1) { + // Both markers in one chunk — process immediately. + const pasteContent = afterStart.slice(0, endIdx); + pasteRef.current.active = false; + const remaining = afterStart.slice(endIdx + PASTE_END_LENGTH); + + if (pasteContent.length > 0) { + handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); + } + if (remaining.length > 0) { + dispatchTerminalInput(remaining, handlerRef.current); + } + return; + } + + // Only start marker — buffer as first chunk. + if (afterStart) { + pasteRef.current.chunks.push(afterStart); + } + return; + } + + if (pasteRef.current.active) { + pasteRef.current.chunks.push(raw); + // Only join+search when this chunk might contain the end marker. + if (raw.includes("201~")) { + const combined = pasteRef.current.chunks.join(""); + const endIdx = combined.indexOf(PASTE_END); + if (endIdx !== -1) { + const pasteContent = combined.slice(0, endIdx); + pasteRef.current.active = false; + const remaining = combined.slice(endIdx + PASTE_END_LENGTH); + pasteRef.current.chunks = []; + + // Dispatch the pasted text as a single event. + if (pasteContent.length > 0) { + handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); + } + + // Handle any remaining input after the paste end marker. + if (remaining.length > 0) { + dispatchTerminalInput(remaining, handlerRef.current); + } + return; + } + return; + } + return; + } + + // ----- Normal (non-paste) input ----- dispatchTerminalInput(data, handlerRef.current); }; diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts index 3e3c182..97d15a5 100644 --- a/src/ui/promptBuffer.ts +++ b/src/ui/promptBuffer.ts @@ -171,6 +171,120 @@ export function getCurrentSlashToken(state: PromptBufferState): string | null { return line; } +/** + * Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. + * When the user pastes a large block of text (>10 lines or >1000 chars), a compact + * marker is inserted instead of the full content. The actual content is stored in a + * Map and expanded back before submission. + */ +export const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+?\d+ lines|\d+ chars))?\]/g; + +/** + * Find the paste marker that ends exactly at `state.cursor`, if any. + * Returns the marker's start and end positions, or `null`. + */ +export function findPasteMarkerBefore(state: PromptBufferState): { start: number; end: number } | null { + // Walk backwards through all markers and return the one that ends at the cursor. + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index + match[0].length === state.cursor) { + return { start: match.index, end: match.index + match[0].length }; + } + } + return null; +} + +/** + * Find the paste marker that starts exactly at `state.cursor`, if any. + * Returns the marker's start and end positions, or `null`. + */ +export function findPasteMarkerAt(state: PromptBufferState): { start: number; end: number } | null { + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index === state.cursor) { + return { start: match.index, end: match.index + match[0].length }; + } + } + return null; +} + +/** + * If the cursor is immediately after a paste marker, delete the entire marker + * (atomic backspace). Returns the new state, or `state` unchanged if no marker. + */ +export function deletePasteMarkerBackward(state: PromptBufferState): PromptBufferState | null { + const marker = findPasteMarkerBefore(state); + if (!marker) return null; + const text = state.text.slice(0, marker.start) + state.text.slice(marker.end); + return { text, cursor: marker.start }; +} + +/** + * If the cursor is at the start of a paste marker, delete the entire marker + * (atomic forward delete). Returns the new state, or `state` unchanged if no marker. + */ +export function deletePasteMarkerForward(state: PromptBufferState): PromptBufferState | null { + const marker = findPasteMarkerAt(state); + if (!marker) return null; + const text = state.text.slice(0, marker.start) + state.text.slice(marker.end); + return { text, cursor: marker.start }; +} + +/** + * Sanitize stored paste content (filter control chars, expand tabs). + * Called lazily on expand/submit, not during paste to keep paste instant. + */ +export function cleanPasteContent(text: string): string { + return text + .replace(/\r\n|\r/g, "\n") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/\t/g, " "); +} + +/** + * Expand paste markers in the text back to their original (cleaned) content. + * @param text - Text potentially containing paste markers. + * @param pastes - Map of paste ID → original content. + */ +export function expandPasteMarkers(text: string, pastes: Map): string { + if (pastes.size === 0) return text; + let result = text; + for (const [pasteId, pasteContent] of pastes) { + const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+?\\d+ lines|\\d+ chars))?\\]`, "g"); + result = result.replace(markerRegex, () => cleanPasteContent(pasteContent)); + } + return result; +} + +/** + * Find the paste marker that contains `state.cursor`, if any. + * Returns the marker's start, end, and numeric paste ID, or `null`. + */ +export function findPasteMarkerContaining(state: PromptBufferState): { start: number; end: number; id: number } | null { + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index <= state.cursor && match.index + match[0].length >= state.cursor) { + return { + start: match.index, + end: match.index + match[0].length, + id: Number.parseInt(match[1]!, 10), + }; + } + } + return null; +} + +/** + * Check whether the given text contains any paste markers. + */ +export function hasActivePasteMarkers(text: string): boolean { + PASTE_MARKER_REGEX.lastIndex = 0; + return PASTE_MARKER_REGEX.test(text); +} + function locate(state: PromptBufferState): { line: number; column: number; From bb95daff1995dabf191e7888909423e5018e71a3 Mon Sep 17 00:00:00 2001 From: Kayro Date: Wed, 20 May 2026 22:30:59 +0800 Subject: [PATCH 191/217] fix: validate paste markers by ID to prevent false positives - hasActivePasteMarkers now checks validIds map, not just regex match - deletePasteMarkerBackward/Forward only atomically delete real paste markers - renderBufferWithCursor and renderFocusedText only highlight markers with valid IDs - PASTE_MARKER_REGEX requires line/char suffix (no bare [paste #N]) - Fix empty buffer cursor rendering in renderFocusedText regression - All render/test call sites updated to pass pastesRef.current --- src/ui/PromptInput.tsx | 42 +++++++++++++++++++++++++----------------- src/ui/promptBuffer.ts | 35 ++++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index ad73d83..0eaa169 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -188,7 +188,7 @@ export const PromptInput = React.memo(function PromptInput({ const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); const hasRunningProcess = runningProcesses && runningProcesses.size > 0; - const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text); + const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text, pastesRef.current); const hasExpandedRegions = expandedRegionsRef.current.size > 0; const processOrPasteHint = hasRunningProcess ? " · ctrl+o view output" @@ -431,12 +431,12 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.delete) { - updateBuffer((s) => deletePasteMarkerForward(s) ?? deleteForward(s)); + updateBuffer((s) => deletePasteMarkerForward(s, pastesRef.current) ?? deleteForward(s)); return; } if (key.backspace) { - updateBuffer((s) => deletePasteMarkerBackward(s) ?? backspace(s)); + updateBuffer((s) => deletePasteMarkerBackward(s, pastesRef.current) ?? backspace(s)); return; } @@ -872,7 +872,7 @@ export const PromptInput = React.memo(function PromptInput({ borderDimColor > - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)} {inlineHint ? {inlineHint} : null} +): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); + const validIds = validPastes ?? new Map(); if (text.length === 0 && placeholder) { if (!isFocused) { @@ -997,18 +1003,18 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool return renderCursorCell(" ") + chalk.dim(` ${placeholder}`); } + if (text.length === 0) { + return isFocused ? renderCursorCell(" ") : ""; + } + if (!isFocused) { - return highlightPasteMarkersInText(text); + return highlightPasteMarkersInText(text, validIds); } - // Focused: scan through the text, highlight paste markers, and insert - // the cursor cell at the correct position. This approach handles the - // case where the cursor sits at the start of (or inside) a paste marker. - return renderFocusedText(text, cursor); + return renderFocusedText(text, cursor, validIds); } -/** Highlight paste markers in a plain string (no cursor). */ -function highlightPasteMarkersInText(s: string): string { +function highlightPasteMarkersInText(s: string, validIds: Map): string { if (!s.includes("[paste #")) return s; PASTE_MARKER_REGEX.lastIndex = 0; let result = ""; @@ -1016,7 +1022,8 @@ function highlightPasteMarkersInText(s: string): string { let match: RegExpExecArray | null; while ((match = PASTE_MARKER_REGEX.exec(s)) !== null) { result += s.slice(pos, match.index); - result += chalk.yellow(match[0]); + const id = Number.parseInt(match[1]!, 10); + result += validIds.has(id) ? chalk.yellow(match[0]) : match[0]; pos = match.index + match[0].length; } result += s.slice(pos); @@ -1029,7 +1036,7 @@ function highlightPasteMarkersInText(s: string): string { * anywhere (including inside or at the boundary of a paste marker) and the * marker will still be highlighted correctly. */ -function renderFocusedText(text: string, cursor: number): string { +function renderFocusedText(text: string, cursor: number, validIds: Map): string { let result = ""; let pos = 0; PASTE_MARKER_REGEX.lastIndex = 0; @@ -1038,14 +1045,15 @@ function renderFocusedText(text: string, cursor: number): string { while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) { const markerStart = match.index; const markerEnd = match.index + match[0].length; + const id = Number.parseInt(match[1]!, 10); + const isReal = validIds.has(id); // 1. Non-marker segment before this marker. result += renderTextSegmentWithCursor(text, pos, markerStart, cursor, false); pos = markerStart; - // 2. Marker segment — highlighted with chalk.yellow. - // The cursor may fall inside it. - result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, true); + // 2. Marker segment — highlighted only if it corresponds to a real paste. + result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, isReal); pos = markerEnd; } diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts index 97d15a5..3e0a710b 100644 --- a/src/ui/promptBuffer.ts +++ b/src/ui/promptBuffer.ts @@ -177,7 +177,7 @@ export function getCurrentSlashToken(state: PromptBufferState): string | null { * marker is inserted instead of the full content. The actual content is stored in a * Map and expanded back before submission. */ -export const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+?\d+ lines|\d+ chars))?\]/g; +export const PASTE_MARKER_REGEX = /\[paste #(\d+) (\+?\d+ lines|\d+ chars)\]/g; /** * Find the paste marker that ends exactly at `state.cursor`, if any. @@ -214,9 +214,16 @@ export function findPasteMarkerAt(state: PromptBufferState): { start: number; en * If the cursor is immediately after a paste marker, delete the entire marker * (atomic backspace). Returns the new state, or `state` unchanged if no marker. */ -export function deletePasteMarkerBackward(state: PromptBufferState): PromptBufferState | null { +export function deletePasteMarkerBackward( + state: PromptBufferState, + validIds: Map +): PromptBufferState | null { const marker = findPasteMarkerBefore(state); if (!marker) return null; + // Only delete if this is a real paste marker (ID in validIds). + PASTE_MARKER_REGEX.lastIndex = 0; + const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end)); + if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null; const text = state.text.slice(0, marker.start) + state.text.slice(marker.end); return { text, cursor: marker.start }; } @@ -225,9 +232,16 @@ export function deletePasteMarkerBackward(state: PromptBufferState): PromptBuffe * If the cursor is at the start of a paste marker, delete the entire marker * (atomic forward delete). Returns the new state, or `state` unchanged if no marker. */ -export function deletePasteMarkerForward(state: PromptBufferState): PromptBufferState | null { +export function deletePasteMarkerForward( + state: PromptBufferState, + validIds: Map +): PromptBufferState | null { const marker = findPasteMarkerAt(state); if (!marker) return null; + // Only delete if this is a real paste marker (ID in validIds). + PASTE_MARKER_REGEX.lastIndex = 0; + const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end)); + if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null; const text = state.text.slice(0, marker.start) + state.text.slice(marker.end); return { text, cursor: marker.start }; } @@ -252,7 +266,7 @@ export function expandPasteMarkers(text: string, pastes: Map): s if (pastes.size === 0) return text; let result = text; for (const [pasteId, pasteContent] of pastes) { - const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+?\\d+ lines|\\d+ chars))?\\]`, "g"); + const markerRegex = new RegExp(`\\[paste #${pasteId} (\\+?\\d+ lines|\\d+ chars)\\]`, "g"); result = result.replace(markerRegex, () => cleanPasteContent(pasteContent)); } return result; @@ -278,11 +292,18 @@ export function findPasteMarkerContaining(state: PromptBufferState): { start: nu } /** - * Check whether the given text contains any paste markers. + * Check whether the text contains real paste markers (IDs present in validIds). */ -export function hasActivePasteMarkers(text: string): boolean { +export function hasActivePasteMarkers(text: string, validIds: Map): boolean { + if (!text.includes("[paste #")) return false; PASTE_MARKER_REGEX.lastIndex = 0; - return PASTE_MARKER_REGEX.test(text); + let match: RegExpExecArray | null; + while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) { + if (validIds.has(Number.parseInt(match[1]!, 10))) { + return true; + } + } + return false; } function locate(state: PromptBufferState): { From 27fcfd02e0c8cf564f39fa8d3fc4c9a878dbe501 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 21 May 2026 10:36:19 +0800 Subject: [PATCH 192/217] feat: add normalizeLlmToolCalls() to replace missing/empty tool call IDs --- src/session.ts | 34 ++++++++++++++++++++-- src/tests/session.test.ts | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/session.ts b/src/session.ts index b4a3c71..54340e7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -570,9 +570,10 @@ export class SessionManager { const toolCalls = Array.from(toolCallsByIndex.entries()) .sort(([left], [right]) => left - right) .map(([, toolCall]) => toolCall); + const normalizedToolCalls = this.normalizeLlmToolCalls(toolCalls); const message: Record = { content }; - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; + if (normalizedToolCalls) { + message.tool_calls = normalizedToolCalls; } if (reasoningContent.length > 0) { message.reasoning_content = reasoningContent; @@ -1180,7 +1181,7 @@ ${skillMd} const rawContent = message?.content; const content = typeof rawContent === "string" ? rawContent : ""; const rawToolCalls = (message as { tool_calls?: unknown[] } | undefined)?.tool_calls ?? null; - toolCalls = Array.isArray(rawToolCalls) && rawToolCalls.length > 0 ? rawToolCalls : null; + toolCalls = this.normalizeLlmToolCalls(rawToolCalls); const rawThinking = (message as { reasoning_content?: unknown } | undefined)?.reasoning_content; const thinking = typeof rawThinking === "string" ? rawThinking : null; const refusal = (message as { refusal?: string } | undefined)?.refusal ?? null; @@ -1899,6 +1900,33 @@ ${skillMd} }; } + private generateToolCallId(): string { + return crypto.randomBytes(16).toString("hex"); + } + + private normalizeLlmToolCalls(rawToolCalls: unknown[] | null | undefined): unknown[] | null { + if (!Array.isArray(rawToolCalls) || rawToolCalls.length === 0) { + return null; + } + + return rawToolCalls.map((toolCall) => { + if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) { + return toolCall; + } + + const record = toolCall as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + if (id) { + return toolCall; + } + + return { + ...record, + id: this.generateToolCallId(), + }; + }); + } + private buildToolMessage( sessionId: string, toolCallId: string, diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 9f3c7fb..fd83199 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1565,6 +1565,66 @@ test("Write tool params prefer file_path even when content appears first", () => assert.equal(toolMessage.meta?.paramsMd, filePath); }); +test("LLM tool calls without ids receive generated 32 character ids", async () => { + const workspace = createTempDir("deepcode-tool-call-id-workspace-"); + const home = createTempDir("deepcode-tool-call-id-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "note.txt"); + fs.writeFileSync(filePath, "hello\n", "utf8"); + const plan = "## Task List\n\n- [ ] Inspect current behavior"; + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "", + type: "function", + function: { + name: "UpdatePlan", + arguments: JSON.stringify({ plan, explanation: "Initial plan" }), + }, + }, + { + type: "function", + function: { + name: "read", + arguments: JSON.stringify({ file_path: filePath }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "inspect note" }); + const assistantMessage = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls); + const toolCalls = (assistantMessage?.messageParams as { tool_calls?: Array<{ id?: unknown }> } | null)?.tool_calls; + + assert.equal(toolCalls?.length, 2); + assert.match(String(toolCalls?.[0]?.id), /^[0-9a-f]{32}$/); + assert.match(String(toolCalls?.[1]?.id), /^[0-9a-f]{32}$/); + assert.notEqual(toolCalls?.[0]?.id, toolCalls?.[1]?.id); + + const toolMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "tool"); + assert.deepEqual( + toolMessages.map((message) => (message.messageParams as { tool_call_id?: unknown } | null)?.tool_call_id), + toolCalls?.map((toolCall) => toolCall.id) + ); + + const readToolMessage = toolMessages.find((message) => JSON.parse(message.content ?? "{}").name === "read"); + assert.equal((readToolMessage?.meta?.function as { name?: string } | undefined)?.name, "read"); + assert.equal(readToolMessage?.meta?.paramsMd, "note.txt"); +}); + test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messages", () => { const manager = createSessionManager(process.cwd(), "machine-id-mixed-tool-badcase"); const assistantMessage = (manager as any).buildAssistantMessage( From 5b51f4066c09764eb1de631109166a8d69013a94 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 21 May 2026 10:40:31 +0800 Subject: [PATCH 193/217] 0.1.24 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 17a77ca..0250531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.23", + "version": "0.1.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.23", + "version": "0.1.24", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index b72fd96..c8809ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.23", + "version": "0.1.24", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", From ec9a219554858262687180b40f5726a133e3bf73 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 21 May 2026 14:41:24 +0800 Subject: [PATCH 194/217] =?UTF-8?q?docs(cli):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=B9=B6=E6=89=A9=E5=B1=95=E5=91=BD=E4=BB=A4=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=92=8C=E5=B8=AE=E5=8A=A9=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /skills、/model、/undo、/mcp 和 /raw 命令说明 - README.md 和 README-en.md 中同步添加对应命令描述 - 详细列出各命令功能,方便用户快速查阅 - 修改 CLI 界面帮助输出,包含所有新增命令提示 - 优化菜单结构,提升用户操作体验 --- README-en.md | 8 ++++++++ README-zh_CN.md | 45 ++++++++++++++++++++++++++------------------- README.md | 45 ++++++++++++++++++++++++++------------------- src/cli.tsx | 5 +++++ 4 files changed, 65 insertions(+), 38 deletions(-) diff --git a/README-en.md b/README-en.md index 55d0cf6..d9719fd 100644 --- a/README-en.md +++ b/README-en.md @@ -66,11 +66,13 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap | `/` | Open the skills / commands menu | | `/new` | Start a fresh conversation | | `/resume` | Choose a previous conversation to continue | +| `/continue` | Continue the active conversation or pick one to resume | | `/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 | +| `/undo` | Restore code and/or conversation to a previous point | | `/exit` | Quit (also `Ctrl+D` twice) | | Key | Action | @@ -126,6 +128,12 @@ Deep Code supports MCP (Model Context Protocol) to connect external services suc For detailed setup instructions, see: [docs/mcp.md](docs/mcp.md) +### How to configure Deep Code to send notifications after a task completes? + +When the AI assistant completes a task, Deep Code can automatically execute a notification script to send the task results to the specified channel (e.g., Slack, system notifications, etc.). + +For detailed configuration instructions, see: [docs/notify_en.md](docs/notify_en.md) + ## Contributing Contributions are welcome! Here's how to get started: diff --git a/README-zh_CN.md b/README-zh_CN.md index 8a427de..7b74a50 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -60,25 +60,27 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: ## 斜杠命令与按键功能 -| 斜杠命令 | 操作 | -|-----------------|---------------------------------------------| -| `/` | 打开 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` | 退出 | +| 斜杠命令 | 操作 | +|-------------|----------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/undo` | 将代码和/或对话恢复到之前的状态 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|---------------|--------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | ## 支持的模型 @@ -111,6 +113,11 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 详细配置指南:[docs/mcp.md](docs/mcp.md) +### 如何配置 Deep Code 任务完成后发送通知? + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +详细配置指南:[docs/notify.md](docs/notify.md) ### 是否支持 Coding Plan? diff --git a/README.md b/README.md index 8a427de..7b74a50 100644 --- a/README.md +++ b/README.md @@ -60,25 +60,27 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: ## 斜杠命令与按键功能 -| 斜杠命令 | 操作 | -|-----------------|---------------------------------------------| -| `/` | 打开 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` | 退出 | +| 斜杠命令 | 操作 | +|-------------|----------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/undo` | 将代码和/或对话恢复到之前的状态 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|---------------|--------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | ## 支持的模型 @@ -111,6 +113,11 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 详细配置指南:[docs/mcp.md](docs/mcp.md) +### 如何配置 Deep Code 任务完成后发送通知? + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +详细配置指南:[docs/notify.md](docs/notify.md) ### 是否支持 Coding Plan? diff --git a/src/cli.tsx b/src/cli.tsx index 66ceb7d..c3876ae 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -41,10 +41,15 @@ if (args.includes("--help") || args.includes("-h")) { " ctrl+x Clear pasted images", " esc Interrupt the current model turn", " / Open the skills/commands menu", + " /skills List available skills", + " /model Select model, thinking mode and effort control", " /new Start a fresh conversation", " /init Initialize an AGENTS.md file with instructions for LLM", " /resume Pick a previous conversation to continue", " /continue Continue the active conversation, or resume one if empty", + " /undo Restore code and/or conversation to a previous point", + " /mcp Show MCP server status and available tools", + " /raw Toggle display mode for viewing or collapsing reasoning content", " /exit Quit", " ctrl+d twice Quit", ].join("\n") + "\n" From 040a3245bbca60301c325ce6b993bf236ed8df4c Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 21 May 2026 14:43:08 +0800 Subject: [PATCH 195/217] =?UTF-8?q?docs(cli):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=B9=B6=E6=89=A9=E5=B1=95=E5=91=BD=E4=BB=A4=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=92=8C=E5=B8=AE=E5=8A=A9=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /skills、/model、/undo、/mcp 和 /raw 命令说明 - README.md 和 README-en.md 中同步添加对应命令描述 - 详细列出各命令功能,方便用户快速查阅 - 修改 CLI 界面帮助输出,包含所有新增命令提示 - 优化菜单结构,提升用户操作体验 --- README-en.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README-en.md b/README-en.md index d9719fd..18e3f13 100644 --- a/README-en.md +++ b/README-en.md @@ -61,19 +61,19 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap ## Slash Commands & Keyboard Shortcuts -| Slash Command | Action | -|------------------|----------------------------------------------------------| -| `/` | Open the skills / commands menu | -| `/new` | Start a fresh conversation | -| `/resume` | Choose a previous conversation to continue | -| `/continue` | Continue the active conversation or pick one to resume | -| `/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 | -| `/undo` | Restore code and/or conversation to a previous point | -| `/exit` | Quit (also `Ctrl+D` twice) | +| Slash Command | Action | +|------------------|---------------------------------------------------------| +| `/` | Open the skills / commands menu | +| `/new` | Start a fresh conversation | +| `/resume` | Choose a previous conversation to continue | +| `/continue` | Continue the active conversation or pick one to resume | +| `/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 | +| `/undo` | Restore code and/or conversation to a previous point | +| `/exit` | Quit (also `Ctrl+D` twice) | | Key | Action | |------------------|----------------------------------------------------------| From 56e75050dce95fadf4bfde76feb80d703699a2a1 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 21 May 2026 15:37:55 +0800 Subject: [PATCH 196/217] =?UTF-8?q?docs(readme):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=BE=BD=E7=AB=A0=E5=B1=95=E7=A4=BA=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E4=BF=A1=E6=81=AF=E5=8F=AF=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 README.md、README-en.md 和 README-zh_CN.md 中添加了 npm 和 GitHub 相关徽章 - 新增版本号、下载量、贡献者、分支、Star、Issue、PR 和许可信息的动态展示链接 - 增强项目主页的视觉效果和信息传达 - 为中英文 README 文件同步更新相同内容及样式 --- README-en.md | 23 +++++++++++++++++++++++ README-zh_CN.md | 22 ++++++++++++++++++++++ README.md | 22 ++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/README-en.md b/README-en.md index 18e3f13..be6442b 100644 --- a/README-en.md +++ b/README-en.md @@ -8,6 +8,9 @@

Deep Code CLI

+[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + English · [中文](./README.md)
@@ -174,3 +177,23 @@ If you find this tool helpful, please consider supporting us by: - Giving us a Star on GitHub (https://github.com/lessweb/deepcode-cli) - Submitting feedback and suggestions - Sharing with your friends and colleagues + + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file diff --git a/README-zh_CN.md b/README-zh_CN.md index 7b74a50..52f0123 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -8,6 +8,9 @@

Deep Code CLI

+[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + [English](README-en.md) · 中文
@@ -173,3 +176,22 @@ npm link - 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) - 向我们提交反馈和建议 - 分享给你的朋友和同事 + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file diff --git a/README.md b/README.md index 7b74a50..52f0123 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@

Deep Code CLI

+[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + [English](README-en.md) · 中文
@@ -173,3 +176,22 @@ npm link - 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) - 向我们提交反馈和建议 - 分享给你的朋友和同事 + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file From b010fb16f92dd01444d683566e41c3ef08c72af7 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 21 May 2026 22:46:17 +0800 Subject: [PATCH 197/217] =?UTF-8?q?docs(readme):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=AE=B8=E5=8F=AF=E8=AF=81=E9=93=BE=E6=8E=A5=E5=88=B0=E4=B8=BB?= =?UTF-8?q?=E5=88=86=E6=94=AF=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 README.md 中许可证链接从 master 更改为 main 分支路径 - 同步更新 README-en.md 中的许可证链接路径 - 同步更新 README-zh_CN.md 中的许可证链接路径 - 保持徽章和其他链接不变,确保一致性和正确性 --- README-en.md | 2 +- README-zh_CN.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README-en.md b/README-en.md index be6442b..1e1323d 100644 --- a/README-en.md +++ b/README-en.md @@ -195,5 +195,5 @@ If you find this tool helpful, please consider supporting us by: [github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square [github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls [github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square -[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE [github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file diff --git a/README-zh_CN.md b/README-zh_CN.md index 52f0123..2909271 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -193,5 +193,5 @@ npm link [github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square [github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls [github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square -[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE [github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file diff --git a/README.md b/README.md index 52f0123..2909271 100644 --- a/README.md +++ b/README.md @@ -193,5 +193,5 @@ npm link [github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square [github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls [github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square -[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE [github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file From c4a2463847d1d294624199d4a066b44b2547df37 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 08:42:47 +0800 Subject: [PATCH 198/217] feat: update README.md --- README-en.md | 16 ++++++++-------- README-zh_CN.md | 16 ++++++++-------- README.md | 16 ++++++++-------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README-en.md b/README-en.md index 1e1323d..4bff6af 100644 --- a/README-en.md +++ b/README-en.md @@ -182,18 +182,18 @@ If you find this tool helpful, please consider supporting us by: [npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli -[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 [npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli -[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 [github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors -[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members -[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers -[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-issues-link]: https://github.com/lessweb/deepcode-cli/issues -[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls -[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE -[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 \ No newline at end of file diff --git a/README-zh_CN.md b/README-zh_CN.md index 2909271..77db497 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -180,18 +180,18 @@ npm link [npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli -[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 [npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli -[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 [github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors -[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members -[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers -[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-issues-link]: https://github.com/lessweb/deepcode-cli/issues -[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls -[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE -[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 \ No newline at end of file diff --git a/README.md b/README.md index 2909271..77db497 100644 --- a/README.md +++ b/README.md @@ -180,18 +180,18 @@ npm link [npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli -[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 [npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli -[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 [github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors -[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members -[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers -[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-issues-link]: https://github.com/lessweb/deepcode-cli/issues -[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls -[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 [github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE -[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square \ No newline at end of file +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 \ No newline at end of file From d7d453f55bd11352a38dadb40ebb375cb656e9ba Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 09:20:55 +0800 Subject: [PATCH 199/217] chore: remove draft doc --- docs/SKILL_new.md | 246 ---------------------------------------------- 1 file changed, 246 deletions(-) delete mode 100644 docs/SKILL_new.md diff --git a/docs/SKILL_new.md b/docs/SKILL_new.md deleted file mode 100644 index 9fc8bd2..0000000 --- a/docs/SKILL_new.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: plan-and-execute -description: Automatically plan and execute requirements. Creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with task planning or when you need to break down and execute complex multi-step requirements. ---- - -# Plan and Execute - -This Skill helps you automatically plan and execute requirements. It creates a structured markdown task list with the UpdatePlan tool and systematically works through each task while keeping progress visible. - -## Quick Start - -When you need to work through a multi-step request: - -1. Analyze the requirements and explore enough project context -2. Clarify unclear or ambiguous requirements with AskUserQuestion -3. Create a markdown task list by calling the UpdatePlan tool -4. Execute tasks one by one, updating the tool plan in real time -5. Revise the remaining plan as new context appears - -## Instructions - -### Step 1: Analyze the requirements - -Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. - -If the original requirements are unclear, incomplete, or ambiguous, call the AskUserQuestion tool before creating the task list. Ask only the questions needed to avoid implementing the wrong behavior, and keep each question specific to the decision that affects the plan or acceptance criteria. - -If a required referenced file path is missing, ask for it with AskUserQuestion: - -``` -What is the path to the referenced file? -``` - -Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. - -- What are the main requirements? -- What tasks need to be completed? -- Are there dependencies between tasks? -- What is the complexity level? -- Which files, modules, commands, or tests are relevant? -- What ambiguity would change the implementation or acceptance criteria? - -### Step 2: Create the task list - -Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: - -```json -{ - "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description" -} -``` - -Use this markdown format for the `plan` content: - -```markdown -## Task List - -- [ ] Task 1 description -- [ ] Task 2 description -- [ ] Task 3 description -``` - -Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. - -### Step 3: Execute tasks systematically - -For each task in the list: - -1. **Refresh the plan**: Before starting the first task and after completing each task, re-evaluate the latest conversation and project context. Update the remaining tasks when scope, order, blockers, or follow-up work changes. -2. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` -3. **Execute the task**: Use appropriate tools to complete the work -4. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished -5. **Move to next task**: Only ONE task should be in progress at a time - -Important rules: -- Always keep the plan aligned with the latest context before executing the next task -- Always call UpdatePlan BEFORE starting work on a task -- Always call UpdatePlan IMMEDIATELY after completing a task -- Always pass the complete current markdown task list, not a partial diff -- Never work on multiple tasks simultaneously -- Remove tasks that are no longer relevant, and add newly discovered tasks before working on them -- If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers - -### Step 4: Handle task breakdown - -If during execution you discover a task is more complex than expected: - -1. Keep the current task as `[>]` -2. Call UpdatePlan with new sub-tasks below it with indentation: - ```markdown - - [>] Main task - - [ ] Sub-task 1 - - [ ] Sub-task 2 - ``` -3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan - -### Step 5: Final verification - -After all tasks are completed (`[x]`): - -1. Review the original requirements to ensure everything is addressed -2. Run any final checks (tests, builds, linting) -3. Call UpdatePlan with every task marked `[x]` -4. Provide a concise completion summary in the final response - -## Task State Symbols - -- `[ ]` - Pending -- `[>]` - In progress -- `[x]` - Completed -- `[!]` - Blocked - -## Examples - -### Example 1: Simple feature request - -**Example requirements:** -```markdown -# 新功能:添加深色模式切换 - -用户应该能够在浅色和深色主题之间切换。 -切换开关应放在设置页面中。 -``` - -**分析后的 UpdatePlan 调用:** -```markdown -## Task List - -- [ ] 在设置页面创建深色模式切换组件 -- [ ] 添加深色模式状态管理(context/store) -- [ ] 实现深色主题的 CSS-in-JS 样式 -- [ ] 更新现有组件以支持主题切换 -- [ ] 运行测试并验证功能 -``` - -**UpdatePlan call during execution:** -```markdown -## Task List - -- [x] 在设置页面创建深色模式切换组件 -- [>] 添加深色模式状态管理(context/store) -- [ ] 实现深色主题的 CSS-in-JS 样式 -- [ ] 更新现有组件以支持主题切换 -- [ ] 运行测试并验证功能 -``` - -### Example 2: Bug fix with investigation - -**Example requirements:** -```markdown -# Fix bug:登录表单提交时崩溃 - -当用户点击提交时,应用崩溃。 -错误信息:"Cannot read property 'email' of undefined" -``` - -**UpdatePlan call after analysis:** -```markdown -## Task List - -- [ ] 在本地复现缺陷 -- [ ] 调查登录表单组件中的错误 -- [ ] 定位 undefined email 属性的根本原因 -- [ ] 实施修复 -- [ ] 添加验证以防止类似问题 -- [ ] 使用各种输入测试修复 -- [ ] 更新错误处理 -``` - -## When to Use This Skill - -Use this Skill when: - -1. **Complex multi-step tasks** - Request requires 3+ distinct steps -2. **Feature implementation** - Building new functionality from requirements -3. **Bug fixing** - Need to investigate, fix, and verify -4. **Refactoring** - Multiple files or components need changes -5. **Detailed requirements** - Specifications need to be translated into concrete tasks -6. **Need progress tracking** - Want visible progress without editing source files - -## When NOT to Use This Skill - -Skip this Skill when: - -1. **Single simple task** - Just one straightforward action needed -2. **Trivial changes** - Quick fixes that don't need planning -3. **Informational requests** - User just wants explanation, not execution -4. **No execution requested** - User only wants brainstorming or a high-level explanation - -## Best Practices - -1. **Be specific with tasks**: "Add login button to navbar" not "Update UI" -2. **Keep tasks atomic**: Each task should be independently completable -3. **Update immediately**: Don't batch status updates, do them in real-time -4. **One task at a time**: Never mark multiple tasks as `[>]` -5. **Handle blockers**: If stuck, create new tasks to resolve the blocker -6. **Verify completion**: Only mark `[x]` when task is fully done - -## Advanced Usage - -### Handling dependencies - -When tasks have dependencies, order them properly: - -```markdown -- [ ] Create database schema -- [ ] Implement API endpoints (depends on schema) -- [ ] Build frontend forms (depends on API) -``` - -### Using sub-tasks - -For complex tasks, break them down: - -```markdown -- [>] Implement authentication system - - [x] Set up JWT library - - [>] Create login endpoint - - [ ] Create logout endpoint - - [ ] Add token refresh logic -``` - -### Adding notes - -Add implementation notes or findings: - -```markdown -- [x] Investigate performance issue - - Note: Found N+1 query in user loader - - Solution: Added dataloader batching -``` - -## Workflow Summary - -1. Analyze the requirements and relevant project context -2. Call AskUserQuestion if the original requirements are unclear or ambiguous -3. Call UpdatePlan with the structured markdown task list -4. Refresh the remaining plan before the first task -5. For each task: - - Update to `[>]` with UpdatePlan - - Execute the task - - Update to `[x]` with UpdatePlan - - Re-evaluate and revise remaining tasks before moving on -6. Call UpdatePlan with all tasks completed and summarize the result - -This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. From 27b9b7feb444cbeb0b10473216e7f6804530234f Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 10:22:12 +0800 Subject: [PATCH 200/217] refactor: adjust calling identifyMatchingSkillNames in createSession and replySession --- src/session.ts | 37 +++++++++++++++++++------------------ src/tests/session.test.ts | 10 ++++++++-- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/session.ts b/src/session.ts index 54340e7..3144f88 100644 --- a/src/session.ts +++ b/src/session.ts @@ -901,20 +901,6 @@ The candidate skills are as follows:\n\n`; const signal = controller?.signal; this.throwIfAborted(signal); - if (userPrompt.text) { - const skills = await this.listSkills(); - const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal }); - this.throwIfAborted(signal); - const skillSet = new Set(skillNames); - const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); - if (Array.isArray(userPrompt.skills)) { - userPrompt.skills.push(...matchedSkill); - } else if (matchedSkill.length > 0) { - userPrompt.skills = matchedSkill; - } - } - userPrompt.skills = await this.normalizeSkills(userPrompt.skills); - this.throwIfAborted(signal); const sessionId = crypto.randomUUID(); this.ensureFileHistorySession(sessionId); const now = new Date().toISOString(); @@ -977,6 +963,21 @@ The candidate skills are as follows:\n\n`; const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { + const skills = await this.listSkills(); + const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal }); + this.throwIfAborted(signal); + const skillSet = new Set(skillNames); + const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); + if (Array.isArray(userPrompt.skills)) { + userPrompt.skills.push(...matchedSkill); + } else if (matchedSkill.length > 0) { + userPrompt.skills = matchedSkill; + } + } + userPrompt.skills = await this.normalizeSkills(userPrompt.skills); + this.throwIfAborted(signal); + if (userPrompt.skills && userPrompt.skills.length > 0) { for (const skill of userPrompt.skills) { if (skill.isLoaded) { @@ -1022,6 +1023,10 @@ ${skillMd} this.reportNewPrompt(); + this.ensureFileHistorySession(sessionId); + const userMessage = this.buildUserMessage(sessionId, userPrompt); + this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { const skills = await this.listSkills(sessionId); const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal, sessionId }); @@ -1037,10 +1042,6 @@ ${skillMd} userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); this.throwIfAborted(signal); - this.ensureFileHistorySession(sessionId); - const userMessage = this.buildUserMessage(sessionId, userPrompt); - this.appendSessionMessage(sessionId, userMessage); - if (userPrompt.skills && userPrompt.skills.length > 0) { for (const skill of userPrompt.skills) { if (skill.isLoaded) { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index fd83199..e5bdcb2 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1952,7 +1952,7 @@ test("SessionManager streams chat completions and counts reasoning progress", as assert.equal(progressEvents[2]?.formattedTokens, "3"); }); -test("SessionManager cancels skill matching before a session is created", async () => { +test("SessionManager persists session and user message before skill matching is cancelled", async () => { const workspace = createTempDir("deepcode-skill-abort-workspace-"); const home = createTempDir("deepcode-skill-abort-home-"); setHomeDir(home); @@ -1981,7 +1981,13 @@ test("SessionManager cancels skill matching before a session is created", async await manager.handleUserPrompt({ text: "please use demo" }); - assert.equal(manager.listSessions().length, 0); + // Session and user message are persisted before skill matching triggers an abort. + assert.equal(manager.listSessions().length, 1); + const [session] = manager.listSessions(); + assert.equal(session?.status, "pending"); + const messages = manager.listSessionMessages(session!.id); + const userMessage = messages.find((m) => m.role === "user"); + assert.equal(userMessage?.content, "please use demo"); }); test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () => { From f1774292a0e2e4420117e1984ce40efd94b38799 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 11:01:31 +0800 Subject: [PATCH 201/217] feat: implement checkpoints store only explicit Write/Edit file paths --- src/common/file-history.ts | 206 ++++++++++++++++++++++++++++--------- src/tests/session.test.ts | 96 +++++++++-------- 2 files changed, 215 insertions(+), 87 deletions(-) diff --git a/src/common/file-history.ts b/src/common/file-history.ts index d5966d9..2a41d9a 100644 --- a/src/common/file-history.ts +++ b/src/common/file-history.ts @@ -1,13 +1,26 @@ import * as childProcess from "child_process"; +import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint"; const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost"; +const MANIFEST_PATH = ".deepcode-file-history.json"; + +type FileHistoryEntry = { + path: string; + blob: string; + mode: "100644"; +}; + +type FileHistoryManifest = { + version: 1; + files: Record; +}; export class GitFileHistory { constructor( - private readonly projectRoot: string, + _projectRoot: string, private readonly gitDir: string ) {} @@ -20,7 +33,7 @@ export class GitFileHistory { try { if (!fs.existsSync(this.gitDir)) { fs.mkdirSync(path.dirname(this.gitDir), { recursive: true }); - this.runGit(["init"], { includeWorkTree: true }); + this.runGit(["init"]); } const current = this.getCurrentCheckpointHash(sessionId); @@ -28,9 +41,9 @@ export class GitFileHistory { return current; } - const emptyTree = this.runGit(["mktree"], { includeWorkTree: false, input: "" }).trim(); - const commitHash = this.createCommit(emptyTree, null, "Initial checkpoint"); - this.runGit(["update-ref", branchRef, commitHash], { includeWorkTree: false }); + const treeHash = this.createTree(emptyManifest()); + const commitHash = this.createCommit(treeHash, null, "Initial checkpoint"); + this.runGit(["update-ref", branchRef, commitHash]); return commitHash; } catch { return undefined; @@ -44,9 +57,7 @@ export class GitFileHistory { } try { - const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`], { - includeWorkTree: false, - }).trim(); + const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`]).trim(); return isCommitHash(hash) ? hash : undefined; } catch { return undefined; @@ -59,10 +70,8 @@ export class GitFileHistory { return undefined; } - const relativePaths = filePaths - .map((filePath) => this.toProjectRelativeGitPath(filePath)) - .filter((filePath): filePath is string => Boolean(filePath)); - if (relativePaths.length === 0) { + const absolutePaths = uniqueAbsolutePaths(filePaths); + if (absolutePaths.length === 0) { return this.getCurrentCheckpointHash(sessionId); } @@ -71,18 +80,30 @@ export class GitFileHistory { if (!parentHash) { return undefined; } - this.runGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); - this.runGit(["add", "-f", "-A", "--", ...relativePaths], { includeWorkTree: true }); - const treeHash = this.runGit(["write-tree"], { includeWorkTree: false }).trim(); - const parentTreeHash = this.runGit(["rev-parse", `${parentHash}^{tree}`], { - includeWorkTree: false, - }).trim(); + + const manifest = this.readManifest(parentHash); + for (const filePath of absolutePaths) { + const key = this.getFileKey(filePath); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + delete manifest.files[key]; + continue; + } + + manifest.files[key] = { + path: filePath, + blob: this.hashFile(filePath), + mode: "100644", + }; + } + + const treeHash = this.createTree(manifest); + const parentTreeHash = this.runGit(["rev-parse", `${parentHash}^{tree}`]).trim(); if (treeHash === parentTreeHash) { return parentHash; } const commitHash = this.createCommit(treeHash, parentHash, message); - this.runGit(["update-ref", branchRef, commitHash, parentHash], { includeWorkTree: false }); + this.runGit(["update-ref", branchRef, commitHash, parentHash]); return commitHash; } catch { return undefined; @@ -101,7 +122,8 @@ export class GitFileHistory { } try { - this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]); + this.readManifest(checkpointHash); return true; } catch { return false; @@ -116,16 +138,24 @@ export class GitFileHistory { if (!branchRef || !fs.existsSync(this.gitDir)) { throw new Error("File history Git repository was not found for this project."); } - this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]); - try { - this.runGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); - } catch { - // If the session branch is missing, fall back to the target tree only. - // The target checkpoint has already been validated above. + const currentHash = this.getCurrentCheckpointHash(sessionId); + const currentManifest = currentHash ? this.readManifest(currentHash) : emptyManifest(); + const targetManifest = this.readManifest(checkpointHash); + + for (const [key, entry] of Object.entries(currentManifest.files)) { + if (!targetManifest.files[key]) { + removeTrackedFile(entry.path); + } } - this.runGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true }); - this.runGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false }); + + for (const entry of Object.values(targetManifest.files)) { + fs.mkdirSync(path.dirname(entry.path), { recursive: true }); + fs.writeFileSync(entry.path, this.readBlob(entry.blob)); + } + + this.runGit(["update-ref", branchRef, checkpointHash]); } private getSessionBranchRef(sessionId: string): string | null { @@ -142,41 +172,125 @@ export class GitFileHistory { } args.push("-m", message); return this.runGit(args, { - includeWorkTree: false, env: getFileHistoryGitEnv(), }).trim(); } - private toProjectRelativeGitPath(filePath: string): string | null { - const absolutePath = path.resolve(filePath); - const relativePath = path.relative(this.projectRoot, absolutePath); - if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - return null; + private createTree(manifest: FileHistoryManifest): string { + const normalizedManifest = normalizeManifest(manifest); + const manifestBlob = this.hashContent(`${JSON.stringify(normalizedManifest, null, 2)}\n`); + const entries: string[] = [`100644 blob ${manifestBlob}\t${MANIFEST_PATH}\0`]; + + for (const [key, entry] of Object.entries(normalizedManifest.files)) { + entries.push(`${entry.mode} blob ${entry.blob}\t${key}\0`); } - return relativePath.split(path.sep).join("/"); + + return this.runGit(["mktree", "-z"], { input: entries.join("") }).trim(); } - private runGit( - args: string[], - options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv } - ): string { - const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`]; - if (options.includeWorkTree) { - gitArgs.push(`--work-tree=${this.projectRoot}`); + private readManifest(commitHash: string): FileHistoryManifest { + const buffer = this.runGitBuffer(["cat-file", "blob", `${commitHash}:${MANIFEST_PATH}`]); + const parsed = JSON.parse(buffer.toString("utf8")) as FileHistoryManifest; + if (!parsed || parsed.version !== 1 || !parsed.files || typeof parsed.files !== "object") { + throw new Error("Invalid file history manifest."); + } + return normalizeManifest(parsed); + } + + private readBlob(blobHash: string): Buffer { + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); + } + return this.runGitBuffer(["cat-file", "blob", blobHash]); + } + + private hashFile(filePath: string): string { + const blobHash = this.runGit(["hash-object", "-w", "--", filePath]).trim(); + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); } - gitArgs.push(...args); + return blobHash; + } + + private hashContent(content: string): string { + const blobHash = this.runGit(["hash-object", "-w", "--stdin"], { input: content }).trim(); + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); + } + return blobHash; + } + + private getFileKey(filePath: string): string { + const hash = crypto.createHash("sha256").update(filePath).digest("hex"); + return `files-${hash}`; + } + + private runGit(args: string[], options: { input?: string | Buffer; env?: NodeJS.ProcessEnv } = {}): string { + return this.spawnGit(args, options, "utf8") as string; + } + + private runGitBuffer(args: string[], options: { input?: string | Buffer; env?: NodeJS.ProcessEnv } = {}): Buffer { + return this.spawnGit(args, options, "buffer") as Buffer; + } + + private spawnGit( + args: string[], + options: { input?: string | Buffer; env?: NodeJS.ProcessEnv }, + encoding: BufferEncoding | "buffer" + ): string | Buffer { + const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`, ...args]; const result = childProcess.spawnSync("git", gitArgs, { - encoding: "utf8", + encoding, input: options.input, env: options.env, stdio: ["pipe", "pipe", "pipe"], }); if (result.status !== 0) { - const detail = (result.stderr || result.stdout || "").trim(); + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout; + const detail = (stderr || stdout || "").trim(); throw new Error(detail || `git ${args.join(" ")} failed`); } - return result.stdout ?? ""; + return result.stdout ?? (encoding === "buffer" ? Buffer.alloc(0) : ""); + } +} + +function emptyManifest(): FileHistoryManifest { + return { version: 1, files: {} }; +} + +function normalizeManifest(manifest: FileHistoryManifest): FileHistoryManifest { + const files: Record = {}; + for (const [key, entry] of Object.entries(manifest.files).sort(([left], [right]) => left.localeCompare(right))) { + if (!isValidStoredPath(key) || !entry || entry.mode !== "100644" || !isCommitHash(entry.blob)) { + throw new Error("Invalid file history manifest."); + } + files[key] = { + path: path.resolve(entry.path), + blob: entry.blob, + mode: "100644", + }; + } + return { version: 1, files }; +} + +function uniqueAbsolutePaths(filePaths: string[]): string[] { + return Array.from(new Set(filePaths.map((filePath) => path.resolve(filePath)))); +} + +function isValidStoredPath(value: string): boolean { + return /^files-[0-9a-f]{64}$/.test(value); +} + +function removeTrackedFile(filePath: string): void { + if (!fs.existsSync(filePath)) { + return; + } + const stat = fs.lstatSync(filePath); + if (stat.isDirectory()) { + return; } + fs.unlinkSync(filePath); } function getFileHistoryGitEnv(): NodeJS.ProcessEnv { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index e5bdcb2..08d61e9 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { GitFileHistory } from "../common/file-history"; import { SessionManager, type SessionMessage } from "../session"; const originalFetch = globalThis.fetch; @@ -1040,6 +1041,54 @@ test("Write tool advances file-history while preserving the user prompt checkpoi assert.equal(fs.existsSync(filePath), false); }); +test("Write checkpoints restore tool-touched files outside the workspace and leave unrelated files alone", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-write-outside-workspace-"); + const outsideDir = createTempDir("deepcode-write-outside-target-"); + const home = createTempDir("deepcode-write-outside-home-"); + setHomeDir(home); + + const outsideFilePath = path.join(outsideDir, "outside.txt"); + const unrelatedWorkspaceFilePath = path.join(workspace, "unrelated.txt"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-outside", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: outsideFilePath, content: "outside\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an outside file" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + assert.ok(userMessage?.checkpointHash); + assert.equal(fs.readFileSync(outsideFilePath, "utf8"), "outside\n"); + + fs.writeFileSync(unrelatedWorkspaceFilePath, "keep\n", "utf8"); + manager.restoreSessionCode(sessionId, userMessage.id); + + assert.equal(fs.existsSync(outsideFilePath), false); + assert.equal(fs.readFileSync(unrelatedWorkspaceFilePath, "utf8"), "keep\n"); +}); + test("missing git executable does not block sessions or Write tool calls", async () => { const workspace = createTempDir("deepcode-no-git-write-workspace-"); const home = createTempDir("deepcode-no-git-write-home-"); @@ -2146,43 +2195,18 @@ function createFileHistoryCommit( ): string { const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); const gitDir = path.join(home, ".deepcode", "projects", projectCode, "file-history", ".git"); - const branchRef = `refs/heads/${sessionId}`; - fs.mkdirSync(path.dirname(gitDir), { recursive: true }); - if (!fs.existsSync(gitDir)) { - runFileHistoryGit(gitDir, workspace, ["init"]); - } - - let parentHash = ""; - try { - parentHash = runFileHistoryGit(gitDir, workspace, ["rev-parse", "--verify", `${branchRef}^{commit}`]).trim(); - } catch { - const emptyTree = runFileHistoryGit(gitDir, workspace, ["mktree"], ""); - parentHash = runFileHistoryGit( - gitDir, - workspace, - ["commit-tree", emptyTree.trim(), "-m", "initial checkpoint"], - "", - fileHistoryCommitEnv() - ).trim(); - runFileHistoryGit(gitDir, workspace, ["update-ref", branchRef, parentHash]); - } - runFileHistoryGit(gitDir, workspace, ["read-tree", "--reset", branchRef]); + const fileHistory = new GitFileHistory(workspace, gitDir); + fileHistory.ensureSession(sessionId); + const filePaths: string[] = []; for (const [relativePath, content] of Object.entries(files)) { const filePath = path.join(workspace, relativePath); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, content, "utf8"); + filePaths.push(filePath); } - runFileHistoryGit(gitDir, workspace, ["add", "-f", "-A", "--", ...Object.keys(files)]); - const treeHash = runFileHistoryGit(gitDir, workspace, ["write-tree"]).trim(); - const commitHash = runFileHistoryGit( - gitDir, - workspace, - ["commit-tree", treeHash, "-p", parentHash, "-m", "checkpoint"], - "", - fileHistoryCommitEnv() - ).trim(); - runFileHistoryGit(gitDir, workspace, ["update-ref", branchRef, commitHash, parentHash]); + const commitHash = fileHistory.recordCheckpoint(sessionId, filePaths, "checkpoint"); + assert.ok(commitHash); return commitHash; } @@ -2205,16 +2229,6 @@ function runFileHistoryGit( ); } -function fileHistoryCommitEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - GIT_AUTHOR_NAME: "DeepCode Test", - GIT_AUTHOR_EMAIL: "deepcode-test@example.com", - GIT_COMMITTER_NAME: "DeepCode Test", - GIT_COMMITTER_EMAIL: "deepcode-test@example.com", - }; -} - function createSessionManager(projectRoot: string, machineId: string): SessionManager { return new SessionManager({ projectRoot, From 683a51106b1d9faa8d58811da80f5857eb641934 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 21:41:14 +0800 Subject: [PATCH 202/217] feat: implement the permission system --- docs/issue_0522.md | 241 +++++++++++++ src/common/permissions.ts | 464 ++++++++++++++++++++++++++ src/prompt.ts | 23 +- src/session.ts | 229 +++++++++++-- src/settings.ts | 97 ++++++ src/tests/permissions.test.ts | 120 +++++++ src/tests/prompt.test.ts | 13 + src/tests/session.test.ts | 192 +++++++++++ src/tests/settings-and-notify.test.ts | 29 ++ src/ui/App.tsx | 74 +++- src/ui/PermissionPrompt.tsx | 229 +++++++++++++ src/ui/PromptInput.tsx | 4 +- templates/tools/bash.md | 28 +- 13 files changed, 1719 insertions(+), 24 deletions(-) create mode 100644 docs/issue_0522.md create mode 100644 src/common/permissions.ts create mode 100644 src/tests/permissions.test.ts create mode 100644 src/ui/PermissionPrompt.tsx diff --git a/docs/issue_0522.md b/docs/issue_0522.md new file mode 100644 index 0000000..2e9fd1a --- /dev/null +++ b/docs/issue_0522.md @@ -0,0 +1,241 @@ +# Deep Code Permission System (设计文档) + +scopes是枚举值,列表如下: + +``` +# PermissionScope +read-in-cwd +read-out-cwd +write-in-cwd +write-out-cwd +delete-in-cwd +delete-out-cwd +query-git-log +mutate-git-log +network +mcp +``` + +settings.json的配置项(例子): + +``` +{ + "permissions": { + "allow": [ + "write-in-cwd" + ], + "deny": [ + "write-out-cwd" + ], + "ask": [ + "read-out-cwd" + ], + "defaultMode": "allowAll|askAll" // 默认是allowAll + } +} +``` + +工具和PermissionScope可能的对应关系: + +- read: read-in-cwd, read-out-cwd +- write: write-in-cwd, write-out-cwd +- edit: write-in-cwd, write-out-cwd +- WebSearch: network +- mcp__*: mcp +- bash: 每一次bash命令需要的scope在sideEffects字段中。如果sideEffects字段为undefined|null,或者sideEffects包含了"unknown"则总是ask +- 其他: 无权限要求,总是允许 + +## bash tool的参数schema新增sideEffects字段 + +目标:让LLM在每一次调用`bash`时显式声明该命令可能需要的权限范围,后端只信任这个结构化字段,不从自然语言`description`中推断权限。 + +需要同步修改两处schema: + +1. `src/prompt.ts`里的`getTools()`内置`bash`工具定义。 +2. `templates/tools/bash.md`里的`bash`工具说明和JSON schema示例。 + +新增字段: + +``` +sideEffects: PermissionScope[] | ["unknown"] +``` + +`bash`可声明的scope只包含文件系统、Git历史和网络权限,不包含`mcp`: + +``` +read-in-cwd +read-out-cwd +write-in-cwd +write-out-cwd +delete-in-cwd +delete-out-cwd +query-git-log +mutate-git-log +network +unknown +``` + +建议schema如下: + +```json +{ + "type": "object", + "properties": { + "command": { + "description": "The command to execute", + "type": "string" + }, + "description": { + "description": "Clear, concise description of what this command does in active voice.", + "type": "string" + }, + "sideEffects": { + "description": "Permission scopes required by this bash command. Use [] only for commands that do not read, write, delete, or access the network. Use [\"unknown\"] when the effects cannot be classified safely.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown" + ] + }, + "uniqueItems": true + } + }, + "required": [ + "command", + "sideEffects" + ], + "additionalProperties": false +} +``` + +字段语义: + +- `sideEffects: []`表示命令不需要权限,例如`date`、`node --version`这类只读取进程环境或输出版本信息的命令。 +- `sideEffects`必须按最小必要权限填写;例如`rg foo src`是`["read-in-cwd"]`,`npm install`通常是`["write-in-cwd", "network"]`。 +- 如果命令访问项目目录之外的路径,需要使用`*-out-cwd`;例如`cat /etc/hosts`是`["read-out-cwd"]`。 +- 删除类操作使用`delete-*`;如果同一条命令还会写入其他文件,再同时声明对应的`write-*`。 +- 查询Git历史使用`query-git-log`;例如`git log`、`git show HEAD`、`git blame`、`git diff HEAD~1..HEAD`这类读取提交历史、提交对象或历史diff的命令。 +- 修改Git历史或引用使用`mutate-git-log`;例如`git commit`、`git reset`、`git rebase`、`git merge`、`git cherry-pick`、`git tag`这类会创建提交、移动引用或改写提交图的命令。 +- Git命令如果同时读写工作区文件,也需要同时声明文件系统scope;例如`git checkout -- src/foo.ts`需要`["write-in-cwd"]`,`git reset --hard HEAD~1`需要`["write-in-cwd", "mutate-git-log"]`。 +- `unknown`只能单独出现为`["unknown"]`,不能和其他scope混用。 + +示例: + +```json +{ "command": "date", "description": "Show current date", "sideEffects": [] } +{ "command": "rg \"TODO\" src", "description": "Search TODO markers in source files", "sideEffects": ["read-in-cwd"] } +{ "command": "npm install", "description": "Install package dependencies", "sideEffects": ["write-in-cwd", "network"] } +{ "command": "rm -rf dist", "description": "Delete build output directory", "sideEffects": ["delete-in-cwd"] } +{ "command": "curl -s https://example.com", "description": "Fetch example.com response", "sideEffects": ["network"] } +{ "command": "git show --stat HEAD", "description": "Show file statistics for HEAD", "sideEffects": ["query-git-log"] } +{ "command": "git blame src/prompt.ts", "description": "Show line authorship for prompt source", "sideEffects": ["read-in-cwd", "query-git-log"] } +{ "command": "git reset --hard HEAD~1", "description": "Reset branch and worktree to previous commit", "sideEffects": ["write-in-cwd", "mutate-git-log"] } +``` + +## 核心数据结构设计 + +``` +export type UserPromptContent = { + text?: string; + imageUrls?: string[]; + skills?: SkillInfo[]; ++ permissions?: [{toolCallId: "...", permission: "allow|deny"}]; ++ alwaysAllows?: [""]; +}; + +export type SessionEntry = { + id: string; + ... + toolCalls: unknown[] | null; // 例如:[{"id":"...","function":{"name":"bash","arguments":"{\"command\": \"...\", \"description\": \"...\"}"}}] + status: SessionStatus; ++ askPermissions?: [{toolCallId: "...", scopes: [""], name: "...", command: "...", description?: "..."}]; +}; + +export type SessionStatus = "... | "completed" | "interrupted" | "ask_permission"; // 新增 ask_permission 状态 + +export type SessionMessage = { + ... + meta?: MessageMeta; + ... +}; + +export type MessageMeta = { + ... ++ permissions?: [{toolCallId: "...", permission: "allow|deny|ask"}]; ++ userPrompt?: UserPromptContent; //对于role为user的消息,持久化userPrompt可方便后续排查问题 +}; +``` + +## 前端流程 + +如果当前会话状态不是ask_permission,则保持现状。会话状态是ask_permission时: + +对SessionEntry.askPermissions中每一个toolCallId的每一个scope,显示权限弹窗(示例): + +``` + + + + + + Do you want to proceed? + ❯ 1. Yes + 2. Yes, and always allow + 3. No +``` + +注意对于read/write/edit的``,格式可以是"工具名称+相对或绝对文件路径",例如:`read ~/dev/main.c` + +如果在权限弹窗过程中,用户按Esc,则走现有的interrupt流程(会话状态也应该变成"interrupted")。 + +提醒注意一种情况:例如askPermissions里面有好几个item的scopes是`["write-in-cwd"]`,如果用户已经在第一个权限弹窗选了"always allow write in CWD `~/dev/qrcode_test/`",则后面的几个scopes是`["write-in-cwd"]`的item就不用显示权限弹窗了。 + +如果用户完成了所有权限弹窗的选择,则判断: + +1. 如果用户提交的结果中包含deny,则需要用户输入user prompt,按回车手动提交replySession()。 + - 如果用户没有输入user prompt就退出了,或者切换到了其他会话。则重新开始这个会话时,由于会话状态还是ask_permission,则会重新显示权限弹窗,要求用户选择。 +2. 如果用户提交的结果中不包含deny,则以`/continue`作为UserPromptContent.text内容,前端自动提交replySession()。 + + +## 后端流程 + +后端主要是对replySession()和activateSession()进行升级: + +1. 支持传入UserPromptContent.permissions和alwaysAllows +2. 如果UserPromptContent.alwaysAllows非空,将其中的scopes追加写入项目级别的settings.json配置文件(`permissions.allow`字段),避免重复写入已存在的项。 +3. 检查当前会话消息列表末尾是否存在连续的role为assistant的有tool_calls的消息,也就是"待执行消息"。如果没有,则走现有流程。 +4. 对于每一条待执行消息,先检查UserPromptContent.permissions中对应的toolCallId的用户授权是allow还是deny + - 如果是allow,则正常执行这个toolCall + - 如果是deny,则直接返回失败结果,报错信息提示LLM用户禁用相关权限。例如: + ``` + { + "ok": false, + "name": "edit", + "error": "用户已禁用了在项目目录之外修改文件的权限,请不要尝试用任何方式修改目录之外的文件" + } + ``` +5. 如果对于某条待执行消息,在UserPromptContent.permissions没有出现对应的toolCallId的用户授权,则检查它的 SessionMessage.meta.permissions[].permission 是allow还是deny还是ask + - 如果是allow,则正常执行这个toolCall + - 如果是deny,则直接返回失败结果,报错信息提示LLM用户禁用相关权限 + - 如果是ask,则直接返回失败结果,报错信息提示LLM用户未授权执行。例如: + ``` + { + "ok": false, + "name": "edit", + "error": "用户暂未授权执行,如果有必要,可重新尝试执行" + } + ``` + - 如果不存在,则正常执行这个toolCall(兼容老版本会话数据) +6. 当LLM返回了新的待执行消息时,不要立即执行,而是: + 1. 根据配置的permissions和defaultMode,计算出SessionMessage.meta.permissions字段 + 2. 如果存在一个待执行消息的SessionMessage.meta.permissions[].permission是ask,则把SessionEntry.status设置为"ask_permission",并设置好SessionEntry.askPermissions,然后退出activateSession,这样就回到了上面的前端流程。 diff --git a/src/common/permissions.ts b/src/common/permissions.ts new file mode 100644 index 0000000..e9aae01 --- /dev/null +++ b/src/common/permissions.ts @@ -0,0 +1,464 @@ +import * as fs from "fs"; +import * as path from "path"; +import type { DeepcodingSettings, PermissionScope, PermissionSettings } from "../settings"; +import { isAbsoluteFilePath, normalizeFilePath } from "./state"; + +export type BashPermissionScope = Exclude | "unknown"; + +export type PermissionDecision = "allow" | "deny" | "ask"; + +export type UserToolPermission = { + toolCallId: string; + permission: "allow" | "deny"; +}; + +export type MessageToolPermission = { + toolCallId: string; + permission: PermissionDecision; +}; + +export type AskPermissionScope = PermissionScope | "unknown"; + +export type AskPermissionRequest = { + toolCallId: string; + scopes: AskPermissionScope[]; + name: string; + command: string; + description?: string; +}; + +export type PermissionToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type PermissionToolExecution = { + toolCallId: string; + content: string; + result: { + ok: boolean; + name: string; + output?: string; + error?: string; + metadata?: Record; + awaitUserResponse?: boolean; + followUpMessages?: Array<{ role: "system"; content: string; contentParams?: unknown | null }>; + }; +}; + +export type PermissionPlan = { + permissions: MessageToolPermission[]; + askPermissions: AskPermissionRequest[]; +}; + +export type ComputeToolCallPermissionsOptions = { + sessionId: string; + projectRoot: string; + toolCalls: unknown[]; + settings?: Required; + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined; +}; + +export function parseToolCallForPermissions(toolCall: unknown): PermissionToolCall | null { + if (!toolCall || typeof toolCall !== "object") { + return null; + } + const record = toolCall as { + id?: unknown; + type?: unknown; + function?: { name?: unknown; arguments?: unknown }; + }; + if (typeof record.id !== "string" || !record.function || typeof record.function !== "object") { + return null; + } + if (typeof record.function.name !== "string") { + return null; + } + return { + id: record.id, + type: "function", + function: { + name: record.function.name, + arguments: typeof record.function.arguments === "string" ? record.function.arguments : "", + }, + }; +} + +export function buildPermissionToolExecution( + toolCall: PermissionToolCall, + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } +): PermissionToolExecution | null { + const permission = resolveToolCallPermission(toolCall.id, options); + if (permission === "allow") { + return null; + } + if (permission === "deny") { + return buildSyntheticToolExecution( + toolCall, + "User denied the required permission for this tool call. Do not try to bypass this decision." + ); + } + return buildSyntheticToolExecution( + toolCall, + "The user has not authorized this tool call yet. Retry only if the permission is still necessary." + ); +} + +export function resolveToolCallPermission( + toolCallId: string, + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } +): PermissionDecision { + const override = options.permissionOverrides?.find((item) => item.toolCallId === toolCallId); + if (override?.permission === "allow" || override?.permission === "deny") { + return override.permission; + } + const messagePermission = options.messagePermissions?.find((item) => item.toolCallId === toolCallId); + if ( + messagePermission?.permission === "allow" || + messagePermission?.permission === "deny" || + messagePermission?.permission === "ask" + ) { + return messagePermission.permission; + } + return "allow"; +} + +export function buildSyntheticToolExecution(toolCall: PermissionToolCall, error: string): PermissionToolExecution { + const result = { + ok: false, + name: toolCall.function.name, + error, + }; + return { + toolCallId: toolCall.id, + content: JSON.stringify(result, null, 2), + result, + }; +} + +export function computeToolCallPermissions(options: ComputeToolCallPermissionsOptions): PermissionPlan { + const permissions: MessageToolPermission[] = []; + const askPermissions: AskPermissionRequest[] = []; + + for (const rawToolCall of options.toolCalls) { + const toolCall = parseToolCallForPermissions(rawToolCall); + if (!toolCall) { + continue; + } + const request = describeToolPermissionRequest({ + sessionId: options.sessionId, + projectRoot: options.projectRoot, + toolCall, + resolveSnippetPath: options.resolveSnippetPath, + }); + const permission = evaluatePermissionScopes(request.scopes, options.settings); + permissions.push({ toolCallId: toolCall.id, permission }); + if (permission === "ask") { + askPermissions.push({ + toolCallId: toolCall.id, + scopes: request.scopes, + name: request.name, + command: request.command, + description: request.description, + }); + } + } + + return { permissions, askPermissions }; +} + +export function describeToolPermissionRequest(options: { + sessionId: string; + projectRoot: string; + toolCall: PermissionToolCall; + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined; +}): AskPermissionRequest { + const name = options.toolCall.function.name; + const args = parseToolArgumentsForPermissions(options.toolCall.function.arguments); + + if (name === "read" || name === "Read") { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("read", filePath), + scopes: filePath ? [isPathInProject(options.projectRoot, filePath) ? "read-in-cwd" : "read-out-cwd"] : [], + }; + } + + if (name === "write" || name === "Write") { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("write", filePath), + scopes: filePath ? [isPathInProject(options.projectRoot, filePath) ? "write-in-cwd" : "write-out-cwd"] : [], + }; + } + + if (name === "edit" || name === "Edit") { + const filePath = resolveEditPermissionPath(options.sessionId, args, options.resolveSnippetPath); + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("edit", filePath), + scopes: filePath + ? [isPathInProject(options.projectRoot, filePath) ? "write-in-cwd" : "write-out-cwd"] + : ["write-out-cwd"], + }; + } + + if (name === "bash" || name === "Bash") { + const command = typeof args.command === "string" ? args.command : "bash"; + const description = typeof args.description === "string" ? args.description : undefined; + return { + toolCallId: options.toolCall.id, + name: "bash", + command, + description, + scopes: parseBashSideEffects(args.sideEffects), + }; + } + + if (name === "WebSearch") { + const query = typeof args.query === "string" ? args.query : "WebSearch"; + return { + toolCallId: options.toolCall.id, + name, + command: query, + scopes: ["network"], + }; + } + + if (name.startsWith("mcp__")) { + return { + toolCallId: options.toolCall.id, + name, + command: name, + scopes: ["mcp"], + }; + } + + return { + toolCallId: options.toolCall.id, + name, + command: name, + scopes: [], + }; +} + +export function evaluatePermissionScopes( + scopes: AskPermissionScope[], + settings: Required = { + allow: [], + deny: [], + ask: [], + defaultMode: "allowAll", + } +): PermissionDecision { + if (scopes.includes("unknown")) { + return "ask"; + } + if (scopes.length === 0) { + return "allow"; + } + const permissionScopes = scopes.filter((scope): scope is PermissionScope => scope !== "unknown"); + if (permissionScopes.some((scope) => settings.deny.includes(scope))) { + return "deny"; + } + if (permissionScopes.some((scope) => settings.ask.includes(scope))) { + return "ask"; + } + if (permissionScopes.every((scope) => settings.allow.includes(scope))) { + return "allow"; + } + return settings.defaultMode === "askAll" ? "ask" : "allow"; +} + +export function parseBashSideEffects(value: unknown): AskPermissionScope[] { + const validScopes = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown", + ]); + if (!Array.isArray(value)) { + return ["unknown"]; + } + const scopes: AskPermissionScope[] = []; + for (const item of value) { + if (typeof item !== "string" || !validScopes.has(item as AskPermissionScope)) { + return ["unknown"]; + } + const scope = item as AskPermissionScope; + if (!scopes.includes(scope)) { + scopes.push(scope); + } + } + if (scopes.includes("unknown")) { + return ["unknown"]; + } + return scopes; +} + +export function parseToolArgumentsForPermissions(rawArguments: string): Record { + if (!rawArguments) { + return {}; + } + try { + const parsed = JSON.parse(rawArguments); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +export function resolveEditPermissionPath( + sessionId: string, + args: Record, + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined +): string { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + if (filePath) { + return filePath; + } + const snippetId = typeof args.snippet_id === "string" ? args.snippet_id : ""; + return snippetId ? (resolveSnippetPath?.(sessionId, snippetId) ?? "") : ""; +} + +export function formatToolPathCommand(toolName: string, filePath: string): string { + return filePath ? `${toolName} ${filePath}` : toolName; +} + +export function isPathInProject(projectRoot: string, filePath: string): boolean { + const normalized = normalizeFilePath(filePath); + const absolutePath = isAbsoluteFilePath(normalized) ? normalized : path.resolve(projectRoot, normalized); + const relative = path.relative(path.resolve(projectRoot), path.resolve(absolutePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function hasUserPermissionReplies(value: { permissions?: unknown; alwaysAllows?: unknown }): boolean { + return Boolean( + (Array.isArray(value.permissions) && value.permissions.length > 0) || + (Array.isArray(value.alwaysAllows) && value.alwaysAllows.length > 0) + ); +} + +export function appendProjectPermissionAllows(projectRoot: string, scopes: PermissionScope[] | undefined): void { + if (!Array.isArray(scopes) || scopes.length === 0) { + return; + } + const validScopes = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", + ]); + const nextScopes = scopes.filter((scope) => validScopes.has(scope)); + if (nextScopes.length === 0) { + return; + } + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + let settings: DeepcodingSettings = {}; + try { + if (fs.existsSync(settingsPath)) { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + settings = parsed as DeepcodingSettings; + } + } + } catch { + settings = {}; + } + const currentAllow = Array.isArray(settings.permissions?.allow) ? settings.permissions.allow : []; + const allow = [...currentAllow]; + for (const scope of nextScopes) { + if (!allow.includes(scope)) { + allow.push(scope); + } + } + if (allow.length === currentAllow.length) { + return; + } + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + `${JSON.stringify( + { + ...settings, + permissions: { + ...(settings.permissions ?? {}), + allow, + }, + }, + null, + 2 + )}\n`, + "utf8" + ); +} + +export function normalizeAskPermissions(value: unknown): AskPermissionRequest[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const result: AskPermissionRequest[] = []; + for (const item of value) { + if (!item || typeof item !== "object") { + continue; + } + const record = item as Record; + if (typeof record.toolCallId !== "string" || typeof record.name !== "string") { + continue; + } + const scopes = Array.isArray(record.scopes) + ? record.scopes.filter((scope): scope is AskPermissionScope => isAskPermissionScope(scope)) + : []; + result.push({ + toolCallId: record.toolCallId, + scopes, + name: record.name, + command: typeof record.command === "string" ? record.command : record.name, + description: typeof record.description === "string" ? record.description : undefined, + }); + } + return result.length > 0 ? result : undefined; +} + +export function isAskPermissionScope(value: unknown): value is AskPermissionScope { + return ( + value === "read-in-cwd" || + value === "read-out-cwd" || + value === "write-in-cwd" || + value === "write-out-cwd" || + value === "delete-in-cwd" || + value === "delete-out-cwd" || + value === "query-git-log" || + value === "mutate-git-log" || + value === "network" || + value === "mcp" || + value === "unknown" + ); +} diff --git a/src/prompt.ts b/src/prompt.ts index 717991b..ba9bf23 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -331,8 +331,29 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe description: 'Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.', }, + sideEffects: { + description: + 'Permission scopes required by this bash command. Use [] only for commands that do not read, write, delete, or access the network. Use ["unknown"] when the effects cannot be classified safely.', + type: "array", + items: { + type: "string", + enum: [ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown", + ], + }, + uniqueItems: true, + }, }, - required: ["command"], + required: ["command", "sideEffects"], additionalProperties: false, }, }, diff --git a/src/session.ts b/src/session.ts index 3144f88..c5da055 100644 --- a/src/session.ts +++ b/src/session.ts @@ -22,13 +22,38 @@ import { type CreateOpenAIClient, type ProcessTimeoutControl, type ProcessTimeoutInfo, + type ToolCallExecution, + type ToolExecutionHooks, } from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; -import type { McpServerConfig } from "./settings"; +import type { McpServerConfig, PermissionScope, PermissionSettings } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; import { killProcessTree } from "./common/process-tree"; import { GitFileHistory } from "./common/file-history"; +import { getSnippet } from "./common/state"; +import { + appendProjectPermissionAllows, + buildPermissionToolExecution, + computeToolCallPermissions, + hasUserPermissionReplies, + normalizeAskPermissions, + parseToolCallForPermissions, + type AskPermissionRequest, + type MessageToolPermission, + type PermissionToolCall, + type UserToolPermission, +} from "./common/permissions"; + +export type { PermissionScope } from "./settings"; +export type { + AskPermissionRequest, + AskPermissionScope, + BashPermissionScope, + MessageToolPermission, + PermissionDecision, + UserToolPermission, +} from "./common/permissions"; const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; @@ -127,7 +152,14 @@ function getTotalTokens(usage: ModelUsage | null | undefined): number { return typeof totalTokens === "number" ? totalTokens : 0; } -export type SessionStatus = "failed" | "pending" | "processing" | "waiting_for_user" | "completed" | "interrupted"; +export type SessionStatus = + | "failed" + | "pending" + | "processing" + | "waiting_for_user" + | "completed" + | "interrupted" + | "ask_permission"; export type ModelUsage = { prompt_tokens: number; @@ -170,6 +202,7 @@ export type SessionEntry = { createTime: string; updateTime: string; processes: Map | null; // {pid: process info} + askPermissions?: AskPermissionRequest[]; }; export type SessionsIndex = { @@ -188,6 +221,8 @@ export type MessageMeta = { isSummary?: boolean; isModelChange?: boolean; skill?: SkillInfo; + permissions?: MessageToolPermission[]; + userPrompt?: UserPromptContent; }; export type SessionMessage = { @@ -216,6 +251,8 @@ export type UserPromptContent = { text?: string; imageUrls?: string[]; skills?: SkillInfo[]; + permissions?: UserToolPermission[]; + alwaysAllows?: PermissionScope[]; }; export type SkillInfo = { @@ -228,7 +265,12 @@ export type SkillInfo = { type SessionManagerOptions = { projectRoot: string; createOpenAIClient: CreateOpenAIClient; - getResolvedSettings: () => { model: string; webSearchTool?: string; mcpServers?: Record }; + getResolvedSettings: () => { + model: string; + webSearchTool?: string; + mcpServers?: Record; + permissions?: Required; + }; renderMarkdown: (text: string) => string; onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; @@ -253,6 +295,7 @@ export class SessionManager { model: string; webSearchTool?: string; mcpServers?: Record; + permissions?: Required; }; private readonly onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; @@ -1002,11 +1045,13 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); + appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "pending", failReason: null, + askPermissions: undefined, updateTime: now, })); @@ -1015,9 +1060,15 @@ ${skillMd} return; } + if (hasUserPermissionReplies(userPrompt) && this.hasTrailingPendingToolCalls(sessionId)) { + this.activeSessionId = sessionId; + await this.activateSession(sessionId, controller, userPrompt); + return; + } + if (this.isContinuePrompt(userPrompt)) { this.activeSessionId = sessionId; - await this.activateSession(sessionId, controller); + await this.activateSession(sessionId, controller, userPrompt); return; } @@ -1070,7 +1121,11 @@ ${skillMd} ); } - async activateSession(sessionId: string, controller?: AbortController): Promise { + async activateSession( + sessionId: string, + controller?: AbortController, + permissionPrompt?: UserPromptContent + ): Promise { const startedAt = Date.now(); const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = this.createOpenAIClient(); @@ -1129,16 +1184,20 @@ ${skillMd} return; } - const pendingToolCalls = this.getTrailingPendingToolCalls(this.listSessionMessages(sessionId)); - if (pendingToolCalls.length > 0) { - const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCalls); + const pendingToolCallMessage = this.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)); + if (pendingToolCallMessage.toolCalls.length > 0) { + const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCallMessage.toolCalls, { + permissionOverrides: permissionPrompt?.permissions, + messagePermissions: pendingToolCallMessage.message?.meta?.permissions, + }); + permissionPrompt = await this.appendDeferredPermissionPrompt(sessionId, permissionPrompt, sessionController); if (this.isInterrupted(sessionId)) { return; } if (toolAppendResult.waitingForUser) { this.updateSessionEntry(sessionId, (entry) => ({ ...entry, - toolCalls: pendingToolCalls, + toolCalls: pendingToolCallMessage.toolCalls, status: "waiting_for_user", updateTime: new Date().toISOString(), })); @@ -1192,12 +1251,47 @@ ${skillMd} return; } const assistantMessage = this.buildAssistantMessage(sessionId, content, toolCalls, thinking); + const permissionPlan = toolCalls + ? computeToolCallPermissions({ + sessionId, + projectRoot: this.projectRoot, + toolCalls, + settings: this.getResolvedSettings().permissions, + resolveSnippetPath: (id, snippetId) => getSnippet(id, snippetId)?.filePath, + }) + : null; + if (permissionPlan) { + assistantMessage.meta = { + ...(assistantMessage.meta ?? {}), + permissions: permissionPlan.permissions, + }; + } this.appendSessionMessage(sessionId, assistantMessage); this.onAssistantMessage(assistantMessage, true); let waitingForUser = false; + const responseUsage = response.usage ?? null; if (toolCalls) { - const toolAppendResult = await this.appendToolMessages(sessionId, toolCalls); + if (permissionPlan?.askPermissions.length) { + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + assistantReply: content, + assistantThinking: thinking, + assistantRefusal: refusal, + toolCalls, + usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), + activeTokens: getTotalTokens(responseUsage), + status: "ask_permission", + failReason: null, + askPermissions: permissionPlan.askPermissions, + updateTime: new Date().toISOString(), + })); + return; + } + const toolAppendResult = await this.appendToolMessages(sessionId, toolCalls, { + messagePermissions: permissionPlan?.permissions, + }); waitingForUser = toolAppendResult.waitingForUser; } @@ -1205,7 +1299,6 @@ ${skillMd} return; } - const responseUsage = response.usage ?? null; this.updateSessionEntry(sessionId, (entry) => ({ ...entry, assistantReply: content, @@ -1217,6 +1310,7 @@ ${skillMd} activeTokens: getTotalTokens(responseUsage), status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed", failReason: refusal ? refusal : entry.failReason, + askPermissions: undefined, updateTime: new Date().toISOString(), })); @@ -1768,6 +1862,7 @@ ${skillMd} visible: true, createTime: now, updateTime: now, + meta: { userPrompt: this.cloneUserPromptForMeta(prompt) }, checkpointHash: this.getCurrentCheckpointHash(sessionId), }; } @@ -1957,8 +2052,15 @@ ${skillMd} }; } - private async appendToolMessages(sessionId: string, toolCalls: unknown[]): Promise<{ waitingForUser: boolean }> { - const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { + private async appendToolMessages( + sessionId: string, + toolCalls: unknown[], + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } = {} + ): Promise<{ waitingForUser: boolean }> { + const hooks: ToolExecutionHooks = { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), @@ -1966,7 +2068,23 @@ ${skillMd} onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath), onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath), shouldStop: () => this.isInterrupted(sessionId), - }); + }; + const parsedToolCalls = toolCalls + .map((toolCall) => parseToolCallForPermissions(toolCall)) + .filter((toolCall): toolCall is PermissionToolCall => Boolean(toolCall)); + const toolExecutions: ToolCallExecution[] = []; + for (const toolCall of parsedToolCalls) { + if (hooks.shouldStop?.()) { + break; + } + const blockedResult = buildPermissionToolExecution(toolCall, options); + if (blockedResult) { + toolExecutions.push(blockedResult); + continue; + } + const executions = await this.toolExecutor.executeToolCalls(sessionId, [toolCall], hooks); + toolExecutions.push(...executions); + } if (this.isInterrupted(sessionId)) { return { waitingForUser: false }; } @@ -1997,6 +2115,72 @@ ${skillMd} return { waitingForUser }; } + private cloneUserPromptForMeta(prompt: UserPromptContent): UserPromptContent { + return { + text: prompt.text, + imageUrls: prompt.imageUrls ? [...prompt.imageUrls] : undefined, + skills: prompt.skills ? prompt.skills.map((skill) => ({ ...skill })) : undefined, + permissions: prompt.permissions ? prompt.permissions.map((permission) => ({ ...permission })) : undefined, + alwaysAllows: prompt.alwaysAllows ? [...prompt.alwaysAllows] : undefined, + }; + } + + private hasTrailingPendingToolCalls(sessionId: string): boolean { + return this.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)).toolCalls.length > 0; + } + + private async appendDeferredPermissionPrompt( + sessionId: string, + userPrompt: UserPromptContent | undefined, + controller: AbortController + ): Promise { + if (!userPrompt || this.isContinuePrompt(userPrompt)) { + return undefined; + } + const text = userPrompt.text ?? ""; + const hasUserContent = + text.trim().length > 0 || + (Array.isArray(userPrompt.imageUrls) && userPrompt.imageUrls.length > 0) || + (Array.isArray(userPrompt.skills) && userPrompt.skills.length > 0); + if (!hasUserContent) { + return undefined; + } + this.reportNewPrompt(); + const signal = controller.signal; + const userMessage = this.buildUserMessage(sessionId, userPrompt); + this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { + const skills = await this.listSkills(sessionId); + const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal, sessionId }); + this.throwIfAborted(signal); + const skillSet = new Set(skillNames); + const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); + if (Array.isArray(userPrompt.skills)) { + userPrompt.skills.push(...matchedSkill); + } else if (matchedSkill.length > 0) { + userPrompt.skills = matchedSkill; + } + } + userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); + this.throwIfAborted(signal); + if (userPrompt.skills && userPrompt.skills.length > 0) { + for (const skill of userPrompt.skills) { + if (skill.isLoaded) { + continue; + } + const skillMd = fs.readFileSync(this.resolveSkillPath(skill.path), "utf8"); + const skillPrompt = `Use the skill document below to assist the user:\n +<${skill.name}-skill path="${this.resolveSkillPath(skill.path)}"> +${skillMd} +`; + const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill); + this.appendSessionMessage(sessionId, skillMessage); + this.onAssistantMessage(skillMessage, true); + } + } + return undefined; + } + private buildOpenAIMessages( messages: SessionMessage[], thinkingEnabled: boolean, @@ -2125,18 +2309,23 @@ ${skillMd} return pairings; } - private getTrailingPendingToolCalls(messages: SessionMessage[]): unknown[] { + private getTrailingPendingToolCallMessage( + messages: SessionMessage[] + ): { message: SessionMessage; toolCalls: unknown[] } | { message: null; toolCalls: [] } { const activeMessages = messages.filter((message) => !message.compacted); const latestMessage = activeMessages[activeMessages.length - 1]; if (!latestMessage || latestMessage.role !== "assistant") { - return []; + return { message: null, toolCalls: [] }; } const toolCalls = this.getAssistantToolCalls(latestMessage); if (toolCalls.length === 0) { - return []; + return { message: null, toolCalls: [] }; } - return toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))); + return { + message: latestMessage, + toolCalls: toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))), + }; } private findPairableToolMessageIndex( @@ -2490,6 +2679,7 @@ ${skillMd} createTime: typeof value.createTime === "string" ? value.createTime : new Date().toISOString(), updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(), processes: this.deserializeProcesses(value.processes), + askPermissions: normalizeAskPermissions(value.askPermissions), }; } @@ -2500,7 +2690,8 @@ ${skillMd} status === "processing" || status === "waiting_for_user" || status === "completed" || - status === "interrupted" + status === "interrupted" || + status === "ask_permission" ) { return status; } diff --git a/src/settings.ts b/src/settings.ts index b5bb869..e0b1776 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -17,6 +17,27 @@ export type McpServerConfig = { env?: Record; }; +export type PermissionScope = + | "read-in-cwd" + | "read-out-cwd" + | "write-in-cwd" + | "write-out-cwd" + | "delete-in-cwd" + | "delete-out-cwd" + | "query-git-log" + | "mutate-git-log" + | "network" + | "mcp"; + +export type PermissionDefaultMode = "allowAll" | "askAll"; + +export type PermissionSettings = { + allow?: PermissionScope[]; + deny?: PermissionScope[]; + ask?: PermissionScope[]; + defaultMode?: PermissionDefaultMode; +}; + export type DeepcodingSettings = { env?: DeepcodingEnv; model?: string; @@ -26,6 +47,7 @@ export type DeepcodingSettings = { notify?: string; webSearchTool?: string; mcpServers?: Record; + permissions?: PermissionSettings; }; export type ResolvedDeepcodingSettings = { @@ -39,6 +61,7 @@ export type ResolvedDeepcodingSettings = { notify?: string; webSearchTool?: string; mcpServers?: Record; + permissions: Required; }; export type ModelConfigSelection = { @@ -75,6 +98,79 @@ function trimString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +const VALID_PERMISSION_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +function normalizePermissionList(value: unknown): PermissionScope[] { + if (!Array.isArray(value)) { + return []; + } + const result: PermissionScope[] = []; + for (const item of value) { + if (typeof item !== "string" || !VALID_PERMISSION_SCOPES.has(item as PermissionScope)) { + continue; + } + const scope = item as PermissionScope; + if (!result.includes(scope)) { + result.push(scope); + } + } + return result; +} + +function mergePermissionLists(...lists: Array): PermissionScope[] { + const result: PermissionScope[] = []; + for (const list of lists) { + for (const scope of list ?? []) { + if (!result.includes(scope)) { + result.push(scope); + } + } + } + return result; +} + +function normalizePermissionDefaultMode(value: unknown): PermissionDefaultMode | undefined { + return value === "allowAll" || value === "askAll" ? value : undefined; +} + +function normalizePermissions(settings: PermissionSettings | null | undefined): Required { + return { + allow: normalizePermissionList(settings?.allow), + deny: normalizePermissionList(settings?.deny), + ask: normalizePermissionList(settings?.ask), + defaultMode: normalizePermissionDefaultMode(settings?.defaultMode) ?? "allowAll", + }; +} + +function mergePermissions( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined +): Required { + const userPermissions = normalizePermissions(userSettings?.permissions); + const projectPermissions = normalizePermissions(projectSettings?.permissions); + return { + allow: mergePermissionLists(userPermissions.allow, projectPermissions.allow), + deny: mergePermissionLists(userPermissions.deny, projectPermissions.deny), + ask: mergePermissionLists(userPermissions.ask, projectPermissions.ask), + defaultMode: projectSettings?.permissions + ? projectPermissions.defaultMode + : userSettings?.permissions + ? userPermissions.defaultMode + : "allowAll", + }; +} + function normalizeEnv(env: DeepcodingSettings["env"]): Record { const result: Record = {}; if (!env) { @@ -233,6 +329,7 @@ export function resolveSettingsSources( notify: notify || undefined, webSearchTool: webSearchTool || undefined, mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), + permissions: mergePermissions(userSettings, projectSettings), }; } diff --git a/src/tests/permissions.test.ts b/src/tests/permissions.test.ts new file mode 100644 index 0000000..adb5388 --- /dev/null +++ b/src/tests/permissions.test.ts @@ -0,0 +1,120 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + appendProjectPermissionAllows, + computeToolCallPermissions, + evaluatePermissionScopes, + hasUserPermissionReplies, + parseBashSideEffects, +} from "../common/permissions"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("parseBashSideEffects accepts valid scopes and normalizes unsafe values to unknown", () => { + assert.deepEqual(parseBashSideEffects(["read-in-cwd", "network", "read-in-cwd"]), ["read-in-cwd", "network"]); + assert.deepEqual(parseBashSideEffects(undefined), ["unknown"]); + assert.deepEqual(parseBashSideEffects(["read-in-cwd", "unknown"]), ["unknown"]); + assert.deepEqual(parseBashSideEffects(["mcp"]), ["unknown"]); +}); + +test("evaluatePermissionScopes applies deny, ask, allow, and default mode precedence", () => { + const settings = { + allow: ["read-in-cwd" as const], + deny: ["write-out-cwd" as const], + ask: ["network" as const], + defaultMode: "askAll" as const, + }; + + assert.equal(evaluatePermissionScopes(["write-out-cwd"], settings), "deny"); + assert.equal(evaluatePermissionScopes(["network"], settings), "ask"); + assert.equal(evaluatePermissionScopes(["read-in-cwd"], settings), "allow"); + assert.equal(evaluatePermissionScopes(["write-in-cwd"], settings), "ask"); + assert.equal(evaluatePermissionScopes([], settings), "allow"); + assert.equal(evaluatePermissionScopes(["unknown"], settings), "ask"); +}); + +test("computeToolCallPermissions maps tool calls to permission requests", () => { + const projectRoot = createTempDir("deepcode-permissions-workspace-"); + const plan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + settings: { + allow: [], + deny: [], + ask: ["write-out-cwd", "network"], + defaultMode: "allowAll", + }, + resolveSnippetPath: () => path.join(projectRoot, "src", "file.ts"), + toolCalls: [ + { + id: "call-write", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/out.txt", content: "x" }) }, + }, + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: "curl https://example.com", sideEffects: ["network"] }), + }, + }, + { + id: "call-edit", + type: "function", + function: { name: "edit", arguments: JSON.stringify({ snippet_id: "snippet_1" }) }, + }, + ], + }); + + assert.deepEqual(plan.permissions, [ + { toolCallId: "call-write", permission: "ask" }, + { toolCallId: "call-bash", permission: "ask" }, + { toolCallId: "call-edit", permission: "allow" }, + ]); + assert.deepEqual( + plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })), + [ + { id: "call-write", scopes: ["write-out-cwd"] }, + { id: "call-bash", scopes: ["network"] }, + ] + ); +}); + +test("appendProjectPermissionAllows writes unique project-level allow scopes", () => { + const projectRoot = createTempDir("deepcode-permission-settings-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ permissions: { allow: ["read-in-cwd"] } }), "utf8"); + + appendProjectPermissionAllows(projectRoot, ["read-in-cwd", "write-in-cwd"]); + appendProjectPermissionAllows(projectRoot, ["write-in-cwd"]); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions.allow, ["read-in-cwd", "write-in-cwd"]); +}); + +test("hasUserPermissionReplies detects permission reply payloads", () => { + assert.equal(hasUserPermissionReplies({}), false); + assert.equal(hasUserPermissionReplies({ permissions: [] }), false); + assert.equal(hasUserPermissionReplies({ permissions: [{ toolCallId: "call-1", permission: "allow" }] }), true); + assert.equal(hasUserPermissionReplies({ alwaysAllows: ["network"] }), true); +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index cc86712..953de7c 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -19,6 +19,19 @@ test("getTools includes UpdatePlan with string plan schema", () => { assert.equal((tool.function.parameters.properties.plan as { type?: unknown }).type, "string"); }); +test("getTools requires bash sideEffects permission scopes", () => { + const tool = getTools().find((candidate) => candidate.function.name === "bash"); + assert.ok(tool); + assert.deepEqual(tool.function.parameters.required, ["command", "sideEffects"]); + const sideEffects = tool.function.parameters.properties.sideEffects as { + type?: unknown; + items?: { enum?: unknown[] }; + }; + assert.equal(sideEffects.type, "array"); + assert.equal(sideEffects.items?.enum?.includes("write-out-cwd"), true); + assert.equal(sideEffects.items?.enum?.includes("unknown"), true); +}); + test("getSystemPrompt always includes WebSearch docs", () => { const prompt = getSystemPrompt("/tmp/project"); assert.equal(prompt.includes("## WebSearch"), true); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 08d61e9..b3c5de9 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1256,6 +1256,162 @@ test("replySession /continue runs trailing pending tool calls before requesting ); }); +test("activateSession pauses for permission when a tool call requires ask", async () => { + const workspace = createTempDir("deepcode-permission-ask-workspace-"); + const home = createTempDir("deepcode-permission-ask-home-"); + setHomeDir(home); + + const manager = createPermissionSessionManager( + workspace, + [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "rg TODO src", + description: "Search TODO markers", + sideEffects: ["read-in-cwd"], + }), + }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ], + { + allow: [], + deny: [], + ask: [], + defaultMode: "askAll", + } + ); + + const sessionId = await manager.createSession({ text: "search todos" }); + const session = manager.getSession(sessionId); + const assistant = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls); + + assert.equal(session?.status, "ask_permission"); + assert.equal(session?.askPermissions?.[0]?.toolCallId, "call-bash"); + assert.deepEqual(session?.askPermissions?.[0]?.scopes, ["read-in-cwd"]); + assert.deepEqual(assistant?.meta?.permissions, [{ toolCallId: "call-bash", permission: "ask" }]); + assert.equal( + manager.listSessionMessages(sessionId).some((message) => message.role === "tool"), + false + ); +}); + +test("replySession applies permission replies, runs pending tools, and stores always allow scopes", async () => { + const workspace = createTempDir("deepcode-permission-allow-workspace-"); + const home = createTempDir("deepcode-permission-allow-home-"); + setHomeDir(home); + fs.writeFileSync(path.join(workspace, "note.txt"), "allowed content\n", "utf8"); + + const manager = createPermissionSessionManager( + workspace, + [createChatResponse("continued", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + { + allow: [], + deny: [], + ask: ["read-in-cwd"], + defaultMode: "allowAll", + } + ); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to read", + [ + { + id: "call-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) }, + }, + ], + null + ) as SessionMessage; + assistant.meta = { ...(assistant.meta ?? {}), permissions: [{ toolCallId: "call-read", permission: "ask" }] }; + (manager as any).appendSessionMessage(sessionId, assistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { + text: "/continue", + permissions: [{ toolCallId: "call-read", permission: "allow" }], + alwaysAllows: ["read-in-cwd"], + }); + + const toolMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "tool"); + const settings = JSON.parse(fs.readFileSync(path.join(workspace, ".deepcode", "settings.json"), "utf8")); + + assert.match(toolMessage?.content ?? "", /allowed content/); + assert.deepEqual(settings.permissions.allow, ["read-in-cwd"]); + assert.equal(manager.getSession(sessionId)?.status, "completed"); +}); + +test("replySession turns denied permission replies into tool errors before appending user text", async () => { + const workspace = createTempDir("deepcode-permission-deny-workspace-"); + const home = createTempDir("deepcode-permission-deny-home-"); + setHomeDir(home); + + const manager = createPermissionSessionManager( + workspace, + [createChatResponse("handled denial", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + { + allow: [], + deny: [], + ask: ["write-out-cwd"], + defaultMode: "allowAll", + } + ); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to write", + [ + { + id: "call-write", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/outside.txt", content: "x" }) }, + }, + ], + null + ) as SessionMessage; + assistant.meta = { ...(assistant.meta ?? {}), permissions: [{ toolCallId: "call-write", permission: "ask" }] }; + (manager as any).appendSessionMessage(sessionId, assistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { + text: "Do not write outside the workspace.", + permissions: [{ toolCallId: "call-write", permission: "deny" }], + }); + + const messages = manager.listSessionMessages(sessionId); + const assistantIndex = messages.findIndex((message) => message.id === assistant.id); + const toolMessage = messages[assistantIndex + 1]; + const userMessage = messages[assistantIndex + 2]; + + assert.equal(toolMessage?.role, "tool"); + assert.match(toolMessage?.content ?? "", /User denied the required permission/); + assert.equal(userMessage?.role, "user"); + assert.equal(userMessage?.content, "Do not write outside the workspace."); +}); + test("replySession preserves raw session messages when a previous tool call is pending", async () => { const workspace = createTempDir("deepcode-pending-tool-workspace-"); const home = createTempDir("deepcode-pending-tool-home-"); @@ -2315,6 +2471,42 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow }); } +function createPermissionSessionManager( + projectRoot: string, + responses: unknown[], + permissions: { + allow: any[]; + deny: any[]; + ask: any[]; + defaultMode: "allowAll" | "askAll"; + } +): SessionManager { + const client = { + chat: { + completions: { + create: async () => { + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + return response; + }, + }, + }, + }; + + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model", permissions }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + function createMockedClientSessionManagerWithClient(projectRoot: string, client: unknown): SessionManager { return new SessionManager({ projectRoot, diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 1707aff..52f8671 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -147,6 +147,35 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre assert.equal(resolved.env.WEBHOOK, "system-webhook"); }); +test("resolveSettingsSources merges permission settings", () => { + const resolved = resolveSettingsSources( + { + permissions: { + allow: ["read-in-cwd", "network"], + ask: ["write-out-cwd"], + defaultMode: "askAll", + }, + }, + { + permissions: { + allow: ["write-in-cwd", "read-in-cwd"], + deny: ["delete-out-cwd"], + defaultMode: "allowAll", + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV + ); + + assert.deepEqual(resolved.permissions.allow, ["read-in-cwd", "network", "write-in-cwd"]); + assert.deepEqual(resolved.permissions.ask, ["write-out-cwd"]); + assert.deepEqual(resolved.permissions.deny, ["delete-out-cwd"]); + assert.equal(resolved.permissions.defaultMode, "allowAll"); +}); + test("resolveSettingsSources merges MCP env with documented priority", () => { const resolved = resolveSettingsSources( { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5419a2a..c8c24f1 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -8,6 +8,7 @@ import { createOpenAIClient } from "../common/openai-client"; import { type LlmStreamProgress, type MessageMeta, + type PermissionScope, type SessionEntry, SessionManager, type SessionMessage, @@ -38,6 +39,7 @@ import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, } from "./askUserQuestion"; +import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; import { buildExitSummaryText } from "./exitSummary"; import { RawMode, useRawModeContext } from "./contexts"; import { renderMessageToStdout } from "./components/MessageView/utils"; @@ -76,6 +78,12 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const [streamProgress, setStreamProgress] = useState(null); const [runningProcesses, setRunningProcesses] = useState(null); const [activeStatus, setActiveStatus] = useState(null); + const [activeAskPermissions, setActiveAskPermissions] = useState(undefined); + const [pendingPermissionReply, setPendingPermissionReply] = useState<{ + sessionId: string; + permissions: PermissionPromptResult["permissions"]; + alwaysAllows: PermissionScope[]; + } | null>(null); const [dismissedQuestionIds, setDismissedQuestionIds] = useState>(() => new Set()); const [isExiting, setIsExiting] = useState(false); const [showWelcome, setShowWelcome] = useState(true); @@ -105,6 +113,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setStatusLine(buildStatusLine(entry)); setRunningProcesses(entry.processes); setActiveStatus(entry.status); + setActiveAskPermissions(entry.askPermissions); }, onLlmStreamProgress: (progress) => { if (progress.phase === "end") { @@ -214,6 +223,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setErrorLine(null); setRunningProcesses(null); setActiveStatus(null); + setActiveAskPermissions(undefined); + setPendingPermissionReply(null); setDismissedQuestionIds(new Set()); setShowWelcome(true); setWelcomeNonce((n) => n + 1); @@ -257,7 +268,16 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. imageUrls: submission.imageUrls, skills: submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, + permissions: submission.permissions, + alwaysAllows: submission.alwaysAllows, }; + const activeSessionId = sessionManager.getActiveSessionId(); + const permissionReply = + pendingPermissionReply && activeSessionId === pendingPermissionReply.sessionId ? pendingPermissionReply : null; + if (permissionReply) { + prompt.permissions = permissionReply.permissions; + prompt.alwaysAllows = permissionReply.alwaysAllows; + } const trimmedText = (submission.text ?? "").trim(); const selectedSkillNames = submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; @@ -277,6 +297,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. processStdoutRef.current.clear(); try { await sessionManager.handleUserPrompt(prompt); + if (permissionReply) { + setPendingPermissionReply(null); + } await refreshSkills(); refreshSessionsList(); } catch (error) { @@ -288,7 +311,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setRunningProcesses(null); } }, - [exit, onRestart, sessionManager, refreshSkills, refreshSessionsList] + [exit, onRestart, pendingPermissionReply, sessionManager, refreshSkills, refreshSessionsList] ); const handleInterrupt = useCallback(() => { @@ -407,9 +430,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setStatusLine(session ? buildStatusLine(session) : ""); setRunningProcesses(session?.processes ?? null); setActiveStatus(session?.status ?? null); + setActiveAskPermissions(session?.askPermissions); + if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { + setPendingPermissionReply(null); + } await refreshSkills(sessionId); }, - [sessionManager, refreshSkills] + [pendingPermissionReply, sessionManager, refreshSkills] ); const handleUndoRestore = useCallback( @@ -605,6 +632,39 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setDismissedQuestionIds((prev) => new Set(prev).add(pendingQuestion.messageId)); }, [pendingQuestion]); + const handlePermissionResult = useCallback( + (result: PermissionPromptResult) => { + const sessionId = sessionManager.getActiveSessionId(); + if (!sessionId) { + return; + } + if (result.hasDeny) { + setPendingPermissionReply({ + sessionId, + permissions: result.permissions, + alwaysAllows: result.alwaysAllows, + }); + setStatusLine("Permission denied. Add a reply, then press Enter to continue."); + return; + } + void handlePrompt({ + text: "/continue", + imageUrls: [], + command: "continue", + permissions: result.permissions, + alwaysAllows: result.alwaysAllows, + }); + }, + [handlePrompt, sessionManager] + ); + + const handlePermissionCancel = useCallback(() => { + sessionManager.interruptActiveSession(); + setActiveStatus("interrupted"); + setActiveAskPermissions(undefined); + refreshSessionsList(); + }, [refreshSessionsList, sessionManager]); + if (mode === RawMode.Raw) { return handleRawModeChange(prev)} />; } @@ -683,6 +743,16 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. onSubmit={handleQuestionAnswers} onCancel={handleQuestionCancel} /> + ) : activeStatus === "ask_permission" && + activeAskPermissions && + activeAskPermissions.length > 0 && + !pendingPermissionReply && + !busy ? ( + ) : isExiting ? null : ( void; + onCancel: () => void; +}; + +type ScopePrompt = { + request: AskPermissionRequest; + scope: AskPermissionScope; +}; + +const ALWAYS_ALLOWED_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React.ReactElement | null { + const prompts = useMemo(() => buildScopePrompts(requests), [requests]); + const [index, setIndex] = useState(0); + const [cursor, setCursor] = useState(0); + const [decisions, setDecisions] = useState>({}); + const [alwaysAllows, setAlwaysAllows] = useState([]); + + const effectiveIndex = findNextPromptIndex(prompts, index, alwaysAllows); + const prompt = prompts[effectiveIndex] ?? null; + const options = prompt ? buildOptions(prompt.scope) : []; + + useEffect(() => { + setIndex(0); + setCursor(0); + setDecisions({}); + setAlwaysAllows([]); + }, [requests]); + + useEffect(() => { + if (!prompt) { + onSubmit(buildResult(requests, decisions, alwaysAllows)); + } + }, [alwaysAllows, decisions, onSubmit, prompt, requests]); + + useEffect(() => { + if (cursor >= options.length) { + setCursor(Math.max(0, options.length - 1)); + } + }, [cursor, options.length]); + + useTerminalInput((input, key) => { + if (!prompt) { + return; + } + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (key.upArrow) { + setCursor((value) => Math.max(0, value - 1)); + return; + } + if (key.downArrow) { + setCursor((value) => Math.min(options.length - 1, value + 1)); + return; + } + if (input && /^[1-3]$/.test(input)) { + const nextCursor = Number(input) - 1; + if (nextCursor >= 0 && nextCursor < options.length) { + commit(options[nextCursor]!.kind); + } + return; + } + if (key.return) { + commit(options[cursor]?.kind ?? "allow"); + } + }); + + if (!prompt) { + return null; + } + + function commit(kind: "allow" | "always" | "deny"): void { + if (!prompt) { + return; + } + if (kind === "always" && isAlwaysAllowedScope(prompt.scope)) { + const scope = prompt.scope; + setAlwaysAllows((prev) => (prev.includes(scope) ? prev : [...prev, scope])); + setDecisions((prev) => ({ + ...prev, + [prompt.request.toolCallId]: prev[prompt.request.toolCallId] === "deny" ? "deny" : "allow", + })); + } else { + setDecisions((prev) => ({ + ...prev, + [prompt.request.toolCallId]: + kind === "deny" ? "deny" : prev[prompt.request.toolCallId] === "deny" ? "deny" : "allow", + })); + } + setIndex(effectiveIndex + 1); + setCursor(0); + } + + return ( + + + + Permission required + + + {" "} + {Math.min(effectiveIndex + 1, prompts.length)}/{prompts.length} + + + {prompt.request.name} + {prompt.request.command} + {prompt.request.description ? {prompt.request.description} : null} + + Do you want to proceed? + + + {options.map((option, optionIndex) => ( + + {optionIndex === cursor ? "> " : " "} + {optionIndex + 1}. {option.label} + + ))} + + + ↑/↓ move · Enter select · Esc interrupt + + + ); +} + +function buildScopePrompts(requests: AskPermissionRequest[]): ScopePrompt[] { + const prompts: ScopePrompt[] = []; + for (const request of requests) { + for (const scope of request.scopes.length > 0 ? request.scopes : ["unknown" as const]) { + prompts.push({ request, scope }); + } + } + return prompts; +} + +function buildOptions(scope: AskPermissionScope): Array<{ kind: "allow" | "always" | "deny"; label: string }> { + const options: Array<{ kind: "allow" | "always" | "deny"; label: string }> = [{ kind: "allow", label: "Yes" }]; + if (isAlwaysAllowedScope(scope)) { + options.push({ kind: "always", label: `Yes, and always allow ${describeScope(scope)}` }); + } + options.push({ kind: "deny", label: "No" }); + return options; +} + +function findNextPromptIndex(prompts: ScopePrompt[], startIndex: number, alwaysAllows: PermissionScope[]): number { + let index = startIndex; + while (index < prompts.length) { + const scope = prompts[index]!.scope; + if (isAlwaysAllowedScope(scope) && alwaysAllows.includes(scope)) { + index += 1; + continue; + } + return index; + } + return prompts.length; +} + +function buildResult( + requests: AskPermissionRequest[], + decisions: Record, + alwaysAllows: PermissionScope[] +): PermissionPromptResult { + const permissions = requests.map((request) => ({ + toolCallId: request.toolCallId, + permission: decisions[request.toolCallId] === "deny" ? ("deny" as const) : ("allow" as const), + })); + return { + permissions, + alwaysAllows, + hasDeny: permissions.some((permission) => permission.permission === "deny"), + }; +} + +function isAlwaysAllowedScope(scope: AskPermissionScope): scope is PermissionScope { + return ALWAYS_ALLOWED_SCOPES.has(scope); +} + +function describeScope(scope: PermissionScope): string { + switch (scope) { + case "read-in-cwd": + return "reads inside this workspace"; + case "read-out-cwd": + return "reads outside this workspace"; + case "write-in-cwd": + return "writes inside this workspace"; + case "write-out-cwd": + return "writes outside this workspace"; + case "delete-in-cwd": + return "deletes inside this workspace"; + case "delete-out-cwd": + return "deletes outside this workspace"; + case "query-git-log": + return "Git history queries"; + case "mutate-git-log": + return "Git history changes"; + case "network": + return "network access"; + case "mcp": + return "MCP tool access"; + default: + return scope; + } +} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 8897fd3..8c808e9 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -46,7 +46,7 @@ import { } from "./fileMentions"; import type { FileMentionItem } from "./fileMentions"; import { readClipboardImageAsync } from "./clipboard"; -import type { SessionEntry, SkillInfo } from "../session"; +import type { PermissionScope, SessionEntry, SkillInfo, UserToolPermission } from "../session"; // Re-exported from prompt modules for backward compatibility export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./prompt"; @@ -68,6 +68,8 @@ export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; + permissions?: UserToolPermission[]; + alwaysAllows?: PermissionScope[]; command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; }; diff --git a/templates/tools/bash.md b/templates/tools/bash.md index 0705120..e8597ab 100644 --- a/templates/tools/bash.md +++ b/templates/tools/bash.md @@ -28,6 +28,11 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. + - The sideEffects argument is required. Declare the minimum permission scopes the command may need. + - Use `sideEffects: []` only for commands that do not read, write, delete, query Git history, mutate Git history, or access the network, such as `date` or `node --version`. + - Use `*-out-cwd` when the command accesses paths outside the current workspace. For example, `cat /etc/hosts` requires `["read-out-cwd"]`. + - Use `query-git-log` for commands such as `git log`, `git show HEAD`, `git blame`, or history diffs. Use `mutate-git-log` for commands such as `git commit`, `git reset`, `git rebase`, `git merge`, `git cherry-pick`, or `git tag`. + - Use `["unknown"]` when you cannot classify the command safely. `unknown` must appear alone. - It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does. - If the output exceeds 30000 characters, output will be truncated before being returned to you. - Always prefer using the dedicated tools for these commands: @@ -60,10 +65,31 @@ Usage notes: "description": { "description": "Clear, concise description of what this command does in active voice. Never use words like \"complex\" or \"risk\" in the description - just describe what it does.\n\nFor simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):\n- ls → \"List files in current directory\"\n- git status → \"Show working tree status\"\n- npm install → \"Install package dependencies\"\n\nFor commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:\n- find . -name \"*.tmp\" -exec rm {} \\; → \"Find and delete all .tmp files recursively\"\n- git reset --hard origin/main → \"Discard all local changes and match remote main\"\n- curl -s url | jq '.data[]' → \"Fetch JSON from URL and extract data array elements\"", "type": "string" + }, + "sideEffects": { + "description": "Permission scopes required by this bash command. Use [] only for commands that do not read, write, delete, or access the network. Use [\"unknown\"] when the effects cannot be classified safely.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown" + ] + }, + "uniqueItems": true } }, "required": [ - "command" + "command", + "sideEffects" ], "additionalProperties": false } From 90c6b2e7ea6c6c1e343c074c06f00c5c09391d68 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 21:42:32 +0800 Subject: [PATCH 203/217] chore: update bash.md --- templates/tools/bash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/tools/bash.md b/templates/tools/bash.md index e8597ab..83027d3 100644 --- a/templates/tools/bash.md +++ b/templates/tools/bash.md @@ -32,7 +32,7 @@ Usage notes: - Use `sideEffects: []` only for commands that do not read, write, delete, query Git history, mutate Git history, or access the network, such as `date` or `node --version`. - Use `*-out-cwd` when the command accesses paths outside the current workspace. For example, `cat /etc/hosts` requires `["read-out-cwd"]`. - Use `query-git-log` for commands such as `git log`, `git show HEAD`, `git blame`, or history diffs. Use `mutate-git-log` for commands such as `git commit`, `git reset`, `git rebase`, `git merge`, `git cherry-pick`, or `git tag`. - - Use `["unknown"]` when you cannot classify the command safely. `unknown` must appear alone. + - Use `["unknown"]` when you cannot classify the command safely. - It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does. - If the output exceeds 30000 characters, output will be truncated before being returned to you. - Always prefer using the dedicated tools for these commands: From 104acff28f6fdc72ee7731813c3aa6069eb73235 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 23 May 2026 00:02:13 +0800 Subject: [PATCH 204/217] feat: enhance appendProjectPermissionAllows to support inherited permissions --- .gitignore | 1 + src/common/permissions.ts | 35 ++++++++-- src/session.ts | 4 +- src/tests/permissions.test.ts | 120 ++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 11b67ce..8f054d4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .vscode/ *.tgz *.log +.deepcode/settings.json diff --git a/src/common/permissions.ts b/src/common/permissions.ts index e9aae01..aa87e0d 100644 --- a/src/common/permissions.ts +++ b/src/common/permissions.ts @@ -360,7 +360,11 @@ export function hasUserPermissionReplies(value: { permissions?: unknown; alwaysA ); } -export function appendProjectPermissionAllows(projectRoot: string, scopes: PermissionScope[] | undefined): void { +export function appendProjectPermissionAllows( + projectRoot: string, + scopes: PermissionScope[] | undefined, + options: { inheritedPermissions?: Required } = {} +): void { if (!Array.isArray(scopes) || scopes.length === 0) { return; } @@ -392,14 +396,35 @@ export function appendProjectPermissionAllows(projectRoot: string, scopes: Permi } catch { settings = {}; } - const currentAllow = Array.isArray(settings.permissions?.allow) ? settings.permissions.allow : []; + + const existingPermissions = settings.permissions; + const permissions: PermissionSettings = existingPermissions + ? { ...existingPermissions } + : options.inheritedPermissions + ? { + allow: [...options.inheritedPermissions.allow], + deny: [...options.inheritedPermissions.deny], + ask: [...options.inheritedPermissions.ask], + defaultMode: options.inheritedPermissions.defaultMode, + } + : {}; + + const currentAllow = Array.isArray(permissions.allow) ? permissions.allow : []; const allow = [...currentAllow]; for (const scope of nextScopes) { if (!allow.includes(scope)) { allow.push(scope); } } - if (allow.length === currentAllow.length) { + const currentDeny = Array.isArray(permissions.deny) ? permissions.deny : undefined; + const currentAsk = Array.isArray(permissions.ask) ? permissions.ask : undefined; + const deny = currentDeny ? currentDeny.filter((scope) => !nextScopes.includes(scope)) : permissions.deny; + const ask = currentAsk ? currentAsk.filter((scope) => !nextScopes.includes(scope)) : permissions.ask; + const changed = + allow.length !== currentAllow.length || + (currentDeny ? (deny as PermissionScope[]).length !== currentDeny.length : false) || + (currentAsk ? (ask as PermissionScope[]).length !== currentAsk.length : false); + if (existingPermissions && !changed) { return; } fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); @@ -409,7 +434,9 @@ export function appendProjectPermissionAllows(projectRoot: string, scopes: Permi { ...settings, permissions: { - ...(settings.permissions ?? {}), + ...permissions, + deny, + ask, allow, }, }, diff --git a/src/session.ts b/src/session.ts index c5da055..a8a194e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1045,7 +1045,9 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); - appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows); + appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows, { + inheritedPermissions: this.getResolvedSettings().permissions, + }); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, diff --git a/src/tests/permissions.test.ts b/src/tests/permissions.test.ts index adb5388..8babf11 100644 --- a/src/tests/permissions.test.ts +++ b/src/tests/permissions.test.ts @@ -106,6 +106,126 @@ test("appendProjectPermissionAllows writes unique project-level allow scopes", ( assert.deepEqual(settings.permissions.allow, ["read-in-cwd", "write-in-cwd"]); }); +test("appendProjectPermissionAllows seeds inherited permissions before adding allow scopes", () => { + const projectRoot = createTempDir("deepcode-permission-settings-default-"); + + appendProjectPermissionAllows(projectRoot, ["query-git-log"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "query-git-log"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows moves inherited ask and deny scopes into allow", () => { + const projectRoot = createTempDir("deepcode-permission-settings-move-inherited-"); + + appendProjectPermissionAllows(projectRoot, ["network", "write-out-cwd"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network", "mcp"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "network", "write-out-cwd"], + deny: [], + ask: ["mcp"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows writes inherited permissions even when scope is already allowed", () => { + const projectRoot = createTempDir("deepcode-permission-settings-inherited-existing-"); + + appendProjectPermissionAllows(projectRoot, ["read-in-cwd"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: [], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd"], + deny: [], + ask: ["network"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows preserves existing project permissions", () => { + const projectRoot = createTempDir("deepcode-permission-settings-explicit-default-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ permissions: { allow: ["read-in-cwd"], defaultMode: "allowAll" } }), + "utf8" + ); + + appendProjectPermissionAllows(projectRoot, ["query-git-log"], { + inheritedPermissions: { + allow: ["write-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "query-git-log"], + defaultMode: "allowAll", + }); +}); + +test("appendProjectPermissionAllows removes existing ask and deny conflicts", () => { + const projectRoot = createTempDir("deepcode-permission-settings-existing-conflict-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + permissions: { + allow: ["read-in-cwd"], + deny: ["network", "write-out-cwd"], + ask: ["network", "mcp"], + defaultMode: "askAll", + }, + }), + "utf8" + ); + + appendProjectPermissionAllows(projectRoot, ["network"]); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "network"], + deny: ["write-out-cwd"], + ask: ["mcp"], + defaultMode: "askAll", + }); +}); + test("hasUserPermissionReplies detects permission reply payloads", () => { assert.equal(hasUserPermissionReplies({}), false); assert.equal(hasUserPermissionReplies({ permissions: [] }), false); From bacb6a4fab37a38be434485eeea194dd36f6b4bc Mon Sep 17 00:00:00 2001 From: xinggitxing Date: Sat, 23 May 2026 18:32:52 +0800 Subject: [PATCH 205/217] feat(ui): add session deletion with Delete key confirmation - Add SessionManager.deleteSession() to remove session index entry and messages file - Add Delete key to trigger session deletion confirmation in SessionList - Two-step confirmation: Enter to confirm, Esc to cancel - Separate backspace (search) and delete (delete trigger) key behavior - Clear active session if deleted session was the active one - Add comprehensive test coverage for deleteSession --- src/session.ts | 22 +++++++ src/tests/session.test.ts | 129 ++++++++++++++++++++++++++++++++++++++ src/ui/App.tsx | 8 +++ src/ui/SessionList.tsx | 71 +++++++++++++++++---- 4 files changed, 218 insertions(+), 12 deletions(-) diff --git a/src/session.ts b/src/session.ts index 54340e7..a3a6dd1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1476,6 +1476,28 @@ ${skillMd} return index.entries.find((entry) => entry.id === sessionId) ?? null; } + /** + * Delete a session by its ID. + * Removes the session entry from the index and deletes the associated messages file. + * Returns true if the session was found and deleted, false otherwise. + */ + deleteSession(sessionId: string): boolean { + const index = this.loadSessionsIndex(); + const entryIndex = index.entries.findIndex((entry) => entry.id === sessionId); + if (entryIndex === -1) { + return false; + } + + // Remove from index + index.entries.splice(entryIndex, 1); + this.saveSessionsIndex(index); + + // Remove messages file + this.removeSessionMessages([sessionId]); + + return true; + } + listSessionMessages(sessionId: string): SessionMessage[] { const messagePath = this.getSessionMessagesPath(sessionId); if (!fs.existsSync(messagePath)) { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index fd83199..a8d943f 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -2123,6 +2123,135 @@ test("SessionManager adjusts the active Bash timeout control and session metadat assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString()); }); +test("SessionManager.deleteSession removes session entry from the index", () => { + const workspace = createTempDir("deepcode-delete-workspace-"); + const home = createTempDir("deepcode-delete-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete"); + (manager as any).activateSession = async () => {}; + + // Create two sessions + const session1 = createSessionAndMessages(manager, "session-delete-1", "First session"); + const session2 = createSessionAndMessages(manager, "session-delete-2", "Second session"); + + assert.equal(manager.listSessions().length, 2); + + // Delete the first session + const result = manager.deleteSession(session1); + assert.equal(result, true); + + const remaining = manager.listSessions(); + assert.equal(remaining.length, 1); + assert.equal(remaining[0]?.id, session2); +}); + +test("SessionManager.deleteSession removes the messages file", () => { + const workspace = createTempDir("deepcode-delete-msg-workspace-"); + const home = createTempDir("deepcode-delete-msg-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-msg"); + (manager as any).activateSession = async () => {}; + + const sessionId = createSessionAndMessages(manager, "session-delete-msg", "Test session"); + const messagePath = path.join( + home, + ".deepcode", + "projects", + workspace.replace(/[\\\\/]/g, "-").replace(/:/g, ""), + `${sessionId}.jsonl` + ); + + // Verify messages file exists + assert.ok(fs.existsSync(messagePath)); + + manager.deleteSession(sessionId); + + // Verify messages file is removed + assert.equal(fs.existsSync(messagePath), false); +}); + +test("SessionManager.deleteSession returns false when session does not exist", () => { + const workspace = createTempDir("deepcode-delete-nonexist-workspace-"); + const home = createTempDir("deepcode-delete-nonexist-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-nonexist"); + + const result = manager.deleteSession("nonexistent-session-id"); + assert.equal(result, false); + assert.equal(manager.listSessions().length, 0); +}); + +test("SessionManager.deleteSession does not affect other sessions", () => { + const workspace = createTempDir("deepcode-delete-others-workspace-"); + const home = createTempDir("deepcode-delete-others-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-others"); + (manager as any).activateSession = async () => {}; + + const session1 = createSessionAndMessages(manager, "session-keep-1", "Keep session 1"); + const session2 = createSessionAndMessages(manager, "session-keep-2", "Keep session 2"); + + // Delete non-existent session + const result = manager.deleteSession("non-existent"); + assert.equal(result, false); + assert.equal(manager.listSessions().length, 2); + + // Delete one session + assert.equal(manager.deleteSession(session1), true); + assert.equal(manager.listSessions().length, 1); + assert.equal(manager.listSessions()[0]?.id, session2); + + // The remaining session should still have its messages accessible + const messages = manager.listSessionMessages(session2); + assert.ok(messages.length > 0); +}); + +/** + * Helper: creates a session and writes a few messages to it so we can test + * that deleteSession removes both the index entry and the messages file. + */ +function createSessionAndMessages(manager: SessionManager, sessionId: string, summary: string): string { + const now = new Date().toISOString(); + const index = (manager as any).loadSessionsIndex(); + index.entries.push({ + id: sessionId, + summary, + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage: null, + usagePerModel: null, + activeTokens: 0, + createTime: now, + updateTime: now, + processes: null, + }); + (manager as any).saveSessionsIndex(index); + + // Write a couple of message lines to the messages file + const projectDir = (manager as any).getProjectStorage().projectDir; + const messagePath = path.join(projectDir, `${sessionId}.jsonl`); + const msg = JSON.stringify({ + id: "msg-1", + sessionId, + role: "user", + content: summary, + visible: true, + createTime: now, + updateTime: now, + }); + fs.writeFileSync(messagePath, `${msg}\n`, "utf8"); + + return sessionId; +} + function hasGit(): boolean { try { execFileSync("git", ["--version"], { stdio: "ignore" }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5419a2a..942bbf8 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -658,6 +658,14 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. sessions={sessions} 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(); + }} /> ) : view === "undo" ? ( void; onCancel: () => void; + onDelete?: (sessionId: string) => void; }; /** @@ -36,9 +37,10 @@ export function filterSessions(sessions: SessionEntry[], query: string): Session }); } -export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { +export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): React.ReactElement { const [index, setIndex] = useState(0); const [searchQuery, setSearchQuery] = useState(""); + const [confirmDeleteSessionId, setConfirmDeleteSessionId] = useState(null); const { columns, rows } = useWindowSize(); // Filter sessions by search query @@ -77,7 +79,23 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac setIndex(0); }, []); + const selectedSession = filteredSessions[safeIndex]; + useInput((input, key) => { + // If in delete confirmation mode, handle confirm/cancel + if (confirmDeleteSessionId) { + if (key.return) { + onDelete?.(confirmDeleteSessionId); + setConfirmDeleteSessionId(null); + return; + } + if (key.escape) { + setConfirmDeleteSessionId(null); + return; + } + return; + } + // ESC: clear search first, then cancel if (key.escape) { if (searchQuery) { @@ -95,13 +113,25 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return; } - // Backspace / Delete: remove last search character - if (key.backspace || key.delete) { + // 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 (searchQuery) { handleBackspace(); return; } - // If no search query, navigation keys below handle the rest + // No search query: start delete confirmation if session is selected + if (selectedSession && onDelete) { + setConfirmDeleteSessionId(selectedSession.id); + return; + } } // Printable character: append to search query @@ -211,20 +241,23 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac ) : ( visibleSessions.map((session, i) => { const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + const isConfirming = confirmDeleteSessionId === session.id; return ( - {actualIndex === safeIndex ? "> " : " "} + {isSelected ? "> " : " "} - + {formatSessionTitle(session.summary || "Untitled")} - ({formatSessionStatus(session.status)}) + {isConfirming ? ( + [Delete? Enter=yes, Esc=no] + ) : ( + ({formatSessionStatus(session.status)}) + )} {formatTimestamp(session.updateTime)} @@ -245,14 +278,28 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac {/* Footer */} - {hasActiveSearch ? ( + {confirmDeleteSessionId ? ( + + Delete this session? + + Enter + + to confirm · + + Esc + + to cancel + + ) : hasActiveSearch ? ( Esc clear search · ↑/↓ navigate · Enter select · Esc again to cancel ) : ( - Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel + + Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel · Del delete + )} From 928551e127b0df5f77b44aaee030863f838406e0 Mon Sep 17 00:00:00 2001 From: dengm Date: Sat, 23 May 2026 19:58:33 +0800 Subject: [PATCH 206/217] feat: add closed-border markdown table rendering with CJK/emoji support - Detect markdown tables and render with Unicode box-drawing characters - Calculate visual terminal width for CJK/emoji (2 cols) vs ASCII (1 col) - Wrap long cells across multiple lines, prefer word-boundary breaks - Allocate column widths: narrow columns (#, status, count, date) minimal, content columns kept >= 12 chars - Render tables with to prevent Ink from breaking box-drawing lines at cell boundary spaces - Expose renderMarkdownSegments() for per-segment wrapping control --- Screenshot_2026-05-23_195028.png | Bin 0 -> 105561 bytes src/ui/components/MessageView/index.tsx | 17 +- src/ui/components/MessageView/markdown.ts | 324 +++++++++++++++++++--- src/ui/index.ts | 2 +- 4 files changed, 304 insertions(+), 39 deletions(-) create mode 100644 Screenshot_2026-05-23_195028.png diff --git a/Screenshot_2026-05-23_195028.png b/Screenshot_2026-05-23_195028.png new file mode 100644 index 0000000000000000000000000000000000000000..870fbaae9e6cb8f3940673faac16d3811fea2f41 GIT binary patch literal 105561 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 - - {content ? {renderMarkdown(content)} : null} + + {content + ? renderMarkdownSegments(content, Math.max(20, contentWidth - 4)).map((seg, i) => { + if (seg.kind === "table") { + return ( + + {seg.body} + + ); + } + return {seg.body}; + }) + : null} ); diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index 11fb0ea..8c86534 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -1,22 +1,61 @@ import chalk from "chalk"; -export function renderMarkdown(text: string): string { - if (!text) { - return ""; - } +/** + * A rendered piece of markdown. Consumers should use `wrap="truncate-end"` for + * `table` segments and the default wrap mode for `text` segments so that Ink + * never breaks box-drawing lines at cell boundary spaces. + */ +export type MarkdownSegment = + | { kind: "text"; body: string } + | { kind: "table"; body: string } + | { kind: "code"; body: string; lang: string }; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Render markdown to a single string (backward-compatible). */ +export function renderMarkdown(text: string, maxWidth?: number): string { + return renderMarkdownSegments(text, maxWidth) + .map((s) => s.body) + .join(""); +} + +/** Render markdown, returning typed segments so the caller can choose the + right `` per segment. */ +export function renderMarkdownSegments(text: string, maxWidth?: number): MarkdownSegment[] { + if (!text) return []; + const segments: MarkdownSegment[] = []; const fenceSegments = splitByFences(text); - return fenceSegments - .map((segment) => { - if (segment.kind === "code") { - const langTag = segment.lang ? chalk.dim(`[${segment.lang}]`) + "\n" : ""; - return langTag + chalk.cyan(segment.body); + + for (const seg of fenceSegments) { + if (seg.kind === "code") { + const langTag = seg.lang ? chalk.dim(`[${seg.lang}]`) + "\n" : ""; + segments.push({ kind: "code", body: langTag + chalk.cyan(seg.body), lang: seg.lang }); + continue; + } + const blocks = splitTableBlocks(seg.body); + for (const b of blocks) { + if (b.kind === "table") { + segments.push({ kind: "table", body: renderTableBorder(b.rows, maxWidth) }); + } else { + const body = b.body + .split("\n") + .map((line) => renderInlineLine(line)) + .join("\n"); + if (body) segments.push({ kind: "text", body }); } - return renderInlineBlock(segment.body); - }) - .join(""); + } + } + + return segments; } +// --------------------------------------------------------------------------- +// Code fences +// --------------------------------------------------------------------------- + type FenceSegment = { kind: "text"; body: string } | { kind: "code"; lang: string; body: string }; function splitByFences(text: string): FenceSegment[] { @@ -28,35 +67,27 @@ function splitByFences(text: string): FenceSegment[] { let fenceBody: string[] = []; const flushText = () => { - if (buffer.length === 0) { - return; + if (buffer.length > 0) { + segments.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; } - segments.push({ kind: "text", body: buffer.join("\n") }); - buffer = []; }; for (const line of lines) { - const fenceMatch = /^\s*```(\w*)\s*$/.exec(line); - if (fenceMatch) { + const m = /^\s*```(\w*)\s*$/.exec(line); + if (m) { if (!inFence) { flushText(); inFence = true; - fenceLang = fenceMatch[1] ?? ""; + fenceLang = m[1] ?? ""; fenceBody = []; } else { segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); inFence = false; - fenceLang = ""; - fenceBody = []; } continue; } - - if (inFence) { - fenceBody.push(line); - } else { - buffer.push(line); - } + (inFence ? fenceBody : buffer).push(line); } if (inFence) { @@ -68,13 +99,238 @@ function splitByFences(text: string): FenceSegment[] { return segments; } -function renderInlineBlock(text: string): string { - return text - .split("\n") - .map((line) => renderInlineLine(line)) - .join("\n"); +// --------------------------------------------------------------------------- +// Table parsing +// --------------------------------------------------------------------------- + +type TableBlock = { kind: "text"; body: string } | { kind: "table"; rows: string[][] }; + +function splitTableBlocks(text: string): TableBlock[] { + const lines = text.split(/\r?\n/); + const blocks: TableBlock[] = []; + let buffer: string[] = []; + let tableRows: string[][] = []; + let inTable = false; + + const flushText = () => { + if (buffer.length > 0) { + blocks.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; + } + }; + const flushTable = () => { + if (tableRows.length >= 2) { + blocks.push({ kind: "table", rows: tableRows }); + } else if (tableRows.length > 0) { + buffer.push(...tableRows.map((r) => r.join(" | "))); + } + tableRows = []; + }; + + const sepRe = /^\|?\s*:?[-]{3,}:?\s*(\|\s*:?[-]{3,}:?\s*)*\|?\s*$/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const nextTrimmed = (lines[i + 1] ?? "").trim(); + + // skip separator line + if (inTable && sepRe.test(trimmed) && tableRows.length === 1) continue; + + const isRow = /^\|.+\|$/.test(trimmed); + const isHeader = isRow && i + 1 < lines.length && sepRe.test(nextTrimmed); + + if (isHeader && !inTable) { + flushText(); + inTable = true; + tableRows = [ + trimmed + .split("|") + .filter(Boolean) + .map((s) => s.trim()), + ]; + continue; + } + + if (isRow && inTable) { + tableRows.push( + trimmed + .split("|") + .filter(Boolean) + .map((s) => s.trim()) + ); + continue; + } + + if (inTable && !isRow) { + flushTable(); + inTable = false; + } + buffer.push(line); + } + + return inTable ? [...blocks, ...flushTableResult(tableRows)] : [...blocks, ...flushTextOnly(buffer, tableRows)]; +} + +function flushTableResult(rows: string[][]): TableBlock[] { + if (rows.length >= 2) return [{ kind: "table", rows }]; + if (rows.length > 0) return [{ kind: "text", body: rows.map((r) => r.join(" | ")).join("\n") }]; + return []; +} + +function flushTextOnly(buffer: string[], tableRows: string[][]): TableBlock[] { + const result: TableBlock[] = []; + if (buffer.length > 0) result.push({ kind: "text", body: buffer.join("\n") }); + if (tableRows.length >= 2) result.push({ kind: "table", rows: tableRows }); + else if (tableRows.length > 0) result.push({ kind: "text", body: tableRows.map((r) => r.join(" | ")).join("\n") }); + return result; +} + +// --------------------------------------------------------------------------- +// Terminal visual width (CJK / emoji = 2 cols, ASCII = 1) +// --------------------------------------------------------------------------- + +function visualWidth(text: string): number { + let w = 0; + for (const ch of text) { + if (ch.length >= 2) { + w += 2; + continue; + } + const code = ch.codePointAt(0) ?? ch.charCodeAt(0); + w += isWideChar(code) ? 2 : 1; + } + return w; +} + +function isWideChar(code: number): boolean { + return ( + (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo + (code >= 0x2329 && code <= 0x232a) || // Misc technical + (code >= 0x2e80 && code <= 0xa4cf) || // CJK Radicals, Kangxi, CJK all + (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables + (code >= 0xf900 && code <= 0xfaff) || // CJK Compat + (code >= 0xfe10 && code <= 0xfe6f) || // CJK Compat Forms + (code >= 0xff00 && code <= 0xffe6) || // Fullwidth + (code >= 0x20000 && code <= 0x3fffd) || // CJK Ext B+ + (code >= 0x1f300 && code <= 0x1faff) || // Emoji & pictographs + (code >= 0x2600 && code <= 0x27bf) || // Misc Symbols + (code >= 0x2300 && code <= 0x23ff) || // Misc Technical + (code >= 0x2b00 && code <= 0x2bff) || // Misc Symbols & Arrows + (code >= 0x1f000 && code <= 0x1f02f) // Mahjong & Domino + ); +} + +// --------------------------------------------------------------------------- +// Table rendering +// --------------------------------------------------------------------------- + +function renderTableBorder(rows: string[][], maxWidth?: number): string { + if (rows.length === 0) return ""; + + const colCount = rows[0].length; + const calcW = (cs: number[]) => cs.reduce((a, b) => a + b + 2, 0) + cs.length + 1; + + // Ideal widths — longest word / 1.5 so cells can wrap in 2-3 lines + const ideal: number[] = Array.from({ length: colCount }, (_, i) => { + const texts = rows.map((r) => r[i] ?? ""); + const maxLine = Math.max(...texts.map((t) => visualWidth(t))); + const words = texts.flatMap((t) => t.split(/\s+/)); + const maxWord = Math.max(4, ...words.map((w) => visualWidth(w))); + return Math.max(maxWord + 2, Math.ceil(maxLine / 1.5)); + }); + + const colWidths = [...ideal]; + + // Shrink to fit terminal width + if (maxWidth != null && calcW(colWidths) > maxWidth) { + const narrow = new Set([0, 1, colCount - 2, colCount - 1]); // #, status, count, date + const MIN_NARROW = 6; + const MIN_CONTENT = 12; + const contentCols = Array.from({ length: colCount }, (_, i) => i).filter((i) => !narrow.has(i)); + + // Cap narrow columns first + for (const ci of narrow) colWidths[ci] = Math.min(colWidths[ci], MIN_NARROW); + + // Shrink until we fit + while (calcW(colWidths) > maxWidth) { + // Try narrow columns first + let shrunk = false; + for (const ci of narrow) { + if (colWidths[ci] > 4 && calcW(colWidths) > maxWidth) { + colWidths[ci]--; + shrunk = true; + } + } + if (shrunk) continue; + // Then content columns + const widest = contentCols.reduce((a, b) => (colWidths[a] > colWidths[b] ? a : b), contentCols[0]); + if (colWidths[widest] > MIN_CONTENT) colWidths[widest]--; + else break; + } + } + + // Word-wrap a single cell + const wrapCell = (text: string, width: number): string[] => { + if (!text) return [""]; + const lines: string[] = []; + let cur = ""; + const flush = () => { + if (cur.trim()) lines.push(cur.replace(/\s+$/, "")); + cur = ""; + }; + + for (const ch of text) { + const cw = visualWidth(ch); + if (visualWidth(cur) + cw > width) { + const lastSpace = cur.lastIndexOf(" "); + if (lastSpace > width / 3) { + const carry = cur.slice(lastSpace + 1); + cur = cur.slice(0, lastSpace); + flush(); + cur = carry + ch; + } else { + flush(); + cur = ch; + } + } else { + cur += ch; + } + } + if (cur.trim()) lines.push(cur.replace(/\s+$/, "")); + return lines.length > 0 ? lines : [""]; + }; + + const wrapped = rows.map((r) => r.map((c, ci) => wrapCell(c, colWidths[ci]))); + const heights = wrapped.map((wr) => Math.max(1, ...wr.map((lines) => lines.length))); + + const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - visualWidth(s))); + + const top = "┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐"; + const hdr = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; + const sep = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; + const bot = "└" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "┘"; + + const out: string[] = [top]; + + for (let ri = 0; ri < wrapped.length; ri++) { + const h = heights[ri]; + for (let li = 0; li < h; li++) { + const line = wrapped[ri].map((cellLines, ci) => " " + pad(cellLines[li] ?? "", colWidths[ci]) + " "); + out.push("│" + line.join("│") + "│"); + } + if (ri === 0 && rows.length > 1) out.push(hdr); + else if (ri < rows.length - 1) out.push(sep); + } + + out.push(bot); + return out.join("\n"); } +// --------------------------------------------------------------------------- +// Inline formatting (headings, lists, quotes, bold/italic/code) +// --------------------------------------------------------------------------- + function renderInlineLine(line: string): string { const headingMatch = /^(\s*)(#{1,6})\s+(.*)$/.exec(line); if (headingMatch) { @@ -105,9 +361,7 @@ function renderInlineLine(line: string): string { } function renderInlineSpans(text: string): string { - if (!text) { - return text; - } + if (!text) return text; let result = text; result = result.replace(/`([^`]+)`/g, (_, inner) => chalk.cyan(inner)); result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); diff --git a/src/ui/index.ts b/src/ui/index.ts index d899d4b..1348903 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -54,7 +54,7 @@ export { } from "./askUserQuestion"; export { readClipboardImage, type ClipboardImage } from "./clipboard"; export { buildLoadingText, type LoadingTextInput } from "./loadingText"; -export { renderMarkdown } from "./components/MessageView/markdown"; +export { renderMarkdown, renderMarkdownSegments, type MarkdownSegment } from "./components/MessageView/markdown"; export { EMPTY_BUFFER, insertText, From 809670952601f7dedf738008190b9d8d84054491 Mon Sep 17 00:00:00 2001 From: dengmik-commits Date: Sat, 23 May 2026 20:05:13 +0800 Subject: [PATCH 207/217] add screenshot --- Screenshot_2026-05-23_195028.png | Bin 105561 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Screenshot_2026-05-23_195028.png b/Screenshot_2026-05-23_195028.png index 870fbaae9e6cb8f3940673faac16d3811fea2f41..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 GIT binary patch literal 0 HcmV?d00001 literal 105561 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 Date: Mon, 25 May 2026 08:46:26 +0800 Subject: [PATCH 208/217] feat: add getScopeRiskColor function for permission scope risk mapping --- src/common/permissions.ts | 35 ++++++++++++++++++- src/tests/permission-prompt.test.ts | 19 +++++++++++ src/tests/permissions.test.ts | 33 ++++++++++++++++++ src/ui/PermissionPrompt.tsx | 53 ++++++++++++++++++++++++++--- 4 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 src/tests/permission-prompt.test.ts diff --git a/src/common/permissions.ts b/src/common/permissions.ts index aa87e0d..564bfeb 100644 --- a/src/common/permissions.ts +++ b/src/common/permissions.ts @@ -164,9 +164,10 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp const permission = evaluatePermissionScopes(request.scopes, options.settings); permissions.push({ toolCallId: toolCall.id, permission }); if (permission === "ask") { + const askScopes = getPermissionScopesRequiringAsk(request.scopes, options.settings); askPermissions.push({ toolCallId: toolCall.id, - scopes: request.scopes, + scopes: askScopes.length > 0 ? askScopes : request.scopes, name: request.name, command: request.command, description: request.description, @@ -285,6 +286,38 @@ export function evaluatePermissionScopes( return settings.defaultMode === "askAll" ? "ask" : "allow"; } +export function getPermissionScopesRequiringAsk( + scopes: AskPermissionScope[], + settings: Required = { + allow: [], + deny: [], + ask: [], + defaultMode: "allowAll", + } +): AskPermissionScope[] { + const result: AskPermissionScope[] = []; + for (const scope of scopes) { + if (scope === "unknown") { + result.push(scope); + continue; + } + if (settings.deny.includes(scope)) { + continue; + } + if (settings.ask.includes(scope)) { + result.push(scope); + continue; + } + if (settings.allow.includes(scope)) { + continue; + } + if (settings.defaultMode === "askAll") { + result.push(scope); + } + } + return result; +} + export function parseBashSideEffects(value: unknown): AskPermissionScope[] { const validScopes = new Set([ "read-in-cwd", diff --git a/src/tests/permission-prompt.test.ts b/src/tests/permission-prompt.test.ts new file mode 100644 index 0000000..aa4f372 --- /dev/null +++ b/src/tests/permission-prompt.test.ts @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getScopeRiskColor } from "../ui/PermissionPrompt"; + +test("getScopeRiskColor maps permission scopes by risk", () => { + assert.equal(getScopeRiskColor("read-in-cwd"), "#22c55e"); + assert.equal(getScopeRiskColor("query-git-log"), "#22c55e"); + + assert.equal(getScopeRiskColor("read-out-cwd"), "#f59e0b"); + assert.equal(getScopeRiskColor("write-in-cwd"), "#f59e0b"); + assert.equal(getScopeRiskColor("network"), "#f59e0b"); + assert.equal(getScopeRiskColor("mcp"), "#f59e0b"); + + assert.equal(getScopeRiskColor("write-out-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("delete-in-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("delete-out-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("mutate-git-log"), "#ef4444"); + assert.equal(getScopeRiskColor("unknown"), "#ef4444"); +}); diff --git a/src/tests/permissions.test.ts b/src/tests/permissions.test.ts index 8babf11..3a28616 100644 --- a/src/tests/permissions.test.ts +++ b/src/tests/permissions.test.ts @@ -93,6 +93,39 @@ test("computeToolCallPermissions maps tool calls to permission requests", () => ); }); +test("computeToolCallPermissions only asks for scopes not already allowed", () => { + const projectRoot = createTempDir("deepcode-permissions-filter-workspace-"); + const plan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + settings: { + allow: ["read-in-cwd"], + deny: [], + ask: [], + defaultMode: "askAll", + }, + toolCalls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "curl -s http://localhost:8899/ && ls index.html", + sideEffects: ["network", "read-in-cwd"], + }), + }, + }, + ], + }); + + assert.deepEqual(plan.permissions, [{ toolCallId: "call-bash", permission: "ask" }]); + assert.deepEqual( + plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })), + [{ id: "call-bash", scopes: ["network"] }] + ); +}); + test("appendProjectPermissionAllows writes unique project-level allow scopes", () => { const projectRoot = createTempDir("deepcode-permission-settings-"); const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); diff --git a/src/ui/PermissionPrompt.tsx b/src/ui/PermissionPrompt.tsx index 4613639..03881a5 100644 --- a/src/ui/PermissionPrompt.tsx +++ b/src/ui/PermissionPrompt.tsx @@ -20,6 +20,13 @@ type ScopePrompt = { scope: AskPermissionScope; }; +type PromptOption = { + kind: "allow" | "always" | "deny"; + label: string; + scopeDescription?: string; + scopeColor?: string; +}; + const ALWAYS_ALLOWED_SCOPES = new Set([ "read-in-cwd", "read-out-cwd", @@ -138,7 +145,7 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React {options.map((option, optionIndex) => ( {optionIndex === cursor ? "> " : " "} - {optionIndex + 1}. {option.label} + {optionIndex + 1}. {renderOptionLabel(option)} ))} @@ -149,6 +156,18 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React ); } +function renderOptionLabel(option: PromptOption): React.ReactNode { + if (option.scopeDescription && option.scopeColor) { + return ( + <> + {option.label} + {option.scopeDescription} + + ); + } + return option.label; +} + function buildScopePrompts(requests: AskPermissionRequest[]): ScopePrompt[] { const prompts: ScopePrompt[] = []; for (const request of requests) { @@ -159,10 +178,15 @@ function buildScopePrompts(requests: AskPermissionRequest[]): ScopePrompt[] { return prompts; } -function buildOptions(scope: AskPermissionScope): Array<{ kind: "allow" | "always" | "deny"; label: string }> { - const options: Array<{ kind: "allow" | "always" | "deny"; label: string }> = [{ kind: "allow", label: "Yes" }]; +function buildOptions(scope: AskPermissionScope): PromptOption[] { + const options: PromptOption[] = [{ kind: "allow", label: "Yes" }]; if (isAlwaysAllowedScope(scope)) { - options.push({ kind: "always", label: `Yes, and always allow ${describeScope(scope)}` }); + options.push({ + kind: "always", + label: "Yes, and always allow ", + scopeDescription: describeScope(scope), + scopeColor: getScopeRiskColor(scope), + }); } options.push({ kind: "deny", label: "No" }); return options; @@ -201,6 +225,27 @@ function isAlwaysAllowedScope(scope: AskPermissionScope): scope is PermissionSco return ALWAYS_ALLOWED_SCOPES.has(scope); } +export function getScopeRiskColor(scope: AskPermissionScope): string { + switch (scope) { + case "read-in-cwd": + case "query-git-log": + return "#22c55e"; + case "read-out-cwd": + case "write-in-cwd": + case "network": + case "mcp": + return "#f59e0b"; + case "write-out-cwd": + case "delete-in-cwd": + case "delete-out-cwd": + case "mutate-git-log": + case "unknown": + return "#ef4444"; + default: + return "#ef4444"; + } +} + function describeScope(scope: PermissionScope): string { switch (scope) { case "read-in-cwd": From 5d6f727eb686ebdf662e715fe8513c5df05adfbb Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 11:15:09 +0800 Subject: [PATCH 209/217] feat: add permission.md and update README.md --- README-en.md | 4 ++ README-zh_CN.md | 5 +++ README.md | 5 +++ docs/permission.md | 101 ++++++++++++++++++++++++++++++++++++++++++ docs/permission_en.md | 100 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 docs/permission.md create mode 100644 docs/permission_en.md diff --git a/README-en.md b/README-en.md index 4bff6af..c1d4acb 100644 --- a/README-en.md +++ b/README-en.md @@ -137,6 +137,10 @@ When the AI assistant completes a task, Deep Code can automatically execute a no For detailed configuration instructions, see: [docs/notify_en.md](docs/notify_en.md) +### Does Deep Code only support YOLO mode? + +No. Deep Code has a built-in fine-grained permission control mechanism that lets you confirm operations before the AI assistant executes shell commands, reads/writes files, accesses the network, and more. You can configure each permission scope's policy — always allow, always ask, or deny — via the `permissions` field in `settings.json`. See [docs/permission.md](docs/permission.md) for details. + ## Contributing Contributions are welcome! Here's how to get started: diff --git a/README-zh_CN.md b/README-zh_CN.md index 77db497..2643756 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -122,6 +122,10 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 详细配置指南:[docs/notify.md](docs/notify.md) +### Deep Code 只支持 YOLO 模式吗? + +不是。Deep Code 内置了细粒度的权限控制机制,支持在 AI 助手执行 Shell 命令、读写文件、访问网络等操作前进行确认。你可以通过 `settings.json` 中的 `permissions` 字段按需配置每种权限范围的策略:始终允许、始终询问、或直接拒绝。详见 [docs/permission.md](docs/permission.md)。 + ### 是否支持 Coding Plan? 支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: @@ -136,6 +140,7 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 "thinkingEnabled": true } ``` + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/README.md b/README.md index 77db497..2643756 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 详细配置指南:[docs/notify.md](docs/notify.md) +### Deep Code 只支持 YOLO 模式吗? + +不是。Deep Code 内置了细粒度的权限控制机制,支持在 AI 助手执行 Shell 命令、读写文件、访问网络等操作前进行确认。你可以通过 `settings.json` 中的 `permissions` 字段按需配置每种权限范围的策略:始终允许、始终询问、或直接拒绝。详见 [docs/permission.md](docs/permission.md)。 + ### 是否支持 Coding Plan? 支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: @@ -136,6 +140,7 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 "thinkingEnabled": true } ``` + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/docs/permission.md b/docs/permission.md new file mode 100644 index 0000000..91c19c6 --- /dev/null +++ b/docs/permission.md @@ -0,0 +1,101 @@ +# Deep Code 权限机制 + +Deep Code 内置了一套细粒度的权限控制机制,在 AI 助手执行工具调用(如执行 Shell 命令、读写文件、访问网络等)前,根据用户配置的策略决定是自动放行、直接拒绝、还是弹出交互式确认。 + +## 概述 + +每次 AI 助手调用工具时,系统会自动分析该操作涉及的**权限范围(Permission Scope)**,然后根据 `settings.json` 中的权限配置做出决策。对于需要用户确认的操作,会在终端中弹出交互式选择界面,用户可以选择: + +- **Yes** — 仅本次放行 +- **Yes, and always allow** — 本次放行,并将该权限范围写入项目配置文件,后续同类操作不再询问 +- **No** — 拒绝本次操作 + +## 权限范围 + +Deep Code 定义了以下 10 种权限范围,覆盖了工具调用的各类风险场景: + +| 权限范围 | 说明 | +| -------- | ---- | +| `read-in-cwd` | 读取当前工作区内的文件 | +| `read-out-cwd` | 读取当前工作区外的文件 | +| `write-in-cwd` | 在当前工作区内创建或覆写文件 | +| `write-out-cwd` | 在当前工作区外创建或覆写文件 | +| `delete-in-cwd` | 删除当前工作区内的文件 | +| `delete-out-cwd` | 删除当前工作区外的文件 | +| `query-git-log` | 查询 Git 历史(如 `git log`、`git show`、`git blame`) | +| `mutate-git-log` | 修改 Git 历史(如 `git commit`、`git rebase`、`git tag`) | +| `network` | 访问网络(如 `curl`、`npm install` 等联网操作) | +| `mcp` | 调用 MCP 外部工具 | + +此外还有一个特殊的 `unknown` 范围,当 LLM 无法准确分类命令的副作用时使用,**`unknown` 总是触发询问**。 + +## 权限配置 + +在 `~/.deepcode/settings.json`(用户级)或 `.deepcode/settings.json`(项目级)中通过 `permissions` 字段配置: + +```json +{ + "permissions": { + "allow": [], + "deny": [], + "ask": [], + "defaultMode": "allowAll" + } +} +``` + +### 配置项说明 + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| `allow` | `string[]` | 始终自动放行的权限范围列表 | +| `deny` | `string[]` | 始终自动拒绝的权限范围列表 | +| `ask` | `string[]` | 始终弹出询问的权限范围列表 | +| `defaultMode` | `"allowAll"` \| `"askAll"` | 未在 `allow`/`deny`/`ask` 中明确列出的权限范围的默认处理方式。默认为 `"allowAll"` | + +### 优先级规则 + +当一个工具调用涉及多个权限范围时,决策按以下优先级进行: + +1. 若任一范围命中 `deny` → **拒绝** +2. 若任一范围命中 `ask` → **询问** +3. 若所有范围均在 `allow` 中 → **自动放行** +4. 否则 → 按 `defaultMode` 处理 + +### 示例:宽松模式(默认) + +```json +{ + "permissions": { + "defaultMode": "allowAll" + } +} +``` + +默认行为:所有操作自动放行,无需确认。 + +### 示例:严格模式 + +```json +{ + "permissions": { + "allow": ["read-in-cwd", "write-in-cwd", "query-git-log"], + "defaultMode": "askAll" + } +} +``` + +此配置的效果: +- 工作区内读写、Git 查询 → 自动放行 +- 其他操作都需要用户确认。 + + +## 持久化机制 + +当用户在权限提示中选择 "Yes, and always allow" 后,对应的权限范围会被写入当前项目的 `.deepcode/settings.json` 文件中: + +- 新增范围会追加到 `permissions.allow` 列表 +- 如果该范围之前存在于 `deny` 或 `ask` 中,会被自动移除 +- 不会重复写入已存在的范围 + +这样后续同类操作就不再询问。 diff --git a/docs/permission_en.md b/docs/permission_en.md new file mode 100644 index 0000000..dae739c --- /dev/null +++ b/docs/permission_en.md @@ -0,0 +1,100 @@ +# Deep Code Permission Mechanism + +Deep Code includes a fine-grained permission control mechanism. Before the AI assistant executes a tool call (such as running a shell command, reading/writing files, accessing the network, etc.), the system determines whether to auto-allow, auto-deny, or prompt for interactive confirmation based on your configured policy. + +## Overview + +Each time the AI assistant invokes a tool, the system automatically analyzes the **permission scopes** involved and makes a decision based on the permission configuration in `settings.json`. For operations requiring user confirmation, an interactive prompt appears in the terminal with the following choices: + +- **Yes** — Allow this one time only +- **Yes, and always allow** — Allow this time and persistently save the scope to the project configuration so future calls skip the prompt +- **No** — Deny this operation + +## Permission Scopes + +Deep Code defines the following 10 permission scopes, covering various risk scenarios for tool calls: + +| Permission Scope | Description | +| ---------------- | ----------- | +| `read-in-cwd` | Read files inside the current workspace | +| `read-out-cwd` | Read files outside the current workspace | +| `write-in-cwd` | Create or overwrite files inside the current workspace | +| `write-out-cwd` | Create or overwrite files outside the current workspace | +| `delete-in-cwd` | Delete files inside the current workspace | +| `delete-out-cwd` | Delete files outside the current workspace | +| `query-git-log` | Query Git history (e.g., `git log`, `git show`, `git blame`) | +| `mutate-git-log` | Mutate Git history (e.g., `git commit`, `git rebase`, `git tag`) | +| `network` | Access the network (e.g., `curl`, `npm install`) | +| `mcp` | Invoke MCP external tools | + +There is also a special `unknown` scope used when the LLM cannot classify a command's side effects — **`unknown` always triggers a prompt**. + +## Permission Configuration + +Configure permissions in `~/.deepcode/settings.json` (user-level) or `.deepcode/settings.json` (project-level) via the `permissions` field: + +```json +{ + "permissions": { + "allow": [], + "deny": [], + "ask": [], + "defaultMode": "allowAll" + } +} +``` + +### Configuration Fields + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `allow` | `string[]` | Permission scopes that are always auto-allowed | +| `deny` | `string[]` | Permission scopes that are always auto-denied | +| `ask` | `string[]` | Permission scopes that always trigger a confirmation prompt | +| `defaultMode` | `"allowAll"` \| `"askAll"` | Default behavior for scopes not explicitly listed in `allow`/`deny`/`ask`. Defaults to `"allowAll"` | + +### Priority Rules + +When a tool call involves multiple permission scopes, the decision follows this priority: + +1. If any scope matches `deny` → **Deny** +2. If any scope matches `ask` → **Prompt** +3. If all scopes are in `allow` → **Auto-allow** +4. Otherwise → use `defaultMode` + +### Example: Relaxed Mode (default) + +```json +{ + "permissions": { + "defaultMode": "allowAll" + } +} +``` + +Default behavior: all operations are auto-allowed with no confirmation required. + +### Example: Strict Mode + +```json +{ + "permissions": { + "allow": ["read-in-cwd", "write-in-cwd", "query-git-log"], + "defaultMode": "askAll" + } +} +``` + +With this configuration: +- Reading/writing inside the workspace and querying Git history → auto-allowed +- All other operations → require user confirmation + +## Persistence + +When you select "Yes, and always allow" in a permission prompt, the corresponding scope is written to the project's `.deepcode/settings.json`: + +- The scope is appended to the `permissions.allow` list +- If the scope was previously in `deny` or `ask`, it is automatically removed +- Duplicate scopes are not written again + +This means subsequent calls involving the same scope will no longer prompt for confirmation. From 7c95312f68623c9da6ce1c865f2dd996d16b2cf5 Mon Sep 17 00:00:00 2001 From: dengm Date: Mon, 25 May 2026 11:17:12 +0800 Subject: [PATCH 210/217] =?UTF-8?q?fix:=20improve=20table=20column=20width?= =?UTF-8?q?=20allocation=20=E2=80=94=20use=20natural=20widths=20and=20grow?= =?UTF-8?q?=20to=20fill=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the aggressive maxLine/1.5 ideal-width heuristic with full natural widths. When the total fits within the available terminal width (defaulting to 120 cols), distribute slack proportionally to content columns instead of leaving them cramped. Detect narrow label columns by actual content width (≤8 chars) rather than hardcoded position ([0, 1, -2, -1]). When compression is necessary, start from per-column minimums (longest word) and share the remaining budget proportionally based on each column's deficit. This fixes the "tables too narrow and too tall" issue reported on PR #115 where every column was forced to ~67% of its natural width regardless of available screen real estate. --- src/ui/components/MessageView/markdown.ts | 84 +++++++++++++++-------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index 8c86534..f5b72bc 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -231,42 +231,68 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { const colCount = rows[0].length; const calcW = (cs: number[]) => cs.reduce((a, b) => a + b + 2, 0) + cs.length + 1; - // Ideal widths — longest word / 1.5 so cells can wrap in 2-3 lines - const ideal: number[] = Array.from({ length: colCount }, (_, i) => { + // Natural width per column: longest line + cell padding + const natural: number[] = Array.from({ length: colCount }, (_, i) => { + const texts = rows.map((r) => r[i] ?? ""); + const maxLine = Math.max(4, ...texts.map((t) => visualWidth(t))); + return maxLine + 2; + }); + + // Minimum width per column: longest word + padding (can't go below this) + const minWidths: number[] = Array.from({ length: colCount }, (_, i) => { const texts = rows.map((r) => r[i] ?? ""); - const maxLine = Math.max(...texts.map((t) => visualWidth(t))); const words = texts.flatMap((t) => t.split(/\s+/)); const maxWord = Math.max(4, ...words.map((w) => visualWidth(w))); - return Math.max(maxWord + 2, Math.ceil(maxLine / 1.5)); + return maxWord + 2; }); - const colWidths = [...ideal]; - - // Shrink to fit terminal width - if (maxWidth != null && calcW(colWidths) > maxWidth) { - const narrow = new Set([0, 1, colCount - 2, colCount - 1]); // #, status, count, date - const MIN_NARROW = 6; - const MIN_CONTENT = 12; - const contentCols = Array.from({ length: colCount }, (_, i) => i).filter((i) => !narrow.has(i)); - - // Cap narrow columns first - for (const ci of narrow) colWidths[ci] = Math.min(colWidths[ci], MIN_NARROW); - - // Shrink until we fit - while (calcW(colWidths) > maxWidth) { - // Try narrow columns first - let shrunk = false; - for (const ci of narrow) { - if (colWidths[ci] > 4 && calcW(colWidths) > maxWidth) { - colWidths[ci]--; - shrunk = true; + let colWidths: number[]; + const totalNatural = calcW(natural); + const totalMin = calcW(minWidths); + + const effectiveMax = maxWidth ?? 120; // default to a generous terminal width + + if (totalNatural <= effectiveMax) { + // Content fits comfortably — use natural widths and grow to fill available space + colWidths = [...natural]; + const slack = effectiveMax - totalNatural; + if (slack > 0) { + // Distribute slack proportionally to content columns (skip tiny label columns) + const isLabel = colWidths.map((w) => w <= 8); + const candidates = colWidths.map((w, i) => (isLabel[i] ? 0 : w)); + const totalWeight = candidates.reduce((a, b) => a + b, 0); + if (totalWeight > 0) { + for (let ci = 0; ci < colCount; ci++) { + if (candidates[ci] > 0) { + colWidths[ci] += Math.floor((slack * candidates[ci]) / totalWeight); + } } } - if (shrunk) continue; - // Then content columns - const widest = contentCols.reduce((a, b) => (colWidths[a] > colWidths[b] ? a : b), contentCols[0]); - if (colWidths[widest] > MIN_CONTENT) colWidths[widest]--; - else break; + } + } else if (totalMin >= effectiveMax) { + // Even minimums don't fit — use mins and accept truncation + colWidths = [...minWidths]; + } else { + // Need to compress — start from mins, share remaining budget proportionally + const budget = effectiveMax - totalMin; + const deficits = natural.map((n, i) => Math.max(0, n - minWidths[i])); + const totalDeficit = deficits.reduce((a, b) => a + b, 0); + colWidths = [...minWidths]; + if (totalDeficit > 0) { + for (let ci = 0; ci < colCount; ci++) { + colWidths[ci] += Math.floor((budget * deficits[ci]) / totalDeficit); + } + } + // Distribute any leftover due to flooring + let used = calcW(colWidths); + const deficitByIdx = colWidths.map((w, i) => ({ i, gap: natural[i] - w })); + deficitByIdx.sort((a, b) => b.gap - a.gap); + for (const { i } of deficitByIdx) { + if (used >= effectiveMax) break; + if (colWidths[i] < natural[i]) { + colWidths[i]++; + used = calcW(colWidths); + } } } From abed1495821b1c9d82f870d05e62dd83afffbaa6 Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 25 May 2026 11:47:09 +0800 Subject: [PATCH 211/217] =?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")); + } +} From 679eb003515d15a9bf2c6f3d147650cd5d768fb9 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 14:45:39 +0800 Subject: [PATCH 212/217] feat: enhance markdown table rendering with CJK support and improved width handling --- src/tests/markdown.test.ts | 55 ++++++++++++++++++++++- src/ui/components/MessageView/index.tsx | 10 +++-- src/ui/components/MessageView/markdown.ts | 54 ++++++++++++---------- 3 files changed, 92 insertions(+), 27 deletions(-) diff --git a/src/tests/markdown.test.ts b/src/tests/markdown.test.ts index a0127fc..bc5d33c 100644 --- a/src/tests/markdown.test.ts +++ b/src/tests/markdown.test.ts @@ -1,11 +1,26 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { renderMarkdown } from "../ui"; +import { renderMarkdown, renderMarkdownSegments } from "../ui"; function stripAnsi(text: string): string { return text.replace(/\[[0-9;]*m/g, ""); } +function visualWidth(text: string): number { + let width = 0; + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + width += + ch.length >= 2 || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xff00 && code <= 0xffe6) + ? 2 + : 1; + } + return width; +} + test("renderMarkdown returns empty string for empty input", () => { assert.equal(renderMarkdown(""), ""); }); @@ -38,3 +53,41 @@ test("renderMarkdown handles plain text unchanged in stripped form", () => { const result = stripAnsi(renderMarkdown(text)); assert.equal(result, text); }); + +test("renderMarkdownSegments renders CJK table cells within the requested width", () => { + const table = [ + "| 编号 | 状态 | 任务 | 备注 |", + "|---|---|---|---|", + "| 1 | ✅ | 写代码 | 这是一个很长很长的中文备注用于验证表格在终端宽度不足时是否能够自动换行而不是溢出 |", + ].join("\n"); + + const segment = renderMarkdownSegments(table, 60).find((item) => item.kind === "table"); + assert.ok(segment); + const lines = stripAnsi(segment.body).split("\n"); + assert.equal(lines[0].startsWith("┌"), true); + assert.equal(lines.at(-1)?.startsWith("└"), true); + assert.equal( + lines.every((line) => visualWidth(line) <= 60), + true + ); + assert.equal(lines.length > 4, true); +}); + +test("renderMarkdown preserves empty table cells", () => { + const result = stripAnsi(renderMarkdown("| A | B | C |\n|---|---|---|\n|x||z|", 80)); + const bodyRow = result.split("\n").find((line) => line.includes("x") && line.includes("z")); + assert.ok(bodyRow); + assert.equal((bodyRow.match(/│/g) ?? []).length, 4); +}); + +test("renderMarkdown keeps text separated from rendered table blocks", () => { + const result = stripAnsi(renderMarkdown("Before\n| A | B |\n|---|---|\n| 1 | 2 |\nAfter", 40)); + assert.equal(result.includes("Before\n┌"), true); + assert.equal(result.includes("┘\nAfter"), true); +}); + +test("renderMarkdown does not render tables inside code fences", () => { + const result = stripAnsi(renderMarkdown("```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```", 40)); + assert.equal(result.includes("| A | B |"), true); + assert.equal(result.includes("┌"), false); +}); diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index 093dbc2..9c31551 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -71,9 +71,13 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps ? renderMarkdownSegments(content, Math.max(20, contentWidth - 4)).map((seg, i) => { if (seg.kind === "table") { return ( - - {seg.body} - + + {seg.body.split("\n").map((line, lineIndex) => ( + + {line} + + ))} + ); } return {seg.body}; diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index f5b72bc..3ebb58b 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -18,7 +18,11 @@ export type MarkdownSegment = export function renderMarkdown(text: string, maxWidth?: number): string { return renderMarkdownSegments(text, maxWidth) .map((s) => s.body) - .join(""); + .reduce((out, body) => { + if (!out) return body; + if (!body) return out; + return out.endsWith("\n") || body.startsWith("\n") ? out + body : `${out}\n${body}`; + }, ""); } /** Render markdown, returning typed segments so the caller can choose the @@ -128,6 +132,12 @@ function splitTableBlocks(text: string): TableBlock[] { }; const sepRe = /^\|?\s*:?[-]{3,}:?\s*(\|\s*:?[-]{3,}:?\s*)*\|?\s*$/; + const parseRow = (row: string) => { + let body = row.trim(); + if (body.startsWith("|")) body = body.slice(1); + if (body.endsWith("|")) body = body.slice(0, -1); + return body.split("|").map((s) => s.trim()); + }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -143,22 +153,12 @@ function splitTableBlocks(text: string): TableBlock[] { if (isHeader && !inTable) { flushText(); inTable = true; - tableRows = [ - trimmed - .split("|") - .filter(Boolean) - .map((s) => s.trim()), - ]; + tableRows = [parseRow(trimmed)]; continue; } if (isRow && inTable) { - tableRows.push( - trimmed - .split("|") - .filter(Boolean) - .map((s) => s.trim()) - ); + tableRows.push(parseRow(trimmed)); continue; } @@ -229,21 +229,26 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { if (rows.length === 0) return ""; const colCount = rows[0].length; + const normalizedRows = rows.map((row) => + Array.from({ length: colCount }, (_, i) => { + return row[i] ?? ""; + }) + ); const calcW = (cs: number[]) => cs.reduce((a, b) => a + b + 2, 0) + cs.length + 1; - // Natural width per column: longest line + cell padding + // Natural width per column, measured as terminal cells rather than UTF-16 units. const natural: number[] = Array.from({ length: colCount }, (_, i) => { - const texts = rows.map((r) => r[i] ?? ""); + const texts = normalizedRows.map((r) => r[i] ?? ""); const maxLine = Math.max(4, ...texts.map((t) => visualWidth(t))); - return maxLine + 2; + return maxLine; }); - // Minimum width per column: longest word + padding (can't go below this) + // Keep minimums small so long CJK text or unbroken tokens can wrap by character. const minWidths: number[] = Array.from({ length: colCount }, (_, i) => { - const texts = rows.map((r) => r[i] ?? ""); - const words = texts.flatMap((t) => t.split(/\s+/)); - const maxWord = Math.max(4, ...words.map((w) => visualWidth(w))); - return maxWord + 2; + const headerWidth = visualWidth(normalizedRows[0]?.[i] ?? ""); + const labelColumn = natural[i] <= 12; + const minReadable = labelColumn ? natural[i] : Math.max(4, Math.min(headerWidth, 12)); + return Math.min(natural[i], minReadable); }); let colWidths: number[]; @@ -270,8 +275,11 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { } } } else if (totalMin >= effectiveMax) { - // Even minimums don't fit — use mins and accept truncation colWidths = [...minWidths]; + while (calcW(colWidths) > effectiveMax && colWidths.some((w) => w > 1)) { + const widest = colWidths.reduce((maxIdx, width, idx) => (width > colWidths[maxIdx] ? idx : maxIdx), 0); + colWidths[widest]--; + } } else { // Need to compress — start from mins, share remaining budget proportionally const budget = effectiveMax - totalMin; @@ -327,7 +335,7 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { return lines.length > 0 ? lines : [""]; }; - const wrapped = rows.map((r) => r.map((c, ci) => wrapCell(c, colWidths[ci]))); + const wrapped = normalizedRows.map((r) => r.map((c, ci) => wrapCell(c, colWidths[ci]))); const heights = wrapped.map((wr) => Math.max(1, ...wr.map((lines) => lines.length))); const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - visualWidth(s))); From 670b118cd64c28a01c0a1ae985279e0807300e2d Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 25 May 2026 15:10:37 +0800 Subject: [PATCH 213/217] =?UTF-8?q?fix(session):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=8B=92=E7=BB=9D=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 permission_denied 会话状态表示权限被拒绝 - 添加 denySessionPermission 方法以更新会话状态为拒绝并设置失败原因 - 在权限拒绝时清除提示草稿并调用拒绝权限处理逻辑 - 中断会话时清除提示草稿以防止残留输入 - 会话列表中新增 permission_denied 状态对应的 UI 状态映射为 denied --- src/session.ts | 17 ++++++++++++++++- src/ui/App.tsx | 3 +++ src/ui/SessionList.tsx | 4 ++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index c81ae4a..4b34f20 100644 --- a/src/session.ts +++ b/src/session.ts @@ -159,7 +159,8 @@ export type SessionStatus = | "waiting_for_user" | "completed" | "interrupted" - | "ask_permission"; + | "ask_permission" + | "permission_denied"; export type ModelUsage = { prompt_tokens: number; @@ -1532,6 +1533,20 @@ ${skillMd} return !this.sessionControllers.has(sessionId); } + /** + * Mark a session's permission as denied by the user. + * Updates the session entry status and failReason so the denial is visible in the session list. + */ + denySessionPermission(sessionId: string, reason?: string): void { + const now = new Date().toISOString(); + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + status: "permission_denied", + failReason: reason ?? "Permission denied by user", + updateTime: now, + })); + } + adjustActiveBashTimeout(deltaMs: number): BashTimeoutAdjustment | null { const sessionId = this.activeSessionId; if (!sessionId || !Number.isFinite(deltaMs)) { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 71e9ca3..ae94fa0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -669,6 +669,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl alwaysAllows: result.alwaysAllows, }); setStatusLine("Permission denied. Add a reply, then press Enter to continue."); + setPromptDraft(null); + sessionManager.denySessionPermission(sessionId); return; } void handlePrompt({ @@ -686,6 +688,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl sessionManager.interruptActiveSession(); setActiveStatus("interrupted"); setActiveAskPermissions(undefined); + setPromptDraft(null); refreshSessionsList(); }, [refreshSessionsList, sessionManager]); diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 82ca797..2d83b84 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -332,6 +332,10 @@ export function formatSessionStatus(status: SessionStatus): string { return "failed"; case "interrupted": return "stopped"; + case "ask_permission": + return "waiting"; + case "permission_denied": + return "denied"; default: return status; } From f1ecc26f4cf9f29c820138d005cfa604a76ffce4 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 16:03:22 +0800 Subject: [PATCH 214/217] feat: update the MCP spawn to avoid DEP0190 problem --- src/mcp/mcp-client.ts | 61 +++++++++++++++++++++++++----------- src/tests/mcp-client.test.ts | 34 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 src/tests/mcp-client.test.ts diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index b859bf5..26a7a32 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -1,6 +1,5 @@ import { spawn, type ChildProcess } from "child_process"; import { createInterface, type Interface } from "readline"; -import * as os from "os"; import * as path from "path"; import { killProcessTree } from "../common/process-tree"; @@ -97,6 +96,13 @@ type ReadResourceResult = { export type McpNotificationHandler = (method: string, params?: Record) => void; +export type McpSpawnSpec = { + command: string; + args: string[]; + shell: boolean; + windowsHide?: boolean; +}; + export class McpClient { private process: ChildProcess | null = null; private reader: Interface | null = null; @@ -130,25 +136,14 @@ export class McpClient { ...this.env, }; const args = this.withNpxYesArg(this.command, this.args); + const spawnSpec = createMcpSpawnSpec(this.command, args); - const isWindows = os.platform() === "win32"; - - if (isWindows) { - // On Windows, shell: true lets cmd.exe resolve the command via - // PATHEXT (npx → npx.cmd, etc.) without blindly appending .cmd, - // which would break absolute paths like process.execPath. - this.process = spawn(this.command, args, { - stdio: ["pipe", "pipe", "pipe"], - env: childEnv, - shell: true, - windowsHide: true, - }); - } else { - this.process = spawn(this.command, args, { - stdio: ["pipe", "pipe", "pipe"], - env: childEnv, - }); - } + this.process = spawn(spawnSpec.command, spawnSpec.args, { + stdio: ["pipe", "pipe", "pipe"], + env: childEnv, + shell: spawnSpec.shell, + windowsHide: spawnSpec.windowsHide, + }); let resolved = false; const safeReject = (err: Error) => { @@ -421,3 +416,31 @@ export class McpClient { return new Error(stderr ? `${message}. stderr: ${stderr}` : message); } } + +export function createMcpSpawnSpec( + command: string, + args: string[], + platform: NodeJS.Platform = process.platform +): McpSpawnSpec { + if (platform === "win32") { + return { + // On Windows, shell: true lets cmd.exe resolve the command via PATHEXT + // (npx -> npx.cmd, etc.). Pass one quoted command line with no spawn + // args to avoid Node 24 DEP0190. + command: [command, ...args].map(quoteWindowsShellArg).join(" "), + args: [], + shell: true, + windowsHide: true, + }; + } + + return { + command, + args, + shell: false, + }; +} + +function quoteWindowsShellArg(arg: string): string { + return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`; +} diff --git a/src/tests/mcp-client.test.ts b/src/tests/mcp-client.test.ts new file mode 100644 index 0000000..e161aad --- /dev/null +++ b/src/tests/mcp-client.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createMcpSpawnSpec } from "../mcp/mcp-client"; + +test("createMcpSpawnSpec keeps non-Windows MCP launches shell-free", () => { + assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "darwin"), { + command: "npx", + args: ["-y", "@playwright/mcp@latest"], + shell: false, + }); +}); + +test("createMcpSpawnSpec avoids Windows shell args for Node 24", () => { + assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "win32"), { + command: '"npx" "-y" "@playwright/mcp@latest"', + args: [], + shell: true, + windowsHide: true, + }); +}); + +test("createMcpSpawnSpec quotes Windows command paths and arguments", () => { + const spec = createMcpSpawnSpec( + String.raw`C:\Program Files\nodejs\node.exe`, + [String.raw`C:\tmp\mcp server.cjs`, 'a "quoted" value'], + "win32" + ); + + assert.equal( + spec.command, + String.raw`"C:\Program Files\nodejs\node.exe" "C:\tmp\mcp server.cjs" "a \"quoted\" value"` + ); + assert.deepEqual(spec.args, []); +}); From a1b31c635263d22c486559f2c029242d51e35462 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 16:15:14 +0800 Subject: [PATCH 215/217] feat(session): Add support for permission_denied status --- src/session.ts | 3 ++- src/tests/session.test.ts | 51 +++++++++++++++++++++++++++++++++++ src/tests/sessionList.test.ts | 2 ++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index 4b34f20..a9fc39e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2730,7 +2730,8 @@ ${skillMd} status === "waiting_for_user" || status === "completed" || status === "interrupted" || - status === "ask_permission" + status === "ask_permission" || + status === "permission_denied" ) { return status; } diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index e0a863e..95de8e3 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1313,6 +1313,57 @@ test("activateSession pauses for permission when a tool call requires ask", asyn ); }); +test("SessionManager preserves permission_denied status when sessions are reloaded", async () => { + const workspace = createTempDir("deepcode-permission-denied-workspace-"); + const home = createTempDir("deepcode-permission-denied-home-"); + setHomeDir(home); + + const permissions = { + allow: [], + deny: [], + ask: [], + defaultMode: "askAll" as const, + }; + const manager = createPermissionSessionManager( + workspace, + [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "rg TODO src", + description: "Search TODO markers", + sideEffects: ["read-in-cwd"], + }), + }, + }, + ], + }, + }, + ], + }, + ], + permissions + ); + + const sessionId = await manager.createSession({ text: "search todos" }); + manager.denySessionPermission(sessionId); + + const reloadedManager = createPermissionSessionManager(workspace, [], permissions); + const reloadedSession = reloadedManager.getSession(sessionId); + + assert.equal(reloadedSession?.status, "permission_denied"); + assert.equal(reloadedSession?.failReason, "Permission denied by user"); +}); + test("replySession applies permission replies, runs pending tools, and stores always allow scopes", async () => { const workspace = createTempDir("deepcode-permission-allow-workspace-"); const home = createTempDir("deepcode-permission-allow-home-"); diff --git a/src/tests/sessionList.test.ts b/src/tests/sessionList.test.ts index 3dfda33..6fe41c7 100644 --- a/src/tests/sessionList.test.ts +++ b/src/tests/sessionList.test.ts @@ -18,6 +18,8 @@ test("formatSessionStatus maps status values to display labels", () => { assert.equal(formatSessionStatus("waiting_for_user"), "waiting"); assert.equal(formatSessionStatus("failed"), "failed"); assert.equal(formatSessionStatus("interrupted"), "stopped"); + assert.equal(formatSessionStatus("ask_permission"), "waiting"); + assert.equal(formatSessionStatus("permission_denied"), "denied"); assert.equal(formatSessionStatus("unknown_status" as any), "unknown_status"); }); From 09ae2b43f02a5dbb0c1e36e0825d8910b17061b0 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 16:29:49 +0800 Subject: [PATCH 216/217] chore: clean up non-project files --- Screenshot_2026-05-23_195028.png | 0 docs/issue_0522.md | 241 ------------------------------- 2 files changed, 241 deletions(-) delete mode 100644 Screenshot_2026-05-23_195028.png delete mode 100644 docs/issue_0522.md diff --git a/Screenshot_2026-05-23_195028.png b/Screenshot_2026-05-23_195028.png deleted file mode 100644 index e69de29..0000000 diff --git a/docs/issue_0522.md b/docs/issue_0522.md deleted file mode 100644 index 2e9fd1a..0000000 --- a/docs/issue_0522.md +++ /dev/null @@ -1,241 +0,0 @@ -# Deep Code Permission System (设计文档) - -scopes是枚举值,列表如下: - -``` -# PermissionScope -read-in-cwd -read-out-cwd -write-in-cwd -write-out-cwd -delete-in-cwd -delete-out-cwd -query-git-log -mutate-git-log -network -mcp -``` - -settings.json的配置项(例子): - -``` -{ - "permissions": { - "allow": [ - "write-in-cwd" - ], - "deny": [ - "write-out-cwd" - ], - "ask": [ - "read-out-cwd" - ], - "defaultMode": "allowAll|askAll" // 默认是allowAll - } -} -``` - -工具和PermissionScope可能的对应关系: - -- read: read-in-cwd, read-out-cwd -- write: write-in-cwd, write-out-cwd -- edit: write-in-cwd, write-out-cwd -- WebSearch: network -- mcp__*: mcp -- bash: 每一次bash命令需要的scope在sideEffects字段中。如果sideEffects字段为undefined|null,或者sideEffects包含了"unknown"则总是ask -- 其他: 无权限要求,总是允许 - -## bash tool的参数schema新增sideEffects字段 - -目标:让LLM在每一次调用`bash`时显式声明该命令可能需要的权限范围,后端只信任这个结构化字段,不从自然语言`description`中推断权限。 - -需要同步修改两处schema: - -1. `src/prompt.ts`里的`getTools()`内置`bash`工具定义。 -2. `templates/tools/bash.md`里的`bash`工具说明和JSON schema示例。 - -新增字段: - -``` -sideEffects: PermissionScope[] | ["unknown"] -``` - -`bash`可声明的scope只包含文件系统、Git历史和网络权限,不包含`mcp`: - -``` -read-in-cwd -read-out-cwd -write-in-cwd -write-out-cwd -delete-in-cwd -delete-out-cwd -query-git-log -mutate-git-log -network -unknown -``` - -建议schema如下: - -```json -{ - "type": "object", - "properties": { - "command": { - "description": "The command to execute", - "type": "string" - }, - "description": { - "description": "Clear, concise description of what this command does in active voice.", - "type": "string" - }, - "sideEffects": { - "description": "Permission scopes required by this bash command. Use [] only for commands that do not read, write, delete, or access the network. Use [\"unknown\"] when the effects cannot be classified safely.", - "type": "array", - "items": { - "type": "string", - "enum": [ - "read-in-cwd", - "read-out-cwd", - "write-in-cwd", - "write-out-cwd", - "delete-in-cwd", - "delete-out-cwd", - "query-git-log", - "mutate-git-log", - "network", - "unknown" - ] - }, - "uniqueItems": true - } - }, - "required": [ - "command", - "sideEffects" - ], - "additionalProperties": false -} -``` - -字段语义: - -- `sideEffects: []`表示命令不需要权限,例如`date`、`node --version`这类只读取进程环境或输出版本信息的命令。 -- `sideEffects`必须按最小必要权限填写;例如`rg foo src`是`["read-in-cwd"]`,`npm install`通常是`["write-in-cwd", "network"]`。 -- 如果命令访问项目目录之外的路径,需要使用`*-out-cwd`;例如`cat /etc/hosts`是`["read-out-cwd"]`。 -- 删除类操作使用`delete-*`;如果同一条命令还会写入其他文件,再同时声明对应的`write-*`。 -- 查询Git历史使用`query-git-log`;例如`git log`、`git show HEAD`、`git blame`、`git diff HEAD~1..HEAD`这类读取提交历史、提交对象或历史diff的命令。 -- 修改Git历史或引用使用`mutate-git-log`;例如`git commit`、`git reset`、`git rebase`、`git merge`、`git cherry-pick`、`git tag`这类会创建提交、移动引用或改写提交图的命令。 -- Git命令如果同时读写工作区文件,也需要同时声明文件系统scope;例如`git checkout -- src/foo.ts`需要`["write-in-cwd"]`,`git reset --hard HEAD~1`需要`["write-in-cwd", "mutate-git-log"]`。 -- `unknown`只能单独出现为`["unknown"]`,不能和其他scope混用。 - -示例: - -```json -{ "command": "date", "description": "Show current date", "sideEffects": [] } -{ "command": "rg \"TODO\" src", "description": "Search TODO markers in source files", "sideEffects": ["read-in-cwd"] } -{ "command": "npm install", "description": "Install package dependencies", "sideEffects": ["write-in-cwd", "network"] } -{ "command": "rm -rf dist", "description": "Delete build output directory", "sideEffects": ["delete-in-cwd"] } -{ "command": "curl -s https://example.com", "description": "Fetch example.com response", "sideEffects": ["network"] } -{ "command": "git show --stat HEAD", "description": "Show file statistics for HEAD", "sideEffects": ["query-git-log"] } -{ "command": "git blame src/prompt.ts", "description": "Show line authorship for prompt source", "sideEffects": ["read-in-cwd", "query-git-log"] } -{ "command": "git reset --hard HEAD~1", "description": "Reset branch and worktree to previous commit", "sideEffects": ["write-in-cwd", "mutate-git-log"] } -``` - -## 核心数据结构设计 - -``` -export type UserPromptContent = { - text?: string; - imageUrls?: string[]; - skills?: SkillInfo[]; -+ permissions?: [{toolCallId: "...", permission: "allow|deny"}]; -+ alwaysAllows?: [""]; -}; - -export type SessionEntry = { - id: string; - ... - toolCalls: unknown[] | null; // 例如:[{"id":"...","function":{"name":"bash","arguments":"{\"command\": \"...\", \"description\": \"...\"}"}}] - status: SessionStatus; -+ askPermissions?: [{toolCallId: "...", scopes: [""], name: "...", command: "...", description?: "..."}]; -}; - -export type SessionStatus = "... | "completed" | "interrupted" | "ask_permission"; // 新增 ask_permission 状态 - -export type SessionMessage = { - ... - meta?: MessageMeta; - ... -}; - -export type MessageMeta = { - ... -+ permissions?: [{toolCallId: "...", permission: "allow|deny|ask"}]; -+ userPrompt?: UserPromptContent; //对于role为user的消息,持久化userPrompt可方便后续排查问题 -}; -``` - -## 前端流程 - -如果当前会话状态不是ask_permission,则保持现状。会话状态是ask_permission时: - -对SessionEntry.askPermissions中每一个toolCallId的每一个scope,显示权限弹窗(示例): - -``` - - - - - - Do you want to proceed? - ❯ 1. Yes - 2. Yes, and always allow - 3. No -``` - -注意对于read/write/edit的``,格式可以是"工具名称+相对或绝对文件路径",例如:`read ~/dev/main.c` - -如果在权限弹窗过程中,用户按Esc,则走现有的interrupt流程(会话状态也应该变成"interrupted")。 - -提醒注意一种情况:例如askPermissions里面有好几个item的scopes是`["write-in-cwd"]`,如果用户已经在第一个权限弹窗选了"always allow write in CWD `~/dev/qrcode_test/`",则后面的几个scopes是`["write-in-cwd"]`的item就不用显示权限弹窗了。 - -如果用户完成了所有权限弹窗的选择,则判断: - -1. 如果用户提交的结果中包含deny,则需要用户输入user prompt,按回车手动提交replySession()。 - - 如果用户没有输入user prompt就退出了,或者切换到了其他会话。则重新开始这个会话时,由于会话状态还是ask_permission,则会重新显示权限弹窗,要求用户选择。 -2. 如果用户提交的结果中不包含deny,则以`/continue`作为UserPromptContent.text内容,前端自动提交replySession()。 - - -## 后端流程 - -后端主要是对replySession()和activateSession()进行升级: - -1. 支持传入UserPromptContent.permissions和alwaysAllows -2. 如果UserPromptContent.alwaysAllows非空,将其中的scopes追加写入项目级别的settings.json配置文件(`permissions.allow`字段),避免重复写入已存在的项。 -3. 检查当前会话消息列表末尾是否存在连续的role为assistant的有tool_calls的消息,也就是"待执行消息"。如果没有,则走现有流程。 -4. 对于每一条待执行消息,先检查UserPromptContent.permissions中对应的toolCallId的用户授权是allow还是deny - - 如果是allow,则正常执行这个toolCall - - 如果是deny,则直接返回失败结果,报错信息提示LLM用户禁用相关权限。例如: - ``` - { - "ok": false, - "name": "edit", - "error": "用户已禁用了在项目目录之外修改文件的权限,请不要尝试用任何方式修改目录之外的文件" - } - ``` -5. 如果对于某条待执行消息,在UserPromptContent.permissions没有出现对应的toolCallId的用户授权,则检查它的 SessionMessage.meta.permissions[].permission 是allow还是deny还是ask - - 如果是allow,则正常执行这个toolCall - - 如果是deny,则直接返回失败结果,报错信息提示LLM用户禁用相关权限 - - 如果是ask,则直接返回失败结果,报错信息提示LLM用户未授权执行。例如: - ``` - { - "ok": false, - "name": "edit", - "error": "用户暂未授权执行,如果有必要,可重新尝试执行" - } - ``` - - 如果不存在,则正常执行这个toolCall(兼容老版本会话数据) -6. 当LLM返回了新的待执行消息时,不要立即执行,而是: - 1. 根据配置的permissions和defaultMode,计算出SessionMessage.meta.permissions字段 - 2. 如果存在一个待执行消息的SessionMessage.meta.permissions[].permission是ask,则把SessionEntry.status设置为"ask_permission",并设置好SessionEntry.askPermissions,然后退出activateSession,这样就回到了上面的前端流程。 From d07d225a072ffea03bcac41d5b6ab70bc46575cb Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 16:42:02 +0800 Subject: [PATCH 217/217] 0.1.25 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12b9abc..dfa3fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.24", + "version": "0.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.24", + "version": "0.1.25", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index ef70520..71c171c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.24", + "version": "0.1.25", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module",