diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js
index c8599fd04..7830aaaa6 100644
--- a/packages/cli/src/i18n/locales/ca.js
+++ b/packages/cli/src/i18n/locales/ca.js
@@ -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?',
diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js
index 4cc0d50e5..0f4164fcf 100644
--- a/packages/cli/src/i18n/locales/de.js
+++ b/packages/cli/src/i18n/locales/de.js
@@ -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?',
diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js
index 268aa5048..4415e0720 100644
--- a/packages/cli/src/i18n/locales/en.js
+++ b/packages/cli/src/i18n/locales/en.js
@@ -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?',
diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js
index 433b61f4a..2f982c45f 100644
--- a/packages/cli/src/i18n/locales/fr.js
+++ b/packages/cli/src/i18n/locales/fr.js
@@ -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 ?',
diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js
index 94a4e5ffc..549fd3fbf 100644
--- a/packages/cli/src/i18n/locales/ja.js
+++ b/packages/cli/src/i18n/locales/ja.js
@@ -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:':
'セッションの続行方法を選択してください:',
diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js
index 74e9630dc..c30d72b17 100644
--- a/packages/cli/src/i18n/locales/pt.js
+++ b/packages/cli/src/i18n/locales/pt.js
@@ -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?',
diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js
index f2071ffed..8631a832f 100644
--- a/packages/cli/src/i18n/locales/ru.js
+++ b/packages/cli/src/i18n/locales/ru.js
@@ -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?',
diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js
index 5ab279b51..a86bce271 100644
--- a/packages/cli/src/i18n/locales/zh-TW.js
+++ b/packages/cli/src/i18n/locales/zh-TW.js
@@ -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 格式?',
diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js
index 701732e87..ccdbec812 100644
--- a/packages/cli/src/i18n/locales/zh.js
+++ b/packages/cli/src/i18n/locales/zh.js
@@ -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 格式?',
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 1e88ae9c3..fe0c68de4 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -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(
+ ,
+ );
+ const callsAfterInitialRender = mockedMeasureElement.mock.calls.length;
+
+ historyManager.history = makeTodoHistory('in_progress');
+ view.rerender(
+ ,
+ );
+
+ expect(mockedMeasureElement).toHaveBeenCalledTimes(
+ callsAfterInitialRender,
+ );
+ });
});
describe('Keyboard Input Handling', () => {
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index c9d36d61f..59f77b32e 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -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
diff --git a/packages/cli/src/ui/components/StickyTodoList.test.tsx b/packages/cli/src/ui/components/StickyTodoList.test.tsx
index 0c2af5454..5a6d7b940 100644
--- a/packages/cli/src/ui/components/StickyTodoList.test.tsx
+++ b/packages/cli/src/ui/components/StickyTodoList.test.tsx
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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();
+ }
+ });
});
diff --git a/packages/cli/src/ui/components/StickyTodoList.tsx b/packages/cli/src/ui/components/StickyTodoList.tsx
index 7f68591a8..ca3eec4c0 100644
--- a/packages/cli/src/ui/components/StickyTodoList.tsx
+++ b/packages/cli/src/ui/components/StickyTodoList.tsx
@@ -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 = ({
+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 = ({
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 = ({
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 (
= ({
{t('Current tasks')}
- {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 = ({
: Colors.Foreground;
return (
-
+
{todoNumber}
{STATUS_ICONS[todo.status]}
-
+
{todo.content}
@@ -80,6 +108,28 @@ export const StickyTodoList: React.FC = ({
);
})}
+ {hiddenTodoCount > 0 && (
+
+
+
+
+
+ {t('... and {{count}} more', {
+ count: String(hiddenTodoCount),
+ })}
+
+
+
+ )}
);
};
+
+export const StickyTodoList = memo(
+ StickyTodoListComponent,
+ (previousProps, nextProps) =>
+ previousProps.width === nextProps.width &&
+ previousProps.maxVisibleItems === nextProps.maxVisibleItems &&
+ getStickyTodosRenderKey(previousProps.todos) ===
+ getStickyTodosRenderKey(nextProps.todos),
+);
diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx
index f66e5a713..4022b98ba 100644
--- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx
+++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx
@@ -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 = () => {
)}
{uiState.btwItem && (
diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx
index b79eda8ec..2b5191676 100644
--- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx
+++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx
@@ -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 = () => {
)}
{uiState.btwItem && (
diff --git a/packages/cli/src/ui/utils/todoSnapshot.test.ts b/packages/cli/src/ui/utils/todoSnapshot.test.ts
index 815d974b7..d2f86ca1c 100644
--- a/packages/cli/src/ui/utils/todoSnapshot.test.ts
+++ b/packages/cli/src/ui/utils/todoSnapshot.test.ts
@@ -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);
});
});
diff --git a/packages/cli/src/ui/utils/todoSnapshot.ts b/packages/cli/src/ui/utils/todoSnapshot.ts
index 3f297e30d..2fbbe7594 100644
--- a/packages/cli/src/ui/utils/todoSnapshot.ts
+++ b/packages/cli/src/ui/utils/todoSnapshot.ts
@@ -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 = {
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,
+ );
+}