Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cap-todo-panel-rows.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 79 additions & 1 deletion apps/kimi-code/src/tui/components/chrome/todo-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down
186 changes: 185 additions & 1 deletion apps/kimi-code/test/tui/components/panels/todo-panel.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
});
});
Loading