Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/message-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": minor
---

Rate assistant responses with thumbs up/down. Click the thumbs buttons next to the copy button on any assistant message, or press `<leader>=` / `<leader>-` in the terminal UI. Only shown when telemetry is enabled; feedback is sent to Kilo to help improve model and prompt quality.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Add a thumbs-up / thumbs-down widget next to the existing copy button on every a
| Surface | Approach |
|---|---|
| VS Code extension | Thumbs buttons inline next to the copy button |
| TUI | Keybinds (`<leader>+` / `<leader>-`) on the last assistant message |
| TUI | Keybinds (`<leader>=` / `<leader>-`) on the last assistant message |

### Telemetry Payload

Expand Down
3 changes: 3 additions & 0 deletions packages/kilo-telemetry/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export enum TelemetryEvent {
// Config Events
TELEMETRY_DISABLED = "Telemetry Disabled",

// Feedback
FEEDBACK_SUBMITTED = "Feedback Submitted",

// Errors
ERROR = "Error",
}
16 changes: 16 additions & 0 deletions packages/kilo-telemetry/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,22 @@ export namespace Telemetry {
track(TelemetryEvent.ERROR, { error, context })
}

// Feedback
export interface FeedbackProperties extends Record<string, unknown> {
providerID: string
modelID: string
variant?: string
rating: "up" | "down" | "cleared"
previousRating?: "up" | "down"
sessionID?: string
messageID?: string
parentMessageID?: string
}

export function trackFeedback(props: FeedbackProperties) {
track(TelemetryEvent.FEEDBACK_SUBMITTED, props)
}

export async function shutdown(): Promise<void> {
await Client.shutdown()
}
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
display: flex;
align-items: center;
justify-content: flex-start;
gap: 2px;
margin-top: 2px;

[data-component="icon-button"] {
Expand Down
54 changes: 54 additions & 0 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
)
}

export interface MessageFeedbackControls {
enabled?: boolean
rating?: "up" | "down"
onRate?: (rating: "up" | "down" | null) => void
}

export interface MessagePartProps {
part: PartType
message: MessageType
Expand All @@ -136,6 +142,7 @@ export interface MessagePartProps {
turnDiffSummary?: () => JSX.Element
animate?: boolean
working?: boolean
feedback?: MessageFeedbackControls
}

export type PartComponent = Component<MessagePartProps>
Expand Down Expand Up @@ -971,6 +978,7 @@ export function Part(props: MessagePartProps) {
turnDiffSummary={props.turnDiffSummary}
animate={props.animate}
working={props.working}
feedback={props.feedback}
/>
</Show>
)
Expand Down Expand Up @@ -1325,6 +1333,52 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
/>
</Tooltip>
<Show when={props.feedback?.enabled}>
<Tooltip
value={
props.feedback?.rating === "up"
? i18n.t("ui.message.feedback.clearRating")
: i18n.t("ui.message.feedback.helpful")
}
placement="top"
gutter={4}
>
<IconButton
icon={props.feedback?.rating === "up" ? "thumbs-up-filled" : "thumbs-up"}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const next = props.feedback?.rating === "up" ? null : "up"
props.feedback?.onRate?.(next)
}}
aria-pressed={props.feedback?.rating === "up"}
aria-label={i18n.t("ui.message.feedback.helpful")}
/>
</Tooltip>
<Tooltip
value={
props.feedback?.rating === "down"
? i18n.t("ui.message.feedback.clearRating")
: i18n.t("ui.message.feedback.notHelpful")
}
placement="top"
gutter={4}
>
<IconButton
icon={props.feedback?.rating === "down" ? "thumbs-down-filled" : "thumbs-down"}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const next = props.feedback?.rating === "down" ? null : "down"
props.feedback?.onRate?.(next)
}}
aria-pressed={props.feedback?.rating === "down"}
aria-label={i18n.t("ui.message.feedback.notHelpful")}
/>
</Tooltip>
</Show>
</div>
</Show>
<Show when={summary()}>
Expand Down
8 changes: 3 additions & 5 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { EditorContext, IndexingStatus } from "./services/cli-backend/types
import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController"
import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete"
import { buildWebviewHtml } from "./utils"
import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry"
import { TelemetryProxy, type TelemetryPropertiesProvider, pushTelemetryState } from "./services/telemetry"
import {
sessionToWebview,
indexProvidersById,
Expand Down Expand Up @@ -346,10 +346,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}

// Always push connection state first so the UI can render appropriately.
this.postMessage({
type: "connectionState",
state: this.connectionState,
})
this.postMessage({ type: "connectionState", state: this.connectionState })
pushTelemetryState((m) => this.postMessage(m))
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.

WARNING: Telemetry opt-out changes do not reach an open webview

pushTelemetryState only runs during webview sync, and there is no vscode.env.onDidChangeTelemetryEnabled subscription. If the user disables telemetry while the sidebar is already open, feedback.telemetryEnabled() remains true, so the buttons stay visible and feedbackSubmitted can still persist a rating until the webview reloads. Wire the telemetry change event to post telemetryState and dispose that listener with the other webview disposables.


// Re-send ready so the webview can recover after refresh.
if (serverInfo) {
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/services/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { TelemetryEventName, type TelemetryPropertiesProvider } from "./types"
export { TelemetryProxy } from "./telemetry-proxy"
export { pushTelemetryState } from "./webview-state"
3 changes: 3 additions & 0 deletions packages/kilo-vscode/src/services/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export enum TelemetryEventName {
FREE_MODELS_LINK_CLICKED = "Free Models Link Clicked",
CREATE_ORGANIZATION_LINK_CLICKED = "Create Organization Link Clicked",
GHOST_SERVICE_DISABLED = "Ghost Service Disabled",

// Feedback
FEEDBACK_SUBMITTED = "Feedback Submitted",
}

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/kilo-vscode/src/services/telemetry/webview-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as vscode from "vscode"

/**
* Push the current VS Code telemetry-enabled flag to a webview. Called on
* webview ready / re-sync so the webview can gate feedback UI on the flag.
*/
export function pushTelemetryState(post: (msg: { type: "telemetryState"; enabled: boolean }) => void): void {
post({ type: "telemetryState", enabled: vscode.env.isTelemetryEnabled })
}
78 changes: 78 additions & 0 deletions packages/kilo-vscode/tests/unit/feedback-payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from "bun:test"
import { buildFeedbackProperties, isKiloGateway } from "../../webview-ui/src/context/feedback-payload"

const baseInput = {
messageID: "msg_abc",
sessionID: "ses_xyz",
parentMessageID: "msg_parent",
modelID: "claude-sonnet-4-5",
variant: undefined as string | undefined,
}

describe("isKiloGateway", () => {
it("matches the canonical kilo provider", () => {
expect(isKiloGateway("kilo")).toBe(true)
})

it("matches aliased kilo providers", () => {
expect(isKiloGateway("kilo-dev")).toBe(true)
expect(isKiloGateway("kilocloud")).toBe(true)
})

it("does not match direct providers", () => {
expect(isKiloGateway("anthropic")).toBe(false)
expect(isKiloGateway("openai")).toBe(false)
expect(isKiloGateway("openrouter")).toBe(false)
})
})

describe("buildFeedbackProperties — non-Kilo providers", () => {
it("includes only provider/model/rating (no session or message IDs)", () => {
const props = buildFeedbackProperties({ ...baseInput, providerID: "anthropic", next: "up" })
expect(props).toEqual({
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
rating: "up",
})
expect(props).not.toHaveProperty("sessionID")
expect(props).not.toHaveProperty("messageID")
expect(props).not.toHaveProperty("parentMessageID")
})

it("includes variant when set", () => {
const props = buildFeedbackProperties({ ...baseInput, providerID: "openai", variant: "preview", next: "down" })
expect(props.variant).toBe("preview")
})

it("includes previousRating when provided", () => {
const props = buildFeedbackProperties({ ...baseInput, providerID: "anthropic", next: "down" }, "up")
expect(props.previousRating).toBe("up")
})

it("uses 'cleared' when next is null", () => {
const props = buildFeedbackProperties({ ...baseInput, providerID: "anthropic", next: null }, "up")
expect(props.rating).toBe("cleared")
expect(props.previousRating).toBe("up")
})
})

describe("buildFeedbackProperties — Kilo Gateway", () => {
it("includes sessionID, messageID, parentMessageID", () => {
const props = buildFeedbackProperties({ ...baseInput, providerID: "kilo", next: "up" })
expect(props).toEqual({
providerID: "kilo",
modelID: "claude-sonnet-4-5",
rating: "up",
sessionID: "ses_xyz",
messageID: "msg_abc",
parentMessageID: "msg_parent",
})
})

it("treats aliased kilo providers the same", () => {
const props = buildFeedbackProperties({ ...baseInput, providerID: "kilo-cloud", next: "up" })
expect(props.sessionID).toBe("ses_xyz")
expect(props.messageID).toBe("msg_abc")
expect(props.parentMessageID).toBe("msg_parent")
})
})
17 changes: 10 additions & 7 deletions packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { ProviderProvider } from "../src/context/provider"
import { ConfigProvider } from "../src/context/config"
import { DisplayProvider } from "../src/context/display"
import { NotificationsProvider } from "../src/context/notifications"
import { FeedbackProvider } from "../src/context/feedback"
import { SessionProvider, useSession } from "../src/context/session"
import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { ChatView } from "../src/components/chat"
Expand Down Expand Up @@ -3170,13 +3171,15 @@ export const AgentManagerApp: Component = () => {
<DisplayProvider>
<NotificationsProvider>
<SessionProvider>
<IndexingProvider>
<WorktreeModeProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</WorktreeModeProvider>
</IndexingProvider>
<FeedbackProvider>
<IndexingProvider>
<WorktreeModeProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</WorktreeModeProvider>
</IndexingProvider>
</FeedbackProvider>
</SessionProvider>
</NotificationsProvider>
</DisplayProvider>
Expand Down
9 changes: 6 additions & 3 deletions packages/kilo-vscode/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ registerVscodeToolOverrides()
import HistoryView from "./components/history/HistoryView"
import { MigrationWizard } from "./components/migration" // legacy-migration
import { NotificationsProvider } from "./context/notifications"
import { FeedbackProvider } from "./context/feedback"
import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2"
import "./styles/chat.css"

Expand Down Expand Up @@ -354,9 +355,11 @@ const App: Component = () => {
<IndexingProvider>
<NotificationsProvider>
<SessionProvider>
<DataBridge>
<AppContent />
</DataBridge>
<FeedbackProvider>
<DataBridge>
<AppContent />
</DataBridge>
</FeedbackProvider>
</SessionProvider>
</NotificationsProvider>
</IndexingProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import { Part, PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part"
import type { MessageFeedbackControls } from "@kilocode/kilo-ui/message-part"
import type {
AssistantMessage as SDKAssistantMessage,
Part as SDKPart,
Expand Down Expand Up @@ -62,6 +63,7 @@ function matchToolRequest<T extends { tool?: { callID: string; messageID: string
interface AssistantMessageProps {
message: SDKAssistantMessage
showAssistantCopyPartID?: string | null
feedback?: MessageFeedbackControls
}

function TodoToolCard(props: { part: ToolPart }) {
Expand Down Expand Up @@ -167,6 +169,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
message={props.message as SDKMessage}
showAssistantCopyPartID={props.showAssistantCopyPartID}
reasoningAutoCollapse={display.reasoningAutoCollapse()}
feedback={props.feedback}
animate={
part.type === "tool" &&
((part as unknown as ToolPart).state?.status === "pending" ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ErrorDisplay } from "./ErrorDisplay"
import { useServer } from "../../context/server"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import { useFeedback } from "../../context/feedback"
import { visibleError } from "../../context/session-errors"
import type { ErrorDisplayProps } from "./ErrorDisplay"
import type { Message as WebMessage } from "../../types/messages"
Expand Down Expand Up @@ -68,6 +69,7 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
const server = useServer()
const session = useSession()
const language = useLanguage()
const feedback = useFeedback()

const emptyParts: SDKPart[] = []
const emptyDiffs: SnapshotFileDiff[] = []
Expand Down Expand Up @@ -174,7 +176,26 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
<Show when={assistantMessages().length > 0}>
<div class="vscode-session-turn-assistant">
<For each={assistantMessages()}>
{(msg) => <AssistantMessage message={msg} showAssistantCopyPartID={showAssistantCopyPartID()} />}
{(amsg) => (
<AssistantMessage
message={amsg}
showAssistantCopyPartID={showAssistantCopyPartID()}
feedback={{
enabled: feedback.telemetryEnabled(),
rating: feedback.getRating(amsg.id),
onRate: (next) =>
feedback.rate({
messageID: amsg.id,
sessionID: amsg.sessionID,
parentMessageID: amsg.parentID,
providerID: amsg.providerID,
modelID: amsg.modelID,
variant: (amsg as SDKAssistantMessage & { variant?: string }).variant,
next,
}),
}}
/>
)}
</For>
</div>
</Show>
Expand Down
Loading
Loading