mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
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:
parent
4be0234d10
commit
eea4e10eea
12 changed files with 895 additions and 95 deletions
|
|
@ -57,6 +57,7 @@ describe('App', () => {
|
|||
streamingState: StreamingState.Idle,
|
||||
quittingMessages: null,
|
||||
dialogsVisible: false,
|
||||
stickyTodos: null,
|
||||
mainControlsRef: { current: null },
|
||||
historyManager: {
|
||||
addItem: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
57
packages/cli/src/ui/components/StickyTodoList.test.tsx
Normal file
57
packages/cli/src/ui/components/StickyTodoList.test.tsx
Normal 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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
85
packages/cli/src/ui/components/StickyTodoList.tsx
Normal file
85
packages/cli/src/ui/components/StickyTodoList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
183
packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx
Normal file
183
packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
123
packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx
Normal file
123
packages/cli/src/ui/layouts/ScreenReaderAppLayout.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
199
packages/cli/src/ui/utils/todoSnapshot.test.ts
Normal file
199
packages/cli/src/ui/utils/todoSnapshot.test.ts
Normal 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
109
packages/cli/src/ui/utils/todoSnapshot.ts
Normal file
109
packages/cli/src/ui/utils/todoSnapshot.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue