fix(cli): keep sticky todo panel compact (#3647)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* fix(cli): keep sticky todo panel compact

* fix(cli): stabilize sticky todo redraws

* fix(cli): address sticky todo review feedback

* fix(cli): size sticky todo number column correctly

* fix(cli): address sticky todo review feedback
This commit is contained in:
Yan Shen 2026-04-30 00:03:15 +08:00 committed by GitHub
parent cae09279fa
commit 9861114ff3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 568 additions and 45 deletions

View file

@ -1708,6 +1708,7 @@ export default {
"S'ha trobat {{count}} fitxer d'ordres TOML:",
'Found {{count}} TOML command files:':
"S'han trobat {{count}} fitxers d'ordres TOML:",
'Current tasks': 'Tasques actuals',
'... and {{count}} more': '... i {{count}} més',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'El format TOML és obsolet. Voleu migrar-los al format Markdown?',

View file

@ -1617,6 +1617,7 @@ export default {
'Found {{count}} TOML command file:': '{{count}} TOML-Befehlsdatei gefunden:',
'Found {{count}} TOML command files:':
'{{count}} TOML-Befehlsdateien gefunden:',
'Current tasks': 'Aktuelle Aufgaben',
'... and {{count}} more': '... und {{count}} weitere',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'Das TOML-Format ist veraltet. Möchten Sie sie ins Markdown-Format migrieren?',

View file

@ -1691,6 +1691,7 @@ export default {
'Command Format Migration': 'Command Format Migration',
'Found {{count}} TOML command file:': 'Found {{count}} TOML command file:',
'Found {{count}} TOML command files:': 'Found {{count}} TOML command files:',
'Current tasks': 'Current tasks',
'... and {{count}} more': '... and {{count}} more',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'The TOML format is deprecated. Would you like to migrate them to Markdown format?',

View file

@ -1673,6 +1673,7 @@ export default {
'Trouvé {{count}} fichier de commande TOML :',
'Found {{count}} TOML command files:':
'Trouvé {{count}} fichiers de commande TOML :',
'Current tasks': 'Tâches actuelles',
'... and {{count}} more': '... et {{count}} de plus',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'Le format TOML est obsolète. Souhaitez-vous les migrer vers le format Markdown ?',

View file

@ -991,6 +991,8 @@ export default {
'進捗: {{done}}/{{total}} タスク完了',
', {{inProgress}} in progress': '、{{inProgress}} 進行中',
'Pending Tasks:': '保留中のタスク:',
'Current tasks': '現在のタスク',
'... and {{count}} more': '... 他 {{count}} 件',
'What would you like to do?': '何をしますか?',
'Choose how to proceed with your session:':
'セッションの続行方法を選択してください:',

View file

@ -1645,6 +1645,7 @@ export default {
'Encontrado {{count}} arquivo de comando TOML:',
'Found {{count}} TOML command files:':
'Encontrados {{count}} arquivos de comando TOML:',
'Current tasks': 'Tarefas atuais',
'... and {{count}} more': '... e mais {{count}}',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'O formato TOML está obsoleto. Você gostaria de migrá-los para o formato Markdown?',

View file

@ -1540,6 +1540,7 @@ export default {
'Found {{count}} TOML command file:': 'Найден {{count}} файл команд TOML:',
'Found {{count}} TOML command files:':
'Найдено {{count}} файлов команд TOML:',
'Current tasks': 'Текущие задачи',
'... and {{count}} more': '... и ещё {{count}}',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'Формат TOML устарел. Хотите перенести их в формат Markdown?',

View file

@ -1430,6 +1430,7 @@ export default {
'Command Format Migration': '命令格式遷移',
'Found {{count}} TOML command file:': '發現 {{count}} 個 TOML 命令文件:',
'Found {{count}} TOML command files:': '發現 {{count}} 個 TOML 命令文件:',
'Current tasks': '目前任務',
'... and {{count}} more': '... 以及其他 {{count}} 個',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'TOML 格式已棄用。是否將它們遷移到 Markdown 格式?',

View file

@ -1609,6 +1609,7 @@ export default {
'Command Format Migration': '命令格式迁移',
'Found {{count}} TOML command file:': '发现 {{count}} 个 TOML 命令文件:',
'Found {{count}} TOML command files:': '发现 {{count}} 个 TOML 命令文件:',
'Current tasks': '当前任务',
'... and {{count}} more': '... 以及其他 {{count}} 个',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'TOML 格式已弃用。是否将它们迁移到 Markdown 格式?',

View file

@ -29,7 +29,7 @@ import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { ToolCallStatus } from './types.js';
import { type HistoryItem, ToolCallStatus } from './types.js';
import { useContext } from 'react';
import { Box, measureElement } from 'ink';
@ -1389,6 +1389,43 @@ describe('AppContainer State Management', () => {
describe('Terminal Height Calculation', () => {
const mockedMeasureElement = measureElement as Mock;
const mockedUseTerminalSize = useTerminalSize as Mock;
const makeTodoHistory = (
status: 'pending' | 'in_progress' | 'completed',
): HistoryItem[] => [
{
type: 'tool_group',
id: 1,
tools: [
{
callId: 'todo-1',
name: 'TodoWrite',
description: 'Update todos',
resultDisplay: {
type: 'todo_list',
todos: [
{
id: 'todo-1',
content: 'Run focused tests',
status,
},
],
},
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
},
{
type: 'gemini',
id: 2,
text: 'First response after todo',
},
{
type: 'gemini',
id: 3,
text: 'Second response after todo',
},
];
it('should prevent terminal height from being less than 1', () => {
const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty');
@ -1424,6 +1461,44 @@ describe('AppContainer State Management', () => {
// Check the height argument specifically
expect(lastCall[2]).toBe(1);
});
it('does not remeasure footer height for sticky todo status-only updates', () => {
const historyManager = {
history: makeTodoHistory('pending'),
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
truncateToItem: vi.fn(),
};
mockedUseHistory.mockReturnValue(historyManager);
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 24 });
mockedMeasureElement.mockReturnValue({ width: 80, height: 4 });
const view = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
const callsAfterInitialRender = mockedMeasureElement.mock.calls.length;
historyManager.history = makeTodoHistory('in_progress');
view.rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
expect(mockedMeasureElement).toHaveBeenCalledTimes(
callsAfterInitialRender,
);
});
});
describe('Keyboard Input Handling', () => {

View file

@ -58,7 +58,13 @@ import {
type WaitingToolCall,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { getStickyTodos } from './utils/todoSnapshot.js';
import {
getStickyTodos,
getStickyTodoMaxVisibleItems,
getStickyTodosLayoutKey,
getStickyTodosRenderKey,
} from './utils/todoSnapshot.js';
import type { TodoItem } from './components/TodoDisplay.js';
import { validateAuthMethod } from '../config/auth.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import process from 'node:process';
@ -166,6 +172,20 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
});
}
function useStableStickyTodos(todos: TodoItem[] | null): TodoItem[] | null {
const renderKey = getStickyTodosRenderKey(todos);
const stableTodosRef = useRef<{
renderKey: string;
todos: TodoItem[] | null;
} | null>(null);
if (stableTodosRef.current?.renderKey !== renderKey) {
stableTodosRef.current = { renderKey, todos };
}
return stableTodosRef.current.todos;
}
// Exported for tests. Given a newest-first list of messages, return a list
// with duplicates removed, keeping the first (newest) occurrence of each.
export function dedupeNewestFirst(messages: readonly string[]): string[] {
@ -1261,10 +1281,11 @@ export const AppContainer = (props: AppContainerProps) => {
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const stickyTodos = useMemo(
const rawStickyTodos = useMemo(
() => getStickyTodos(historyManager.history, pendingHistoryItems),
[historyManager.history, pendingHistoryItems],
);
const stickyTodos = useStableStickyTodos(rawStickyTodos);
// Terminal tab progress bar (OSC 9;4) for iTerm2/Ghostty
useTerminalProgress(streamingState, isToolExecuting(pendingHistoryItems));
@ -1606,24 +1627,39 @@ export const AppContainer = (props: AppContainerProps) => {
!dialogsVisible &&
!isFeedbackDialogOpen &&
streamingState !== StreamingState.WaitingForConfirmation;
const stickyTodoWidth = Math.min(mainAreaWidth, 64);
const stickyTodoMaxVisibleItems =
getStickyTodoMaxVisibleItems(terminalHeight);
const stickyTodosLayoutKey = shouldShowStickyTodos
? getStickyTodosLayoutKey(
stickyTodos,
stickyTodoWidth,
stickyTodoMaxVisibleItems,
)
: 'hidden';
const [controlsHeight, setControlsHeight] = useState(0);
useLayoutEffect(() => {
if (!mainControlsRef.current) {
setControlsHeight(0);
setControlsHeight((previousHeight) =>
previousHeight === 0 ? previousHeight : 0,
);
return;
}
const fullFooterMeasurement = measureElement(mainControlsRef.current);
setControlsHeight(fullFooterMeasurement.height);
setControlsHeight((previousHeight) =>
previousHeight === fullFooterMeasurement.height
? previousHeight
: fullFooterMeasurement.height,
);
}, [
buffer,
terminalWidth,
terminalHeight,
btwItem,
dialogsVisible,
shouldShowStickyTodos,
stickyTodos,
stickyTodosLayoutKey,
]);
// agentViewState is declared earlier (before handleFinalSubmit) so it

View file

@ -6,9 +6,21 @@
import { render } from 'ink-testing-library';
import { describe, expect, it } from 'vitest';
import {
getStickyTodoMaxVisibleItems,
STICKY_TODO_MAX_VISIBLE_ITEMS,
} from '../utils/todoSnapshot.js';
import { StickyTodoList } from './StickyTodoList.js';
import type { TodoItem } from './TodoDisplay.js';
function makeTodos(count: number): TodoItem[] {
return Array.from({ length: count }, (_, index) => ({
id: `todo-${index + 1}`,
content: `Task ${index + 1}`,
status: 'pending' as const,
}));
}
describe('StickyTodoList', () => {
it('keeps each task number attached to the original task after sorting', () => {
const todos: TodoItem[] = [
@ -54,4 +66,87 @@ describe('StickyTodoList', () => {
output.indexOf('Summarize results'),
);
});
it('keeps long todo lists compact with a hidden item summary', () => {
const todos: TodoItem[] = [
{
id: 'active',
content:
'This active task has a very long description that should not wrap across multiple rows in the sticky panel',
status: 'in_progress',
},
{
id: 'pending-1',
content: 'Run cli tests',
status: 'pending',
},
{
id: 'pending-2',
content: 'Run core tests',
status: 'pending',
},
{
id: 'done',
content: 'Summarize results',
status: 'completed',
},
];
const { lastFrame } = render(
<StickyTodoList todos={todos} width={42} maxVisibleItems={2} />,
);
const output = lastFrame() ?? '';
const lines = output.split('\n').filter(Boolean);
expect(output).toContain('Current tasks');
expect(output).toContain('This active task has a very long');
expect(output).not.toContain('multiple rows in the sticky panel');
expect(output).toContain('Run cli tests');
expect(output).not.toContain('Run core tests');
expect(output).not.toContain('Summarize results');
expect(output).toContain('... and 2 more');
expect(lines).toHaveLength(6);
});
it('sizes the number column for original todo numbers after sorting', () => {
const todos = makeTodos(10).map((todo, index) => ({
...todo,
status: index === 9 ? ('in_progress' as const) : ('completed' as const),
}));
const { lastFrame } = render(
<StickyTodoList todos={todos} width={24} maxVisibleItems={1} />,
);
const output = lastFrame() ?? '';
expect(output).toContain('10. ◐ Task 10');
expect(output).toContain('... and 9 more');
});
it('derives a viewport-aware visible item count', () => {
expect(getStickyTodoMaxVisibleItems(8)).toBe(1);
expect(getStickyTodoMaxVisibleItems(15)).toBe(3);
expect(getStickyTodoMaxVisibleItems(80)).toBe(5);
});
it('falls back to the maximum visible item count for non-finite maxVisibleItems', () => {
const todos = makeTodos(STICKY_TODO_MAX_VISIBLE_ITEMS + 1);
for (const maxVisibleItems of [Number.NaN, Number.POSITIVE_INFINITY]) {
const { lastFrame, unmount } = render(
<StickyTodoList
todos={todos}
width={42}
maxVisibleItems={maxVisibleItems}
/>,
);
const output = lastFrame() ?? '';
expect(output).toContain(`Task ${STICKY_TODO_MAX_VISIBLE_ITEMS}`);
expect(output).not.toContain(`Task ${STICKY_TODO_MAX_VISIBLE_ITEMS + 1}`);
expect(output).toContain('... and 1 more');
unmount();
}
});
});

View file

@ -5,17 +5,22 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import { Box, Text } from 'ink';
import { t } from '../../i18n/index.js';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { getOrderedStickyTodos } from '../utils/todoSnapshot.js';
import {
getOrderedStickyTodos,
getStickyTodosRenderKey,
STICKY_TODO_MAX_VISIBLE_ITEMS,
} from '../utils/todoSnapshot.js';
import type { TodoItem } from './TodoDisplay.js';
interface StickyTodoListProps {
todos: TodoItem[];
width: number;
maxVisibleItems?: number;
}
const STATUS_ICONS = {
@ -24,9 +29,21 @@ const STATUS_ICONS = {
completed: '●',
} as const;
export const StickyTodoList: React.FC<StickyTodoListProps> = ({
function clampVisibleTodoCount(value: number): number {
if (!Number.isFinite(value)) {
return STICKY_TODO_MAX_VISIBLE_ITEMS;
}
return Math.max(
1,
Math.min(STICKY_TODO_MAX_VISIBLE_ITEMS, Math.floor(value)),
);
}
const StickyTodoListComponent: React.FC<StickyTodoListProps> = ({
todos,
width,
maxVisibleItems = STICKY_TODO_MAX_VISIBLE_ITEMS,
}) => {
const orderedTodos = useMemo(() => getOrderedStickyTodos(todos), [todos]);
const todoNumberById = useMemo(
@ -39,7 +56,18 @@ export const StickyTodoList: React.FC<StickyTodoListProps> = ({
return null;
}
const numberColumnWidth = String(orderedTodos.length).length + 2;
const visibleTodoCount = clampVisibleTodoCount(maxVisibleItems);
const visibleTodos = orderedTodos.slice(0, visibleTodoCount);
const hiddenTodoCount = orderedTodos.length - visibleTodos.length;
const numberColumnWidth =
Math.max(
...visibleTodos.map(
(todo, index) =>
(todoNumberById.get(todo.id) ?? `${index + 1}.`).length,
),
) + 1;
// 6 = 2 (status icon column) + 2 (border columns) + 2 (paddingX columns).
const contentColumnWidth = Math.max(1, width - numberColumnWidth - 6);
return (
<Box
@ -53,7 +81,7 @@ export const StickyTodoList: React.FC<StickyTodoListProps> = ({
<Text color={theme.text.secondary} bold>
{t('Current tasks')}
</Text>
{orderedTodos.map((todo, index) => {
{visibleTodos.map((todo, index) => {
const todoNumber = todoNumberById.get(todo.id) ?? `${index + 1}.`;
const itemColor =
todo.status === 'in_progress'
@ -61,18 +89,18 @@ export const StickyTodoList: React.FC<StickyTodoListProps> = ({
: Colors.Foreground;
return (
<Box key={todo.id} flexDirection="row" minHeight={1}>
<Box key={todo.id} flexDirection="row" height={1}>
<Box width={numberColumnWidth}>
<Text color={theme.text.secondary}>{todoNumber}</Text>
</Box>
<Box width={2}>
<Text color={itemColor}>{STATUS_ICONS[todo.status]}</Text>
</Box>
<Box flexGrow={1}>
<Box width={contentColumnWidth}>
<Text
color={itemColor}
strikethrough={todo.status === 'completed'}
wrap="wrap"
wrap="truncate-end"
>
{todo.content}
</Text>
@ -80,6 +108,28 @@ export const StickyTodoList: React.FC<StickyTodoListProps> = ({
</Box>
);
})}
{hiddenTodoCount > 0 && (
<Box flexDirection="row" height={1}>
<Box width={numberColumnWidth} />
<Box width={2} />
<Box width={contentColumnWidth}>
<Text color={theme.text.secondary} wrap="truncate-end">
{t('... and {{count}} more', {
count: String(hiddenTodoCount),
})}
</Text>
</Box>
</Box>
)}
</Box>
);
};
export const StickyTodoList = memo(
StickyTodoListComponent,
(previousProps, nextProps) =>
previousProps.width === nextProps.width &&
previousProps.maxVisibleItems === nextProps.maxVisibleItems &&
getStickyTodosRenderKey(previousProps.todos) ===
getStickyTodosRenderKey(nextProps.todos),
);

View file

@ -21,6 +21,7 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAgentViewState } from '../contexts/AgentViewContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { StreamingState } from '../types.js';
import { getStickyTodoMaxVisibleItems } from '../utils/todoSnapshot.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
@ -30,6 +31,9 @@ export const DefaultAppLayout: React.FC = () => {
const hasAgents = agents.size > 0;
const isAgentTab = activeView !== 'main' && agents.has(activeView);
const stickyTodoWidth = Math.min(uiState.mainAreaWidth, 64);
const stickyTodoMaxVisibleItems = getStickyTodoMaxVisibleItems(
uiState.terminalHeight,
);
const shouldShowStickyTodos =
uiState.stickyTodos !== null &&
!uiState.dialogsVisible &&
@ -81,6 +85,7 @@ export const DefaultAppLayout: React.FC = () => {
<StickyTodoList
todos={uiState.stickyTodos!}
width={stickyTodoWidth}
maxVisibleItems={stickyTodoMaxVisibleItems}
/>
)}
{uiState.btwItem && (

View file

@ -16,10 +16,14 @@ import { StickyTodoList } from '../components/StickyTodoList.js';
import { BtwMessage } from '../components/messages/BtwMessage.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { StreamingState } from '../types.js';
import { getStickyTodoMaxVisibleItems } from '../utils/todoSnapshot.js';
export const ScreenReaderAppLayout: React.FC = () => {
const uiState = useUIState();
const stickyTodoWidth = Math.min(uiState.mainAreaWidth, 64);
const stickyTodoMaxVisibleItems = getStickyTodoMaxVisibleItems(
uiState.terminalHeight,
);
const shouldShowStickyTodos =
uiState.stickyTodos !== null &&
!uiState.dialogsVisible &&
@ -47,6 +51,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
<StickyTodoList
todos={uiState.stickyTodos!}
width={stickyTodoWidth}
maxVisibleItems={stickyTodoMaxVisibleItems}
/>
)}
{uiState.btwItem && (

View file

@ -7,7 +7,13 @@
import { describe, expect, it } from 'vitest';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { ToolCallStatus } from '../types.js';
import { getStickyTodos } from './todoSnapshot.js';
import {
STICKY_TODO_MAX_VISIBLE_ITEMS,
getStickyTodoMaxVisibleItems,
getStickyTodos,
getStickyTodosLayoutKey,
getStickyTodosRenderKey,
} from './todoSnapshot.js';
function makeTodoToolGroup(
content: string,
@ -102,11 +108,21 @@ function makeEmptyTodoToolGroup(
return item;
}
function makeGeminiHistoryItem(text: string, id: number): HistoryItem {
return {
type: 'gemini',
id,
text,
};
}
describe('getStickyTodos', () => {
it('returns the latest todo snapshot from history', () => {
const history = [
makeTodoToolGroup('first task', 1),
makeTodoToolGroup('latest history task', 2),
makeGeminiHistoryItem('First response after todo', 3),
makeGeminiHistoryItem('Second response after todo', 4),
] as HistoryItem[];
expect(getStickyTodos(history, [])).toEqual([
@ -118,19 +134,13 @@ describe('getStickyTodos', () => {
]);
});
it('prefers pending todo snapshots over history', () => {
it('does not show sticky todos while a pending todo snapshot is visible', () => {
const history = [makeTodoToolGroup('history task', 1)] as HistoryItem[];
const pendingHistoryItems = [
makeTodoToolGroup('pending task'),
] as HistoryItemWithoutId[];
expect(getStickyTodos(history, pendingHistoryItems)).toEqual([
{
id: 'todo-pending task',
content: 'pending task',
status: 'pending',
},
]);
expect(getStickyTodos(history, pendingHistoryItems)).toBeNull();
});
it('returns null when the latest todo snapshot clears the list', () => {
@ -142,6 +152,40 @@ describe('getStickyTodos', () => {
expect(getStickyTodos(history, pendingHistoryItems)).toBeNull();
});
it('keeps sticky todos hidden when the latest history todo is still the newest item', () => {
const history = [
makeGeminiHistoryItem('Earlier response', 1),
makeTodoToolGroup('latest history task', 2),
] as HistoryItem[];
expect(getStickyTodos(history, [])).toBeNull();
});
it('keeps sticky todos hidden when the latest history todo has only one following item', () => {
const history = [
makeTodoToolGroup('latest history task', 1),
makeGeminiHistoryItem('One response after todo', 2),
] as HistoryItem[];
expect(getStickyTodos(history, [])).toBeNull();
});
it('shows sticky todos once later history has likely moved the inline todo away', () => {
const history = [
makeTodoToolGroup('latest history task', 1),
makeGeminiHistoryItem('First response after todo', 2),
makeGeminiHistoryItem('Second response after todo', 3),
] as HistoryItem[];
expect(getStickyTodos(history, [])).toEqual([
{
id: 'todo-latest history task',
content: 'latest history task',
status: 'pending',
},
]);
});
it('returns null when the latest history todo snapshot is fully completed', () => {
const history = [
makeCustomTodoToolGroup(
@ -159,12 +203,14 @@ describe('getStickyTodos', () => {
],
1,
),
makeGeminiHistoryItem('First response after todo', 2),
makeGeminiHistoryItem('Second response after todo', 3),
] as HistoryItem[];
expect(getStickyTodos(history, [])).toBeNull();
});
it('keeps showing a fully completed pending snapshot until the turn finishes', () => {
it('keeps sticky todos hidden for a completed pending snapshot', () => {
const history = [
makeTodoToolGroup('older history task', 1),
] as HistoryItem[];
@ -183,17 +229,124 @@ describe('getStickyTodos', () => {
]),
] as HistoryItemWithoutId[];
expect(getStickyTodos(history, pendingHistoryItems)).toEqual([
{
id: 'todo-1',
content: 'Run tests',
status: 'completed',
},
{
id: 'todo-2',
content: 'Summarize results',
status: 'completed',
},
]);
expect(getStickyTodos(history, pendingHistoryItems)).toBeNull();
});
});
describe('sticky todo layout helpers', () => {
it('keeps the layout key stable for status-only updates', () => {
const pendingTodos = [
{
id: 'todo-1',
content: 'Run focused tests',
status: 'pending' as const,
},
];
const inProgressTodos = [
{
id: 'todo-1',
content: 'Run focused tests',
status: 'in_progress' as const,
},
];
expect(getStickyTodosLayoutKey(pendingTodos, 64, 5)).toBe(
getStickyTodosLayoutKey(inProgressTodos, 64, 5),
);
expect(getStickyTodosRenderKey(pendingTodos)).not.toBe(
getStickyTodosRenderKey(inProgressTodos),
);
});
it('changes the layout key when wrapping-sensitive inputs change', () => {
const todos = [
{
id: 'todo-1',
content: 'Run focused tests',
status: 'pending' as const,
},
];
expect(getStickyTodosLayoutKey(todos, 64, 5)).not.toBe(
getStickyTodosLayoutKey(todos, 40, 5),
);
expect(getStickyTodosLayoutKey(todos, 64, 5)).not.toBe(
getStickyTodosLayoutKey(
[{ ...todos[0], content: 'Run focused tests and build' }],
64,
5,
),
);
expect(getStickyTodosLayoutKey(todos, 64, 5)).not.toBe(
getStickyTodosLayoutKey(todos, 64, 2),
);
});
it('keeps the layout key stable when only hidden todos change', () => {
const todos = Array.from({ length: 6 }, (_, index) => ({
id: `todo-${index + 1}`,
content: `Task ${index + 1}`,
status: 'pending' as const,
}));
const changedHiddenTodo = todos.map((todo, index) =>
index === 5 ? { ...todo, content: 'Changed hidden task' } : todo,
);
expect(getStickyTodosLayoutKey(todos, 64, 5)).toBe(
getStickyTodosLayoutKey(changedHiddenTodo, 64, 5),
);
expect(getStickyTodosLayoutKey(todos, 64, 5)).toBe(
getStickyTodosLayoutKey(
[
...todos,
{
id: 'todo-7',
content: 'Additional hidden task',
status: 'pending' as const,
},
],
64,
5,
),
);
});
it('changes the layout key when the hidden item summary first appears', () => {
const visibleTodos = Array.from({ length: 5 }, (_, index) => ({
id: `todo-${index + 1}`,
content: `Task ${index + 1}`,
status: 'pending' as const,
}));
expect(getStickyTodosLayoutKey(visibleTodos, 64, 5)).not.toBe(
getStickyTodosLayoutKey(
[
...visibleTodos,
{
id: 'todo-6',
content: 'First hidden task',
status: 'pending' as const,
},
],
64,
5,
),
);
});
it('derives a bounded sticky todo item count from terminal height', () => {
expect(getStickyTodoMaxVisibleItems(8)).toBe(1);
expect(getStickyTodoMaxVisibleItems(15)).toBe(3);
expect(getStickyTodoMaxVisibleItems(80)).toBe(5);
});
it('falls back to the maximum sticky todo item count for invalid terminal heights', () => {
expect(getStickyTodoMaxVisibleItems(Number.NaN)).toBe(
STICKY_TODO_MAX_VISIBLE_ITEMS,
);
expect(getStickyTodoMaxVisibleItems(-1)).toBe(
STICKY_TODO_MAX_VISIBLE_ITEMS,
);
expect(getStickyTodoMaxVisibleItems(0)).toBe(STICKY_TODO_MAX_VISIBLE_ITEMS);
});
});

View file

@ -12,13 +12,40 @@ import type {
} from '../types.js';
type HistoryLikeItem = HistoryItem | HistoryItemWithoutId;
type SnapshotSearchResult = TodoItem[] | null | undefined;
interface TodoSnapshotSearchResult {
itemIndex: number;
todos: TodoItem[] | null;
}
type SnapshotSearchResult = TodoSnapshotSearchResult | undefined;
// This threshold is item-count based, not line-count based. A single long
// response can fill the viewport while still counting as one item, so the
// sticky panel may stay hidden longer than strictly necessary. That is
// preferable to duplicating a recently committed inline TodoWrite result.
// On tall terminals, TodoWrite -> short text -> small tool call can still
// leave the inline result visible when the sticky panel appears.
const MIN_HISTORY_ITEMS_AFTER_TODO_BEFORE_STICKY = 2;
export const STICKY_TODO_MAX_VISIBLE_ITEMS = 5;
const STICKY_TODO_ROWS_PER_VISIBLE_ITEM = 5;
const STICKY_TODO_STATUS_PRIORITY: Record<TodoItem['status'], number> = {
in_progress: 0,
pending: 1,
completed: 2,
};
function clampStickyTodoVisibleItems(value: number): number {
if (!Number.isFinite(value)) {
return STICKY_TODO_MAX_VISIBLE_ITEMS;
}
return Math.max(
1,
Math.min(STICKY_TODO_MAX_VISIBLE_ITEMS, Math.floor(value)),
);
}
function extractTodosFromResultDisplay(
resultDisplay: unknown,
): TodoItem[] | null {
@ -67,7 +94,10 @@ function findLatestTodoSnapshot(
const tool = item.tools[toolIndex] as IndividualToolCallDisplay;
const todos = extractTodosFromResultDisplay(tool.resultDisplay);
if (todos) {
return todos.length > 0 ? todos : null;
return {
itemIndex,
todos: todos.length > 0 ? todos : null,
};
}
}
}
@ -79,21 +109,42 @@ function areAllTodosCompleted(todos: readonly TodoItem[]): boolean {
return todos.length > 0 && todos.every((todo) => todo.status === 'completed');
}
function isRecentHistoryTodoSnapshot(
snapshotItemIndex: number,
historyLength: number,
): boolean {
const historyItemsAfterSnapshot = historyLength - snapshotItemIndex - 1;
return historyItemsAfterSnapshot < MIN_HISTORY_ITEMS_AFTER_TODO_BEFORE_STICKY;
}
export function getStickyTodos(
history: readonly HistoryItem[],
pendingHistoryItems: readonly HistoryItemWithoutId[],
): TodoItem[] | null {
const pendingSnapshot = findLatestTodoSnapshot(pendingHistoryItems);
if (pendingSnapshot !== undefined) {
return pendingSnapshot;
}
const historySnapshot = findLatestTodoSnapshot(history);
if (historySnapshot && areAllTodosCompleted(historySnapshot)) {
// The pending TodoWrite result is already rendered inline above the
// composer, so defer the sticky panel until the turn commits to history.
return null;
}
return historySnapshot ?? null;
const historySnapshot = findLatestTodoSnapshot(history);
if (historySnapshot === undefined || historySnapshot.todos === null) {
return null;
}
// Ink Static writes committed history to scrollback, and does not expose a
// reliable per-item viewport API. Treat very recent TodoWrite snapshots as
// still visible so the footer does not duplicate the inline result.
if (isRecentHistoryTodoSnapshot(historySnapshot.itemIndex, history.length)) {
return null;
}
if (areAllTodosCompleted(historySnapshot.todos)) {
return null;
}
return historySnapshot.todos;
}
export function getOrderedStickyTodos(todos: readonly TodoItem[]): TodoItem[] {
@ -107,3 +158,46 @@ export function getOrderedStickyTodos(todos: readonly TodoItem[]): TodoItem[] {
)
.map(({ todo }) => todo);
}
export function getStickyTodosRenderKey(
todos: readonly TodoItem[] | null,
): string {
if (!todos) {
return 'null';
}
return JSON.stringify(
todos.map((todo) => [todo.id, todo.content, todo.status]),
);
}
export function getStickyTodosLayoutKey(
todos: readonly TodoItem[] | null,
width: number,
maxVisibleItems: number,
): string {
if (!todos) {
return 'null';
}
const visibleTodoCount = clampStickyTodoVisibleItems(maxVisibleItems);
const visibleTodos = todos.slice(0, visibleTodoCount);
const hasHiddenTodos = todos.length > visibleTodos.length;
return JSON.stringify({
width,
maxVisibleItems: visibleTodoCount,
hasHiddenTodos,
todos: visibleTodos.map((todo) => [todo.id, todo.content]),
});
}
export function getStickyTodoMaxVisibleItems(terminalHeight: number): number {
if (!Number.isFinite(terminalHeight) || terminalHeight <= 0) {
return STICKY_TODO_MAX_VISIBLE_ITEMS;
}
return clampStickyTodoVisibleItems(
terminalHeight / STICKY_TODO_ROWS_PER_VISIBLE_ITEM,
);
}