Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<版本号>`
## ⚡ 快速开始(源码版)

### ⚙️ 环境要求
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────
Expand Down
102 changes: 101 additions & 1 deletion packages/builtin-tools/src/tools/FileEditTool/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ' ')
}
Comment on lines +66 to +74
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSDoc doesn't match the implementation.

The doc says it "collapses leading whitespace on each line to a canonical form", but str.replace(/\t/g, ' ') replaces every tab in the string regardless of position (interior tabs included). Either tighten the implementation to leading-only whitespace or update the comment to describe what actually happens (global tab→4-space expansion). Note the global behavior is also what the rest of the cascade relies on, so the comment is the simpler fix.

📝 Suggested doc tweak
 /**
- * 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.
+ * Normalizes whitespace for fuzzy matching by globally expanding every
+ * tab character to 4 spaces. 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. Tab width is fixed at 4 — files using a
+ * different tab width will not match via this fallback.
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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, ' ')
}
/**
* Normalizes whitespace for fuzzy matching by globally expanding every
* tab character to 4 spaces. 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. Tab width is fixed at 4 files using a
* different tab width will not match via this fallback.
*/
function normalizeWhitespace(str: string): string {
return str.replace(/\t/g, ' ')
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/builtin-tools/src/tools/FileEditTool/utils.ts` around lines 66 - 74,
The JSDoc for normalizeWhitespace is inaccurate: the function currently performs
a global tab expansion, not leading-whitespace collapsing. Update the comment
above normalizeWhitespace to state that it converts all tab characters to four
spaces (global replacement) for consistent fuzzy matching between Read tool
output and file contents, and remove the mention of collapsing leading
whitespace so it matches the implementation.


/**
* 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
Expand All @@ -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)
}
Comment on lines +143 to +193
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

mapNormalizedMatchBackToFile can return a truncated substring when the match extends to EOF.

The loop terminates as soon as origPos >= fileContent.length, which occurs naturally when the match ends at the end of fileContent. In that case origEnd stays -1 and the ratio fallback (fileContent.length / normalizedFile.length) is used — but that ratio is only valid if tab density is uniform across the whole file. With non-uniform tabs, the fallback under-estimates origEnd and silently returns a shorter substring than the actual match.

Counter-example:

  • fileContent = "\t\t\t\t\t\t\t\t\t\there" (10 leading tabs, length 14)
  • normalizedFile.length = 44
  • search = " here" → match at normalized index 36, length 8 → origStart = 9
  • ratio = 14/44 ≈ 0.318 → origEnd = round(9 + 8 * 0.318) = 12
  • returned: "\the" instead of "\there"

Because the callers in FileEditTool.ts (lines 303-315 and 458-467) and UI.tsx (lines 296-304) feed this result directly into file.split(actualOldString) for occurrence counting and into getPatchForEdit without any validation, a truncated return can silently miscount matches and produce the wrong replacement.

🔧 Proposed fix — explicitly snap origEnd to end-of-file when the loop exhausted fileContent
   while (origPos < fileContent.length && normPos <= normalizedStart + normalizedLength) {
     // ... existing body ...
   }

-  // 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)
-  }
+  // If the loop exhausted fileContent while the match still ran to (or past) its end,
+  // the match extends to EOF and origEnd should be the end of fileContent.
+  if (origEnd === -1 && normPos >= normalizedStart + normalizedLength) {
+    origEnd = fileContent.length
+  }
+  // Last-resort fallback (should not normally be reached given the cascade above).
+  if (origStart === -1) origStart = 0
+  if (origEnd === -1) origEnd = fileContent.length
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/builtin-tools/src/tools/FileEditTool/utils.ts` around lines 143 -
193, mapNormalizedMatchBackToFile can under-estimate origEnd when the loop exits
because origPos reached fileContent.length (match at EOF), causing the ratio
fallback to produce a truncated substring; fix by detecting when the loop
terminated due to reaching the end of file (origPos >= fileContent.length) and,
in that case, set origEnd = fileContent.length (or clamp origEnd to
fileContent.length) before applying the ratio fallback so matches that extend to
EOF return the full tail; update the logic inside mapNormalizedMatchBackToFile
to explicitly snap/clip origEnd to fileContent.length when the file was
exhausted.


/**
* When old_string matched via quote normalization (curly quotes in file,
* straight quotes from model), apply the same curly quote style to new_string
Expand Down
7 changes: 7 additions & 0 deletions src/components/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -99,6 +101,7 @@ function MessageImpl({
isUserContinuation = false,
lastThinkingBlockId,
latestBashOutputUUID,
shouldCollapseDiffs,
}: Props): React.ReactNode {
switch (message.type) {
case 'attachment':
Expand Down Expand Up @@ -181,6 +184,7 @@ function MessageImpl({
isUserContinuation={isUserContinuation}
lookups={lookups}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
))}
</Box>
Expand Down Expand Up @@ -293,6 +297,7 @@ function UserMessage({
isUserContinuation,
lookups,
isTranscriptMode,
shouldCollapseDiffs,
}: {
message: NormalizedUserMessage
addMargin: boolean
Expand All @@ -309,6 +314,7 @@ function UserMessage({
isUserContinuation: boolean
lookups: ReturnType<typeof buildMessageLookups>
isTranscriptMode: boolean
shouldCollapseDiffs?: boolean
}): React.ReactNode {
const { columns } = useTerminalSize()
switch (param.type) {
Expand Down Expand Up @@ -344,6 +350,7 @@ function UserMessage({
verbose={verbose}
width={columns - 5}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
default:
Expand Down
3 changes: 3 additions & 0 deletions src/components/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type Props = {
columns: number
isLoading: boolean
lookups: ReturnType<typeof buildMessageLookups>
shouldCollapseDiffs?: boolean
}

/**
Expand Down Expand Up @@ -141,6 +142,7 @@ function MessageRowImpl({
columns,
isLoading,
lookups,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const isTranscriptMode = screen === 'transcript'
const isGrouped = msg.type === 'grouped_tool_use'
Expand Down Expand Up @@ -221,6 +223,7 @@ function MessageRowImpl({
isUserContinuation={isUserContinuation}
lastThinkingBlockId={lastThinkingBlockId}
latestBashOutputUUID={latestBashOutputUUID}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
// OffscreenFreeze: the outer React.memo already bails for static messages,
Expand Down
7 changes: 7 additions & 0 deletions src/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<MessageRow
Expand All @@ -838,6 +844,7 @@ const MessagesImpl = ({
columns={columns}
isLoading={isLoading}
lookups={lookups}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Props = {
verbose: boolean
width: number | string
isTranscriptMode?: boolean
shouldCollapseDiffs?: boolean
}

export function UserToolResultMessage({
Expand All @@ -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) {
Expand Down Expand Up @@ -96,6 +98,7 @@ export function UserToolResultMessage({
verbose={verbose}
width={width}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Props = {
verbose: boolean
width: number | string
isTranscriptMode?: boolean
shouldCollapseDiffs?: boolean
}

export function UserToolSuccessMessage({
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading