From eea4e10eea1d74b42f8efdba00bac6d0447ab998 Mon Sep 17 00:00:00 2001 From: Yan Shen Date: Sun, 26 Apr 2026 12:21:30 +0800 Subject: [PATCH] feat(cli): add sticky todo panel to app layouts (#3507) * feat(cli): add sticky todo panel to app layouts * fix(cli): hide sticky todos during feedback dialog --- packages/cli/src/ui/App.test.tsx | 1 + packages/cli/src/ui/AppContainer.test.tsx | 4 +- packages/cli/src/ui/AppContainer.tsx | 199 ++++++++++-------- .../src/ui/components/StickyTodoList.test.tsx | 57 +++++ .../cli/src/ui/components/StickyTodoList.tsx | 85 ++++++++ .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../src/ui/layouts/DefaultAppLayout.test.tsx | 183 ++++++++++++++++ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 14 ++ .../ui/layouts/ScreenReaderAppLayout.test.tsx | 123 +++++++++++ .../src/ui/layouts/ScreenReaderAppLayout.tsx | 14 ++ .../cli/src/ui/utils/todoSnapshot.test.ts | 199 ++++++++++++++++++ packages/cli/src/ui/utils/todoSnapshot.ts | 109 ++++++++++ 12 files changed, 895 insertions(+), 95 deletions(-) create mode 100644 packages/cli/src/ui/components/StickyTodoList.test.tsx create mode 100644 packages/cli/src/ui/components/StickyTodoList.tsx create mode 100644 packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx create mode 100644 packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx create mode 100644 packages/cli/src/ui/utils/todoSnapshot.test.ts create mode 100644 packages/cli/src/ui/utils/todoSnapshot.ts diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 8df422f4b..7dcc98d90 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -57,6 +57,7 @@ describe('App', () => { streamingState: StreamingState.Idle, quittingMessages: null, dialogsVisible: false, + stickyTodos: null, mainControlsRef: { current: null }, historyManager: { addItem: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index a213c2fa9..0ecd6dd6c 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -31,6 +31,7 @@ import { } from './contexts/UIActionsContext.js'; import { ToolCallStatus } from './types.js'; import { useContext } from 'react'; +import { Box, measureElement } from 'ink'; // Mock useStdout to capture terminal title writes let mockStdout: { write: ReturnType }; @@ -50,7 +51,7 @@ let capturedUIActions: UIActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; - return null; + return ; } vi.mock('./App.js', () => ({ @@ -122,7 +123,6 @@ import { useSessionStats } from './contexts/SessionContext.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; -import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { ShellExecutionService } from '@qwen-code/qwen-code-core'; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5a3deeb27..c16e5305d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -58,6 +58,7 @@ import { type WaitingToolCall, } from '@qwen-code/qwen-code-core'; import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js'; +import { getStickyTodos } from './utils/todoSnapshot.js'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; import process from 'node:process'; @@ -1229,6 +1230,10 @@ export const AppContainer = (props: AppContainerProps) => { () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], ); + const stickyTodos = useMemo( + () => getStickyTodos(historyManager.history, pendingHistoryItems), + [historyManager.history, pendingHistoryItems], + ); // Terminal tab progress bar (OSC 9;4) for iTerm2/Ghostty useTerminalProgress(streamingState, isToolExecuting(pendingHistoryItems)); @@ -1281,40 +1286,6 @@ export const AppContainer = (props: AppContainerProps) => { (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding); - const [controlsHeight, setControlsHeight] = useState(0); - - useLayoutEffect(() => { - if (mainControlsRef.current) { - const fullFooterMeasurement = measureElement(mainControlsRef.current); - if (fullFooterMeasurement.height > 0) { - setControlsHeight(fullFooterMeasurement.height); - } - } - }, [buffer, terminalWidth, terminalHeight, btwItem]); - - // agentViewState is declared earlier (before handleFinalSubmit) so it - // is available for input routing. Referenced here for layout computation. - - // Compute available terminal height based on controls measurement. - // When in-process agents are present the AgentTabBar renders an extra - // row at the top of the layout; subtract it so downstream consumers - // (shell, transcript, etc.) don't overestimate available space. - const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0; - const availableTerminalHeight = Math.max( - 0, - terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight, - ); - - config.setShellExecutionConfig({ - terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - terminalHeight: Math.max( - Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), - 1, - ), - pager: settings.merged.tools?.shell?.pager, - showColor: settings.merged.tools?.shell?.showColor, - }); - const isFocused = useFocus(); useBracketedPaste(); @@ -1342,16 +1313,6 @@ export const AppContainer = (props: AppContainerProps) => { const initialPrompt = useMemo(() => config.getQuestion(), [config]); const initialPromptSubmitted = useRef(false); - useEffect(() => { - if (activePtyId) { - ShellExecutionService.resizePty( - activePtyId, - Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - Math.max(Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1), - ); - } - }, [terminalWidth, availableTerminalHeight, activePtyId]); - useEffect(() => { if ( initialPrompt && @@ -1561,8 +1522,106 @@ export const AppContainer = (props: AppContainerProps) => { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, } = useIdeTrustListener(); + const { + isFeedbackDialogOpen, + openFeedbackDialog, + closeFeedbackDialog, + temporaryCloseFeedbackDialog, + submitFeedback, + } = useFeedbackDialog({ + config, + settings, + streamingState, + history: historyManager.history, + sessionStats, + }); + const dialogsVisible = + showWelcomeBackDialog || + shouldShowIdePrompt || + shouldShowCommandMigrationNudge || + isFolderTrustDialogOpen || + !!shellConfirmationRequest || + !!confirmationRequest || + confirmUpdateExtensionRequests.length > 0 || + !!codingPlanUpdateRequest || + settingInputRequests.length > 0 || + pluginChoiceRequests.length > 0 || + !!loopDetectionConfirmationRequest || + isThemeDialogOpen || + isSettingsDialogOpen || + isMemoryDialogOpen || + isModelDialogOpen || + isTrustDialogOpen || + activeArenaDialog !== null || + isPermissionsDialogOpen || + isAuthDialogOpen || + isAuthenticating || + isEditorDialogOpen || + showIdeRestartPrompt || + isSubagentCreateDialogOpen || + isAgentsManagerDialogOpen || + isMcpDialogOpen || + isHooksDialogOpen || + isApprovalModeDialogOpen || + isResumeDialogOpen || + isDeleteDialogOpen || + isExtensionsManagerDialogOpen || + isRewindSelectorOpen; + dialogsVisibleRef.current = dialogsVisible; + const shouldShowStickyTodos = + stickyTodos !== null && + !dialogsVisible && + !isFeedbackDialogOpen && + streamingState !== StreamingState.WaitingForConfirmation; + const [controlsHeight, setControlsHeight] = useState(0); + + useLayoutEffect(() => { + if (!mainControlsRef.current) { + setControlsHeight(0); + return; + } + + const fullFooterMeasurement = measureElement(mainControlsRef.current); + setControlsHeight(fullFooterMeasurement.height); + }, [ + buffer, + terminalWidth, + terminalHeight, + btwItem, + dialogsVisible, + shouldShowStickyTodos, + stickyTodos, + ]); + + // agentViewState is declared earlier (before handleFinalSubmit) so it + // is available for input routing. Referenced here for layout computation. + const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0; + const availableTerminalHeight = Math.max( + 0, + terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight, + ); + + config.setShellExecutionConfig({ + terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), + terminalHeight: Math.max( + Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), + 1, + ), + pager: settings.merged.tools?.shell?.pager, + showColor: settings.merged.tools?.shell?.showColor, + }); const isInitialMount = useRef(true); + useEffect(() => { + if (activePtyId) { + ShellExecutionService.resizePty( + activePtyId, + Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), + Math.max(Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1), + ); + } + }, [terminalWidth, availableTerminalHeight, activePtyId]); + useEffect(() => { if (ideNeedsRestart) { // IDE trust changed, force a restart. @@ -2132,42 +2191,6 @@ export const AppContainer = (props: AppContainerProps) => { stdout, ]); - const nightly = props.version.includes('nightly'); - - const dialogsVisible = - showWelcomeBackDialog || - shouldShowIdePrompt || - shouldShowCommandMigrationNudge || - isFolderTrustDialogOpen || - !!shellConfirmationRequest || - !!confirmationRequest || - confirmUpdateExtensionRequests.length > 0 || - !!codingPlanUpdateRequest || - settingInputRequests.length > 0 || - pluginChoiceRequests.length > 0 || - !!loopDetectionConfirmationRequest || - isThemeDialogOpen || - isSettingsDialogOpen || - isMemoryDialogOpen || - isModelDialogOpen || - isTrustDialogOpen || - activeArenaDialog !== null || - isPermissionsDialogOpen || - isAuthDialogOpen || - isAuthenticating || - isEditorDialogOpen || - showIdeRestartPrompt || - isSubagentCreateDialogOpen || - isAgentsManagerDialogOpen || - isMcpDialogOpen || - isHooksDialogOpen || - isApprovalModeDialogOpen || - isResumeDialogOpen || - isDeleteDialogOpen || - isExtensionsManagerDialogOpen || - isRewindSelectorOpen; - dialogsVisibleRef.current = dialogsVisible; - // Drain queued messages when idle. `queueDrainNonce` re-fires the effect // after each submission settles so multi-step queues drain end-to-end. const queueDrainingRef = useRef(false); @@ -2201,19 +2224,7 @@ export const AppContainer = (props: AppContainerProps) => { queueDrainNonce, ]); - const { - isFeedbackDialogOpen, - openFeedbackDialog, - closeFeedbackDialog, - temporaryCloseFeedbackDialog, - submitFeedback, - } = useFeedbackDialog({ - config, - settings, - streamingState, - history: historyManager.history, - sessionStats, - }); + const nightly = props.version.includes('nightly'); const uiState: UIState = useMemo( () => ({ @@ -2289,6 +2300,7 @@ export const AppContainer = (props: AppContainerProps) => { staticExtraHeight, dialogsVisible, pendingHistoryItems, + stickyTodos, btwItem, setBtwItem, cancelBtw, @@ -2406,6 +2418,7 @@ export const AppContainer = (props: AppContainerProps) => { staticExtraHeight, dialogsVisible, pendingHistoryItems, + stickyTodos, btwItem, setBtwItem, cancelBtw, diff --git a/packages/cli/src/ui/components/StickyTodoList.test.tsx b/packages/cli/src/ui/components/StickyTodoList.test.tsx new file mode 100644 index 000000000..0c2af5454 --- /dev/null +++ b/packages/cli/src/ui/components/StickyTodoList.test.tsx @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, expect, it } from 'vitest'; +import { StickyTodoList } from './StickyTodoList.js'; +import type { TodoItem } from './TodoDisplay.js'; + +describe('StickyTodoList', () => { + it('keeps each task number attached to the original task after sorting', () => { + const todos: TodoItem[] = [ + { + id: 'done', + content: 'Summarize results', + status: 'completed', + }, + { + id: 'pending', + content: 'Run cli tests', + status: 'pending', + }, + { + id: 'active', + content: 'Run core tests', + status: 'in_progress', + }, + ]; + + const { lastFrame } = render(); + const output = lastFrame() ?? ''; + const lines = output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + expect(output).toContain('Current tasks'); + expect(output).toContain('╭'); + expect( + lines.find((line) => line.includes('Run core tests')) ?? '', + ).toContain('3.'); + expect( + lines.find((line) => line.includes('Run cli tests')) ?? '', + ).toContain('2.'); + expect( + lines.find((line) => line.includes('Summarize results')) ?? '', + ).toContain('1.'); + expect(output.indexOf('Run core tests')).toBeLessThan( + output.indexOf('Run cli tests'), + ); + expect(output.indexOf('Run cli tests')).toBeLessThan( + output.indexOf('Summarize results'), + ); + }); +}); diff --git a/packages/cli/src/ui/components/StickyTodoList.tsx b/packages/cli/src/ui/components/StickyTodoList.tsx new file mode 100644 index 000000000..7f68591a8 --- /dev/null +++ b/packages/cli/src/ui/components/StickyTodoList.tsx @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { 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 type { TodoItem } from './TodoDisplay.js'; + +interface StickyTodoListProps { + todos: TodoItem[]; + width: number; +} + +const STATUS_ICONS = { + pending: '○', + in_progress: '◐', + completed: '●', +} as const; + +export const StickyTodoList: React.FC = ({ + todos, + width, +}) => { + const orderedTodos = useMemo(() => getOrderedStickyTodos(todos), [todos]); + const todoNumberById = useMemo( + () => + new Map(todos.map((todo, index) => [todo.id, `${index + 1}.`] as const)), + [todos], + ); + + if (todos.length === 0) { + return null; + } + + const numberColumnWidth = String(orderedTodos.length).length + 2; + + return ( + + + {t('Current tasks')} + + {orderedTodos.map((todo, index) => { + const todoNumber = todoNumberById.get(todo.id) ?? `${index + 1}.`; + const itemColor = + todo.status === 'in_progress' + ? Colors.AccentGreen + : Colors.Foreground; + + return ( + + + {todoNumber} + + + {STATUS_ICONS[todo.status]} + + + + {todo.content} + + + + ); + })} + + ); +}; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a51961128..a0ddd9c9d 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -17,6 +17,7 @@ import type { SettingInputRequest, PluginChoiceRequest, } from '../types.js'; +import type { TodoItem } from '../components/TodoDisplay.js'; import type { QwenAuthState } from '../hooks/useQwenAuth.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; @@ -110,6 +111,7 @@ export interface UIState { staticExtraHeight: number; dialogsVisible: boolean; pendingHistoryItems: HistoryItemWithoutId[]; + stickyTodos: TodoItem[] | null; btwItem: HistoryItemBtw | null; setBtwItem: (item: HistoryItemBtw | null) => void; cancelBtw: () => void; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx new file mode 100644 index 000000000..29aab2f93 --- /dev/null +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, type Mock } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { DefaultAppLayout } from './DefaultAppLayout.js'; +import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; +import { + UIActionsContext, + type UIActions, +} from '../contexts/UIActionsContext.js'; +import { useAgentViewState } from '../contexts/AgentViewContext.js'; +import { StreamingState } from '../types.js'; + +vi.mock('../components/MainContent.js', () => ({ + MainContent: () => MainContent, +})); + +vi.mock('../components/DialogManager.js', () => ({ + DialogManager: () => DialogManager, +})); + +vi.mock('../components/Composer.js', () => ({ + Composer: () => Composer, +})); + +vi.mock('../components/ExitWarning.js', () => ({ + ExitWarning: () => ExitWarning, +})); + +vi.mock('../components/messages/BtwMessage.js', () => ({ + BtwMessage: () => BtwMessage, +})); + +vi.mock('../components/StickyTodoList.js', () => ({ + StickyTodoList: () => StickyTodoList, +})); + +vi.mock('../components/agent-view/AgentTabBar.js', () => ({ + AgentTabBar: () => AgentTabBar, +})); + +vi.mock('../components/agent-view/AgentChatView.js', () => ({ + AgentChatView: () => AgentChatView, +})); + +vi.mock('../components/agent-view/AgentComposer.js', () => ({ + AgentComposer: () => AgentComposer, +})); + +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 80 }), +})); + +vi.mock('../contexts/AgentViewContext.js', () => ({ + useAgentViewState: vi.fn(), +})); + +const mockedUseAgentViewState = useAgentViewState as Mock; + +const mockUIActions = { + refreshStatic: vi.fn(), +} as unknown as UIActions; + +const baseUIState: Partial = { + dialogsVisible: false, + isFeedbackDialogOpen: false, + mainControlsRef: { current: null }, + mainAreaWidth: 80, + terminalWidth: 80, + streamingState: StreamingState.Idle, + historyManager: { + addItem: vi.fn(), + history: [], + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + truncateToItem: vi.fn(), + }, + stickyTodos: [ + { + id: 'todo-1', + content: 'Pinned task', + status: 'pending', + }, + ], + btwItem: null, +}; + +const renderLayout = (uiState: Partial) => + render( + + + + + , + ); + +describe('DefaultAppLayout', () => { + it('renders sticky todo list before the composer in the main view', () => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'main', + agents: new Map(), + }); + + const { lastFrame } = renderLayout(baseUIState); + const output = lastFrame() ?? ''; + + expect(output).toContain('StickyTodoList'); + expect(output.indexOf('StickyTodoList')).toBeGreaterThan( + output.indexOf('MainContent'), + ); + expect(output.indexOf('StickyTodoList')).toBeLessThan( + output.indexOf('Composer'), + ); + }); + + it('does not render sticky todo list when dialogs are visible', () => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'main', + agents: new Map(), + }); + + const { lastFrame } = renderLayout({ + ...baseUIState, + dialogsVisible: true, + }); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('StickyTodoList'); + expect(output).toContain('DialogManager'); + }); + + it('does not render sticky todo list while waiting for confirmation', () => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'main', + agents: new Map(), + }); + + const { lastFrame } = renderLayout({ + ...baseUIState, + streamingState: StreamingState.WaitingForConfirmation, + }); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('StickyTodoList'); + expect(output).toContain('Composer'); + }); + + it('does not render sticky todo list when feedback dialog is open', () => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'main', + agents: new Map(), + }); + + const { lastFrame } = renderLayout({ + ...baseUIState, + isFeedbackDialogOpen: true, + }); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('StickyTodoList'); + expect(output).toContain('Composer'); + }); + + it('does not render sticky todo list in an agent tab view', () => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'agent-1', + agents: new Map([['agent-1', {}]]), + }); + + const { lastFrame } = renderLayout(baseUIState); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('StickyTodoList'); + expect(output).toContain('AgentChatView'); + expect(output).toContain('AgentComposer'); + }); +}); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 88efdbefd..f66e5a713 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -11,6 +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 { 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'; @@ -19,6 +20,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAgentViewState } from '../contexts/AgentViewContext.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { StreamingState } from '../types.js'; export const DefaultAppLayout: React.FC = () => { const uiState = useUIState(); @@ -27,6 +29,12 @@ export const DefaultAppLayout: React.FC = () => { const { columns: terminalWidth } = useTerminalSize(); const hasAgents = agents.size > 0; const isAgentTab = activeView !== 'main' && agents.has(activeView); + const stickyTodoWidth = Math.min(uiState.mainAreaWidth, 64); + const shouldShowStickyTodos = + uiState.stickyTodos !== null && + !uiState.dialogsVisible && + !uiState.isFeedbackDialogOpen && + uiState.streamingState !== StreamingState.WaitingForConfirmation; // Clear terminal on view switch so previous view's output // is removed. refreshStatic clears the terminal and bumps the @@ -69,6 +77,12 @@ export const DefaultAppLayout: React.FC = () => { ) : ( <> + {shouldShowStickyTodos && ( + + )} {uiState.btwItem && ( ({ + Notifications: () => Notifications, +})); + +vi.mock('../components/MainContent.js', () => ({ + MainContent: () => MainContent, +})); + +vi.mock('../components/DialogManager.js', () => ({ + DialogManager: () => DialogManager, +})); + +vi.mock('../components/Composer.js', () => ({ + Composer: () => Composer, +})); + +vi.mock('../components/Footer.js', () => ({ + Footer: () => Footer, +})); + +vi.mock('../components/ExitWarning.js', () => ({ + ExitWarning: () => ExitWarning, +})); + +vi.mock('../components/messages/BtwMessage.js', () => ({ + BtwMessage: () => BtwMessage, +})); + +vi.mock('../components/StickyTodoList.js', () => ({ + StickyTodoList: () => StickyTodoList, +})); + +const baseUIState: Partial = { + dialogsVisible: false, + isFeedbackDialogOpen: false, + mainAreaWidth: 80, + terminalWidth: 80, + streamingState: StreamingState.Idle, + historyManager: { + addItem: vi.fn(), + history: [], + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + truncateToItem: vi.fn(), + }, + stickyTodos: [ + { + id: 'todo-1', + content: 'Pinned task', + status: 'pending', + }, + ], + btwItem: null, +}; + +const renderLayout = (uiState: Partial) => + render( + + + , + ); + +describe('ScreenReaderAppLayout', () => { + it('renders sticky todo list before the composer', () => { + const { lastFrame } = renderLayout(baseUIState); + const output = lastFrame() ?? ''; + + expect(output).toContain('StickyTodoList'); + expect(output.indexOf('StickyTodoList')).toBeGreaterThan( + output.indexOf('MainContent'), + ); + expect(output.indexOf('StickyTodoList')).toBeLessThan( + output.indexOf('Composer'), + ); + }); + + it('does not render sticky todo list when dialogs are visible', () => { + const { lastFrame } = renderLayout({ + ...baseUIState, + dialogsVisible: true, + }); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('StickyTodoList'); + expect(output).toContain('DialogManager'); + }); + + it('does not render sticky todo list while waiting for confirmation', () => { + const { lastFrame } = renderLayout({ + ...baseUIState, + streamingState: StreamingState.WaitingForConfirmation, + }); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('StickyTodoList'); + expect(output).toContain('Composer'); + }); + + it('does not render sticky todo list when feedback dialog is open', () => { + const { lastFrame } = renderLayout({ + ...baseUIState, + isFeedbackDialogOpen: true, + }); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('StickyTodoList'); + expect(output).toContain('Composer'); + }); +}); diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index e601db4b9..b79eda8ec 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -12,11 +12,19 @@ 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 { BtwMessage } from '../components/messages/BtwMessage.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { StreamingState } from '../types.js'; export const ScreenReaderAppLayout: React.FC = () => { const uiState = useUIState(); + const stickyTodoWidth = Math.min(uiState.mainAreaWidth, 64); + const shouldShowStickyTodos = + uiState.stickyTodos !== null && + !uiState.dialogsVisible && + !uiState.isFeedbackDialogOpen && + uiState.streamingState !== StreamingState.WaitingForConfirmation; return ( @@ -35,6 +43,12 @@ export const ScreenReaderAppLayout: React.FC = () => { ) : ( <> + {shouldShowStickyTodos && ( + + )} {uiState.btwItem && ( , + withId?: number, +): HistoryItem | HistoryItemWithoutId { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: 'todo-custom', + name: 'TodoWrite', + description: 'Update todos', + resultDisplay: { + type: 'todo_list' as const, + todos, + }, + status: ToolCallStatus.Success, + confirmationDetails: undefined, + }, + ], + }; + + if (withId !== undefined) { + return { ...item, id: withId }; + } + + return item; +} + +function makeEmptyTodoToolGroup( + withId?: number, +): HistoryItem | HistoryItemWithoutId { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: 'todo-clear', + name: 'TodoWrite', + description: 'Clear todos', + resultDisplay: { + type: 'todo_list' as const, + todos: [], + }, + status: ToolCallStatus.Success, + confirmationDetails: undefined, + }, + ], + }; + + if (withId !== undefined) { + return { ...item, id: withId }; + } + + return item; +} + +describe('getStickyTodos', () => { + it('returns the latest todo snapshot from history', () => { + const history = [ + makeTodoToolGroup('first task', 1), + makeTodoToolGroup('latest history task', 2), + ] as HistoryItem[]; + + expect(getStickyTodos(history, [])).toEqual([ + { + id: 'todo-latest history task', + content: 'latest history task', + status: 'pending', + }, + ]); + }); + + it('prefers pending todo snapshots over history', () => { + 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', + }, + ]); + }); + + it('returns null when the latest todo snapshot clears the list', () => { + const history = [makeTodoToolGroup('history task', 1)] as HistoryItem[]; + const pendingHistoryItems = [ + makeEmptyTodoToolGroup(), + ] as HistoryItemWithoutId[]; + + expect(getStickyTodos(history, pendingHistoryItems)).toBeNull(); + }); + + it('returns null when the latest history todo snapshot is fully completed', () => { + const history = [ + makeCustomTodoToolGroup( + [ + { + id: 'todo-1', + content: 'Run tests', + status: 'completed', + }, + { + id: 'todo-2', + content: 'Summarize results', + status: 'completed', + }, + ], + 1, + ), + ] as HistoryItem[]; + + expect(getStickyTodos(history, [])).toBeNull(); + }); + + it('keeps showing a fully completed pending snapshot until the turn finishes', () => { + const history = [ + makeTodoToolGroup('older history task', 1), + ] as HistoryItem[]; + const pendingHistoryItems = [ + makeCustomTodoToolGroup([ + { + id: 'todo-1', + content: 'Run tests', + status: 'completed', + }, + { + id: 'todo-2', + content: 'Summarize results', + status: 'completed', + }, + ]), + ] as HistoryItemWithoutId[]; + + expect(getStickyTodos(history, pendingHistoryItems)).toEqual([ + { + id: 'todo-1', + content: 'Run tests', + status: 'completed', + }, + { + id: 'todo-2', + content: 'Summarize results', + status: 'completed', + }, + ]); + }); +}); diff --git a/packages/cli/src/ui/utils/todoSnapshot.ts b/packages/cli/src/ui/utils/todoSnapshot.ts new file mode 100644 index 000000000..3f297e30d --- /dev/null +++ b/packages/cli/src/ui/utils/todoSnapshot.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { TodoItem } from '../components/TodoDisplay.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../types.js'; + +type HistoryLikeItem = HistoryItem | HistoryItemWithoutId; +type SnapshotSearchResult = TodoItem[] | null | undefined; +const STICKY_TODO_STATUS_PRIORITY: Record = { + in_progress: 0, + pending: 1, + completed: 2, +}; + +function extractTodosFromResultDisplay( + resultDisplay: unknown, +): TodoItem[] | null { + if (!resultDisplay) { + return null; + } + + if (typeof resultDisplay === 'object') { + const candidate = resultDisplay as Record; + if ( + candidate['type'] === 'todo_list' && + Array.isArray(candidate['todos']) + ) { + return candidate['todos'] as TodoItem[]; + } + } + + if (typeof resultDisplay === 'string') { + try { + const parsed = JSON.parse(resultDisplay) as Record; + if (parsed['type'] === 'todo_list' && Array.isArray(parsed['todos'])) { + return parsed['todos'] as TodoItem[]; + } + } catch { + return null; + } + } + + return null; +} + +function findLatestTodoSnapshot( + items: readonly HistoryLikeItem[], +): SnapshotSearchResult { + for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) { + const item = items[itemIndex]; + if (item.type !== 'tool_group') { + continue; + } + + for ( + let toolIndex = item.tools.length - 1; + toolIndex >= 0; + toolIndex -= 1 + ) { + const tool = item.tools[toolIndex] as IndividualToolCallDisplay; + const todos = extractTodosFromResultDisplay(tool.resultDisplay); + if (todos) { + return todos.length > 0 ? todos : null; + } + } + } + + return undefined; +} + +function areAllTodosCompleted(todos: readonly TodoItem[]): boolean { + return todos.length > 0 && todos.every((todo) => todo.status === 'completed'); +} + +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)) { + return null; + } + + return historySnapshot ?? null; +} + +export function getOrderedStickyTodos(todos: readonly TodoItem[]): TodoItem[] { + return todos + .map((todo, index) => ({ todo, index })) + .sort( + (left, right) => + STICKY_TODO_STATUS_PRIORITY[left.todo.status] - + STICKY_TODO_STATUS_PRIORITY[right.todo.status] || + left.index - right.index, + ) + .map(({ todo }) => todo); +}