diff --git a/.changeset/message-feedback.md b/.changeset/message-feedback.md new file mode 100644 index 00000000000..080c200bd34 --- /dev/null +++ b/.changeset/message-feedback.md @@ -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 `=` / `-` in the terminal UI. Only shown when telemetry is enabled; feedback is sent to Kilo to help improve model and prompt quality. diff --git a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md index b638dba13d2..834906dc84e 100644 --- a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md +++ b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md @@ -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 (`+` / `-`) on the last assistant message | +| TUI | Keybinds (`=` / `-`) on the last assistant message | ### Telemetry Payload diff --git a/packages/kilo-telemetry/src/events.ts b/packages/kilo-telemetry/src/events.ts index d61d4ae06b6..59b489a34ac 100644 --- a/packages/kilo-telemetry/src/events.ts +++ b/packages/kilo-telemetry/src/events.ts @@ -42,6 +42,9 @@ export enum TelemetryEvent { // Config Events TELEMETRY_DISABLED = "Telemetry Disabled", + // Feedback + FEEDBACK_SUBMITTED = "Feedback Submitted", + // Errors ERROR = "Error", } diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 94a8d946ce8..88b5a887d41 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -241,6 +241,22 @@ export namespace Telemetry { track(TelemetryEvent.ERROR, { error, context }) } + // Feedback + export interface FeedbackProperties extends Record { + 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 { await Client.shutdown() } diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index e887aaf7770..942e77c45d9 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -24,12 +24,19 @@ display: flex; align-items: center; justify-content: flex-start; + gap: 2px; margin-top: 2px; [data-component="icon-button"] { width: 20px; height: 20px; } + + /* Thumbs up/down: fill the outline on hover to preview the rated state. */ + [data-component="icon-button"][data-icon="thumbs-up"]:hover [data-slot="icon-svg"] path, + [data-component="icon-button"][data-icon="thumbs-down"]:hover [data-slot="icon-svg"] path { + fill: currentColor; + } } } diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ef88e3ec379..3edb43ceed6 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -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 @@ -136,6 +142,7 @@ export interface MessagePartProps { turnDiffSummary?: () => JSX.Element animate?: boolean working?: boolean + feedback?: MessageFeedbackControls } export type PartComponent = Component @@ -971,6 +978,7 @@ export function Part(props: MessagePartProps) { turnDiffSummary={props.turnDiffSummary} animate={props.animate} working={props.working} + feedback={props.feedback} /> ) @@ -1325,6 +1333,52 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} /> + + + 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")} + /> + + + 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")} + /> + + diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index b11a766659b..89e04572492 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -49,7 +49,10 @@ export default [ // (canvases must never leave the paint tree — see render.tsx), and // render-call wiring that must live at the top of // `AgentManagerContent` alongside the existing selection/session state. - rules: { complexity: ["error", 74], "max-lines": ["error", 3200] }, + // Raised from 3200 → 3210 for the per-message feedback `FeedbackProvider` + // wiring, which sits inside the provider chain and cannot be extracted + // without adding an intermediate wrapper component. + rules: { complexity: ["error", 74], "max-lines": ["error", 3210] }, }, { files: ["src/agent-manager/AgentManagerProvider.ts"], diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 3b0ebc44b29..45c21865ed2 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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, watchTelemetryState } from "./services/telemetry" // prettier-ignore import { sessionToWebview, indexProvidersById, @@ -211,6 +211,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private initConnectionPromise: Promise | null = null private webviewMessageDisposable: vscode.Disposable | null = null private autocompleteConfigDisposable: vscode.Disposable | null = null + private telemetryStateDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null private autoApproveBridge: ReturnType | null = null @@ -346,10 +347,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)) // Re-send ready so the webview can recover after refresh. if (serverInfo) { @@ -576,6 +575,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) + this.telemetryStateDisposable?.dispose() + this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { const intercepted = await interceptMessage(message, { workspaceDir: (sid) => this.getWorkspaceDirectory(sid ?? this.currentSession?.id), @@ -3400,6 +3401,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibilityDisposable?.dispose() this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() + this.telemetryStateDisposable?.dispose() this.autoApproveBridge?.dispose() this.streams.dispose() this.isWebviewReady = false diff --git a/packages/kilo-vscode/src/services/telemetry/index.ts b/packages/kilo-vscode/src/services/telemetry/index.ts index 2ed56fb0271..d6ca56e8387 100644 --- a/packages/kilo-vscode/src/services/telemetry/index.ts +++ b/packages/kilo-vscode/src/services/telemetry/index.ts @@ -1,2 +1,3 @@ export { TelemetryEventName, type TelemetryPropertiesProvider } from "./types" export { TelemetryProxy } from "./telemetry-proxy" +export { pushTelemetryState, watchTelemetryState } from "./webview-state" diff --git a/packages/kilo-vscode/src/services/telemetry/types.ts b/packages/kilo-vscode/src/services/telemetry/types.ts index 74c676a2204..5993151d037 100644 --- a/packages/kilo-vscode/src/services/telemetry/types.ts +++ b/packages/kilo-vscode/src/services/telemetry/types.ts @@ -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", } /** diff --git a/packages/kilo-vscode/src/services/telemetry/webview-state.ts b/packages/kilo-vscode/src/services/telemetry/webview-state.ts new file mode 100644 index 00000000000..fdd31318b1c --- /dev/null +++ b/packages/kilo-vscode/src/services/telemetry/webview-state.ts @@ -0,0 +1,21 @@ +import * as vscode from "vscode" + +type Post = (msg: { type: "telemetryState"; enabled: boolean }) => void + +/** + * 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: Post): void { + post({ type: "telemetryState", enabled: vscode.env.isTelemetryEnabled }) +} + +/** + * Re-push telemetry state whenever the user toggles the VS Code telemetry + * setting while a webview is open, so feedback UI shows/hides in real time. + */ +export function watchTelemetryState(post: Post): vscode.Disposable { + return vscode.env.onDidChangeTelemetryEnabled((enabled) => { + post({ type: "telemetryState", enabled }) + }) +} diff --git a/packages/kilo-vscode/tests/unit/feedback-payload.test.ts b/packages/kilo-vscode/tests/unit/feedback-payload.test.ts new file mode 100644 index 00000000000..67d18caadb5 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/feedback-payload.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "bun:test" +import { buildFeedbackProperties } 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("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", + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b5d270f87e2..3dd3c934639 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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" @@ -3170,13 +3171,15 @@ export const AgentManagerApp: Component = () => { - - - - - - - + + + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 8665a763fa0..6082f0745b6 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -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" @@ -354,9 +355,11 @@ const App: Component = () => { - - - + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 01d03b0a5b3..f65519ccc52 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -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, @@ -62,6 +63,7 @@ function matchToolRequest = (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" || diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx index fa48cb25b29..6a7d32ba6d7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx @@ -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" @@ -68,6 +69,7 @@ export const VscodeSessionTurn: Component = (props) => { const server = useServer() const session = useSession() const language = useLanguage() + const feedback = useFeedback() const emptyParts: SDKPart[] = [] const emptyDiffs: SnapshotFileDiff[] = [] @@ -174,7 +176,26 @@ export const VscodeSessionTurn: Component = (props) => { 0}>
- {(msg) => } + {(amsg) => ( + + 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, + }), + }} + /> + )}
diff --git a/packages/kilo-vscode/webview-ui/src/context/feedback-payload.ts b/packages/kilo-vscode/webview-ui/src/context/feedback-payload.ts new file mode 100644 index 00000000000..c9bc7383050 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/feedback-payload.ts @@ -0,0 +1,41 @@ +/** + * Pure helpers for shaping the feedback telemetry payload. + * + * Payload rules: + * - Non-Kilo-Gateway providers: providerID, modelID, variant?, rating, previousRating? only. + * No session or message IDs — they can't be correlated to upstream data. + * - Kilo Gateway providers: add sessionID, messageID, parentMessageID. The + * gateway can join parentMessageID against its `x-kilo-request` header logs. + */ + +export type Rating = "up" | "down" + +export interface RateInput { + messageID: string + sessionID: string + parentMessageID: string + providerID: string + modelID: string + variant?: string + next: Rating | null +} + +export function isKiloGateway(providerID: string): boolean { + return providerID === "kilo" +} + +export function buildFeedbackProperties(input: RateInput, previousRating?: Rating): Record { + const properties: Record = { + providerID: input.providerID, + modelID: input.modelID, + rating: input.next ?? "cleared", + } + if (input.variant) properties.variant = input.variant + if (previousRating) properties.previousRating = previousRating + if (isKiloGateway(input.providerID)) { + properties.sessionID = input.sessionID + properties.messageID = input.messageID + properties.parentMessageID = input.parentMessageID + } + return properties +} diff --git a/packages/kilo-vscode/webview-ui/src/context/feedback.tsx b/packages/kilo-vscode/webview-ui/src/context/feedback.tsx new file mode 100644 index 00000000000..a52544f04e8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/feedback.tsx @@ -0,0 +1,71 @@ +/** + * Feedback context + * + * Tracks per-message thumbs up/down ratings (in-memory only) and the VS Code + * telemetry-enabled flag. The context exposes a single `rate()` callback that + * updates local state and fires a telemetry event. + * + * State is not persisted — ratings reset on page reload / session switch. + */ + +import { createContext, useContext, createSignal, onCleanup } from "solid-js" +import type { ParentComponent, Accessor } from "solid-js" +import { useVSCode } from "./vscode" +import type { ExtensionMessage } from "../types/messages" +import { TelemetryEventName } from "../../../src/services/telemetry/types" +import { buildFeedbackProperties, type Rating, type RateInput } from "./feedback-payload" + +export type { Rating, RateInput } from "./feedback-payload" + +interface FeedbackContextValue { + telemetryEnabled: Accessor + getRating: (messageID: string) => Rating | undefined + rate: (input: RateInput) => void +} + +const FeedbackContext = createContext() + +export const FeedbackProvider: ParentComponent = (props) => { + const vscode = useVSCode() + const [telemetryEnabled, setTelemetryEnabled] = createSignal(false) + const [ratings, setRatings] = createSignal>({}) + + const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { + if (message.type !== "telemetryState") return + // Drop stored ratings if the user just revoked consent. + if (telemetryEnabled() && !message.enabled) setRatings({}) + setTelemetryEnabled(message.enabled) + }) + + onCleanup(unsubscribe) + + const getRating = (messageID: string) => ratings()[messageID] + + const rate = (input: RateInput) => { + if (!telemetryEnabled()) return + const prev = ratings()[input.messageID] + + setRatings((current) => { + const updated = { ...current } + if (input.next === null) delete updated[input.messageID] + else updated[input.messageID] = input.next + return updated + }) + + vscode.postMessage({ + type: "telemetry", + event: TelemetryEventName.FEEDBACK_SUBMITTED, + properties: buildFeedbackProperties(input, prev), + }) + } + + const value: FeedbackContextValue = { telemetryEnabled, getRating, rate } + + return {props.children} +} + +export function useFeedback(): FeedbackContextValue { + const context = useContext(FeedbackContext) + if (!context) throw new Error("useFeedback must be used within a FeedbackProvider") + return context +} diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index ea309a6a669..ee4fbe235bd 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -13,6 +13,7 @@ import { createSignal, createMemo, type ParentComponent } from "solid-js" import { VSCodeProvider } from "../context/vscode" import { ServerProvider } from "../context/server" +import { FeedbackProvider } from "../context/feedback" import { ProviderContext } from "../context/provider" import { flattenModels, findModel as _findModel } from "../context/provider-utils" import { ConfigProvider, ConfigContext } from "../context/config" @@ -317,46 +318,48 @@ export const StoryProviders: ParentComponent = (props) => { return ( - - - - - "" as any, - t, - }} - > - "en", t }}> - - - - - - - - - {props.noPadding ? ( - props.children - ) : ( -
{props.children}
- )} -
-
-
-
-
-
-
-
-
-
-
-
-
-
+ + + + + + "" as any, + t, + }} + > + "en", t }}> + + + + + + + + + {props.noPadding ? ( + props.children + ) : ( +
{props.children}
+ )} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index d9cb4b1425a..e90444fca98 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -725,6 +725,11 @@ export interface ExtensionDataReadyMessage { type: "extensionDataReady" } +export interface TelemetryStateMessage { + type: "telemetryState" + enabled: boolean +} + // ============================================ // Marketplace Messages // ============================================ @@ -934,4 +939,5 @@ export type ExtensionMessage = | McpStatusLoadedMessage | ClearPendingPromptsMessage | ExtensionDataReadyMessage + | TelemetryStateMessage | RemoteStatusMessage diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index ecd1e8d03d5..a9aa66e4f07 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -86,6 +86,7 @@ import { useTuiConfig } from "../../context/tui-config" import { formatMarkdownTables } from "../../util/markdown" // kilocode_change import { bell } from "@/kilocode/bell" // kilocode_change import { SessionIndexing } from "@/kilocode/components/session-indexing" // kilocode_change +import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "../../plugin" import { DialogGoUpsell } from "../../component/dialog-go-upsell" @@ -472,6 +473,44 @@ export function Session() { } } + // kilocode_change start - message feedback + function submitFeedback(rating: "up" | "down", dialog: DialogContext) { + if (!Telemetry.isEnabled()) { + toast.show({ message: "Feedback disabled: telemetry is off", variant: "info" }) + dialog.clear() + return + } + const revertID = session()?.revert?.messageID + const lastAssistant = messages().findLast( + (msg): msg is AssistantMessage => msg.role === "assistant" && (!revertID || msg.id < revertID), + ) + if (!lastAssistant) { + toast.show({ message: "No assistant messages found", variant: "error" }) + dialog.clear() + return + } + const providerID = lastAssistant.providerID + const payload: Telemetry.FeedbackProperties = { + providerID, + modelID: lastAssistant.modelID, + rating, + } + const variant = (lastAssistant as AssistantMessage & { variant?: string }).variant + if (variant) payload.variant = variant + if (providerID.startsWith("kilo")) { + payload.sessionID = lastAssistant.sessionID + payload.messageID = lastAssistant.id + payload.parentMessageID = lastAssistant.parentID + } + Telemetry.trackFeedback(payload) + toast.show({ + message: rating === "up" ? "Thanks for the feedback!" : "Thanks — we'll use this to improve.", + variant: "success", + }) + dialog.clear() + } + // kilocode_change end + const command = useCommandDialog() command.register(() => [ { @@ -947,6 +986,22 @@ export function Session() { dialog.clear() }, }, + // kilocode_change start - message feedback + { + title: "Rate last assistant message helpful", + value: "messages.feedback.up", + keybind: "messages_feedback_up", + category: "Session", + onSelect: (dialog) => submitFeedback("up", dialog), + }, + { + title: "Rate last assistant message not helpful", + value: "messages.feedback.down", + keybind: "messages_feedback_down", + category: "Session", + onSelect: (dialog) => submitFeedback("down", dialog), + }, + // kilocode_change end { title: "Copy session transcript", value: "session.copy", diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts index 214168858de..34cc8d27d81 100644 --- a/packages/opencode/src/config/keybinds.ts +++ b/packages/opencode/src/config/keybinds.ts @@ -51,6 +51,10 @@ const KeybindsSchema = Schema.Struct({ messages_copy: keybind("y", "Copy message"), messages_undo: keybind("u", "Undo message"), messages_redo: keybind("r", "Redo message"), + // kilocode_change start - message feedback + messages_feedback_up: keybind("=", "Rate last assistant message helpful"), + messages_feedback_down: keybind("-", "Rate last assistant message not helpful"), + // kilocode_change end messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), tool_details: keybind("none", "Toggle tool details visibility"), model_list: keybind("m", "List available models"), diff --git a/packages/opencode/test/kilocode/telemetry/feedback.test.ts b/packages/opencode/test/kilocode/telemetry/feedback.test.ts new file mode 100644 index 00000000000..548fd43848a --- /dev/null +++ b/packages/opencode/test/kilocode/telemetry/feedback.test.ts @@ -0,0 +1,9 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { TelemetryEvent } from "@kilocode/kilo-telemetry" + +describe("TelemetryEvent.FEEDBACK_SUBMITTED", () => { + test("enum value is human-readable title case", () => { + expect(String(TelemetryEvent.FEEDBACK_SUBMITTED)).toBe("Feedback Submitted") + }) +}) diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index f7db04e664f..ebc54bb16e3 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -111,6 +111,12 @@ const icons = { models: ``, discard: ``, // kilocode_change "arrow-undo-down": ``, + // kilocode_change start + "thumbs-up": ``, + "thumbs-down": ``, + "thumbs-up-filled": ``, + "thumbs-down-filled": ``, + // kilocode_change end } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index f31f108cb13..cc31149a9e4 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -140,6 +140,11 @@ export const dict = { "ui.message.revertMessage": "إعادة التعيين إلى هذه النقطة", "ui.message.copyResponse": "نسخ الرد", "ui.message.copied": "تم النسخ!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "كان هذا مفيدًا", + "ui.message.feedback.notHelpful": "لم يكن هذا مفيدًا", + "ui.message.feedback.clearRating": "مسح التقييم", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "تمت المقاطعة", "ui.message.queued": "في الانتظار", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 6d4a826bcde..01063f8dd68 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -140,6 +140,11 @@ export const dict = { "ui.message.revertMessage": "Redefinir para este ponto", "ui.message.copyResponse": "Copiar resposta", "ui.message.copied": "Copiado!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Isso foi útil", + "ui.message.feedback.notHelpful": "Isso não foi útil", + "ui.message.feedback.clearRating": "Limpar avaliação", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Interrompido", "ui.message.queued": "Na fila", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 62970c7279f..f991b8e57f1 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -144,6 +144,11 @@ export const dict = { "ui.message.revertMessage": "Resetuj na ovu tačku", "ui.message.copyResponse": "Kopiraj odgovor", "ui.message.copied": "Kopirano!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Ovo je bilo korisno", + "ui.message.feedback.notHelpful": "Ovo nije bilo korisno", + "ui.message.feedback.clearRating": "Obriši ocjenu", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Prekinuto", "ui.message.queued": "U redu", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 20303221038..ae8c039256e 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -139,6 +139,11 @@ export const dict = { "ui.message.revertMessage": "Nulstil til dette punkt", "ui.message.copyResponse": "Kopier svar", "ui.message.copied": "Kopieret!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Dette var nyttigt", + "ui.message.feedback.notHelpful": "Dette var ikke nyttigt", + "ui.message.feedback.clearRating": "Ryd bedømmelse", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Afbrudt", "ui.message.queued": "I kø", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index bc0358ea056..7b31ca6371c 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -145,6 +145,11 @@ export const dict = { "ui.message.revertMessage": "Auf diesen Punkt zurücksetzen", "ui.message.copyResponse": "Antwort kopieren", "ui.message.copied": "Kopiert!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Das war hilfreich", + "ui.message.feedback.notHelpful": "Das war nicht hilfreich", + "ui.message.feedback.clearRating": "Bewertung löschen", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Unterbrochen", "ui.message.queued": "In Warteschlange", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 4ed9e9c01b6..87104057beb 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -152,6 +152,11 @@ export const dict: Record = { "ui.message.revertMessage": "Revert to here", "ui.message.copyResponse": "Copy response", "ui.message.copied": "Copied", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "This was helpful", + "ui.message.feedback.notHelpful": "This wasn't helpful", + "ui.message.feedback.clearRating": "Clear rating", + // kilocode_change end "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", "ui.message.interrupted": "Interrupted", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 90899358219..3393999f819 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -140,6 +140,11 @@ export const dict = { "ui.message.revertMessage": "Restablecer a este punto", "ui.message.copyResponse": "Copiar respuesta", "ui.message.copied": "¡Copiado!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Esto fue útil", + "ui.message.feedback.notHelpful": "Esto no fue útil", + "ui.message.feedback.clearRating": "Borrar valoración", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Interrumpido", "ui.message.queued": "En cola", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 35f6702c5d4..94c43211c1a 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -140,6 +140,11 @@ export const dict = { "ui.message.revertMessage": "Réinitialiser à ce point", "ui.message.copyResponse": "Copier la réponse", "ui.message.copied": "Copié !", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "C'était utile", + "ui.message.feedback.notHelpful": "Ce n'était pas utile", + "ui.message.feedback.clearRating": "Effacer la notation", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Interrompu", "ui.message.queued": "En file", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 2daf8cf2443..8e52e788ff4 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -139,6 +139,11 @@ export const dict = { "ui.message.revertMessage": "この時点までリセット", "ui.message.copyResponse": "応答をコピー", "ui.message.copied": "コピーしました!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "役に立ちました", + "ui.message.feedback.notHelpful": "役に立ちませんでした", + "ui.message.feedback.clearRating": "評価をクリア", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "中断", "ui.message.queued": "待機中", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index ee6e1f83096..ebe8991b06d 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -140,6 +140,11 @@ export const dict = { "ui.message.revertMessage": "이 시점으로 초기화", "ui.message.copyResponse": "응답 복사", "ui.message.copied": "복사됨!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "도움이 됐어요", + "ui.message.feedback.notHelpful": "도움이 안 됐어요", + "ui.message.feedback.clearRating": "평가 지우기", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "중단됨", "ui.message.queued": "대기 중", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index c3cab8540d2..02712e4bce7 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -151,6 +151,11 @@ export const dict: Record = { "ui.message.copyMessage": "Bericht kopiëren", "ui.message.copyResponse": "Antwoord kopiëren", "ui.message.copied": "Gekopieerd", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Dit was nuttig", + "ui.message.feedback.notHelpful": "Dit was niet nuttig", + "ui.message.feedback.clearRating": "Beoordeling wissen", + // kilocode_change end "ui.message.forkMessage": "Fork to new session", "ui.message.revertMessage": "Hiernaar terugzetten", "ui.message.revert": "Hiernaar terugdraaien", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 377fa7a95a8..855aba356ae 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -143,6 +143,11 @@ export const dict: Record = { "ui.message.revertMessage": "Tilbakestill til dette punktet", "ui.message.copyResponse": "Kopier svar", "ui.message.copied": "Kopiert!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Dette var nyttig", + "ui.message.feedback.notHelpful": "Dette var ikke nyttig", + "ui.message.feedback.clearRating": "Fjern vurdering", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Avbrutt", "ui.message.queued": "I kø", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 05a1a2e1662..67ff2e02314 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -139,6 +139,11 @@ export const dict = { "ui.message.revertMessage": "Zresetuj do tego punktu", "ui.message.copyResponse": "Kopiuj odpowiedź", "ui.message.copied": "Skopiowano!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "To było pomocne", + "ui.message.feedback.notHelpful": "To nie było pomocne", + "ui.message.feedback.clearRating": "Wyczyść ocenę", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Przerwano", "ui.message.queued": "W kolejce", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 067cbd0e67b..75941554e69 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -139,6 +139,11 @@ export const dict = { "ui.message.revertMessage": "Сбросить до этого момента", "ui.message.copyResponse": "Копировать ответ", "ui.message.copied": "Скопировано!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Это было полезно", + "ui.message.feedback.notHelpful": "Это было бесполезно", + "ui.message.feedback.clearRating": "Очистить оценку", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "Прервано", "ui.message.queued": "В очереди", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 92299360185..6e3395b5f9e 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -141,6 +141,11 @@ export const dict = { "ui.message.revertMessage": "รีเซ็ตไปยังจุดนี้", "ui.message.copyResponse": "คัดลอกคำตอบ", "ui.message.copied": "คัดลอกแล้ว!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "สิ่งนี้มีประโยชน์", + "ui.message.feedback.notHelpful": "สิ่งนี้ไม่มีประโยชน์", + "ui.message.feedback.clearRating": "ล้างการให้คะแนน", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "ถูกขัดจังหวะ", "ui.message.queued": "อยู่ในคิว", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index f7b55f6ad8e..3537faf07d8 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -147,6 +147,11 @@ export const dict = { "ui.message.revert": "Revert to here", "ui.message.copyResponse": "Yanıtı kopyala", "ui.message.copied": "Kopyalandı", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Bu yardımcı oldu", + "ui.message.feedback.notHelpful": "Bu yardımcı olmadı", + "ui.message.feedback.clearRating": "Değerlendirmeyi temizle", + // kilocode_change end "ui.message.interrupted": "Kesildi", "ui.message.queued": "Sırada", "ui.message.attachment.alt": "ek", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index 3ebf9ce3396..86e6949612c 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -156,6 +156,11 @@ export const dict = { "ui.message.copyMessage": "Копіювати повідомлення", "ui.message.copyResponse": "Копіювати відповідь", "ui.message.copied": "Скопійовано", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "Це було корисно", + "ui.message.feedback.notHelpful": "Це не було корисно", + "ui.message.feedback.clearRating": "Очистити оцінку", + // kilocode_change end "ui.message.forkMessage": "Fork to new session", "ui.message.revertMessage": "Повернутися сюди", "ui.message.revert": "Повернутися до цього місця", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 48423ab232c..14803bdeeff 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -144,6 +144,11 @@ export const dict = { "ui.message.revertMessage": "重置到此点", "ui.message.copyResponse": "复制回复", "ui.message.copied": "已复制!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "这有帮助", + "ui.message.feedback.notHelpful": "这没有帮助", + "ui.message.feedback.clearRating": "清除评分", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "已中断", "ui.message.queued": "排队中", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 276db511540..4af7bda4bcb 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -144,6 +144,11 @@ export const dict = { "ui.message.revertMessage": "重設至此點", "ui.message.copyResponse": "複製回覆", "ui.message.copied": "已複製!", + // kilocode_change start - message feedback + "ui.message.feedback.helpful": "這有幫助", + "ui.message.feedback.notHelpful": "這沒有幫助", + "ui.message.feedback.clearRating": "清除評分", + // kilocode_change end "ui.message.revert": "Revert to here", "ui.message.interrupted": "已中斷", "ui.message.queued": "排隊中",