fix(vscode): compact todo update cards (#9687)

* fix(vscode): compact todo update cards

* chore: update kilo-vscode visual regression baselines

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Marius
2026-04-29 17:31:04 +02:00
committed by GitHub
parent fe3cbd8623
commit 9028174cfd
8 changed files with 248 additions and 3 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Show compact todo update cards when checking off items in long todo lists.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9b47f6adfc72399cb1535be3a69661b027eec3068880aba35821d5240e0c3492
size 16739
@@ -221,6 +221,20 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] {
margin-bottom: 0px;
}
[data-component="todos"] {
[data-slot="message-part-todo-hidden"] {
padding: 2px 0 2px 28px;
color: var(--text-weaker);
font-size: var(--font-size-small);
line-height: var(--line-height-normal);
}
[data-slot="message-part-todo-content"][data-changed="changed"] {
color: var(--text-base);
font-weight: var(--font-weight-medium);
}
}
/* User message copy button: collapse wrapper when idle, show on hover.
* The wrapper must be tall enough to fully contain the button (20px)
* so that hovering the button keeps [data-component="user-message"]
@@ -85,6 +85,17 @@ interface Diagnostic {
severity?: number
}
type TodoView = {
mode?: "full" | "compact"
todos?: TodoItem[]
hiddenBefore?: number
hiddenAfter?: number
}
type TodoItem = Todo & {
changed?: boolean
}
function getDiagnostics(
diagnosticsByFile: Record<string, Diagnostic[]> | undefined,
filePath: string | undefined,
@@ -2403,6 +2414,7 @@ ToolRegistry.register({
name: "todowrite",
render(props) {
const i18n = useI18n()
const view = createMemo(() => (isTodoView(props.metadata?.view) ? props.metadata.view : undefined))
const todos = createMemo(() => {
const meta = props.metadata?.todos
if (Array.isArray(meta)) return meta
@@ -2412,6 +2424,7 @@ ToolRegistry.register({
return []
})
const shown = createMemo(() => view()?.todos ?? todos())
const pending = createMemo(() => busy(props.status))
const subtitle = createMemo(() => {
@@ -2434,20 +2447,27 @@ ToolRegistry.register({
/>
}
>
<Show when={todos().length}>
<Show when={shown().length}>
<div data-component="todos">
<For each={todos()}>
{(todo: Todo) => (
<Show when={view()?.mode === "compact" && (view()?.hiddenBefore ?? 0) > 0}>
<div data-slot="message-part-todo-hidden">{hiddenText("earlier", view()?.hiddenBefore ?? 0)}</div>
</Show>
<For each={shown()}>
{(todo: TodoItem) => (
<Checkbox readOnly checked={todo.status === "completed"}>
<span
data-slot="message-part-todo-content"
data-completed={todo.status === "completed" ? "completed" : undefined}
data-changed={todo.changed ? "changed" : undefined}
>
{todo.content}
</span>
</Checkbox>
)}
</For>
<Show when={view()?.mode === "compact" && (view()?.hiddenAfter ?? 0) > 0}>
<div data-slot="message-part-todo-hidden">{hiddenText("later", view()?.hiddenAfter ?? 0)}</div>
</Show>
</div>
</Show>
</BasicTool>
@@ -2455,6 +2475,17 @@ ToolRegistry.register({
},
})
function isTodoView(value: unknown): value is TodoView {
if (!value || typeof value !== "object") return false
const view = value as TodoView
return Array.isArray(view.todos)
}
function hiddenText(dir: "earlier" | "later", count: number) {
const noun = count === 1 ? "to-do" : "to-dos"
return `${count} ${dir} ${noun} hidden`
}
ToolRegistry.register({
name: "question",
render(props) {
@@ -338,6 +338,37 @@ const todoWriteDocsOverview: ToolPart = {
},
}
const compactTodos = docsTodos.map((todo, index) =>
index < 5 ? { ...todo, status: "completed" } : { ...todo, status: index === 5 ? "pending" : todo.status },
)
const compactViewTodos = compactTodos.slice(3, 6).map((todo, index) => ({ ...todo, changed: index === 1 }))
const todoWriteCompactUpdate: ToolPart = {
id: "part-todo-compact-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-todo-compact-001",
tool: "todowrite",
state: {
status: "completed",
input: { todos: compactTodos },
output: "Updated 10 todos",
title: "Todo List Updated",
metadata: {
todos: compactTodos,
view: {
mode: "compact",
todos: compactViewTodos,
hiddenBefore: 3,
hiddenAfter: 4,
changed: 1,
},
},
time: { start: now - 3000, end: now - 2800 },
},
}
const todoWritePermission: PermissionRequest = {
id: "perm-todo-001",
sessionID: SESSION_ID,
@@ -667,6 +698,18 @@ export const TodoWriteDocsOverview: Story = {
},
}
export const TodoWriteCompactUpdate: Story = {
name: "TodoWrite - Compact update",
render: () => {
const data = dataWith([todoWriteCompactUpdate])
return (
<StoryProviders data={data} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 12. Permission dock — edit tool with file patterns
// ---------------------------------------------------------------------------
@@ -0,0 +1,77 @@
export namespace TodoView {
export type Todo = {
content: string
status: string
priority: string
}
export type Item = Todo & {
changed?: boolean
}
export type Info = {
mode: "full" | "compact"
todos: Item[]
hiddenBefore: number
hiddenAfter: number
changed: number
}
export function calculate(before: Todo[], after: Todo[]): Info {
const diff = after
.map((todo, index) => ({
index,
changed: !same(before[index], todo),
}))
.filter((item) => item.changed)
const wide =
before.length === 0 ||
after.length === 0 ||
structural(before, after) ||
terminal(after) ||
diff.length === 0
if (wide) return full(after, diff.length)
const first = Math.max(0, Math.min(...diff.map((item) => item.index)) - 1)
const last = Math.min(after.length - 1, Math.max(...diff.map((item) => item.index)) + 1)
const hidden = first + after.length - last - 1
if (hidden === 0) return full(after, diff.length)
const set = new Set(diff.map((item) => item.index))
return {
mode: "compact",
todos: after.slice(first, last + 1).map((todo, index) => ({
...todo,
changed: set.has(first + index),
})),
hiddenBefore: first,
hiddenAfter: after.length - last - 1,
changed: diff.length,
}
}
function full(todos: Todo[], changed: number): Info {
return {
mode: "full",
todos,
hiddenBefore: 0,
hiddenAfter: 0,
changed,
}
}
function same(before: Todo | undefined, after: Todo) {
if (!before) return false
return before.content === after.content && before.status === after.status && before.priority === after.priority
}
function terminal(todos: Todo[]) {
return todos.every((todo) => todo.status === "completed" || todo.status === "cancelled")
}
function structural(before: Todo[], after: Todo[]) {
if (before.length !== after.length) return true
return after.some((todo, index) => before[index]?.content !== todo.content)
}
}
+14
View File
@@ -2,6 +2,9 @@ import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"
import { Todo } from "../session/todo"
// kilocode_change start
import { TodoView } from "../kilocode/todo-view"
// kilocode_change end
// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape
// here rather than referencing its `.shape` — the LLM-visible JSON Schema is
@@ -20,6 +23,9 @@ export const Parameters = Schema.Struct({
type Metadata = {
todos: Todo.Info[]
// kilocode_change start
view?: TodoView.Info
// kilocode_change end
}
export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Service>(
@@ -39,6 +45,11 @@ export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Servi
metadata: {},
})
// kilocode_change start
const before = yield* todo.get(ctx.sessionID)
const view = TodoView.calculate(before, params.todos)
// kilocode_change end
yield* todo.update({
sessionID: ctx.sessionID,
todos: params.todos,
@@ -49,6 +60,9 @@ export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Servi
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
// kilocode_change start
view,
// kilocode_change end
},
}
}),
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test"
import { TodoView } from "../../src/kilocode/todo-view"
function item(content: string, status = "pending"): TodoView.Todo {
return { content, status, priority: "medium" }
}
describe("TodoView.calculate", () => {
test("shows the full list when todos are first created", () => {
const after = [item("Inspect files"), item("Implement fix"), item("Run checks")]
const view = TodoView.calculate([], after)
expect(view.mode).toBe("full")
expect(view.todos).toEqual(after)
expect(view.hiddenBefore).toBe(0)
expect(view.hiddenAfter).toBe(0)
})
test("shows a compact window around the changed item", () => {
const before = Array.from({ length: 10 }, (_, index) => item(`Task ${index + 1}`))
const after = before.map((todo, index) => (index === 4 ? { ...todo, status: "completed" } : todo))
const view = TodoView.calculate(before, after)
expect(view.mode).toBe("compact")
expect(view.hiddenBefore).toBe(3)
expect(view.hiddenAfter).toBe(4)
expect(view.todos.map((todo) => todo.content)).toEqual(["Task 4", "Task 5", "Task 6"])
expect(view.todos.map((todo) => Boolean(todo.changed))).toEqual([false, true, false])
})
test("shows the full list when all todos are terminal", () => {
const before = [item("Inspect files", "completed"), item("Implement fix"), item("Run checks")]
const after = before.map((todo) => ({ ...todo, status: "completed" }))
const view = TodoView.calculate(before, after)
expect(view.mode).toBe("full")
expect(view.todos).toEqual(after)
})
test("shows the full list when todo content is rewritten", () => {
const before = [item("One"), item("Two"), item("Three"), item("Four")]
const after = [item("One"), item("Two changed"), item("Three"), item("Four")]
const view = TodoView.calculate(before, after)
expect(view.mode).toBe("full")
expect(view.todos).toEqual(after)
})
test("shows the full list when todos are added", () => {
const before = [item("One"), item("Two"), item("Three")]
const after = [...before, item("Four")]
const view = TodoView.calculate(before, after)
expect(view.mode).toBe("full")
expect(view.todos).toEqual(after)
})
})