From 34bb9b2063ac0c468c1bb6b51251d4498dac4262 Mon Sep 17 00:00:00 2001 From: sheny Date: Sun, 26 Apr 2026 22:28:58 +0800 Subject: [PATCH 1/5] fix(cli): keep sticky todo panel compact --- .../src/ui/components/StickyTodoList.test.tsx | 52 +++++++++++++- .../cli/src/ui/components/StickyTodoList.tsx | 52 ++++++++++++-- .../src/ui/layouts/DefaultAppLayout.test.tsx | 1 + .../cli/src/ui/layouts/DefaultAppLayout.tsx | 9 ++- .../ui/layouts/ScreenReaderAppLayout.test.tsx | 1 + .../src/ui/layouts/ScreenReaderAppLayout.tsx | 9 ++- .../cli/src/ui/utils/todoSnapshot.test.ts | 71 +++++++++++++------ packages/cli/src/ui/utils/todoSnapshot.ts | 41 +++++++++-- 8 files changed, 202 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/ui/components/StickyTodoList.test.tsx b/packages/cli/src/ui/components/StickyTodoList.test.tsx index 0c2af54547f..f3fcbb21030 100644 --- a/packages/cli/src/ui/components/StickyTodoList.test.tsx +++ b/packages/cli/src/ui/components/StickyTodoList.test.tsx @@ -6,7 +6,10 @@ import { render } from 'ink-testing-library'; import { describe, expect, it } from 'vitest'; -import { StickyTodoList } from './StickyTodoList.js'; +import { + getStickyTodoMaxVisibleItems, + StickyTodoList, +} from './StickyTodoList.js'; import type { TodoItem } from './TodoDisplay.js'; describe('StickyTodoList', () => { @@ -54,4 +57,51 @@ 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('derives a viewport-aware visible item count', () => { + expect(getStickyTodoMaxVisibleItems(8)).toBe(1); + expect(getStickyTodoMaxVisibleItems(15)).toBe(3); + expect(getStickyTodoMaxVisibleItems(80)).toBe(5); + }); }); diff --git a/packages/cli/src/ui/components/StickyTodoList.tsx b/packages/cli/src/ui/components/StickyTodoList.tsx index 7f68591a884..3204a0d3b7d 100644 --- a/packages/cli/src/ui/components/StickyTodoList.tsx +++ b/packages/cli/src/ui/components/StickyTodoList.tsx @@ -16,6 +16,7 @@ import type { TodoItem } from './TodoDisplay.js'; interface StickyTodoListProps { todos: TodoItem[]; width: number; + maxVisibleItems?: number; } const STATUS_ICONS = { @@ -24,9 +25,33 @@ const STATUS_ICONS = { completed: '●', } as const; +const DEFAULT_MAX_VISIBLE_TODOS = 5; +const MIN_VISIBLE_TODOS = 1; +const TERMINAL_ROWS_PER_VISIBLE_TODO = 5; + +function clampVisibleTodoCount(value: number): number { + if (!Number.isFinite(value)) { + return DEFAULT_MAX_VISIBLE_TODOS; + } + + return Math.max( + MIN_VISIBLE_TODOS, + Math.min(DEFAULT_MAX_VISIBLE_TODOS, Math.floor(value)), + ); +} + +export function getStickyTodoMaxVisibleItems(terminalHeight: number): number { + if (!Number.isFinite(terminalHeight) || terminalHeight <= 0) { + return DEFAULT_MAX_VISIBLE_TODOS; + } + + return clampVisibleTodoCount(terminalHeight / TERMINAL_ROWS_PER_VISIBLE_TODO); +} + export const StickyTodoList: React.FC = ({ todos, width, + maxVisibleItems = DEFAULT_MAX_VISIBLE_TODOS, }) => { const orderedTodos = useMemo(() => getOrderedStickyTodos(todos), [todos]); const todoNumberById = useMemo( @@ -39,7 +64,11 @@ 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 = String(todos.length).length + 2; + 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 +90,18 @@ export const StickyTodoList: React.FC = ({ : Colors.Foreground; return ( - + {todoNumber} {STATUS_ICONS[todo.status]} - + {todo.content} @@ -80,6 +109,19 @@ export const StickyTodoList: React.FC = ({ ); })} + {hiddenTodoCount > 0 && ( + + + + + + {t('... and {{count}} more', { + count: String(hiddenTodoCount), + })} + + + + )} ); }; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 29aab2f934c..05d935d375a 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -37,6 +37,7 @@ vi.mock('../components/messages/BtwMessage.js', () => ({ })); vi.mock('../components/StickyTodoList.js', () => ({ + getStickyTodoMaxVisibleItems: () => 5, StickyTodoList: () => StickyTodoList, })); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index f66e5a71366..0e4cb144c0c 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -11,7 +11,10 @@ import { MainContent } from '../components/MainContent.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; -import { StickyTodoList } from '../components/StickyTodoList.js'; +import { + getStickyTodoMaxVisibleItems, + StickyTodoList, +} from '../components/StickyTodoList.js'; import { BtwMessage } from '../components/messages/BtwMessage.js'; import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; import { AgentChatView } from '../components/agent-view/AgentChatView.js'; @@ -30,6 +33,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 +87,7 @@ export const DefaultAppLayout: React.FC = () => { )} {uiState.btwItem && ( diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx index 297308098b3..4a49473474b 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx @@ -40,6 +40,7 @@ vi.mock('../components/messages/BtwMessage.js', () => ({ })); vi.mock('../components/StickyTodoList.js', () => ({ + getStickyTodoMaxVisibleItems: () => 5, StickyTodoList: () => StickyTodoList, })); diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index b79eda8ec3e..e9ea6c5fbf2 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -12,7 +12,10 @@ import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { Footer } from '../components/Footer.js'; import { ExitWarning } from '../components/ExitWarning.js'; -import { StickyTodoList } from '../components/StickyTodoList.js'; +import { + getStickyTodoMaxVisibleItems, + StickyTodoList, +} from '../components/StickyTodoList.js'; import { BtwMessage } from '../components/messages/BtwMessage.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { StreamingState } from '../types.js'; @@ -20,6 +23,9 @@ import { StreamingState } from '../types.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 +53,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 815d974b781..f48ded5aee7 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.test.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.test.ts @@ -102,11 +102,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 +128,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 +146,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 +197,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 +223,6 @@ 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(); }); }); diff --git a/packages/cli/src/ui/utils/todoSnapshot.ts b/packages/cli/src/ui/utils/todoSnapshot.ts index 3f297e30d7b..07ae4ec67ab 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.ts @@ -12,7 +12,14 @@ 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; + +const MIN_HISTORY_ITEMS_AFTER_TODO_BEFORE_STICKY = 2; const STICKY_TODO_STATUS_PRIORITY: Record = { in_progress: 0, pending: 1, @@ -67,7 +74,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 +89,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; + // The pending TodoWrite result is already rendered inline above the + // composer, so defer the sticky panel until the turn commits to history. + return null; } const historySnapshot = findLatestTodoSnapshot(history); - if (historySnapshot && areAllTodosCompleted(historySnapshot)) { + 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 ?? null; + return historySnapshot.todos; } export function getOrderedStickyTodos(todos: readonly TodoItem[]): TodoItem[] { From a184fd8c15ccdefd65a98ca98c11a95da75cb266 Mon Sep 17 00:00:00 2001 From: sheny Date: Mon, 27 Apr 2026 00:18:20 +0800 Subject: [PATCH 2/5] fix(cli): stabilize sticky todo redraws --- packages/cli/src/ui/AppContainer.test.tsx | 77 ++++++++++++++++++- packages/cli/src/ui/AppContainer.tsx | 48 ++++++++++-- .../src/ui/components/StickyTodoList.test.tsx | 6 +- .../cli/src/ui/components/StickyTodoList.tsx | 39 +++++----- .../src/ui/layouts/DefaultAppLayout.test.tsx | 1 - .../cli/src/ui/layouts/DefaultAppLayout.tsx | 6 +- .../ui/layouts/ScreenReaderAppLayout.test.tsx | 1 - .../src/ui/layouts/ScreenReaderAppLayout.tsx | 6 +- .../cli/src/ui/utils/todoSnapshot.test.ts | 63 ++++++++++++++- packages/cli/src/ui/utils/todoSnapshot.ts | 45 +++++++++++ 10 files changed, 251 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0ecd6dd6c1d..08d30bd072b 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'; @@ -1351,6 +1351,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'); @@ -1386,6 +1423,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 c16e5305d2d..396d11114df 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'; @@ -160,6 +166,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[] { @@ -1230,10 +1250,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)); @@ -1573,24 +1594,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 f3fcbb21030..0986e7852a9 100644 --- a/packages/cli/src/ui/components/StickyTodoList.test.tsx +++ b/packages/cli/src/ui/components/StickyTodoList.test.tsx @@ -6,10 +6,8 @@ import { render } from 'ink-testing-library'; import { describe, expect, it } from 'vitest'; -import { - getStickyTodoMaxVisibleItems, - StickyTodoList, -} from './StickyTodoList.js'; +import { getStickyTodoMaxVisibleItems } from '../utils/todoSnapshot.js'; +import { StickyTodoList } from './StickyTodoList.js'; import type { TodoItem } from './TodoDisplay.js'; describe('StickyTodoList', () => { diff --git a/packages/cli/src/ui/components/StickyTodoList.tsx b/packages/cli/src/ui/components/StickyTodoList.tsx index 3204a0d3b7d..b054b139268 100644 --- a/packages/cli/src/ui/components/StickyTodoList.tsx +++ b/packages/cli/src/ui/components/StickyTodoList.tsx @@ -5,12 +5,16 @@ */ 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 { @@ -25,33 +29,21 @@ const STATUS_ICONS = { completed: '●', } as const; -const DEFAULT_MAX_VISIBLE_TODOS = 5; -const MIN_VISIBLE_TODOS = 1; -const TERMINAL_ROWS_PER_VISIBLE_TODO = 5; - function clampVisibleTodoCount(value: number): number { if (!Number.isFinite(value)) { - return DEFAULT_MAX_VISIBLE_TODOS; + return STICKY_TODO_MAX_VISIBLE_ITEMS; } return Math.max( - MIN_VISIBLE_TODOS, - Math.min(DEFAULT_MAX_VISIBLE_TODOS, Math.floor(value)), + 1, + Math.min(STICKY_TODO_MAX_VISIBLE_ITEMS, Math.floor(value)), ); } -export function getStickyTodoMaxVisibleItems(terminalHeight: number): number { - if (!Number.isFinite(terminalHeight) || terminalHeight <= 0) { - return DEFAULT_MAX_VISIBLE_TODOS; - } - - return clampVisibleTodoCount(terminalHeight / TERMINAL_ROWS_PER_VISIBLE_TODO); -} - -export const StickyTodoList: React.FC = ({ +const StickyTodoListComponent: React.FC = ({ todos, width, - maxVisibleItems = DEFAULT_MAX_VISIBLE_TODOS, + maxVisibleItems = STICKY_TODO_MAX_VISIBLE_ITEMS, }) => { const orderedTodos = useMemo(() => getOrderedStickyTodos(todos), [todos]); const todoNumberById = useMemo( @@ -125,3 +117,12 @@ export const StickyTodoList: React.FC = ({ ); }; + +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.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 05d935d375a..29aab2f934c 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -37,7 +37,6 @@ vi.mock('../components/messages/BtwMessage.js', () => ({ })); vi.mock('../components/StickyTodoList.js', () => ({ - getStickyTodoMaxVisibleItems: () => 5, StickyTodoList: () => StickyTodoList, })); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 0e4cb144c0c..4022b98ba53 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -11,10 +11,7 @@ import { MainContent } from '../components/MainContent.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; -import { - getStickyTodoMaxVisibleItems, - StickyTodoList, -} from '../components/StickyTodoList.js'; +import { StickyTodoList } from '../components/StickyTodoList.js'; import { BtwMessage } from '../components/messages/BtwMessage.js'; import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; import { AgentChatView } from '../components/agent-view/AgentChatView.js'; @@ -24,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(); diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx index 4a49473474b..297308098b3 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx @@ -40,7 +40,6 @@ vi.mock('../components/messages/BtwMessage.js', () => ({ })); vi.mock('../components/StickyTodoList.js', () => ({ - getStickyTodoMaxVisibleItems: () => 5, StickyTodoList: () => StickyTodoList, })); diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index e9ea6c5fbf2..2b5191676f3 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -12,13 +12,11 @@ import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { Footer } from '../components/Footer.js'; import { ExitWarning } from '../components/ExitWarning.js'; -import { - getStickyTodoMaxVisibleItems, - StickyTodoList, -} from '../components/StickyTodoList.js'; +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(); diff --git a/packages/cli/src/ui/utils/todoSnapshot.test.ts b/packages/cli/src/ui/utils/todoSnapshot.test.ts index f48ded5aee7..65179b91ff2 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.test.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.test.ts @@ -7,7 +7,12 @@ import { describe, expect, it } from 'vitest'; import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; import { ToolCallStatus } from '../types.js'; -import { getStickyTodos } from './todoSnapshot.js'; +import { + getStickyTodoMaxVisibleItems, + getStickyTodos, + getStickyTodosLayoutKey, + getStickyTodosRenderKey, +} from './todoSnapshot.js'; function makeTodoToolGroup( content: string, @@ -226,3 +231,59 @@ describe('getStickyTodos', () => { 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('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); + }); +}); diff --git a/packages/cli/src/ui/utils/todoSnapshot.ts b/packages/cli/src/ui/utils/todoSnapshot.ts index 07ae4ec67ab..569103a6850 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.ts @@ -20,6 +20,9 @@ interface TodoSnapshotSearchResult { type SnapshotSearchResult = TodoSnapshotSearchResult | undefined; 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, @@ -138,3 +141,45 @@ 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'; + } + + return JSON.stringify({ + width, + maxVisibleItems, + todos: todos.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 Math.max( + 1, + Math.min( + STICKY_TODO_MAX_VISIBLE_ITEMS, + Math.floor(terminalHeight / STICKY_TODO_ROWS_PER_VISIBLE_ITEM), + ), + ); +} From 16753ebda5dac9fb17f57e1d5ffa0d3303113573 Mon Sep 17 00:00:00 2001 From: sheny Date: Mon, 27 Apr 2026 09:50:12 +0800 Subject: [PATCH 3/5] fix(cli): address sticky todo review feedback --- .../src/ui/components/StickyTodoList.test.tsx | 34 ++++++++++++++++++- .../cli/src/ui/components/StickyTodoList.tsx | 3 +- .../cli/src/ui/utils/todoSnapshot.test.ts | 11 ++++++ packages/cli/src/ui/utils/todoSnapshot.ts | 4 +++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/StickyTodoList.test.tsx b/packages/cli/src/ui/components/StickyTodoList.test.tsx index 0986e7852a9..1be581532ed 100644 --- a/packages/cli/src/ui/components/StickyTodoList.test.tsx +++ b/packages/cli/src/ui/components/StickyTodoList.test.tsx @@ -6,10 +6,21 @@ import { render } from 'ink-testing-library'; import { describe, expect, it } from 'vitest'; -import { getStickyTodoMaxVisibleItems } from '../utils/todoSnapshot.js'; +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[] = [ @@ -102,4 +113,25 @@ describe('StickyTodoList', () => { 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 b054b139268..9d86e5f3882 100644 --- a/packages/cli/src/ui/components/StickyTodoList.tsx +++ b/packages/cli/src/ui/components/StickyTodoList.tsx @@ -59,7 +59,8 @@ const StickyTodoListComponent: React.FC = ({ const visibleTodoCount = clampVisibleTodoCount(maxVisibleItems); const visibleTodos = orderedTodos.slice(0, visibleTodoCount); const hiddenTodoCount = orderedTodos.length - visibleTodos.length; - const numberColumnWidth = String(todos.length).length + 2; + const numberColumnWidth = String(visibleTodoCount).length + 2; + // 6 = 2 (status icon column) + 2 (border columns) + 2 (paddingX columns). const contentColumnWidth = Math.max(1, width - numberColumnWidth - 6); return ( diff --git a/packages/cli/src/ui/utils/todoSnapshot.test.ts b/packages/cli/src/ui/utils/todoSnapshot.test.ts index 65179b91ff2..ceb50d8b981 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.test.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'; import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; import { ToolCallStatus } from '../types.js'; import { + STICKY_TODO_MAX_VISIBLE_ITEMS, getStickyTodoMaxVisibleItems, getStickyTodos, getStickyTodosLayoutKey, @@ -286,4 +287,14 @@ describe('sticky todo layout helpers', () => { 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 569103a6850..d5ff7c219b5 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.ts @@ -19,6 +19,10 @@ interface TodoSnapshotSearchResult { 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. 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; From f902597250ce140dbf6a5bc55569ab281112b196 Mon Sep 17 00:00:00 2001 From: sheny Date: Mon, 27 Apr 2026 18:47:22 +0800 Subject: [PATCH 4/5] fix(cli): size sticky todo number column correctly --- .../cli/src/ui/components/StickyTodoList.test.tsx | 15 +++++++++++++++ packages/cli/src/ui/components/StickyTodoList.tsx | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/StickyTodoList.test.tsx b/packages/cli/src/ui/components/StickyTodoList.test.tsx index 1be581532ed..5a6d7b94096 100644 --- a/packages/cli/src/ui/components/StickyTodoList.test.tsx +++ b/packages/cli/src/ui/components/StickyTodoList.test.tsx @@ -108,6 +108,21 @@ describe('StickyTodoList', () => { 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); diff --git a/packages/cli/src/ui/components/StickyTodoList.tsx b/packages/cli/src/ui/components/StickyTodoList.tsx index 9d86e5f3882..ca3eec4c0db 100644 --- a/packages/cli/src/ui/components/StickyTodoList.tsx +++ b/packages/cli/src/ui/components/StickyTodoList.tsx @@ -59,7 +59,13 @@ const StickyTodoListComponent: React.FC = ({ const visibleTodoCount = clampVisibleTodoCount(maxVisibleItems); const visibleTodos = orderedTodos.slice(0, visibleTodoCount); const hiddenTodoCount = orderedTodos.length - visibleTodos.length; - const numberColumnWidth = String(visibleTodoCount).length + 2; + 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); From ebc3094b163c16197185186a827b9f425afe370c Mon Sep 17 00:00:00 2001 From: sheny Date: Tue, 28 Apr 2026 15:05:57 +0800 Subject: [PATCH 5/5] fix(cli): address sticky todo review feedback --- packages/cli/src/i18n/locales/ca.js | 1 + packages/cli/src/i18n/locales/de.js | 1 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/fr.js | 1 + packages/cli/src/i18n/locales/ja.js | 2 + packages/cli/src/i18n/locales/pt.js | 1 + packages/cli/src/i18n/locales/ru.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + .../cli/src/ui/utils/todoSnapshot.test.ts | 52 +++++++++++++++++++ packages/cli/src/ui/utils/todoSnapshot.ts | 30 ++++++++--- 11 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index c8599fd044e..7830aaaa6cf 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 d4b6e94bf0e..03f941b4359 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1615,6 +1615,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 c2a427bdb70..06c4c28e01e 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1689,6 +1689,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 138f7bf8da0..0b109038740 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1671,6 +1671,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 2c722563908..cacce27519d 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 db681a19ad1..51426aac36a 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1643,6 +1643,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 ed2af31b34c..8747ca8b1e8 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1538,6 +1538,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 9460ba71df6..69e6fca0451 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1428,6 +1428,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 4c3c98ba604..6c98c17c236 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1607,6 +1607,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/utils/todoSnapshot.test.ts b/packages/cli/src/ui/utils/todoSnapshot.test.ts index ceb50d8b981..d2f86ca1c7f 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.test.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.test.ts @@ -282,6 +282,58 @@ describe('sticky todo layout helpers', () => { ); }); + 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); diff --git a/packages/cli/src/ui/utils/todoSnapshot.ts b/packages/cli/src/ui/utils/todoSnapshot.ts index d5ff7c219b5..2fbbe7594a2 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.ts @@ -23,6 +23,8 @@ type SnapshotSearchResult = TodoSnapshotSearchResult | undefined; // 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; @@ -33,6 +35,17 @@ const STICKY_TODO_STATUS_PRIORITY: Record = { 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 { @@ -167,10 +180,15 @@ export function getStickyTodosLayoutKey( return 'null'; } + const visibleTodoCount = clampStickyTodoVisibleItems(maxVisibleItems); + const visibleTodos = todos.slice(0, visibleTodoCount); + const hasHiddenTodos = todos.length > visibleTodos.length; + return JSON.stringify({ width, - maxVisibleItems, - todos: todos.map((todo) => [todo.id, todo.content]), + maxVisibleItems: visibleTodoCount, + hasHiddenTodos, + todos: visibleTodos.map((todo) => [todo.id, todo.content]), }); } @@ -179,11 +197,7 @@ export function getStickyTodoMaxVisibleItems(terminalHeight: number): number { return STICKY_TODO_MAX_VISIBLE_ITEMS; } - return Math.max( - 1, - Math.min( - STICKY_TODO_MAX_VISIBLE_ITEMS, - Math.floor(terminalHeight / STICKY_TODO_ROWS_PER_VISIBLE_ITEM), - ), + return clampStickyTodoVisibleItems( + terminalHeight / STICKY_TODO_ROWS_PER_VISIBLE_ITEM, ); }