mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
52bdc6fced
commit
76cbf86e20
3 changed files with 269 additions and 2 deletions
5
.changeset/cap-todo-panel-rows.md
Normal file
5
.changeset/cap-todo-panel-rows.md
Normal 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.
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue