feat(tui): show hidden todo status breakdown in collapsed panel (#1122)

This commit is contained in:
liruifengv 2026-06-26 15:50:58 +08:00 committed by GitHub
parent 36cbdb29c0
commit 820d77ab4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 113 additions and 3 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Show the done / in progress / pending breakdown of hidden todos in the collapsed todo panel.

View file

@ -28,6 +28,7 @@ const MAX_VISIBLE = 5;
export interface VisibleTodos {
readonly rows: readonly TodoItem[];
readonly hidden: number;
readonly hiddenCounts: Record<TodoStatus, number>;
}
/**
@ -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<TodoStatus, number> = { 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<TodoStatus, number>): string {
return STATUS_LABELS.filter(({ status }) => counts[status] > 0)
.map(({ status, label }) => `${counts[status]} ${label}`)
.join(' · ');
}

View file

@ -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('');
});
});