Merge pull request #9718 from Kilo-Org/feature/message-feedback

feat: per-message thumbs up/down feedback
This commit is contained in:
Joshua Lambert
2026-05-07 01:51:04 -04:00
committed by GitHub
52 changed files with 859 additions and 96 deletions
+6
View File
@@ -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.
@@ -80,6 +80,7 @@ export const CodeWithAiNav: NavSection[] = [
children: "Git Commit Generation",
},
{ href: "/code-with-ai/features/speech-to-text", children: "Voice Transcription" },
{ href: "/code-with-ai/features/message-feedback", children: "Message Feedback" },
{
href: "/code-with-ai/features/browser-use",
children: "Agent Behavior",
@@ -0,0 +1,76 @@
---
title: "Message Feedback"
description: "Rate assistant responses with thumbs up/down to help Kilo improve"
---
# Message Feedback
You can give a thumbs up or thumbs down to any response Kilo gives you. It's a quick way to tell us when something worked well, or when it didn't.
{% callout type="info" %}
Feedback is only available when telemetry is on. If you turn telemetry off, the buttons no longer display. See [Turning it off](#turning-it-off) below.
{% /callout %}
## How to use it
{% tabs %}
{% tab label="VSCode" %}
When Kilo finishes a response, you'll see thumbs-up and thumbs-down buttons next to the **Copy response** button. Click one to send your rating. Click the same button again to take it back, or click the other one to change your mind.
If you reload the window or switch to a different session, the feedback status will be reset.
{% /tab %}
{% tab label="CLI" %}
In the terminal, two keybinds rate the most recent assistant message:
| Action | Keybind |
|---|---|
| Helpful | `<leader>=` |
| Not helpful | `<leader>-` |
`<leader>` defaults to `Ctrl+X`, so you press `Ctrl+X` and then `=` for thumbs-up, or `Ctrl+X` and then `-` for thumbs-down.
{% /tab %}
{% /tabs %}
## Why it matters
Your feedback is the most direct way to tell us what's working and what isn't. We use it to tune the prompts behind Kilo's behavior, pick better default models, and find and fix problems faster than we could on our own.
## What we send
When you rate a response, here's what gets sent:
- That you rated a response thumbs up or thumbs down.
- The provider and model ID that produced the response (for example, `anthropic` and `claude-sonnet-4-5`).
- For responses that came through Kilo Gateway, the request ID so we can match the rating back to the session.
No further information is included in the rating feedback. For more information on telemetry, see [PRIVACY.md](https://github.com/Kilo-Org/kilocode/blob/main/PRIVACY.md).
## Turning it off
Feedback is part of telemetry, so turning telemetry off will cause the feedback buttons to no longer appear.
{% tabs %}
{% tab label="VSCode" %}
Open Settings (`Cmd+,` on Mac, `Ctrl+,` on Windows/Linux), search for **`telemetry.telemetryLevel`**, and set it to **Off**. The feedback buttons will no longer appear. Set it back to **All** to bring them back. (For more on what this setting controls, see [VS Code's telemetry docs](https://code.visualstudio.com/docs/getstarted/telemetry).)
{% /tab %}
{% tab label="CLI" %}
Set `KILO_TELEMETRY_LEVEL=off` in your environment before starting Kilo:
```bash
KILO_TELEMETRY_LEVEL=off kilo
```
With telemetry off, pressing the feedback keybinds shows a message saying feedback is disabled, and nothing is sent.
{% /tab %}
{% /tabs %}
@@ -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
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"أوقف لقطات Kilo لهذا المشروع. ستفقد ميزة التراجع/الإعادة لتعديلات Kilo، لكن git سيستمر في تتبع كل شيء.",
"ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "كان هذا مفيدًا",
"ui.message.feedback.notHelpful": "لم يكن هذا مفيدًا",
"ui.message.feedback.clearRating": "مسح التقييم",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Desligue os snapshots do Kilo para este projeto. Você perde desfazer/refazer das mudanças feitas pelo Kilo, mas o git continua rastreando tudo.",
"ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Isso foi útil",
"ui.message.feedback.notHelpful": "Isso não foi útil",
"ui.message.feedback.clearRating": "Limpar avaliação",
}
+5
View File
@@ -97,4 +97,9 @@ export const dict = {
"Isključi Kilo snapshotove za ovaj projekat. Izgubićete poništi/vrati za izmjene koje napravi Kilo, ali git i dalje prati sve.",
"ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Ovo je bilo korisno",
"ui.message.feedback.notHelpful": "Ovo nije bilo korisno",
"ui.message.feedback.clearRating": "Obriši ocjenu",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Slå Kilos snapshots fra for dette projekt. Du mister fortryd/gentag for Kilo-ændringer, men git sporer stadig alt.",
"ui.messagePart.openInDiffViewer": "Åbn i diff-viser",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dette var nyttigt",
"ui.message.feedback.notHelpful": "Dette var ikke nyttigt",
"ui.message.feedback.clearRating": "Ryd bedømmelse",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Kilo-Snapshots für dieses Projekt ausschalten. Rückgängig/Wiederherstellen für Kilo-Änderungen ist nicht mehr möglich, aber git verfolgt weiterhin alles.",
"ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Das war hilfreich",
"ui.message.feedback.notHelpful": "Das war nicht hilfreich",
"ui.message.feedback.clearRating": "Bewertung löschen",
}
+5
View File
@@ -99,4 +99,9 @@ export const dict = {
// Edit-tool header: hover-revealed action opening the diff in a full tab.
"ui.messagePart.openInDiffViewer": "Open in Diff Viewer",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "This was helpful",
"ui.message.feedback.notHelpful": "This wasn't helpful",
"ui.message.feedback.clearRating": "Clear rating",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Apaga las instantáneas de Kilo para este proyecto. Perderás deshacer/rehacer de los cambios de Kilo, pero git seguirá rastreando todo.",
"ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Esto fue útil",
"ui.message.feedback.notHelpful": "Esto no fue útil",
"ui.message.feedback.clearRating": "Borrar valoración",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Désactivez les instantanés Kilo pour ce projet. Vous perdez l'annulation/restauration des modifications faites par Kilo, mais git continue de tout suivre.",
"ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "C'était utile",
"ui.message.feedback.notHelpful": "Ce n'était pas utile",
"ui.message.feedback.clearRating": "Effacer la notation",
}
+5
View File
@@ -90,4 +90,9 @@ export const dict = {
"このプロジェクトでは Kilo のスナップショットを無効にします。Kilo による変更の取り消し/やり直しはできなくなりますが、git は引き続きすべてを追跡します。",
"ui.messagePart.openInDiffViewer": "差分ビューアで開く",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "役に立ちました",
"ui.message.feedback.notHelpful": "役に立ちませんでした",
"ui.message.feedback.clearRating": "評価をクリア",
}
+5
View File
@@ -90,4 +90,9 @@ export const dict = {
"이 프로젝트의 Kilo 스냅샷을 끕니다. Kilo 변경에 대한 실행 취소/다시 실행은 사용할 수 없지만 git은 여전히 모든 것을 추적합니다.",
"ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "도움이 됐어요",
"ui.message.feedback.notHelpful": "도움이 안 됐어요",
"ui.message.feedback.clearRating": "평가 지우기",
}
+5
View File
@@ -94,4 +94,9 @@ export const dict = {
"Zet Kilo-snapshots uit voor dit project. Je verliest ongedaan maken/opnieuw doen van Kilo-wijzigingen, maar git blijft alles volgen.",
"ui.messagePart.openInDiffViewer": "Openen in diff-viewer",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dit was nuttig",
"ui.message.feedback.notHelpful": "Dit was niet nuttig",
"ui.message.feedback.clearRating": "Beoordeling wissen",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Slå av Kilos snapshots for dette prosjektet. Du mister angre/gjør om for Kilo-endringer, men git fortsetter å spore alt.",
"ui.messagePart.openInDiffViewer": "Åpne i diff-viser",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dette var nyttig",
"ui.message.feedback.notHelpful": "Dette var ikke nyttig",
"ui.message.feedback.clearRating": "Fjern vurdering",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Wyłącz snapshoty Kilo dla tego projektu. Stracisz cofanie/przywracanie zmian Kilo, ale git nadal śledzi wszystko.",
"ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "To było pomocne",
"ui.message.feedback.notHelpful": "To nie było pomocne",
"ui.message.feedback.clearRating": "Wyczyść ocenę",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Выключите снимки Kilo для этого проекта. Вы потеряете отмену/повтор изменений Kilo, но git по-прежнему отслеживает всё.",
"ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Это было полезно",
"ui.message.feedback.notHelpful": "Это было бесполезно",
"ui.message.feedback.clearRating": "Очистить оценку",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"ปิดสแน็ปช็อตของ Kilo สำหรับโปรเจกต์นี้ คุณจะสูญเสียการยกเลิก/ทำซ้ำสำหรับการเปลี่ยนแปลงของ Kilo แต่ git ยังคงติดตามทุกอย่าง",
"ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "สิ่งนี้มีประโยชน์",
"ui.message.feedback.notHelpful": "สิ่งนี้ไม่มีประโยชน์",
"ui.message.feedback.clearRating": "ล้างการให้คะแนน",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Bu proje için Kilo anlık görüntülerini kapat. Kilo değişiklikleri için geri alma/yeniden yapmayı kaybedersiniz, ancak git her şeyi izlemeye devam eder.",
"ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Bu yardımcı oldu",
"ui.message.feedback.notHelpful": "Bu yardımcı olmadı",
"ui.message.feedback.clearRating": "Değerlendirmeyi temizle",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Вимкніть знімки Kilo для цього проєкту. Ви втратите скасування/повторення для змін Kilo, але git продовжить відстежувати все.",
"ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Це було корисно",
"ui.message.feedback.notHelpful": "Це не було корисно",
"ui.message.feedback.clearRating": "Очистити оцінку",
}
+5
View File
@@ -87,4 +87,9 @@ export const dict = {
"关闭本项目的 Kilo 快照。你将失去对 Kilo 更改的撤销/重做,但 git 仍会追踪所有内容。",
"ui.messagePart.openInDiffViewer": "在差异查看器中打开",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "这有帮助",
"ui.message.feedback.notHelpful": "这没有帮助",
"ui.message.feedback.clearRating": "清除评分",
}
+5
View File
@@ -87,4 +87,9 @@ export const dict = {
"關閉本專案的 Kilo 快照。你將失去對 Kilo 變更的撤銷/重做,但 git 仍會追蹤所有內容。",
"ui.messagePart.openInDiffViewer": "在差異檢視器中開啟",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "這有幫助",
"ui.message.feedback.notHelpful": "這沒有幫助",
"ui.message.feedback.clearRating": "清除評分",
}
+3
View File
@@ -42,6 +42,9 @@ export enum TelemetryEvent {
// Config Events
TELEMETRY_DISABLED = "Telemetry Disabled",
// Feedback
FEEDBACK_SUBMITTED = "Feedback Submitted",
// Errors
ERROR = "Error",
}
+16
View File
@@ -244,6 +244,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()
}
@@ -1 +1,29 @@
export * from "@opencode-ai/ui/icon-button"
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, splitProps } from "solid-js"
import { Icon, IconProps } from "./icon"
export interface IconButtonProps extends ComponentProps<typeof Kobalte> {
icon: IconProps["name"]
size?: "small" | "normal" | "large"
iconSize?: IconProps["size"]
variant?: "primary" | "secondary" | "ghost"
}
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"])
return (
<Kobalte
{...rest}
data-component="icon-button"
data-icon={props.icon}
data-size={split.size || "normal"}
data-variant={split.variant || "secondary"}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
<Icon name={props.icon} size={split.iconSize ?? (split.size === "large" ? "normal" : "small")} />
</Kobalte>
)
}
+28 -7
View File
@@ -1,9 +1,31 @@
import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon"
import { splitProps } from "solid-js"
const icons = {
"circuit-board": `<path d="M12.5 1H3.5C2.121 1 1 2.121 1 3.5V12.5C1 13.879 2.121 15 3.5 15H12.5C13.879 15 15 13.879 15 12.5V3.5C15 2.121 13.879 1 12.5 1ZM6 6.5C6 6.775 5.775 7 5.5 7C5.225 7 5 6.775 5 6.5C5 6.225 5.225 6 5.5 6C5.775 6 6 6.225 6 6.5ZM12.5 14H6V11.5C6 11.225 6.225 11 6.5 11H9.092C9.299 11.581 9.849 12 10.5 12C11.327 12 12 11.327 12 10.5C12 9.673 11.327 9 10.5 9C9.849 9 9.299 9.419 9.092 10H6.5C5.673 10 5 10.673 5 11.5V14H3.5C2.673 14 2 13.327 2 12.5V3.5C2 2.673 2.673 2 3.5 2H5V5.092C4.419 5.299 4 5.849 4 6.5C4 7.327 4.673 8 5.5 8C6.327 8 7 7.327 7 6.5C7 5.849 6.581 5.299 6 5.092V2H12.5C13.327 2 14 2.673 14 3.5V6H10.908C10.701 5.419 10.151 5 9.5 5C8.673 5 8 5.673 8 6.5C8 7.327 8.673 8 9.5 8C10.151 8 10.701 7.581 10.908 7H14V12.5C14 13.327 13.327 14 12.5 14ZM10 10.5C10 10.225 10.225 10 10.5 10C10.775 10 11 10.225 11 10.5C11 10.775 10.775 11 10.5 11C10.225 11 10 10.775 10 10.5ZM10 6.5C10 6.775 9.775 7 9.5 7C9.225 7 9 6.775 9 6.5C9 6.225 9.225 6 9.5 6C9.775 6 10 6.225 10 6.5Z" fill="currentColor"/>`,
organization: `<path d="M6.00195 4.00002C6.00195 2.89655 6.89649 2.00201 7.99995 2.00201C9.10342 2.00201 9.99796 2.89655 9.99796 4.00002C9.99796 5.10348 9.10342 5.99802 7.99995 5.99802C6.89649 5.99802 6.00195 5.10348 6.00195 4.00002ZM7.99995 3.00201C7.44877 3.00201 7.00195 3.44883 7.00195 4.00002C7.00195 4.5512 7.44877 4.99802 7.99995 4.99802C8.55114 4.99802 8.99796 4.5512 8.99796 4.00002C8.99796 3.44883 8.55114 3.00201 7.99995 3.00201ZM11 4.5C11 3.67157 11.6716 3 12.5 3C13.3284 3 14 3.67157 14 4.5C14 5.32843 13.3284 6 12.5 6C11.6716 6 11 5.32843 11 4.5ZM12.5 4C12.2239 4 12 4.22386 12 4.5C12 4.77614 12.2239 5 12.5 5C12.7761 5 13 4.77614 13 4.5C13 4.22386 12.7761 4 12.5 4ZM3.5 3C2.67157 3 2 3.67157 2 4.5C2 5.32843 2.67157 6 3.5 6C4.32843 6 5 5.32843 5 4.5C5 3.67157 4.32843 3 3.5 3ZM3 4.5C3 4.22386 3.22386 4 3.5 4C3.77614 4 4 4.22386 4 4.5C4 4.77614 3.77614 5 3.5 5C3.22386 5 3 4.77614 3 4.5ZM4.26756 6.99969C4.09739 7.29387 4 7.63541 4 7.99969L2 7.99969V10.5C2 11.3285 2.67157 12 3.5 12C3.71194 12 3.91361 11.9561 4.09639 11.8768C4.1705 12.2082 4.28572 12.524 4.43643 12.8187C4.14721 12.9356 3.83112 13 3.5 13C2.11929 13 1 11.8807 1 10.5V7.99969C1 7.44741 1.44772 6.99969 2 6.99969H4.26756ZM11.5636 12.8187C11.8528 12.9356 12.1689 13 12.5 13C13.8807 13 15 11.8807 15 10.5V7.99969C15 7.44741 14.5523 6.99969 14 6.99969H11.7324C11.9026 7.29387 12 7.63541 12 7.9997L14 7.99969V10.5C14 11.3285 13.3284 12 12.5 12C12.2881 12 12.0864 11.9561 11.9036 11.8768C11.8295 12.2082 11.7143 12.524 11.5636 12.8187ZM6 6.99969C5.44772 6.99969 5 7.44741 5 7.99969V11C5 12.6569 6.34315 14 8 14C9.65685 14 11 12.6569 11 11V7.99969C11 7.44741 10.5523 6.99969 10 6.99969H6ZM6 7.99969L10 7.99969V11C10 12.1046 9.10457 13 8 13C6.89543 13 6 12.1046 6 11V7.99969Z" fill="currentColor"/>`,
const icons: Record<string, { path: string; viewBox: string }> = {
"circuit-board": {
viewBox: "0 0 16 16",
path: `<path d="M12.5 1H3.5C2.121 1 1 2.121 1 3.5V12.5C1 13.879 2.121 15 3.5 15H12.5C13.879 15 15 13.879 15 12.5V3.5C15 2.121 13.879 1 12.5 1ZM6 6.5C6 6.775 5.775 7 5.5 7C5.225 7 5 6.775 5 6.5C5 6.225 5.225 6 5.5 6C5.775 6 6 6.225 6 6.5ZM12.5 14H6V11.5C6 11.225 6.225 11 6.5 11H9.092C9.299 11.581 9.849 12 10.5 12C11.327 12 12 11.327 12 10.5C12 9.673 11.327 9 10.5 9C9.849 9 9.299 9.419 9.092 10H6.5C5.673 10 5 10.673 5 11.5V14H3.5C2.673 14 2 13.327 2 12.5V3.5C2 2.673 2.673 2 3.5 2H5V5.092C4.419 5.299 4 5.849 4 6.5C4 7.327 4.673 8 5.5 8C6.327 8 7 7.327 7 6.5C7 5.849 6.581 5.299 6 5.092V2H12.5C13.327 2 14 2.673 14 3.5V6H10.908C10.701 5.419 10.151 5 9.5 5C8.673 5 8 5.673 8 6.5C8 7.327 8.673 8 9.5 8C10.151 8 10.701 7.581 10.908 7H14V12.5C14 13.327 13.327 14 12.5 14ZM10 10.5C10 10.225 10.225 10 10.5 10C10.775 10 11 10.225 11 10.5C11 10.775 10.775 11 10.5 11C10.225 11 10 10.775 10 10.5ZM10 6.5C10 6.775 9.775 7 9.5 7C9.225 7 9 6.775 9 6.5C9 6.225 9.225 6 9.5 6C9.775 6 10 6.225 10 6.5Z" fill="currentColor"/>`,
},
organization: {
viewBox: "0 0 16 16",
path: `<path d="M6.00195 4.00002C6.00195 2.89655 6.89649 2.00201 7.99995 2.00201C9.10342 2.00201 9.99796 2.89655 9.99796 4.00002C9.99796 5.10348 9.10342 5.99802 7.99995 5.99802C6.89649 5.99802 6.00195 5.10348 6.00195 4.00002ZM7.99995 3.00201C7.44877 3.00201 7.00195 3.44883 7.00195 4.00002C7.00195 4.5512 7.44877 4.99802 7.99995 4.99802C8.55114 4.99802 8.99796 4.5512 8.99796 4.00002C8.99796 3.44883 8.55114 3.00201 7.99995 3.00201ZM11 4.5C11 3.67157 11.6716 3 12.5 3C13.3284 3 14 3.67157 14 4.5C14 5.32843 13.3284 6 12.5 6C11.6716 6 11 5.32843 11 4.5ZM12.5 4C12.2239 4 12 4.22386 12 4.5C12 4.77614 12.2239 5 12.5 5C12.7761 5 13 4.77614 13 4.5C13 4.22386 12.7761 4 12.5 4ZM3.5 3C2.67157 3 2 3.67157 2 4.5C2 5.32843 2.67157 6 3.5 6C4.32843 6 5 5.32843 5 4.5C5 3.67157 4.32843 3 3.5 3ZM3 4.5C3 4.22386 3.22386 4 3.5 4C3.77614 4 4 4.22386 4 4.5C4 4.77614 3.77614 5 3.5 5C3.22386 5 3 4.77614 3 4.5ZM4.26756 6.99969C4.09739 7.29387 4 7.63541 4 7.99969L2 7.99969V10.5C2 11.3285 2.67157 12 3.5 12C3.71194 12 3.91361 11.9561 4.09639 11.8768C4.1705 12.2082 4.28572 12.524 4.43643 12.8187C4.14721 12.9356 3.83112 13 3.5 13C2.11929 13 1 11.8807 1 10.5V7.99969C1 7.44741 1.44772 6.99969 2 6.99969H4.26756ZM11.5636 12.8187C11.8528 12.9356 12.1689 13 12.5 13C13.8807 13 15 11.8807 15 10.5V7.99969C15 7.44741 14.5523 6.99969 14 6.99969H11.7324C11.9026 7.29387 12 7.63541 12 7.9997L14 7.99969V10.5C14 11.3285 13.3284 12 12.5 12C12.2881 12 12.0864 11.9561 11.9036 11.8768C11.8295 12.2082 11.7143 12.524 11.5636 12.8187ZM6 6.99969C5.44772 6.99969 5 7.44741 5 7.99969V11C5 12.6569 6.34315 14 8 14C9.65685 14 11 12.6569 11 11V7.99969C11 7.44741 10.5523 6.99969 10 6.99969H6ZM6 7.99969L10 7.99969V11C10 12.1046 9.10457 13 8 13C6.89543 13 6 12.1046 6 11V7.99969Z" fill="currentColor"/>`,
},
"thumbs-up": {
viewBox: "0 0 20 20",
path: `<path d="M1 8.25a1.25 1.25 0 1 1 2.5 0v7.5a1.25 1.25 0 1 1-2.5 0v-7.5ZM11 3V1.7c0-.268.14-.526.395-.607A2 2 0 0 1 14 3c0 .995-.182 1.948-.514 2.826-.204.54.166 1.174.744 1.174h2.52c1.243 0 2.261 1.01 2.146 2.247a23.864 23.864 0 0 1-1.341 5.974C17.153 16.323 16.072 17 14.9 17h-3.192a3 3 0 0 1-1.341-.317l-2.734-1.366A3 3 0 0 0 6.292 15H5V8h.963c.685 0 1.258-.483 1.612-1.068a4.011 4.011 0 0 1 2.166-1.73c.432-.143.853-.386 1.011-.814.16-.432.248-.9.248-1.388Z" stroke="currentColor" fill="none" stroke-linejoin="round"/>`,
},
"thumbs-down": {
viewBox: "0 0 20 20",
path: `<path d="M18.905 12.75a1.25 1.25 0 0 1-2.5 0v-7.5a1.25 1.25 0 0 1 2.5 0v7.5ZM8.905 17v1.3c0 .268-.14.526-.395.607A2 2 0 0 1 5.905 17c0-.995.182-1.948.514-2.826.204-.54-.166-1.174-.744-1.174h-2.52c-1.243 0-2.261-1.01-2.146-2.247a23.864 23.864 0 0 1 1.341-5.974C2.547 3.677 3.628 3 4.8 3h3.192a3 3 0 0 1 1.341.317l2.734 1.366A3 3 0 0 0 13.408 5H15v7h-.963c-.685 0-1.258.483-1.612 1.068a4.011 4.011 0 0 1-2.166 1.73c-.432.143-.853.386-1.011.814-.16.432-.248.9-.248 1.388Z" stroke="currentColor" fill="none" stroke-linejoin="round"/>`,
},
"thumbs-up-filled": {
viewBox: "0 0 20 20",
path: `<path d="M1 8.25a1.25 1.25 0 1 1 2.5 0v7.5a1.25 1.25 0 1 1-2.5 0v-7.5ZM11 3V1.7c0-.268.14-.526.395-.607A2 2 0 0 1 14 3c0 .995-.182 1.948-.514 2.826-.204.54.166 1.174.744 1.174h2.52c1.243 0 2.261 1.01 2.146 2.247a23.864 23.864 0 0 1-1.341 5.974C17.153 16.323 16.072 17 14.9 17h-3.192a3 3 0 0 1-1.341-.317l-2.734-1.366A3 3 0 0 0 6.292 15H5V8h.963c.685 0 1.258-.483 1.612-1.068a4.011 4.011 0 0 1 2.166-1.73c.432-.143.853-.386 1.011-.814.16-.432.248-.9.248-1.388Z" fill="currentColor"/>`,
},
"thumbs-down-filled": {
viewBox: "0 0 20 20",
path: `<path d="M18.905 12.75a1.25 1.25 0 0 1-2.5 0v-7.5a1.25 1.25 0 0 1 2.5 0v7.5ZM8.905 17v1.3c0 .268-.14.526-.395.607A2 2 0 0 1 5.905 17c0-.995.182-1.948.514-2.826.204-.54-.166-1.174-.744-1.174h-2.52c-1.243 0-2.261-1.01-2.146-2.247a23.864 23.864 0 0 1 1.341-5.974C2.547 3.677 3.628 3 4.8 3h3.192a3 3 0 0 1 1.341.317l2.734 1.366A3 3 0 0 0 13.408 5H15v7h-.963c-.685 0-1.258.483-1.612 1.068a4.011 4.011 0 0 1-2.166 1.73c-.432.143-.853.386-1.011.814-.16.432-.248.9-.248 1.388Z" fill="currentColor"/>`,
},
}
type Name = keyof typeof icons
@@ -14,8 +36,7 @@ export interface IconProps extends Omit<Props, "name"> {
export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
const name = local.name as Name
if (!(name in icons)) {
if (!((local.name as Name) in icons)) {
return (
<Upstream
{...others}
@@ -35,8 +56,8 @@ export function Icon(props: IconProps) {
}}
data-slot="icon-svg"
fill="none"
viewBox="0 0 16 16"
innerHTML={icons[name]}
viewBox={icons[local.name as Name].viewBox}
innerHTML={icons[local.name as Name].path}
aria-hidden="true"
{...others}
/>
@@ -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;
}
}
}
@@ -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<MessagePartProps>
@@ -971,6 +978,7 @@ export function Part(props: MessagePartProps) {
turnDiffSummary={props.turnDiffSummary}
animate={props.animate}
working={props.working}
feedback={props.feedback}
/>
</Show>
)
@@ -1342,6 +1350,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()}>
+4 -1
View File
@@ -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"],
+7 -5
View File
@@ -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, getWebviewFontSize } from "./utils"
import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry"
import { TelemetryProxy, type TelemetryPropertiesProvider, pushTelemetryState, watchTelemetryState } from "./services/telemetry" // prettier-ignore
import {
sessionToWebview,
indexProvidersById,
@@ -216,6 +216,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private initConnectionPromise: Promise<void> | 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<typeof createAutoApproveBridge> | null = null
@@ -357,10 +358,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) {
@@ -581,6 +580,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),
@@ -3404,6 +3405,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
+13
View File
@@ -57,6 +57,11 @@ export function activate(context: vscode.ExtensionContext) {
const config = connectionService.getServerConfig()
if (config) {
telemetry.configure(config.baseUrl, config.password)
// Sync the CLI's PostHog client with the current consent state. The
// CLI reads KILO_TELEMETRY_LEVEL once at spawn, so without this call
// a fresh CLI started while VS Code telemetry was off would stay
// opted out for the rest of the session.
telemetry.setEnabled(vscode.env.isTelemetryEnabled)
}
try {
remoteService.setClient(connectionService.getClient())
@@ -72,6 +77,14 @@ export function activate(context: vscode.ExtensionContext) {
}
})
// Propagate runtime telemetry consent changes to the CLI subprocess so its
// PostHog client stays in sync with the user's VS Code telemetry setting.
context.subscriptions.push(
vscode.env.onDidChangeTelemetryEnabled((enabled) => {
telemetry.setEnabled(enabled)
}),
)
// Prewarm the CLI backend early so autocomplete is ready before first editor use.
ensureBackendForAutocomplete(connectionService)
@@ -1,2 +1,3 @@
export { TelemetryEventName, type TelemetryPropertiesProvider } from "./types"
export { TelemetryProxy } from "./telemetry-proxy"
export { pushTelemetryState, watchTelemetryState } from "./webview-state"
@@ -61,6 +61,26 @@ export class TelemetryProxy {
}).catch((err) => console.error("[Kilo New] Telemetry capture failed:", err))
}
/**
* Propagate runtime telemetry consent changes to the CLI. The CLI subprocess
* reads `KILO_TELEMETRY_LEVEL` once at spawn — without this call, toggling
* VS Code telemetry consent leaves the CLI's PostHog client stuck on its
* spawn-time state until the process restarts.
*/
setEnabled(enabled: boolean) {
if (!this.url || !this.password) return
const auth = buildTelemetryAuthHeader(this.password)
fetch(`${this.url}/telemetry/setEnabled`, {
method: "POST",
headers: {
Authorization: auth,
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
}).catch((err) => console.error("[Kilo New] Telemetry setEnabled failed:", err))
}
/**
* No-op — the CLI server handles PostHog shutdown.
*/
@@ -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",
}
/**
@@ -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 })
})
}
@@ -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",
})
})
})
@@ -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"
@@ -3169,13 +3170,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>
+6 -3
View File
@@ -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 = () => {
<IndexingProvider>
<NotificationsProvider>
<SessionProvider>
<DataBridge>
<AppContent />
</DataBridge>
<FeedbackProvider>
<DataBridge>
<AppContent />
</DataBridge>
</FeedbackProvider>
</SessionProvider>
</NotificationsProvider>
</IndexingProvider>
@@ -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,
@@ -63,6 +64,7 @@ function matchToolRequest<T extends { tool?: { callID: string; messageID: string
interface AssistantMessageProps {
message: SDKAssistantMessage
showAssistantCopyPartID?: string | null
feedback?: MessageFeedbackControls
}
function TodoToolCard(props: { part: ToolPart }) {
@@ -168,6 +170,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" ||
@@ -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<VscodeSessionTurnProps> = (props) => {
const server = useServer()
const session = useSession()
const language = useLanguage()
const feedback = useFeedback()
const emptyParts: SDKPart[] = []
const emptyDiffs: SnapshotFileDiff[] = []
@@ -179,7 +181,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>
@@ -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<string, unknown> {
const properties: Record<string, unknown> = {
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
}
@@ -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<boolean>
getRating: (messageID: string) => Rating | undefined
rate: (input: RateInput) => void
}
const FeedbackContext = createContext<FeedbackContextValue>()
export const FeedbackProvider: ParentComponent = (props) => {
const vscode = useVSCode()
const [telemetryEnabled, setTelemetryEnabled] = createSignal(false)
const [ratings, setRatings] = createSignal<Record<string, Rating>>({})
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 <FeedbackContext.Provider value={value}>{props.children}</FeedbackContext.Provider>
}
export function useFeedback(): FeedbackContextValue {
const context = useContext(FeedbackContext)
if (!context) throw new Error("useFeedback must be used within a FeedbackProvider")
return context
}
@@ -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"
@@ -318,46 +319,48 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
return (
<VSCodeProvider>
<ServerProvider>
<ConfigWrapper config={props.config} onConfigChange={props.onConfigChange}>
<DisplayProvider>
<MockProviderProvider>
<DialogProvider>
<LanguageContext.Provider
value={{
locale,
setLocale: noop,
userOverride: () => "" as any,
t,
}}
>
<I18nProvider value={{ locale: () => "en", t }}>
<NotificationsContext.Provider value={notifications}>
<SessionContext.Provider value={session as any}>
<IndexingProvider>
<DataProvider data={data()} directory="/project/">
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<FileComponentProvider component={File}>
<MarkedProvider>
{props.noPadding ? (
props.children
) : (
<div style={{ padding: "12px" }}>{props.children}</div>
)}
</MarkedProvider>
</FileComponentProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</DataProvider>
</IndexingProvider>
</SessionContext.Provider>
</NotificationsContext.Provider>
</I18nProvider>
</LanguageContext.Provider>
</DialogProvider>
</MockProviderProvider>
</DisplayProvider>
</ConfigWrapper>
<FeedbackProvider>
<ConfigWrapper config={props.config} onConfigChange={props.onConfigChange}>
<DisplayProvider>
<MockProviderProvider>
<DialogProvider>
<LanguageContext.Provider
value={{
locale,
setLocale: noop,
userOverride: () => "" as any,
t,
}}
>
<I18nProvider value={{ locale: () => "en", t }}>
<NotificationsContext.Provider value={notifications}>
<SessionContext.Provider value={session as any}>
<IndexingProvider>
<DataProvider data={data()} directory="/project/">
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<FileComponentProvider component={File}>
<MarkedProvider>
{props.noPadding ? (
props.children
) : (
<div style={{ padding: "12px" }}>{props.children}</div>
)}
</MarkedProvider>
</FileComponentProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</DataProvider>
</IndexingProvider>
</SessionContext.Provider>
</NotificationsContext.Provider>
</I18nProvider>
</LanguageContext.Provider>
</DialogProvider>
</MockProviderProvider>
</DisplayProvider>
</ConfigWrapper>
</FeedbackProvider>
</ServerProvider>
</VSCodeProvider>
)
@@ -768,6 +768,11 @@ export interface ExtensionDataReadyMessage {
type: "extensionDataReady"
}
export interface TelemetryStateMessage {
type: "telemetryState"
enabled: boolean
}
// ============================================
// Marketplace Messages
// ============================================
@@ -984,4 +989,5 @@ export type ExtensionMessage =
| McpStatusLoadedMessage
| ClearPendingPromptsMessage
| ExtensionDataReadyMessage
| TelemetryStateMessage
| RemoteStatusMessage
@@ -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 { submitFeedback } from "@/kilocode/cli/cmd/tui/feedback" // kilocode_change
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
@@ -949,6 +950,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, { toast, session, messages }),
},
{
title: "Rate last assistant message not helpful",
value: "messages.feedback.down",
keybind: "messages_feedback_down",
category: "Session",
onSelect: (dialog) => submitFeedback("down", dialog, { toast, session, messages }),
},
// kilocode_change end
{
title: "Copy session transcript",
value: "session.copy",
+4
View File
@@ -51,6 +51,10 @@ const KeybindsSchema = Schema.Struct({
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
// kilocode_change start - message feedback
messages_feedback_up: keybind("<leader>=", "Rate last assistant message helpful"),
messages_feedback_down: keybind("<leader>-", "Rate last assistant message not helpful"),
// kilocode_change end
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
tool_details: keybind("none", "Toggle tool details visibility"),
model_list: keybind("<leader>m", "List available models"),
@@ -0,0 +1,58 @@
// kilocode_change - new file
/**
* Per-message thumbs up/down feedback for the TUI.
*
* Wired via the `messages_feedback_up` / `messages_feedback_down` keybinds
* in the Session route. Kept out of `routes/session/index.tsx` so the
* upstream-shared session route stays free of Kilo telemetry plumbing.
*/
import { Telemetry } from "@kilocode/kilo-telemetry"
import type { AssistantMessage, Message } from "@kilocode/sdk/v2"
import type { DialogContext } from "@tui/ui/dialog"
import type { ToastContext } from "@tui/ui/toast"
interface SessionRevert {
revert?: { messageID: string }
}
interface Context {
toast: ToastContext
session: () => SessionRevert | undefined
messages: () => Message[]
}
export function submitFeedback(rating: "up" | "down", dialog: DialogContext, ctx: Context): void {
if (!Telemetry.isEnabled()) {
ctx.toast.show({ message: "Feedback disabled: telemetry is off", variant: "info" })
dialog.clear()
return
}
const revertID = ctx.session()?.revert?.messageID
const lastAssistant = ctx.messages().findLast(
(msg): msg is AssistantMessage => msg.role === "assistant" && (!revertID || msg.id < revertID),
)
if (!lastAssistant) {
ctx.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 === "kilo") {
payload.sessionID = lastAssistant.sessionID
payload.messageID = lastAssistant.id
payload.parentMessageID = lastAssistant.parentID
}
Telemetry.trackFeedback(payload)
ctx.toast.show({
message: rating === "up" ? "Thanks for the feedback!" : "Thanks — we'll use this to improve.",
variant: "success",
})
dialog.clear()
}
@@ -7,39 +7,68 @@ import { lazy } from "@/util/lazy"
import { errors } from "../../error"
export const TelemetryRoutes = lazy(() =>
new Hono().post(
"/capture",
describeRoute({
summary: "Capture telemetry event",
description: "Forward a telemetry event to PostHog via kilo-telemetry.",
operationId: "telemetry.capture",
responses: {
200: {
description: "Event captured",
content: {
"application/json": {
schema: resolver(z.boolean()),
new Hono()
.post(
"/capture",
describeRoute({
summary: "Capture telemetry event",
description: "Forward a telemetry event to PostHog via kilo-telemetry.",
operationId: "telemetry.capture",
responses: {
200: {
description: "Event captured",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
...errors(400),
},
}),
validator(
"json",
z.object({
event: z.string().meta({ description: "Event name" }),
properties: z.record(z.string(), z.any()).optional().meta({ description: "Event properties" }),
}),
validator(
"json",
z.object({
event: z.string().meta({ description: "Event name" }),
properties: z.record(z.string(), z.any()).optional().meta({ description: "Event properties" }),
}),
),
async (c) => {
const body = c.req.valid("json")
try {
Telemetry.track(body.event as any, body.properties)
} catch {
// fire-and-forget: swallow errors
}
return c.json(true)
},
)
.post(
"/setEnabled",
describeRoute({
summary: "Set PostHog telemetry enabled state",
description:
"Update the PostHog client's opt-in/out state at runtime. " +
"The CLI reads KILO_TELEMETRY_LEVEL once at spawn — this route lets clients " +
"(e.g. the VS Code extension) propagate runtime telemetry consent changes.",
operationId: "telemetry.setEnabled",
responses: {
200: {
description: "State updated",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", z.object({ enabled: z.boolean() })),
async (c) => {
const body = c.req.valid("json")
Telemetry.setEnabled(body.enabled)
return c.json(true)
},
),
async (c) => {
const body = c.req.valid("json")
try {
Telemetry.track(body.event as any, body.properties)
} catch {
// fire-and-forget: swallow errors
}
return c.json(true)
},
),
)
@@ -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")
})
})
+41
View File
@@ -221,6 +221,8 @@ import type {
SyncStartResponses,
TelemetryCaptureErrors,
TelemetryCaptureResponses,
TelemetrySetEnabledErrors,
TelemetrySetEnabledResponses,
TextPartInput,
ToolIdsErrors,
ToolIdsResponses,
@@ -4936,6 +4938,45 @@ export class Telemetry extends HeyApiClient {
},
})
}
/**
* Set PostHog telemetry enabled state
*
* Update the PostHog client's opt-in/out state at runtime. The CLI reads KILO_TELEMETRY_LEVEL once at spawn — this route lets clients (e.g. the VS Code extension) propagate runtime telemetry consent changes.
*/
public setEnabled<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
enabled?: boolean
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "enabled" },
],
},
],
)
return (options?.client ?? this.client).post<TelemetrySetEnabledResponses, TelemetrySetEnabledErrors, ThrowOnError>(
{
url: "/telemetry/setEnabled",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
},
)
}
}
export class Remote extends HeyApiClient {
+30
View File
@@ -6465,6 +6465,36 @@ export type TelemetryCaptureResponses = {
export type TelemetryCaptureResponse = TelemetryCaptureResponses[keyof TelemetryCaptureResponses]
export type TelemetrySetEnabledData = {
body?: {
enabled: boolean
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/telemetry/setEnabled"
}
export type TelemetrySetEnabledErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type TelemetrySetEnabledError = TelemetrySetEnabledErrors[keyof TelemetrySetEnabledErrors]
export type TelemetrySetEnabledResponses = {
/**
* State updated
*/
200: boolean
}
export type TelemetrySetEnabledResponse = TelemetrySetEnabledResponses[keyof TelemetrySetEnabledResponses]
export type RemoteEnableData = {
body?: never
path?: never