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, + ); +}