From 820d77ab4cfad7752358a4692fd3d7def49f005d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 26 Jun 2026 15:50:58 +0800 Subject: [PATCH] feat(tui): show hidden todo status breakdown in collapsed panel (#1122) --- .changeset/collapsed-todo-status-breakdown.md | 5 ++ .../src/tui/components/chrome/todo-panel.ts | 36 ++++++++- .../tui/components/panels/todo-panel.test.ts | 75 +++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 .changeset/collapsed-todo-status-breakdown.md diff --git a/.changeset/collapsed-todo-status-breakdown.md b/.changeset/collapsed-todo-status-breakdown.md new file mode 100644 index 000000000..449eeeebc --- /dev/null +++ b/.changeset/collapsed-todo-status-breakdown.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show the done / in progress / pending breakdown of hidden todos in the collapsed todo panel. diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index 02bba03f2..733d5b8f8 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -28,6 +28,7 @@ const MAX_VISIBLE = 5; export interface VisibleTodos { readonly rows: readonly TodoItem[]; readonly hidden: number; + readonly hiddenCounts: Record; } /** @@ -49,7 +50,11 @@ export interface VisibleTodos { */ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { if (todos.length <= MAX_VISIBLE) { - return { rows: [...todos], hidden: 0 }; + return { + rows: [...todos], + hidden: 0, + hiddenCounts: { done: 0, in_progress: 0, pending: 0 }, + }; } const inProgress: number[] = []; @@ -91,9 +96,18 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { } const sortedIdx = [...picked].toSorted((a, b) => a - b); + + const hiddenCounts: Record = { done: 0, in_progress: 0, pending: 0 }; + for (const [i, todo] of todos.entries()) { + if (!picked.has(i)) { + hiddenCounts[todo.status] += 1; + } + } + return { rows: sortedIdx.map((i) => todos[i] as TodoItem), hidden: todos.length - sortedIdx.length, + hiddenCounts, }; } @@ -151,12 +165,16 @@ export class TodoPanelComponent implements Component { ); } } else { - const { rows, hidden } = selectVisibleTodos(this.todos); + const { rows, hidden, hiddenCounts } = selectVisibleTodos(this.todos); for (const todo of rows) { lines.push(renderRow(todo, c)); } if (hidden > 0) { - lines.push(chalk.hex(c.textDim)(` … +${hidden} more · ctrl+t to expand`)); + const distribution = formatHiddenCounts(hiddenCounts); + const suffix = distribution.length > 0 ? ` (${distribution})` : ''; + lines.push( + chalk.hex(c.textDim)(` … +${hidden} more${suffix} · ctrl+t to expand`), + ); } } @@ -191,3 +209,15 @@ function styleTitle(title: string, status: TodoStatus, colors: ColorPalette): st return chalk.hex(colors.text)(title); } } + +const STATUS_LABELS: readonly { status: TodoStatus; label: string }[] = [ + { status: 'done', label: 'done' }, + { status: 'in_progress', label: 'in progress' }, + { status: 'pending', label: 'pending' }, +]; + +export function formatHiddenCounts(counts: Record): string { + return STATUS_LABELS.filter(({ status }) => counts[status] > 0) + .map(({ status, label }) => `${counts[status]} ${label}`) + .join(' · '); +} diff --git a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts index a3644e8c2..ee6dcbb1c 100644 --- a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { TodoPanelComponent, + formatHiddenCounts, selectVisibleTodos, type TodoItem, } from '#/tui/components/chrome/todo-panel'; @@ -108,6 +109,43 @@ describe('TodoPanelComponent', () => { expect(out).toMatch(/ctrl\+t to expand/); }); + it('collapsed footer shows hidden status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos([ + ...Array.from({ length: 6 }, (_, i) => ({ + title: `ip${i}`, + status: 'in_progress' as const, + })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `p${i}`, status: 'pending' as const })), + ]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+7 more \(3 done · 1 in progress · 3 pending\)/); + expect(out).toMatch(/ctrl\+t to expand/); + }); + + it('collapsed footer omits zero-count statuses', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+3 more \(3 done\)/); + expect(out).not.toMatch(/0 in progress/); + expect(out).not.toMatch(/0 pending/); + }); + + it('expanded footer does not include status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + panel.setExpanded(true); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/all 8 items · ctrl\+t to collapse/); + expect(out).not.toMatch(/\d+ done ·/); + }); + it('renders every todo with a collapse hint when expanded', () => { const panel = new TodoPanelComponent(); panel.setTodos(many(7)); @@ -305,4 +343,41 @@ describe('selectVisibleTodos', () => { expect(rows.map((r) => r.title)).toEqual(['ip0', 'ip1', 'ip2', 'ip3', 'ip4']); expect(hidden).toBe(2); }); + + it('returns hiddenCounts reflecting the hidden items', () => { + const todos: TodoItem[] = [ + ...Array.from({ length: 6 }, (_, i) => T(`ip${i}`, 'in_progress')), + ...Array.from({ length: 3 }, (_, i) => T(`d${i}`, 'done')), + ...Array.from({ length: 3 }, (_, i) => T(`p${i}`, 'pending')), + ]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(7); + expect(hiddenCounts).toEqual({ done: 3, in_progress: 1, pending: 3 }); + }); + + it('returns zero hiddenCounts when count <= 5', () => { + const todos: TodoItem[] = [T('a', 'done'), T('b', 'in_progress'), T('c', 'pending')]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(0); + expect(hiddenCounts).toEqual({ done: 0, in_progress: 0, pending: 0 }); + }); +}); + +describe('formatHiddenCounts', () => { + it('formats all three statuses in done / in progress / pending order', () => { + expect(formatHiddenCounts({ done: 2, in_progress: 1, pending: 3 })).toBe( + '2 done · 1 in progress · 3 pending', + ); + }); + + it('omits zero-count statuses', () => { + expect(formatHiddenCounts({ done: 5, in_progress: 0, pending: 0 })).toBe('5 done'); + expect(formatHiddenCounts({ done: 0, in_progress: 2, pending: 3 })).toBe( + '2 in progress · 3 pending', + ); + }); + + it('returns empty string when all counts are zero', () => { + expect(formatHiddenCounts({ done: 0, in_progress: 0, pending: 0 })).toBe(''); + }); });