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
5 changes: 5 additions & 0 deletions .changeset/calm-cats-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Show submitted review comments as interactive message cards instead of raw markdown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 12 additions & 6 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,9 @@ export function UserMessageDisplay(props: {
interrupted?: boolean
animate?: boolean
queued?: boolean
text?: string
copyText?: string
header?: JSX.Element
onFork?: () => void
onRevert?: () => void
}) {
Expand All @@ -743,7 +746,7 @@ export function UserMessageDisplay(props: {
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
)

const text = createMemo(() => textPart()?.text || "")
const text = createMemo(() => props.text ?? textPart()?.text ?? "")

const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])

Expand Down Expand Up @@ -797,7 +800,7 @@ export function UserMessageDisplay(props: {
}

const handleCopy = async () => {
const content = text()
const content = props.copyText ?? text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
Expand Down Expand Up @@ -840,12 +843,15 @@ export function UserMessageDisplay(props: {
</For>
</div>
</Show>
<Show when={text()}>
<Show when={text() || props.header}>
<>
<div data-slot="user-message-body">
<div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
{props.header}
<Show when={text()}>
<div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
</Show>
<GrowBox animate={!!props.animate} open={!!props.queued}>
<div data-slot="user-message-queued-indicator">
<TextShimmer text={i18n.t("ui.message.queued")} />
Expand Down
8 changes: 7 additions & 1 deletion packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import {
} from "./kilo-provider/handlers/question"
import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion"
import { nativeTitle } from "./kilo-provider/native-tab-title"
import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments"

import {
buildActionContext,
Expand Down Expand Up @@ -798,6 +799,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
message.agent,
message.variant,
parseMessageFiles(message.files),
parseReview(message.review, message.text),
typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined,
typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined,
)
Expand Down Expand Up @@ -1122,6 +1124,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
message.agent,
message.variant,
files,
parseReview(message.review, message.text),
typeof message.command === "string" ? message.command : undefined,
typeof message.commandArgs === "string" ? message.commandArgs : undefined,
)
Expand Down Expand Up @@ -2580,6 +2583,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
agent?: string,
variant?: string,
files?: MessageFile[],
review?: ReviewMessageData,
context?: string,
contextDirectory?: string,
): Promise<void> {
Expand All @@ -2592,6 +2596,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
draftID,
messageID,
files,
review,
})
return
}
Expand All @@ -2606,7 +2611,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source })
}
}
parts.push({ type: "text", text })
parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined })

const sid = resolved!.sid
const dir = resolved!.dir
Expand Down Expand Up @@ -2644,6 +2649,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
draftID,
messageID,
files,
review,
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { KiloClient, Session, TextPartInput, FilePartInput } from "@kilocod
import type { CloudSessionData, EditorContext } from "../../services/cli-backend/types"
import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils"
import type { MessageFile } from "../message-files"
import { reviewMetadata, type ReviewMessageData } from "../../shared/review-comments"

const TIMEOUT = 30_000

Expand Down Expand Up @@ -119,6 +120,7 @@ export async function handleImportAndSend(
agent?: string,
variant?: string,
files?: MessageFile[],
review?: ReviewMessageData,
command?: string,
commandArgs?: string,
): Promise<void> {
Expand Down Expand Up @@ -213,7 +215,7 @@ export async function handleImportAndSend(
parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source })
}
}
parts.push({ type: "text", text })
parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined })

const editorContext = await ctx.gatherEditorContext()
await client.session.promptAsync(
Expand All @@ -240,6 +242,7 @@ export async function handleImportAndSend(
draftID: session.id,
messageID,
files,
review: command ? undefined : review,
})
}
}
118 changes: 118 additions & 0 deletions packages/kilo-vscode/src/shared/review-comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
export interface ReviewCommentData {
id: string
file: string
side: "additions" | "deletions"
line: number
comment: string
selectedText: string
}

export interface ReviewMessageData {
version: 1
comments: ReviewCommentData[]
}

interface ReviewMessageView {
data: ReviewMessageData
body: string
}

const LIMIT = 100
const TOTAL_LIMIT = 1_000_000
const TEXT_LIMIT = 100_000
const SELECTION_LIMIT = 200_000

function escapeInline(value: string): string {
return value.replace(/([\\`*_\[\]{}()#+\-!|])/g, "\\$1")
}

export function formatReviewCommentMarkdown(comment: ReviewCommentData): string {
const lines = [`**${escapeInline(comment.file)}** (line ${comment.line}):`]
if (comment.selectedText) {
const matches = comment.selectedText.match(/`+/g) ?? []
const longest = matches.reduce((max, item) => Math.max(max, item.length), 0)
const fence = "`".repeat(Math.max(3, longest + 1))
lines.push(fence, comment.selectedText, fence)
}
lines.push(comment.comment)
return lines.join("\n")
}

export function formatReviewCommentsMarkdown(comments: ReviewCommentData[]): string {
const lines = ["## Review Comments", ""]
for (const item of comments) {
lines.push(formatReviewCommentMarkdown(item), "")
}
return lines.join("\n").trimEnd()
}

function record(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
return value as Record<string, unknown>
}

function text(value: unknown, limit: number): string | undefined {
if (typeof value !== "string" || value.length > limit) return undefined
return value
}

function parseComment(value: unknown): ReviewCommentData | undefined {
const item = record(value)
if (!item) return undefined

const id = text(item.id, 512)
const file = text(item.file, 4_096)
const comment = text(item.comment, TEXT_LIMIT)
const selectedText = text(item.selectedText, SELECTION_LIMIT)
const side = item.side
const line = item.line
if (!id || !file || comment === undefined || selectedText === undefined) return undefined
const absolute = file.startsWith("/") || file.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(file)
const traversal = file.split(/[\\/]/).includes("..")
if (absolute || traversal || file.includes("\0")) return undefined
if (side !== "additions" && side !== "deletions") return undefined
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) return undefined

return { id, file, side, line, comment, selectedText }
}

function view(value: unknown, content: string): ReviewMessageView | undefined {
const data = record(value)
if (!data || data.version !== 1 || !Array.isArray(data.comments)) return undefined
if (data.comments.length === 0 || data.comments.length > LIMIT) return undefined

const comments: ReviewCommentData[] = []
for (const value of data.comments) {

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.

SUGGESTION: Variable value in the for loop shadows the outer value parameter of the view function.

The outer value is no longer needed past line 80, so this isn't a functional bug, but it's a style guideline violation (no-shadow) and could confuse readers. Consider using a different name like entry or raw.

Suggested change
for (const value of data.comments) {
for (const entry of data.comments) {

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

const item = parseComment(value)
if (!item) return undefined
comments.push(item)
}
const size = comments.reduce(
(total, item) => total + item.id.length + item.file.length + item.comment.length + item.selectedText.length,
0,
)
if (size > TOTAL_LIMIT) return undefined

const prefix = formatReviewCommentsMarkdown(comments)
if (content === prefix) return { data: { version: 1, comments }, body: "" }
if (!content.startsWith(`${prefix}\n\n`)) return undefined
return { data: { version: 1, comments }, body: content.slice(prefix.length + 2) }
}

export function parseReview(value: unknown, content: string): ReviewMessageData | undefined {
return view(value, content)?.data
}

export function reviewMetadata(review: ReviewMessageData): Record<string, unknown> {
return { kilo: { review } }
}

export function reviewBody(review: ReviewMessageData, content: string): string | undefined {
return view(review, content)?.body
}

export function partReview(metadata: unknown, content: string): ReviewMessageView | undefined {
const root = record(metadata)
const kilo = record(root?.kilo)
return view(kilo?.review, content)
}
36 changes: 36 additions & 0 deletions packages/kilo-vscode/tests/unit/review-comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
reviewEditSpeechKey,
} from "../../webview-ui/diff-viewer/review-annotations"
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"
import { parseReview, partReview, reviewMetadata } from "../../src/shared/review-comments"

function diff(file: string, before: string, after: string): WorktreeFileDiff {
return { file, before, after, additions: 1, deletions: 0 }
Expand Down Expand Up @@ -169,6 +170,41 @@ describe("formatReviewCommentsMarkdown", () => {
})
})

describe("review message metadata", () => {
const comments = [comment({ file: "src/a.ts", line: 5, comment: "Fix this", selectedText: "const a = 1" })]
const content = `${formatReviewCommentsMarkdown(comments)}\n\nPlease address this feedback.`
const review = { version: 1 as const, comments }

it("round-trips review comments and extracts the visible body", () => {
expect(partReview(reviewMetadata(review), content)).toEqual({
data: review,
body: "Please address this feedback.",
})
})

it("extracts an empty body from a review-only message", () => {
expect(partReview(reviewMetadata(review), formatReviewCommentsMarkdown(comments))?.body).toBe("")
})

it("rejects malformed review comments", () => {
expect(parseReview({ ...review, comments: [{ ...comments[0], line: 0 }] }, content)).toBeUndefined()
expect(parseReview({ ...review, comments: [{ ...comments[0], side: "context" }] }, content)).toBeUndefined()
expect(parseReview({ ...review, comments: [{ ...comments[0], file: "../secret" }] }, content)).toBeUndefined()
expect(parseReview({ ...review, comments: [{ ...comments[0], file: "/tmp/secret" }] }, content)).toBeUndefined()
})

it("rejects metadata that does not match the hidden text", () => {
expect(parseReview(review, `unrelated hidden text\n\n${content}`)).toBeUndefined()
})

it("rejects oversized aggregate metadata before formatting it", () => {
const oversized = Array.from({ length: 6 }, (_, index) =>
comment({ id: `comment-${index}`, selectedText: "x".repeat(200_000) }),
)
expect(parseReview({ version: 1, comments: oversized }, "irrelevant")).toBeUndefined()
})
})

// ── extractLines ────────────────────────────────────────────────────────────

describe("extractLines", () => {
Expand Down
Loading
Loading