diff --git a/.changeset/cap-todo-panel-rows.md b/.changeset/cap-todo-panel-rows.md new file mode 100644 index 000000000..c1450d389 --- /dev/null +++ b/.changeset/cap-todo-panel-rows.md @@ -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. 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 ede986e60..45ed12c83 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -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(); + 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)); } 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 373d2d0c4..2382c0a60 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 @@ -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); + }); });