diff --git a/README.md b/README.md index 682eaab0e..96454ab47 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ ccb update # 更新到最新版本 CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制 ``` +> **安装/更新失败?** 先 `npm rm -g claude-code-best` 清理旧版本,再 `npm i -g claude-code-best@latest`。仍失败则指定版本号:`npm i -g claude-code-best@<版本号>` + ## ⚡ 快速开始(源码版) ### ⚙️ 环境要求 diff --git a/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts b/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts index eca237141..3745c38bc 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts +++ b/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts @@ -106,6 +106,84 @@ describe("findActualString", () => { const result = findActualString("hello", ""); expect(result).toBe(""); }); + + // ── Tab/space normalization (Bug #2 reproduction) ── + + test("finds match when search uses spaces but file uses tabs", () => { + // File content uses Tab indentation + const fileContent = "\tif (x) {\n\t\treturn 1;\n\t}"; + // User copies from Read output which renders tabs as spaces + const searchWithSpaces = " if (x) {\n return 1;\n }"; + const result = findActualString(fileContent, searchWithSpaces); + expect(result).not.toBeNull(); + expect(result).toBe(fileContent); + }); + + test("finds match when search mixes tabs and spaces inconsistently", () => { + const fileContent = "\tconst x = 1; // comment"; + const searchMixed = " const x = 1; // comment"; + const result = findActualString(fileContent, searchMixed); + expect(result).not.toBeNull(); + }); + + test("finds match for single-line tab-to-space mismatch", () => { + const fileContent = "\t\torder_price = NormalizeDouble(ask, digits);"; + const searchSpaces = " order_price = NormalizeDouble(ask, digits);"; + const result = findActualString(fileContent, searchSpaces); + expect(result).not.toBeNull(); + }); + + // ── CJK / UTF-8 characters (Bug #1 reproduction) ── + + test("finds match with CJK characters in content", () => { + const fileContent = "input int x = 620; // 止盈点数(点) — 32个pip=320点"; + const result = findActualString(fileContent, fileContent); + expect(result).toBe(fileContent); + }); + + test("finds match with CJK characters when tab/space differs", () => { + const fileContent = "\t// 向上突破 → Sell Limit (逆方向做空)"; + const searchSpaces = " // 向上突破 → Sell Limit (逆方向做空)"; + const result = findActualString(fileContent, searchSpaces); + expect(result).not.toBeNull(); + expect(result).toBe(fileContent); + }); + + // ── Multiline with tabs + CJK (combined Bug #1 + #2) ── + + test("finds multiline match with tabs and CJK characters", () => { + const fileContent = "\tif(effective_dir == BREAKOUT_UP)\n\t\t{\n\t\t\t// 向上突破\n\t\t}"; + const searchSpaces = " if(effective_dir == BREAKOUT_UP)\n {\n // 向上突破\n }"; + const result = findActualString(fileContent, searchSpaces); + expect(result).not.toBeNull(); + expect(result).toBe(fileContent); + }); + + // ── Returned string must be a valid substring of fileContent ── + + test("returned string from tab match is a real substring of fileContent", () => { + const fileContent = "prefix\n\t\tindented code\nsuffix"; + const searchSpaces = "prefix\n indented code\nsuffix"; + const result = findActualString(fileContent, searchSpaces); + expect(result).not.toBeNull(); + expect(fileContent.includes(result!)).toBe(true); + }); + + test("returned string from partial tab match is a real substring", () => { + const fileContent = "line1\n\tif (x) {\n\t\tdoStuff();\n\t}\nline5"; + const searchSpaces = " if (x) {\n doStuff();\n }"; + const result = findActualString(fileContent, searchSpaces); + expect(result).not.toBeNull(); + expect(fileContent.includes(result!)).toBe(true); + }); + + test("tab match with mixed indentation levels", () => { + const fileContent = "class Foo {\n\t\tmethod1() {\n\t\t\treturn 42;\n\t\t}\n}"; + const searchSpaces = "class Foo {\n method1() {\n return 42;\n }\n}"; + const result = findActualString(fileContent, searchSpaces); + expect(result).not.toBeNull(); + expect(fileContent.includes(result!)).toBe(true); + }); }); // ─── preserveQuoteStyle ───────────────────────────────────────────────── diff --git a/packages/builtin-tools/src/tools/FileEditTool/utils.ts b/packages/builtin-tools/src/tools/FileEditTool/utils.ts index 6de429b34..2709ba423 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/utils.ts +++ b/packages/builtin-tools/src/tools/FileEditTool/utils.ts @@ -63,9 +63,26 @@ export function stripTrailingWhitespace(str: string): string { return result } +/** + * Normalizes whitespace for fuzzy matching by converting tabs to spaces + * and collapsing leading whitespace on each line to a canonical form. + * This handles the case where Read tool output renders tabs as spaces, + * so users copy spaces from the output but the file actually has tabs. + */ +function normalizeWhitespace(str: string): string { + return str.replace(/\t/g, ' ') +} + /** * Finds the actual string in the file content that matches the search string, - * accounting for quote normalization + * accounting for quote normalization and tab/space differences. + * + * Matching cascade: + * 1. Exact match + * 2. Quote normalization (curly → straight quotes) + * 3. Tab/space normalization (tabs ↔ spaces in leading whitespace) + * 4. Quote + tab/space normalization combined + * * @param fileContent The file content to search in * @param searchString The string to search for * @returns The actual string found in the file, or null if not found @@ -89,9 +106,92 @@ export function findActualString( return fileContent.substring(searchIndex, searchIndex + searchString.length) } + // Try with tab/space normalization — handles the case where Read output + // renders tabs as spaces and the user copies the rendered version + const wsNormalizedFile = normalizeWhitespace(fileContent) + const wsNormalizedSearch = normalizeWhitespace(searchString) + + const wsSearchIndex = wsNormalizedFile.indexOf(wsNormalizedSearch) + if (wsSearchIndex !== -1) { + // Map the match position back to the original file content. + // We need to find the corresponding range in the original string. + return mapNormalizedMatchBackToFile(fileContent, wsNormalizedFile, wsSearchIndex, wsNormalizedSearch.length) + } + + // Try combined: quote normalization + tab/space normalization + const combinedFile = normalizeWhitespace(normalizedFile) + const combinedSearch = normalizeWhitespace(normalizedSearch) + + const combinedIndex = combinedFile.indexOf(combinedSearch) + if (combinedIndex !== -1) { + return mapNormalizedMatchBackToFile(fileContent, combinedFile, combinedIndex, combinedSearch.length) + } + return null } +/** + * Given a match found in a normalized version of fileContent, map the match + * position back to the original fileContent and extract the corresponding + * substring. + * + * Strategy: walk through both strings character by character, building a + * mapping from normalized offset to original offset. When a tab is expanded + * to 4 spaces in the normalized version, the normalized offset advances by 4 + * while the original offset advances by 1. + */ +function mapNormalizedMatchBackToFile( + fileContent: string, + normalizedFile: string, + normalizedStart: number, + normalizedLength: number, +): string { + // Build a sparse mapping from normalized position → original position. + // We only need to map the range [normalizedStart, normalizedStart + normalizedLength]. + let normPos = 0 + let origPos = 0 + let origStart = -1 + let origEnd = -1 + + while (origPos < fileContent.length && normPos <= normalizedStart + normalizedLength) { + if (normPos === normalizedStart) { + origStart = origPos + } + if (normPos === normalizedStart + normalizedLength) { + origEnd = origPos + break + } + + const origChar = fileContent[origPos]! + if (origChar === '\t') { + // Tab expands to 4 spaces in normalized version + const nextNormPos = normPos + 4 + // If normalizedStart falls within this expanded tab, snap to origPos + if (normPos < normalizedStart && nextNormPos > normalizedStart && origStart === -1) { + origStart = origPos + } + if (normPos < normalizedStart + normalizedLength && nextNormPos > normalizedStart + normalizedLength && origEnd === -1) { + origEnd = origPos + 1 + } + normPos = nextNormPos + origPos++ + } else { + normPos++ + origPos++ + } + } + + // Fallback: if we couldn't map precisely, use character-count heuristic + if (origStart === -1) origStart = 0 + if (origEnd === -1) { + // Approximate: use the ratio of original to normalized length + const ratio = fileContent.length / normalizedFile.length + origEnd = Math.round(origStart + normalizedLength * ratio) + } + + return fileContent.substring(origStart, origEnd) +} + /** * When old_string matched via quote normalization (curly quotes in file, * straight quotes from model), apply the same curly quote style to new_string diff --git a/src/components/Message.tsx b/src/components/Message.tsx index c069a4e1d..52f90a446 100644 --- a/src/components/Message.tsx +++ b/src/components/Message.tsx @@ -77,6 +77,8 @@ export type Props = { lastThinkingBlockId?: string | null /** UUID of the latest user bash output message (for auto-expanding) */ latestBashOutputUUID?: string | null + /** Whether to collapse diff display for this message */ + shouldCollapseDiffs?: boolean } function MessageImpl({ @@ -99,6 +101,7 @@ function MessageImpl({ isUserContinuation = false, lastThinkingBlockId, latestBashOutputUUID, + shouldCollapseDiffs, }: Props): React.ReactNode { switch (message.type) { case 'attachment': @@ -181,6 +184,7 @@ function MessageImpl({ isUserContinuation={isUserContinuation} lookups={lookups} isTranscriptMode={isTranscriptMode} + shouldCollapseDiffs={shouldCollapseDiffs} /> ))} @@ -293,6 +297,7 @@ function UserMessage({ isUserContinuation, lookups, isTranscriptMode, + shouldCollapseDiffs, }: { message: NormalizedUserMessage addMargin: boolean @@ -309,6 +314,7 @@ function UserMessage({ isUserContinuation: boolean lookups: ReturnType isTranscriptMode: boolean + shouldCollapseDiffs?: boolean }): React.ReactNode { const { columns } = useTerminalSize() switch (param.type) { @@ -344,6 +350,7 @@ function UserMessage({ verbose={verbose} width={columns - 5} isTranscriptMode={isTranscriptMode} + shouldCollapseDiffs={shouldCollapseDiffs} /> ) default: diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx index dbcfe8e4e..cb74205ba 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -55,6 +55,7 @@ export type Props = { columns: number isLoading: boolean lookups: ReturnType + shouldCollapseDiffs?: boolean } /** @@ -141,6 +142,7 @@ function MessageRowImpl({ columns, isLoading, lookups, + shouldCollapseDiffs, }: Props): React.ReactNode { const isTranscriptMode = screen === 'transcript' const isGrouped = msg.type === 'grouped_tool_use' @@ -221,6 +223,7 @@ function MessageRowImpl({ isUserContinuation={isUserContinuation} lastThinkingBlockId={lastThinkingBlockId} latestBashOutputUUID={latestBashOutputUUID} + shouldCollapseDiffs={shouldCollapseDiffs} /> ) // OffscreenFreeze: the outer React.memo already bails for static messages, diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index 7bcbe96a5..a93eadfdc 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -814,6 +814,12 @@ const MessagesImpl = ({ streamingToolUseIDs, )) + // Collapse diffs for messages beyond the latest N messages. + // verbose (ctrl+o) overrides and always shows full diffs. + const DIFF_COLLAPSE_DISTANCE = 0 + const shouldCollapseDiffs = + renderableMessages.length - 1 - index > DIFF_COLLAPSE_DISTANCE + const k = messageKey(msg) const row = ( ) diff --git a/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx index abd7a8fce..17bab7bcb 100644 --- a/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx @@ -27,6 +27,7 @@ type Props = { verbose: boolean width: number | string isTranscriptMode?: boolean + shouldCollapseDiffs?: boolean } export function UserToolResultMessage({ @@ -39,6 +40,7 @@ export function UserToolResultMessage({ verbose, width, isTranscriptMode, + shouldCollapseDiffs, }: Props): React.ReactNode { const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups) if (!toolUse) { @@ -96,6 +98,7 @@ export function UserToolResultMessage({ verbose={verbose} width={width} isTranscriptMode={isTranscriptMode} + shouldCollapseDiffs={shouldCollapseDiffs} /> ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx index ff215671c..39ea9c893 100644 --- a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx @@ -33,6 +33,7 @@ type Props = { verbose: boolean width: number | string isTranscriptMode?: boolean + shouldCollapseDiffs?: boolean } export function UserToolSuccessMessage({ @@ -46,6 +47,7 @@ export function UserToolSuccessMessage({ verbose, width, isTranscriptMode, + shouldCollapseDiffs, }: Props): React.ReactNode { const [theme] = useTheme() // Hook stays inside feature() ternary so external builds don't pay a @@ -83,12 +85,16 @@ export function UserToolSuccessMessage({ } const toolResult = parsedOutput?.data ?? message.toolUseResult + // Collapse diff display for old messages (verbose/ctrl+o overrides) + const effectiveStyle = + shouldCollapseDiffs && !verbose ? 'condensed' : style + const renderedMessage = tool.renderToolResultMessage?.( toolResult as never, filterToolProgressMessages(progressMessagesForMessage), { - style, + style: effectiveStyle, theme, tools, verbose, diff --git a/src/main.tsx b/src/main.tsx index f19b6f39e..a9fc4468f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6907,6 +6907,9 @@ async function logTenguInit({ allowDangerouslySkipPermissionsPassed, thinkingType: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(thinkingConfig.type === "enabled" && { + thinkingBudgetTokens: thinkingConfig.budgetTokens, + }), ...(systemPromptFlag && { systemPromptFlag: systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 720370db6..ce7f0fc32 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1776,6 +1776,10 @@ async function* queryModel( // captures only primitives instead of paramsFromContext's full closure scope // (messagesForAPI, system, allTools, betas — the entire request-building // context), which would otherwise be pinned until the promise resolves. + // Also capture thinking params for Langfuse observability. + // Pass the entire thinking config object so all fields (type, budget_tokens, + // and any future additions) flow through without cherry-picking. + let langfuseThinking: BetaMessageStreamParams['thinking'] | undefined { const queryParams = paramsFromContext({ model: options.model, @@ -1783,8 +1787,10 @@ async function* queryModel( }) const logMessagesLength = queryParams.messages.length const logBetas = useBetas ? (queryParams.betas ?? []) : [] - const logThinkingType = queryParams.thinking?.type ?? 'disabled' const logEffortValue = queryParams.output_config?.effort + if (queryParams.thinking && queryParams.thinking.type !== 'disabled') { + langfuseThinking = queryParams.thinking + } void options.getToolPermissionContext().then(permissionContext => { logAPIQuery({ model: options.model, @@ -1794,7 +1800,7 @@ async function* queryModel( permissionMode: permissionContext.mode, querySource: options.querySource, queryTracking: options.queryTracking, - thinkingType: logThinkingType, + thinkingConfig, effortValue: logEffortValue, fastMode: isFastMode, previousRequestId, @@ -2545,6 +2551,9 @@ async function* queryModel( maxOutputTokens, thinkingType: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(thinkingConfig.type === 'enabled' && { + thinkingBudgetTokens: thinkingConfig.budgetTokens, + }), fallback_disabled: true, request_id: (streamRequestId ?? 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, @@ -2577,6 +2586,9 @@ async function* queryModel( maxOutputTokens, thinkingType: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(thinkingConfig.type === 'enabled' && { + thinkingBudgetTokens: thinkingConfig.budgetTokens, + }), fallback_disabled: false, request_id: (streamRequestId ?? 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, @@ -2693,6 +2705,9 @@ async function* queryModel( maxOutputTokens, thinkingType: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(thinkingConfig.type === 'enabled' && { + thinkingBudgetTokens: thinkingConfig.budgetTokens, + }), request_id: failedRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, fallback_cause: @@ -2925,6 +2940,7 @@ async function* queryModel( endTime: new Date(), completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined, tools: convertToolsToLangfuse(toolSchemas as unknown[]), + thinking: langfuseThinking, }) void options.getToolPermissionContext().then(permissionContext => { diff --git a/src/services/api/gemini/index.ts b/src/services/api/gemini/index.ts index bf9058b6e..af9a0debb 100644 --- a/src/services/api/gemini/index.ts +++ b/src/services/api/gemini/index.ts @@ -193,6 +193,15 @@ export async function* queryModelGemini( endTime: new Date(), completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined, tools: convertToolsToLangfuse(toolSchemas as unknown[]), + thinking: + thinkingConfig.type !== 'disabled' + ? { + type: thinkingConfig.type, + ...(thinkingConfig.type === 'enabled' && { + budgetTokens: thinkingConfig.budgetTokens, + }), + } + : undefined, }) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) diff --git a/src/services/api/logging.ts b/src/services/api/logging.ts index f7e99847b..25dd133d6 100644 --- a/src/services/api/logging.ts +++ b/src/services/api/logging.ts @@ -23,6 +23,7 @@ import { getAPIProviderForStatsig } from 'src/utils/model/providers.js' import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js' import { jsonStringify } from 'src/utils/slowOperations.js' import { logOTelEvent } from 'src/utils/telemetry/events.js' +import type { ThinkingConfig } from 'src/utils/thinking.js' import { endLLMRequestSpan, isBetaTracingEnabled, @@ -176,7 +177,7 @@ export function logAPIQuery({ permissionMode, querySource, queryTracking, - thinkingType, + thinkingConfig, effortValue, fastMode, previousRequestId, @@ -188,11 +189,13 @@ export function logAPIQuery({ permissionMode?: PermissionMode querySource: string queryTracking?: QueryChainTracking - thinkingType?: 'adaptive' | 'enabled' | 'disabled' + thinkingConfig?: ThinkingConfig effortValue?: EffortLevel | null fastMode?: boolean previousRequestId?: string | null }): void { + const thinkingType = thinkingConfig?.type ?? 'disabled' + const thinkingBudgetTokens = thinkingConfig?.type === 'enabled' ? thinkingConfig.budgetTokens : undefined logEvent('tengu_api_query', { model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, messagesLength, @@ -219,6 +222,9 @@ export function logAPIQuery({ : {}), thinkingType: thinkingType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(thinkingBudgetTokens !== undefined && { + thinkingBudgetTokens, + }), effortValue: effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, fastMode, diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 248c2dac3..f161cb602 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -418,6 +418,7 @@ export async function* queryModelOpenAI( endTime: new Date(), completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined, tools: convertToolsToLangfuse(toolSchemas as unknown[]), + ...(enableThinking && { thinking: { type: 'enabled' } }), }) // Safety: if stream ended without message_stop, assemble and yield whatever we have diff --git a/src/services/langfuse/tracing.ts b/src/services/langfuse/tracing.ts index da6ed00d1..9fbd1250e 100644 --- a/src/services/langfuse/tracing.ts +++ b/src/services/langfuse/tracing.ts @@ -78,6 +78,16 @@ export function recordLLMObservation( endTime?: Date completionStartTime?: Date tools?: unknown + /** Thinking depth configuration used for this request. + * Accepts the full API thinking config object. Fields: + * - type: thinking mode ("enabled", "adaptive", "disabled") + * - budget_tokens (snake_case, from Anthropic API) or budgetTokens (camelCase) + */ + thinking?: { + type: string + budget_tokens?: number + budgetTokens?: number + } }, ): void { if (!rootSpan || !isLangfuseEnabled()) return @@ -97,6 +107,7 @@ export function recordLLMObservation( metadata: { provider: params.provider, model: params.model, + ...(params.thinking && { thinking: params.thinking }), }, ...(params.completionStartTime && { completionStartTime: params.completionStartTime }), }, diff --git a/src/services/tokenEstimation.ts b/src/services/tokenEstimation.ts index 07a7eb59b..32388fca7 100644 --- a/src/services/tokenEstimation.ts +++ b/src/services/tokenEstimation.ts @@ -354,6 +354,7 @@ export async function countTokensViaHaikuFallback( }, startTime: new Date(apiStart), endTime: new Date(), + ...(containsThinking && { thinking: { type: 'enabled', budgetTokens: TOKEN_COUNT_THINKING_BUDGET } }), }) endTrace(langfuseTrace) diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index 6aa66aad3..c1f5bc3c6 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -294,6 +294,12 @@ export async function sideQuery(opts: SideQueryOptions): Promise { startTime: new Date(start), endTime: new Date(), ...(tools && { tools: convertToolsToLangfuse(tools as unknown[]) }), + ...(thinkingConfig && thinkingConfig.type !== 'disabled' && { + thinking: { + type: thinkingConfig.type, + ...(thinkingConfig.type === 'enabled' && { budgetTokens: thinkingConfig.budget_tokens }), + }, + }), }) endTrace(langfuseTrace)