Skip to content

Commit 598d7a3

Browse files
committed
feat(webview): prevent UI bloat by not embedding file contents in UI messages; add formatContentBlockForUi() and use for api_req_started
1 parent 0e7a878 commit 598d7a3

File tree

2 files changed

+183
-3
lines changed

2 files changed

+183
-3
lines changed

src/core/task/Task.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
6666

6767
// integrations
6868
import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
69-
import { findToolName, formatContentBlockToMarkdown } from "../../integrations/misc/export-markdown"
69+
import { findToolName } from "../../integrations/misc/export-markdown"
70+
import { formatContentBlockForUi } from "../../shared/formatContentBlockForUi"
7071
import { RooTerminalProcess } from "../../integrations/terminal/types"
7172
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
7273

@@ -1794,7 +1795,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
17941795
"api_req_started",
17951796
JSON.stringify({
17961797
request:
1797-
currentUserContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n") +
1798+
currentUserContent.map((block) => formatContentBlockForUi(block)).join("\n\n") +
17981799
"\n\nLoading...",
17991800
apiProtocol,
18001801
}),
@@ -1835,7 +1836,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
18351836
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
18361837

18371838
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
1838-
request: finalUserContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
1839+
request: finalUserContent.map((block) => formatContentBlockForUi(block)).join("\n\n"),
18391840
apiProtocol,
18401841
} satisfies ClineApiReqInfo)
18411842

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Anthropic } from "@anthropic-ai/sdk"
2+
3+
/**
4+
* UI-safe formatter for ContentBlockParam that avoids including large or sensitive payloads
5+
* such as full file contents, diffs, or oversized text in UI messages (ui_messages.json).
6+
*
7+
* IMPORTANT:
8+
* - This is ONLY for rendering content into UI messages (e.g., api_req_started.request).
9+
* - Do NOT use this for API conversation history or export; those need full fidelity.
10+
*/
11+
export function formatContentBlockForUi(block: Anthropic.Messages.ContentBlockParam): string {
12+
switch (block.type) {
13+
case "text":
14+
return sanitizeText(block.text ?? "")
15+
case "image":
16+
return "[Image]"
17+
case "tool_use":
18+
return summarizeToolUse(block)
19+
case "tool_result":
20+
if (typeof block.content === "string") {
21+
return sanitizeText(block.content)
22+
} else if (Array.isArray(block.content)) {
23+
// Recursively sanitize nested blocks
24+
return block.content.map(formatContentBlockForUi).join("\n")
25+
} else {
26+
return "[Tool Result]"
27+
}
28+
default:
29+
return `[${block.type}]`
30+
}
31+
}
32+
33+
/**
34+
* Summarize tool_use without dumping large params (like diff/content).
35+
*/
36+
function summarizeToolUse(block: Anthropic.Messages.ToolUseBlockParam): string {
37+
const name = block.name
38+
// Try to extract relevant lightweight params for display
39+
try {
40+
const params = (block as any)?.input ?? (block as any)?.params ?? {}
41+
// Prefer path if present
42+
const directPath = params?.path as string | undefined
43+
44+
// For XML args (e.g., read_file, apply_diff multi-file), collect a small summary of paths
45+
const xmlArgs = typeof params?.args === "string" ? params.args : undefined
46+
const pathsFromXml = xmlArgs ? extractPathsFromXml(xmlArgs) : []
47+
48+
if (name === "read_file") {
49+
const paths = directPath ? [directPath] : pathsFromXml
50+
if (paths.length === 0) return `[Tool Use: ${name}]`
51+
if (paths.length === 1) return `[Tool Use: ${name}] ${paths[0]}`
52+
return `[Tool Use: ${name}] ${paths[0]} (+${paths.length - 1} more)`
53+
}
54+
55+
if (
56+
name === "apply_diff" ||
57+
name === "insert_content" ||
58+
name === "search_and_replace" ||
59+
name === "write_to_file"
60+
) {
61+
const paths = directPath ? [directPath] : pathsFromXml
62+
if (paths.length === 0) return `[Tool Use: ${name}]`
63+
if (paths.length === 1) return `[Tool Use: ${name}] ${paths[0]}`
64+
return `[Tool Use: ${name}] ${paths[0]} (+${paths.length - 1} more)`
65+
}
66+
67+
if (name === "search_files") {
68+
const regex = params?.regex ? ` regex="${String(params.regex)}"` : ""
69+
const fp = params?.file_pattern ? ` file_pattern="${String(params.file_pattern)}"` : ""
70+
const p = params?.path ? ` ${String(params.path)}` : ""
71+
return `[Tool Use: ${name}]${p}${regex}${fp}`
72+
}
73+
74+
// Default: show name only
75+
return `[Tool Use: ${name}]`
76+
} catch {
77+
return `[Tool Use: ${block.name}]`
78+
}
79+
}
80+
81+
/**
82+
* Sanitize a text chunk for UI:
83+
* - Collapse <files> XML to a per-file summary (hide <content> bodies)
84+
* - Truncate very long text
85+
* - Redact obvious diff blobs
86+
*/
87+
function sanitizeText(text: string): string {
88+
if (!text) return ""
89+
90+
// If this looks like a files XML, summarize paths/errors/notices and drop content bodies
91+
if (text.includes("<files") || text.includes("<files")) {
92+
return summarizeFilesXml(text)
93+
}
94+
95+
// If this contains diff markers, replace with a short placeholder
96+
if (looksLikeDiff(text)) {
97+
return "[diff content omitted]"
98+
}
99+
100+
// Generic truncation to keep UI light-weight
101+
const MAX = 2000
102+
if (text.length > MAX) {
103+
const omitted = text.length - MAX
104+
return `${text.slice(0, MAX)}\n[omitted ${omitted} chars]`
105+
}
106+
107+
return text
108+
}
109+
110+
function looksLikeDiff(s: string): boolean {
111+
return (
112+
s.includes("<<<<<<< SEARCH") ||
113+
s.includes(">>>>>>> REPLACE") ||
114+
s.includes("<<<<<<< SEARCH") ||
115+
s.includes(">>>>>>> REPLACE") ||
116+
/^diff --git/m.test(s)
117+
)
118+
}
119+
120+
/**
121+
* Summarize a <files> XML payload by listing file paths and high-level status,
122+
* but never including <content> bodies.
123+
*/
124+
function summarizeFilesXml(xmlLike: string): string {
125+
// Support both escaped and unescaped tags
126+
const decode = (s: string) => s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")
127+
128+
const raw = decode(xmlLike)
129+
const fileRegex = /<file>([\s\S]*?)<\/file>/g
130+
const items: string[] = []
131+
let match: RegExpExecArray | null
132+
133+
while ((match = fileRegex.exec(raw)) !== null) {
134+
const fileBlock = match[1]
135+
const path = matchOne(fileBlock, /<path>([\s\S]*?)<\/path>/)
136+
const error = matchOne(fileBlock, /<error>([\s\S]*?)<\/error>/)
137+
const notice = matchOne(fileBlock, /<notice>([\s\S]*?)<\/notice>/)
138+
const binary = matchOne(fileBlock, /<binary_file(?:[^>]*)>([\s\S]*?)<\/binary_file>/)
139+
140+
let line = path ? `- ${path}` : "- [unknown path]"
141+
if (error) line += ` [error: ${singleLine(error)}]`
142+
if (!error && binary) line += " [binary file]"
143+
if (!error && !binary && notice) line += ` [${singleLine(notice)}]`
144+
145+
items.push(line)
146+
}
147+
148+
if (items.length === 0) {
149+
return "[files omitted]"
150+
}
151+
152+
const MAX_ITEMS = 20
153+
let output = items.slice(0, MAX_ITEMS).join("\n")
154+
if (items.length > MAX_ITEMS) {
155+
output += `\n[+${items.length - MAX_ITEMS} more files]`
156+
}
157+
return output
158+
}
159+
160+
function extractPathsFromXml(xml: string): string[] {
161+
const decode = (s: string) => s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")
162+
const raw = decode(xml)
163+
const pathRegex = /<path>([\s\S]*?)<\/path>/g
164+
const paths: string[] = []
165+
let m: RegExpExecArray | null
166+
while ((m = pathRegex.exec(raw)) !== null) {
167+
paths.push(m[1])
168+
}
169+
return paths
170+
}
171+
172+
function matchOne(source: string, re: RegExp): string | undefined {
173+
const m = re.exec(source)
174+
return m ? m[1] : undefined
175+
}
176+
177+
function singleLine(s: string): string {
178+
return s.replace(/\s+/g, " ").trim()
179+
}

0 commit comments

Comments
 (0)