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
This commit is contained in:
Yan Shen 2026-04-26 12:21:30 +08:00 committed by GitHub
parent 4be0234d10
commit eea4e10eea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 895 additions and 95 deletions

View file

@ -57,6 +57,7 @@ describe('App', () => {
streamingState: StreamingState.Idle,
quittingMessages: null,
dialogsVisible: false,
stickyTodos: null,
mainControlsRef: { current: null },
historyManager: {
addItem: vi.fn(),

View file

@ -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<typeof vi.fn> };
@ -50,7 +51,7 @@ let capturedUIActions: UIActions;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedUIActions = useContext(UIActionsContext)!;
return null;
return <Box ref={capturedUIState.mainControlsRef} />;
}
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';

View file

@ -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,

View file

@ -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(<StickyTodoList todos={todos} width={60} />);
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'),
);
});
});

View file

@ -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<StickyTodoListProps> = ({
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 (
<Box
marginX={2}
width={width}
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
>
<Text color={theme.text.secondary} bold>
{t('Current tasks')}
</Text>
{orderedTodos.map((todo, index) => {
const todoNumber = todoNumberById.get(todo.id) ?? `${index + 1}.`;
const itemColor =
todo.status === 'in_progress'
? Colors.AccentGreen
: Colors.Foreground;
return (
<Box key={todo.id} flexDirection="row" minHeight={1}>
<Box width={numberColumnWidth}>
<Text color={theme.text.secondary}>{todoNumber}</Text>
</Box>
<Box width={2}>
<Text color={itemColor}>{STATUS_ICONS[todo.status]}</Text>
</Box>
<Box flexGrow={1}>
<Text
color={itemColor}
strikethrough={todo.status === 'completed'}
wrap="wrap"
>
{todo.content}
</Text>
</Box>
</Box>
);
})}
</Box>
);
};

View file

@ -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;

View file

@ -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: () => <Text>MainContent</Text>,
}));
vi.mock('../components/DialogManager.js', () => ({
DialogManager: () => <Text>DialogManager</Text>,
}));
vi.mock('../components/Composer.js', () => ({
Composer: () => <Text>Composer</Text>,
}));
vi.mock('../components/ExitWarning.js', () => ({
ExitWarning: () => <Text>ExitWarning</Text>,
}));
vi.mock('../components/messages/BtwMessage.js', () => ({
BtwMessage: () => <Text>BtwMessage</Text>,
}));
vi.mock('../components/StickyTodoList.js', () => ({
StickyTodoList: () => <Text>StickyTodoList</Text>,
}));
vi.mock('../components/agent-view/AgentTabBar.js', () => ({
AgentTabBar: () => <Text>AgentTabBar</Text>,
}));
vi.mock('../components/agent-view/AgentChatView.js', () => ({
AgentChatView: () => <Text>AgentChatView</Text>,
}));
vi.mock('../components/agent-view/AgentComposer.js', () => ({
AgentComposer: () => <Text>AgentComposer</Text>,
}));
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<UIState> = {
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<UIState>) =>
render(
<UIActionsContext.Provider value={mockUIActions}>
<UIStateContext.Provider value={uiState as UIState}>
<DefaultAppLayout />
</UIStateContext.Provider>
</UIActionsContext.Provider>,
);
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');
});
});

View file

@ -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 <Static> output
// is removed. refreshStatic clears the terminal and bumps the
@ -69,6 +77,12 @@ export const DefaultAppLayout: React.FC = () => {
</Box>
) : (
<>
{shouldShowStickyTodos && (
<StickyTodoList
todos={uiState.stickyTodos!}
width={stickyTodoWidth}
/>
)}
{uiState.btwItem && (
<Box marginX={2} width={uiState.mainAreaWidth}>
<BtwMessage

View file

@ -0,0 +1,123 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { ScreenReaderAppLayout } from './ScreenReaderAppLayout.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { StreamingState } from '../types.js';
vi.mock('../components/Notifications.js', () => ({
Notifications: () => <Text>Notifications</Text>,
}));
vi.mock('../components/MainContent.js', () => ({
MainContent: () => <Text>MainContent</Text>,
}));
vi.mock('../components/DialogManager.js', () => ({
DialogManager: () => <Text>DialogManager</Text>,
}));
vi.mock('../components/Composer.js', () => ({
Composer: () => <Text>Composer</Text>,
}));
vi.mock('../components/Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
vi.mock('../components/ExitWarning.js', () => ({
ExitWarning: () => <Text>ExitWarning</Text>,
}));
vi.mock('../components/messages/BtwMessage.js', () => ({
BtwMessage: () => <Text>BtwMessage</Text>,
}));
vi.mock('../components/StickyTodoList.js', () => ({
StickyTodoList: () => <Text>StickyTodoList</Text>,
}));
const baseUIState: Partial<UIState> = {
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<UIState>) =>
render(
<UIStateContext.Provider value={uiState as UIState}>
<ScreenReaderAppLayout />
</UIStateContext.Provider>,
);
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');
});
});

View file

@ -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 (
<Box flexDirection="column" width="90%" height="100%">
@ -35,6 +43,12 @@ export const ScreenReaderAppLayout: React.FC = () => {
</Box>
) : (
<>
{shouldShowStickyTodos && (
<StickyTodoList
todos={uiState.stickyTodos!}
width={stickyTodoWidth}
/>
)}
{uiState.btwItem && (
<Box marginX={2} width={uiState.mainAreaWidth}>
<BtwMessage

View file

@ -0,0 +1,199 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { ToolCallStatus } from '../types.js';
import { getStickyTodos } from './todoSnapshot.js';
function makeTodoToolGroup(
content: string,
withId?: number,
): HistoryItem | HistoryItemWithoutId {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: `todo-${content}`,
name: 'TodoWrite',
description: 'Update todos',
resultDisplay: {
type: 'todo_list' as const,
todos: [
{
id: `todo-${content}`,
content,
status: 'pending' as const,
},
],
},
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
};
if (withId !== undefined) {
return { ...item, id: withId };
}
return item;
}
function makeCustomTodoToolGroup(
todos: Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>,
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',
},
]);
});
});

View file

@ -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<TodoItem['status'], number> = {
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<string, unknown>;
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<string, unknown>;
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);
}