fix(tui): cap todo panel at 5 rows with overflow indicator (#146)

* fix(tui): cap todo panel at 5 rows with overflow indicator

Pick visible todos around in_progress (recent done + upcoming pending),
append `+N more` when truncated.

* fix(tui): make todo selector order-agnostic

The previous selector assumed todos were grouped as done...pending
when no in_progress existed. On interleaved orderings (the TodoList
tool keeps the model-provided order without normalizing) the before
and after windows skipped most candidates and produced fewer than
five rows. Pick directly from status buckets instead.
This commit is contained in:
liruifengv 2026-05-28 15:17:29 +08:00 committed by GitHub
parent 52bdc6fced
commit 76cbf86e20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 269 additions and 2 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Cap the inline todo panel at five rows and show a `+N more` indicator so long task lists no longer fill the screen.

View file

@ -22,6 +22,80 @@ export interface TodoItem {
readonly status: TodoStatus;
}
const MAX_VISIBLE = 5;
export interface VisibleTodos {
readonly rows: readonly TodoItem[];
readonly hidden: number;
}
/**
* Pick which todos to render when the list exceeds {@link MAX_VISIBLE}.
*
* The selector is order-agnostic the TodoList tool keeps whatever
* order the model produced and does not group items by status, so an
* interleaved sequence like `pending, done, pending, done, ...` is
* possible and must still yield MAX_VISIBLE rows when enough exist.
*
* Strategy:
* 1. Include every `in_progress` item (capped at MAX_VISIBLE).
* 2. Fill remaining slots with "what's next" the earliest `pending`
* items in their original positions while reserving one slot for
* "what just finished" the latest `done` item when both kinds
* exist. If one side has too few candidates, the other expands.
*
* Items are returned in their original order.
*/
export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos {
if (todos.length <= MAX_VISIBLE) {
return { rows: [...todos], hidden: 0 };
}
const inProgress: number[] = [];
const pending: number[] = [];
const done: number[] = [];
for (const [i, todo] of todos.entries()) {
if (todo.status === 'in_progress') inProgress.push(i);
else if (todo.status === 'pending') pending.push(i);
else done.push(i);
}
const picked = new Set<number>();
for (const i of inProgress.slice(0, MAX_VISIBLE)) picked.add(i);
if (picked.size < MAX_VISIBLE) {
// Most recent done first; earliest pending first.
const doneCandidates = done.toReversed();
const pendingCandidates = pending;
const remaining = MAX_VISIBLE - picked.size;
let doneCount: number;
let pendingCount: number;
if (doneCandidates.length === 0) {
doneCount = 0;
pendingCount = Math.min(remaining, pendingCandidates.length);
} else if (pendingCandidates.length === 0) {
pendingCount = 0;
doneCount = Math.min(remaining, doneCandidates.length);
} else {
doneCount = 1;
pendingCount = Math.min(remaining - 1, pendingCandidates.length);
if (pendingCount < remaining - 1) {
doneCount = Math.min(doneCandidates.length, remaining - pendingCount);
}
}
for (let i = 0; i < doneCount; i++) picked.add(doneCandidates[i] as number);
for (let i = 0; i < pendingCount; i++) picked.add(pendingCandidates[i] as number);
}
const sortedIdx = [...picked].toSorted((a, b) => a - b);
return {
rows: sortedIdx.map((i) => todos[i] as TodoItem),
hidden: todos.length - sortedIdx.length,
};
}
export class TodoPanelComponent implements Component {
private todos: readonly TodoItem[] = [];
private colors: ColorPalette;
@ -55,13 +129,17 @@ export class TodoPanelComponent implements Component {
render(width: number): string[] {
if (this.todos.length === 0) return [];
const c = this.colors;
const { rows, hidden } = selectVisibleTodos(this.todos);
const lines: string[] = [
chalk.hex(c.border)('─'.repeat(width)),
chalk.hex(c.primary).bold(' Todo'),
];
for (const todo of this.todos) {
for (const todo of rows) {
lines.push(renderRow(todo, c));
}
if (hidden > 0) {
lines.push(chalk.hex(c.textDim)(` … +${hidden} more`));
}
return lines.map((line) => truncateToWidth(line, width));
}

View file

@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { TodoPanelComponent, type TodoItem } from '#/tui/components/chrome/todo-panel';
import {
TodoPanelComponent,
selectVisibleTodos,
type TodoItem,
} from '#/tui/components/chrome/todo-panel';
import { darkColors } from '#/tui/theme/colors';
function strip(text: string): string {
@ -55,4 +59,184 @@ describe('TodoPanelComponent', () => {
expect(out).toMatch(/○ foo/);
expect(out).not.toMatch(/hacked/);
});
it('renders all todos and no overflow footer when count <= 5', () => {
const panel = new TodoPanelComponent(darkColors);
panel.setTodos([
{ title: 'a', status: 'done' },
{ title: 'b', status: 'in_progress' },
{ title: 'c', status: 'pending' },
{ title: 'd', status: 'pending' },
{ title: 'e', status: 'pending' },
]);
const out = strip(panel.render(80).join('\n'));
expect(out).toMatch(/a/);
expect(out).toMatch(/e/);
expect(out).not.toMatch(/\+\d+ more/);
});
it('appends "+N more" footer when count > 5', () => {
const panel = new TodoPanelComponent(darkColors);
panel.setTodos([
{ title: 't0', status: 'done' },
{ title: 't1', status: 'in_progress' },
{ title: 't2', status: 'pending' },
{ title: 't3', status: 'pending' },
{ title: 't4', status: 'pending' },
{ title: 't5', status: 'pending' },
{ title: 't6', status: 'pending' },
]);
const out = strip(panel.render(80).join('\n'));
expect(out).toMatch(/\+2 more/);
});
});
describe('selectVisibleTodos', () => {
const T = (title: string, status: TodoItem['status']): TodoItem => ({ title, status });
it('returns all items unchanged when count <= 5', () => {
const todos: TodoItem[] = [
T('a', 'done'),
T('b', 'in_progress'),
T('c', 'pending'),
];
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows).toEqual(todos);
expect(hidden).toBe(0);
});
it('with 1 in_progress: shows 1 done before + in_progress + 3 pending after', () => {
const todos: TodoItem[] = [
T('d1', 'done'),
T('d2', 'done'),
T('d3', 'done'),
T('ip', 'in_progress'),
T('p1', 'pending'),
T('p2', 'pending'),
T('p3', 'pending'),
T('p4', 'pending'),
T('p5', 'pending'),
];
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.map((r) => r.title)).toEqual(['d3', 'ip', 'p1', 'p2', 'p3']);
expect(hidden).toBe(4);
});
it('with 1 in_progress and no done before: fills with pending after', () => {
const todos: TodoItem[] = [
T('ip', 'in_progress'),
T('p1', 'pending'),
T('p2', 'pending'),
T('p3', 'pending'),
T('p4', 'pending'),
T('p5', 'pending'),
T('p6', 'pending'),
];
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.map((r) => r.title)).toEqual(['ip', 'p1', 'p2', 'p3', 'p4']);
expect(hidden).toBe(2);
});
it('with 1 in_progress and few pending after: expands done before', () => {
const todos: TodoItem[] = [
T('d1', 'done'),
T('d2', 'done'),
T('d3', 'done'),
T('d4', 'done'),
T('d5', 'done'),
T('ip', 'in_progress'),
T('p1', 'pending'),
];
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.map((r) => r.title)).toEqual(['d3', 'd4', 'd5', 'ip', 'p1']);
expect(hidden).toBe(2);
});
it('all pending: shows first 5', () => {
const todos: TodoItem[] = Array.from({ length: 8 }, (_, i) => T(`p${i}`, 'pending'));
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.map((r) => r.title)).toEqual(['p0', 'p1', 'p2', 'p3', 'p4']);
expect(hidden).toBe(3);
});
it('all done: shows last 5', () => {
const todos: TodoItem[] = Array.from({ length: 8 }, (_, i) => T(`d${i}`, 'done'));
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.map((r) => r.title)).toEqual(['d3', 'd4', 'd5', 'd6', 'd7']);
expect(hidden).toBe(3);
});
it('mixed done+pending without in_progress: 1 done + 4 pending', () => {
const todos: TodoItem[] = [
T('d1', 'done'),
T('d2', 'done'),
T('d3', 'done'),
T('p1', 'pending'),
T('p2', 'pending'),
T('p3', 'pending'),
T('p4', 'pending'),
T('p5', 'pending'),
];
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.map((r) => r.title)).toEqual(['d3', 'p1', 'p2', 'p3', 'p4']);
expect(hidden).toBe(3);
});
it('multiple in_progress: all included up to MAX cap', () => {
const todos: TodoItem[] = [
T('ip1', 'in_progress'),
T('ip2', 'in_progress'),
T('ip3', 'in_progress'),
T('p1', 'pending'),
T('p2', 'pending'),
T('p3', 'pending'),
T('p4', 'pending'),
];
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.map((r) => r.title)).toEqual(['ip1', 'ip2', 'ip3', 'p1', 'p2']);
expect(hidden).toBe(2);
});
it('no in_progress, interleaved done/pending order: still picks MAX items', () => {
const todos: TodoItem[] = [
T('p0', 'pending'),
T('d0', 'done'),
T('p1', 'pending'),
T('d1', 'done'),
T('p2', 'pending'),
T('d2', 'done'),
T('p3', 'pending'),
];
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.length).toBe(5);
expect(hidden).toBe(2);
expect(rows.filter((r) => r.status === 'pending').length).toBe(4);
expect(rows.filter((r) => r.status === 'done').length).toBe(1);
});
it('done appearing after in_progress is still treated as recent context', () => {
const todos: TodoItem[] = [
T('ip', 'in_progress'),
T('p1', 'pending'),
T('d1', 'done'),
T('p2', 'pending'),
T('p3', 'pending'),
T('p4', 'pending'),
T('p5', 'pending'),
];
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.length).toBe(5);
expect(hidden).toBe(2);
expect(rows.some((r) => r.status === 'in_progress')).toBe(true);
expect(rows.some((r) => r.status === 'done')).toBe(true);
});
it('more than 5 in_progress: caps at 5 keeping the earliest', () => {
const todos: TodoItem[] = Array.from({ length: 7 }, (_, i) =>
T(`ip${i}`, 'in_progress'),
);
const { rows, hidden } = selectVisibleTodos(todos);
expect(rows.map((r) => r.title)).toEqual(['ip0', 'ip1', 'ip2', 'ip3', 'ip4']);
expect(hidden).toBe(2);
});
});