mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
fix(cli): keep sticky todo panel compact (#3647)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* fix(cli): keep sticky todo panel compact * fix(cli): stabilize sticky todo redraws * fix(cli): address sticky todo review feedback * fix(cli): size sticky todo number column correctly * fix(cli): address sticky todo review feedback
This commit is contained in:
parent
cae09279fa
commit
9861114ff3
17 changed files with 568 additions and 45 deletions
|
|
@ -1708,6 +1708,7 @@ export default {
|
|||
"S'ha trobat {{count}} fitxer d'ordres TOML:",
|
||||
'Found {{count}} TOML command files:':
|
||||
"S'han trobat {{count}} fitxers d'ordres TOML:",
|
||||
'Current tasks': 'Tasques actuals',
|
||||
'... and {{count}} more': '... i {{count}} més',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'El format TOML és obsolet. Voleu migrar-los al format Markdown?',
|
||||
|
|
|
|||
|
|
@ -1617,6 +1617,7 @@ export default {
|
|||
'Found {{count}} TOML command file:': '{{count}} TOML-Befehlsdatei gefunden:',
|
||||
'Found {{count}} TOML command files:':
|
||||
'{{count}} TOML-Befehlsdateien gefunden:',
|
||||
'Current tasks': 'Aktuelle Aufgaben',
|
||||
'... and {{count}} more': '... und {{count}} weitere',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'Das TOML-Format ist veraltet. Möchten Sie sie ins Markdown-Format migrieren?',
|
||||
|
|
|
|||
|
|
@ -1691,6 +1691,7 @@ export default {
|
|||
'Command Format Migration': 'Command Format Migration',
|
||||
'Found {{count}} TOML command file:': 'Found {{count}} TOML command file:',
|
||||
'Found {{count}} TOML command files:': 'Found {{count}} TOML command files:',
|
||||
'Current tasks': 'Current tasks',
|
||||
'... and {{count}} more': '... and {{count}} more',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?',
|
||||
|
|
|
|||
|
|
@ -1673,6 +1673,7 @@ export default {
|
|||
'Trouvé {{count}} fichier de commande TOML :',
|
||||
'Found {{count}} TOML command files:':
|
||||
'Trouvé {{count}} fichiers de commande TOML :',
|
||||
'Current tasks': 'Tâches actuelles',
|
||||
'... and {{count}} more': '... et {{count}} de plus',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'Le format TOML est obsolète. Souhaitez-vous les migrer vers le format Markdown ?',
|
||||
|
|
|
|||
|
|
@ -991,6 +991,8 @@ export default {
|
|||
'進捗: {{done}}/{{total}} タスク完了',
|
||||
', {{inProgress}} in progress': '、{{inProgress}} 進行中',
|
||||
'Pending Tasks:': '保留中のタスク:',
|
||||
'Current tasks': '現在のタスク',
|
||||
'... and {{count}} more': '... 他 {{count}} 件',
|
||||
'What would you like to do?': '何をしますか?',
|
||||
'Choose how to proceed with your session:':
|
||||
'セッションの続行方法を選択してください:',
|
||||
|
|
|
|||
|
|
@ -1645,6 +1645,7 @@ export default {
|
|||
'Encontrado {{count}} arquivo de comando TOML:',
|
||||
'Found {{count}} TOML command files:':
|
||||
'Encontrados {{count}} arquivos de comando TOML:',
|
||||
'Current tasks': 'Tarefas atuais',
|
||||
'... and {{count}} more': '... e mais {{count}}',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'O formato TOML está obsoleto. Você gostaria de migrá-los para o formato Markdown?',
|
||||
|
|
|
|||
|
|
@ -1540,6 +1540,7 @@ export default {
|
|||
'Found {{count}} TOML command file:': 'Найден {{count}} файл команд TOML:',
|
||||
'Found {{count}} TOML command files:':
|
||||
'Найдено {{count}} файлов команд TOML:',
|
||||
'Current tasks': 'Текущие задачи',
|
||||
'... and {{count}} more': '... и ещё {{count}}',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'Формат TOML устарел. Хотите перенести их в формат Markdown?',
|
||||
|
|
|
|||
|
|
@ -1430,6 +1430,7 @@ export default {
|
|||
'Command Format Migration': '命令格式遷移',
|
||||
'Found {{count}} TOML command file:': '發現 {{count}} 個 TOML 命令文件:',
|
||||
'Found {{count}} TOML command files:': '發現 {{count}} 個 TOML 命令文件:',
|
||||
'Current tasks': '目前任務',
|
||||
'... and {{count}} more': '... 以及其他 {{count}} 個',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'TOML 格式已棄用。是否將它們遷移到 Markdown 格式?',
|
||||
|
|
|
|||
|
|
@ -1609,6 +1609,7 @@ export default {
|
|||
'Command Format Migration': '命令格式迁移',
|
||||
'Found {{count}} TOML command file:': '发现 {{count}} 个 TOML 命令文件:',
|
||||
'Found {{count}} TOML command files:': '发现 {{count}} 个 TOML 命令文件:',
|
||||
'Current tasks': '当前任务',
|
||||
'... and {{count}} more': '... 以及其他 {{count}} 个',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'TOML 格式已弃用。是否将它们迁移到 Markdown 格式?',
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
UIActionsContext,
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { ToolCallStatus } from './types.js';
|
||||
import { type HistoryItem, ToolCallStatus } from './types.js';
|
||||
import { useContext } from 'react';
|
||||
import { Box, measureElement } from 'ink';
|
||||
|
||||
|
|
@ -1389,6 +1389,43 @@ describe('AppContainer State Management', () => {
|
|||
describe('Terminal Height Calculation', () => {
|
||||
const mockedMeasureElement = measureElement as Mock;
|
||||
const mockedUseTerminalSize = useTerminalSize as Mock;
|
||||
const makeTodoHistory = (
|
||||
status: 'pending' | 'in_progress' | 'completed',
|
||||
): HistoryItem[] => [
|
||||
{
|
||||
type: 'tool_group',
|
||||
id: 1,
|
||||
tools: [
|
||||
{
|
||||
callId: 'todo-1',
|
||||
name: 'TodoWrite',
|
||||
description: 'Update todos',
|
||||
resultDisplay: {
|
||||
type: 'todo_list',
|
||||
todos: [
|
||||
{
|
||||
id: 'todo-1',
|
||||
content: 'Run focused tests',
|
||||
status,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: ToolCallStatus.Success,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'gemini',
|
||||
id: 2,
|
||||
text: 'First response after todo',
|
||||
},
|
||||
{
|
||||
type: 'gemini',
|
||||
id: 3,
|
||||
text: 'Second response after todo',
|
||||
},
|
||||
];
|
||||
|
||||
it('should prevent terminal height from being less than 1', () => {
|
||||
const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty');
|
||||
|
|
@ -1424,6 +1461,44 @@ describe('AppContainer State Management', () => {
|
|||
// Check the height argument specifically
|
||||
expect(lastCall[2]).toBe(1);
|
||||
});
|
||||
|
||||
it('does not remeasure footer height for sticky todo status-only updates', () => {
|
||||
const historyManager = {
|
||||
history: makeTodoHistory('pending'),
|
||||
addItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
truncateToItem: vi.fn(),
|
||||
};
|
||||
mockedUseHistory.mockReturnValue(historyManager);
|
||||
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 24 });
|
||||
mockedMeasureElement.mockReturnValue({ width: 80, height: 4 });
|
||||
|
||||
const view = render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
const callsAfterInitialRender = mockedMeasureElement.mock.calls.length;
|
||||
|
||||
historyManager.history = makeTodoHistory('in_progress');
|
||||
view.rerender(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedMeasureElement).toHaveBeenCalledTimes(
|
||||
callsAfterInitialRender,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling', () => {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,13 @@ import {
|
|||
type WaitingToolCall,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
|
||||
import { getStickyTodos } from './utils/todoSnapshot.js';
|
||||
import {
|
||||
getStickyTodos,
|
||||
getStickyTodoMaxVisibleItems,
|
||||
getStickyTodosLayoutKey,
|
||||
getStickyTodosRenderKey,
|
||||
} from './utils/todoSnapshot.js';
|
||||
import type { TodoItem } from './components/TodoDisplay.js';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||
import process from 'node:process';
|
||||
|
|
@ -166,6 +172,20 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
|||
});
|
||||
}
|
||||
|
||||
function useStableStickyTodos(todos: TodoItem[] | null): TodoItem[] | null {
|
||||
const renderKey = getStickyTodosRenderKey(todos);
|
||||
const stableTodosRef = useRef<{
|
||||
renderKey: string;
|
||||
todos: TodoItem[] | null;
|
||||
} | null>(null);
|
||||
|
||||
if (stableTodosRef.current?.renderKey !== renderKey) {
|
||||
stableTodosRef.current = { renderKey, todos };
|
||||
}
|
||||
|
||||
return stableTodosRef.current.todos;
|
||||
}
|
||||
|
||||
// Exported for tests. Given a newest-first list of messages, return a list
|
||||
// with duplicates removed, keeping the first (newest) occurrence of each.
|
||||
export function dedupeNewestFirst(messages: readonly string[]): string[] {
|
||||
|
|
@ -1261,10 +1281,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
const stickyTodos = useMemo(
|
||||
const rawStickyTodos = useMemo(
|
||||
() => getStickyTodos(historyManager.history, pendingHistoryItems),
|
||||
[historyManager.history, pendingHistoryItems],
|
||||
);
|
||||
const stickyTodos = useStableStickyTodos(rawStickyTodos);
|
||||
|
||||
// Terminal tab progress bar (OSC 9;4) for iTerm2/Ghostty
|
||||
useTerminalProgress(streamingState, isToolExecuting(pendingHistoryItems));
|
||||
|
|
@ -1606,24 +1627,39 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
!dialogsVisible &&
|
||||
!isFeedbackDialogOpen &&
|
||||
streamingState !== StreamingState.WaitingForConfirmation;
|
||||
const stickyTodoWidth = Math.min(mainAreaWidth, 64);
|
||||
const stickyTodoMaxVisibleItems =
|
||||
getStickyTodoMaxVisibleItems(terminalHeight);
|
||||
const stickyTodosLayoutKey = shouldShowStickyTodos
|
||||
? getStickyTodosLayoutKey(
|
||||
stickyTodos,
|
||||
stickyTodoWidth,
|
||||
stickyTodoMaxVisibleItems,
|
||||
)
|
||||
: 'hidden';
|
||||
const [controlsHeight, setControlsHeight] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mainControlsRef.current) {
|
||||
setControlsHeight(0);
|
||||
setControlsHeight((previousHeight) =>
|
||||
previousHeight === 0 ? previousHeight : 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullFooterMeasurement = measureElement(mainControlsRef.current);
|
||||
setControlsHeight(fullFooterMeasurement.height);
|
||||
setControlsHeight((previousHeight) =>
|
||||
previousHeight === fullFooterMeasurement.height
|
||||
? previousHeight
|
||||
: fullFooterMeasurement.height,
|
||||
);
|
||||
}, [
|
||||
buffer,
|
||||
terminalWidth,
|
||||
terminalHeight,
|
||||
btwItem,
|
||||
dialogsVisible,
|
||||
shouldShowStickyTodos,
|
||||
stickyTodos,
|
||||
stickyTodosLayoutKey,
|
||||
]);
|
||||
|
||||
// agentViewState is declared earlier (before handleFinalSubmit) so it
|
||||
|
|
|
|||
|
|
@ -6,9 +6,21 @@
|
|||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStickyTodoMaxVisibleItems,
|
||||
STICKY_TODO_MAX_VISIBLE_ITEMS,
|
||||
} from '../utils/todoSnapshot.js';
|
||||
import { StickyTodoList } from './StickyTodoList.js';
|
||||
import type { TodoItem } from './TodoDisplay.js';
|
||||
|
||||
function makeTodos(count: number): TodoItem[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `todo-${index + 1}`,
|
||||
content: `Task ${index + 1}`,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('StickyTodoList', () => {
|
||||
it('keeps each task number attached to the original task after sorting', () => {
|
||||
const todos: TodoItem[] = [
|
||||
|
|
@ -54,4 +66,87 @@ describe('StickyTodoList', () => {
|
|||
output.indexOf('Summarize results'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps long todo lists compact with a hidden item summary', () => {
|
||||
const todos: TodoItem[] = [
|
||||
{
|
||||
id: 'active',
|
||||
content:
|
||||
'This active task has a very long description that should not wrap across multiple rows in the sticky panel',
|
||||
status: 'in_progress',
|
||||
},
|
||||
{
|
||||
id: 'pending-1',
|
||||
content: 'Run cli tests',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'pending-2',
|
||||
content: 'Run core tests',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
content: 'Summarize results',
|
||||
status: 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<StickyTodoList todos={todos} width={42} maxVisibleItems={2} />,
|
||||
);
|
||||
const output = lastFrame() ?? '';
|
||||
const lines = output.split('\n').filter(Boolean);
|
||||
|
||||
expect(output).toContain('Current tasks');
|
||||
expect(output).toContain('This active task has a very long');
|
||||
expect(output).not.toContain('multiple rows in the sticky panel');
|
||||
expect(output).toContain('Run cli tests');
|
||||
expect(output).not.toContain('Run core tests');
|
||||
expect(output).not.toContain('Summarize results');
|
||||
expect(output).toContain('... and 2 more');
|
||||
expect(lines).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('sizes the number column for original todo numbers after sorting', () => {
|
||||
const todos = makeTodos(10).map((todo, index) => ({
|
||||
...todo,
|
||||
status: index === 9 ? ('in_progress' as const) : ('completed' as const),
|
||||
}));
|
||||
|
||||
const { lastFrame } = render(
|
||||
<StickyTodoList todos={todos} width={24} maxVisibleItems={1} />,
|
||||
);
|
||||
const output = lastFrame() ?? '';
|
||||
|
||||
expect(output).toContain('10. ◐ Task 10');
|
||||
expect(output).toContain('... and 9 more');
|
||||
});
|
||||
|
||||
it('derives a viewport-aware visible item count', () => {
|
||||
expect(getStickyTodoMaxVisibleItems(8)).toBe(1);
|
||||
expect(getStickyTodoMaxVisibleItems(15)).toBe(3);
|
||||
expect(getStickyTodoMaxVisibleItems(80)).toBe(5);
|
||||
});
|
||||
|
||||
it('falls back to the maximum visible item count for non-finite maxVisibleItems', () => {
|
||||
const todos = makeTodos(STICKY_TODO_MAX_VISIBLE_ITEMS + 1);
|
||||
|
||||
for (const maxVisibleItems of [Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
const { lastFrame, unmount } = render(
|
||||
<StickyTodoList
|
||||
todos={todos}
|
||||
width={42}
|
||||
maxVisibleItems={maxVisibleItems}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame() ?? '';
|
||||
|
||||
expect(output).toContain(`Task ${STICKY_TODO_MAX_VISIBLE_ITEMS}`);
|
||||
expect(output).not.toContain(`Task ${STICKY_TODO_MAX_VISIBLE_ITEMS + 1}`);
|
||||
expect(output).toContain('... and 1 more');
|
||||
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,17 +5,22 @@
|
|||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { getOrderedStickyTodos } from '../utils/todoSnapshot.js';
|
||||
import {
|
||||
getOrderedStickyTodos,
|
||||
getStickyTodosRenderKey,
|
||||
STICKY_TODO_MAX_VISIBLE_ITEMS,
|
||||
} from '../utils/todoSnapshot.js';
|
||||
import type { TodoItem } from './TodoDisplay.js';
|
||||
|
||||
interface StickyTodoListProps {
|
||||
todos: TodoItem[];
|
||||
width: number;
|
||||
maxVisibleItems?: number;
|
||||
}
|
||||
|
||||
const STATUS_ICONS = {
|
||||
|
|
@ -24,9 +29,21 @@ const STATUS_ICONS = {
|
|||
completed: '●',
|
||||
} as const;
|
||||
|
||||
export const StickyTodoList: React.FC<StickyTodoListProps> = ({
|
||||
function clampVisibleTodoCount(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return STICKY_TODO_MAX_VISIBLE_ITEMS;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(STICKY_TODO_MAX_VISIBLE_ITEMS, Math.floor(value)),
|
||||
);
|
||||
}
|
||||
|
||||
const StickyTodoListComponent: React.FC<StickyTodoListProps> = ({
|
||||
todos,
|
||||
width,
|
||||
maxVisibleItems = STICKY_TODO_MAX_VISIBLE_ITEMS,
|
||||
}) => {
|
||||
const orderedTodos = useMemo(() => getOrderedStickyTodos(todos), [todos]);
|
||||
const todoNumberById = useMemo(
|
||||
|
|
@ -39,7 +56,18 @@ export const StickyTodoList: React.FC<StickyTodoListProps> = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
const numberColumnWidth = String(orderedTodos.length).length + 2;
|
||||
const visibleTodoCount = clampVisibleTodoCount(maxVisibleItems);
|
||||
const visibleTodos = orderedTodos.slice(0, visibleTodoCount);
|
||||
const hiddenTodoCount = orderedTodos.length - visibleTodos.length;
|
||||
const numberColumnWidth =
|
||||
Math.max(
|
||||
...visibleTodos.map(
|
||||
(todo, index) =>
|
||||
(todoNumberById.get(todo.id) ?? `${index + 1}.`).length,
|
||||
),
|
||||
) + 1;
|
||||
// 6 = 2 (status icon column) + 2 (border columns) + 2 (paddingX columns).
|
||||
const contentColumnWidth = Math.max(1, width - numberColumnWidth - 6);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -53,7 +81,7 @@ export const StickyTodoList: React.FC<StickyTodoListProps> = ({
|
|||
<Text color={theme.text.secondary} bold>
|
||||
{t('Current tasks')}
|
||||
</Text>
|
||||
{orderedTodos.map((todo, index) => {
|
||||
{visibleTodos.map((todo, index) => {
|
||||
const todoNumber = todoNumberById.get(todo.id) ?? `${index + 1}.`;
|
||||
const itemColor =
|
||||
todo.status === 'in_progress'
|
||||
|
|
@ -61,18 +89,18 @@ export const StickyTodoList: React.FC<StickyTodoListProps> = ({
|
|||
: Colors.Foreground;
|
||||
|
||||
return (
|
||||
<Box key={todo.id} flexDirection="row" minHeight={1}>
|
||||
<Box key={todo.id} flexDirection="row" height={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}>
|
||||
<Box width={contentColumnWidth}>
|
||||
<Text
|
||||
color={itemColor}
|
||||
strikethrough={todo.status === 'completed'}
|
||||
wrap="wrap"
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{todo.content}
|
||||
</Text>
|
||||
|
|
@ -80,6 +108,28 @@ export const StickyTodoList: React.FC<StickyTodoListProps> = ({
|
|||
</Box>
|
||||
);
|
||||
})}
|
||||
{hiddenTodoCount > 0 && (
|
||||
<Box flexDirection="row" height={1}>
|
||||
<Box width={numberColumnWidth} />
|
||||
<Box width={2} />
|
||||
<Box width={contentColumnWidth}>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{t('... and {{count}} more', {
|
||||
count: String(hiddenTodoCount),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const StickyTodoList = memo(
|
||||
StickyTodoListComponent,
|
||||
(previousProps, nextProps) =>
|
||||
previousProps.width === nextProps.width &&
|
||||
previousProps.maxVisibleItems === nextProps.maxVisibleItems &&
|
||||
getStickyTodosRenderKey(previousProps.todos) ===
|
||||
getStickyTodosRenderKey(nextProps.todos),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
|
|||
import { useAgentViewState } from '../contexts/AgentViewContext.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { getStickyTodoMaxVisibleItems } from '../utils/todoSnapshot.js';
|
||||
|
||||
export const DefaultAppLayout: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
|
|
@ -30,6 +31,9 @@ export const DefaultAppLayout: React.FC = () => {
|
|||
const hasAgents = agents.size > 0;
|
||||
const isAgentTab = activeView !== 'main' && agents.has(activeView);
|
||||
const stickyTodoWidth = Math.min(uiState.mainAreaWidth, 64);
|
||||
const stickyTodoMaxVisibleItems = getStickyTodoMaxVisibleItems(
|
||||
uiState.terminalHeight,
|
||||
);
|
||||
const shouldShowStickyTodos =
|
||||
uiState.stickyTodos !== null &&
|
||||
!uiState.dialogsVisible &&
|
||||
|
|
@ -81,6 +85,7 @@ export const DefaultAppLayout: React.FC = () => {
|
|||
<StickyTodoList
|
||||
todos={uiState.stickyTodos!}
|
||||
width={stickyTodoWidth}
|
||||
maxVisibleItems={stickyTodoMaxVisibleItems}
|
||||
/>
|
||||
)}
|
||||
{uiState.btwItem && (
|
||||
|
|
|
|||
|
|
@ -16,10 +16,14 @@ import { StickyTodoList } from '../components/StickyTodoList.js';
|
|||
import { BtwMessage } from '../components/messages/BtwMessage.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { getStickyTodoMaxVisibleItems } from '../utils/todoSnapshot.js';
|
||||
|
||||
export const ScreenReaderAppLayout: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const stickyTodoWidth = Math.min(uiState.mainAreaWidth, 64);
|
||||
const stickyTodoMaxVisibleItems = getStickyTodoMaxVisibleItems(
|
||||
uiState.terminalHeight,
|
||||
);
|
||||
const shouldShowStickyTodos =
|
||||
uiState.stickyTodos !== null &&
|
||||
!uiState.dialogsVisible &&
|
||||
|
|
@ -47,6 +51,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
|||
<StickyTodoList
|
||||
todos={uiState.stickyTodos!}
|
||||
width={stickyTodoWidth}
|
||||
maxVisibleItems={stickyTodoMaxVisibleItems}
|
||||
/>
|
||||
)}
|
||||
{uiState.btwItem && (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
import { getStickyTodos } from './todoSnapshot.js';
|
||||
import {
|
||||
STICKY_TODO_MAX_VISIBLE_ITEMS,
|
||||
getStickyTodoMaxVisibleItems,
|
||||
getStickyTodos,
|
||||
getStickyTodosLayoutKey,
|
||||
getStickyTodosRenderKey,
|
||||
} from './todoSnapshot.js';
|
||||
|
||||
function makeTodoToolGroup(
|
||||
content: string,
|
||||
|
|
@ -102,11 +108,21 @@ function makeEmptyTodoToolGroup(
|
|||
return item;
|
||||
}
|
||||
|
||||
function makeGeminiHistoryItem(text: string, id: number): HistoryItem {
|
||||
return {
|
||||
type: 'gemini',
|
||||
id,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getStickyTodos', () => {
|
||||
it('returns the latest todo snapshot from history', () => {
|
||||
const history = [
|
||||
makeTodoToolGroup('first task', 1),
|
||||
makeTodoToolGroup('latest history task', 2),
|
||||
makeGeminiHistoryItem('First response after todo', 3),
|
||||
makeGeminiHistoryItem('Second response after todo', 4),
|
||||
] as HistoryItem[];
|
||||
|
||||
expect(getStickyTodos(history, [])).toEqual([
|
||||
|
|
@ -118,19 +134,13 @@ describe('getStickyTodos', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('prefers pending todo snapshots over history', () => {
|
||||
it('does not show sticky todos while a pending todo snapshot is visible', () => {
|
||||
const history = [makeTodoToolGroup('history task', 1)] as HistoryItem[];
|
||||
const pendingHistoryItems = [
|
||||
makeTodoToolGroup('pending task'),
|
||||
] as HistoryItemWithoutId[];
|
||||
|
||||
expect(getStickyTodos(history, pendingHistoryItems)).toEqual([
|
||||
{
|
||||
id: 'todo-pending task',
|
||||
content: 'pending task',
|
||||
status: 'pending',
|
||||
},
|
||||
]);
|
||||
expect(getStickyTodos(history, pendingHistoryItems)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the latest todo snapshot clears the list', () => {
|
||||
|
|
@ -142,6 +152,40 @@ describe('getStickyTodos', () => {
|
|||
expect(getStickyTodos(history, pendingHistoryItems)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps sticky todos hidden when the latest history todo is still the newest item', () => {
|
||||
const history = [
|
||||
makeGeminiHistoryItem('Earlier response', 1),
|
||||
makeTodoToolGroup('latest history task', 2),
|
||||
] as HistoryItem[];
|
||||
|
||||
expect(getStickyTodos(history, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps sticky todos hidden when the latest history todo has only one following item', () => {
|
||||
const history = [
|
||||
makeTodoToolGroup('latest history task', 1),
|
||||
makeGeminiHistoryItem('One response after todo', 2),
|
||||
] as HistoryItem[];
|
||||
|
||||
expect(getStickyTodos(history, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('shows sticky todos once later history has likely moved the inline todo away', () => {
|
||||
const history = [
|
||||
makeTodoToolGroup('latest history task', 1),
|
||||
makeGeminiHistoryItem('First response after todo', 2),
|
||||
makeGeminiHistoryItem('Second response after todo', 3),
|
||||
] as HistoryItem[];
|
||||
|
||||
expect(getStickyTodos(history, [])).toEqual([
|
||||
{
|
||||
id: 'todo-latest history task',
|
||||
content: 'latest history task',
|
||||
status: 'pending',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when the latest history todo snapshot is fully completed', () => {
|
||||
const history = [
|
||||
makeCustomTodoToolGroup(
|
||||
|
|
@ -159,12 +203,14 @@ describe('getStickyTodos', () => {
|
|||
],
|
||||
1,
|
||||
),
|
||||
makeGeminiHistoryItem('First response after todo', 2),
|
||||
makeGeminiHistoryItem('Second response after todo', 3),
|
||||
] as HistoryItem[];
|
||||
|
||||
expect(getStickyTodos(history, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps showing a fully completed pending snapshot until the turn finishes', () => {
|
||||
it('keeps sticky todos hidden for a completed pending snapshot', () => {
|
||||
const history = [
|
||||
makeTodoToolGroup('older history task', 1),
|
||||
] as HistoryItem[];
|
||||
|
|
@ -183,17 +229,124 @@ describe('getStickyTodos', () => {
|
|||
]),
|
||||
] as HistoryItemWithoutId[];
|
||||
|
||||
expect(getStickyTodos(history, pendingHistoryItems)).toEqual([
|
||||
{
|
||||
id: 'todo-1',
|
||||
content: 'Run tests',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'todo-2',
|
||||
content: 'Summarize results',
|
||||
status: 'completed',
|
||||
},
|
||||
]);
|
||||
expect(getStickyTodos(history, pendingHistoryItems)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sticky todo layout helpers', () => {
|
||||
it('keeps the layout key stable for status-only updates', () => {
|
||||
const pendingTodos = [
|
||||
{
|
||||
id: 'todo-1',
|
||||
content: 'Run focused tests',
|
||||
status: 'pending' as const,
|
||||
},
|
||||
];
|
||||
const inProgressTodos = [
|
||||
{
|
||||
id: 'todo-1',
|
||||
content: 'Run focused tests',
|
||||
status: 'in_progress' as const,
|
||||
},
|
||||
];
|
||||
|
||||
expect(getStickyTodosLayoutKey(pendingTodos, 64, 5)).toBe(
|
||||
getStickyTodosLayoutKey(inProgressTodos, 64, 5),
|
||||
);
|
||||
expect(getStickyTodosRenderKey(pendingTodos)).not.toBe(
|
||||
getStickyTodosRenderKey(inProgressTodos),
|
||||
);
|
||||
});
|
||||
|
||||
it('changes the layout key when wrapping-sensitive inputs change', () => {
|
||||
const todos = [
|
||||
{
|
||||
id: 'todo-1',
|
||||
content: 'Run focused tests',
|
||||
status: 'pending' as const,
|
||||
},
|
||||
];
|
||||
|
||||
expect(getStickyTodosLayoutKey(todos, 64, 5)).not.toBe(
|
||||
getStickyTodosLayoutKey(todos, 40, 5),
|
||||
);
|
||||
expect(getStickyTodosLayoutKey(todos, 64, 5)).not.toBe(
|
||||
getStickyTodosLayoutKey(
|
||||
[{ ...todos[0], content: 'Run focused tests and build' }],
|
||||
64,
|
||||
5,
|
||||
),
|
||||
);
|
||||
expect(getStickyTodosLayoutKey(todos, 64, 5)).not.toBe(
|
||||
getStickyTodosLayoutKey(todos, 64, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the layout key stable when only hidden todos change', () => {
|
||||
const todos = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `todo-${index + 1}`,
|
||||
content: `Task ${index + 1}`,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
const changedHiddenTodo = todos.map((todo, index) =>
|
||||
index === 5 ? { ...todo, content: 'Changed hidden task' } : todo,
|
||||
);
|
||||
|
||||
expect(getStickyTodosLayoutKey(todos, 64, 5)).toBe(
|
||||
getStickyTodosLayoutKey(changedHiddenTodo, 64, 5),
|
||||
);
|
||||
expect(getStickyTodosLayoutKey(todos, 64, 5)).toBe(
|
||||
getStickyTodosLayoutKey(
|
||||
[
|
||||
...todos,
|
||||
{
|
||||
id: 'todo-7',
|
||||
content: 'Additional hidden task',
|
||||
status: 'pending' as const,
|
||||
},
|
||||
],
|
||||
64,
|
||||
5,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('changes the layout key when the hidden item summary first appears', () => {
|
||||
const visibleTodos = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `todo-${index + 1}`,
|
||||
content: `Task ${index + 1}`,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
|
||||
expect(getStickyTodosLayoutKey(visibleTodos, 64, 5)).not.toBe(
|
||||
getStickyTodosLayoutKey(
|
||||
[
|
||||
...visibleTodos,
|
||||
{
|
||||
id: 'todo-6',
|
||||
content: 'First hidden task',
|
||||
status: 'pending' as const,
|
||||
},
|
||||
],
|
||||
64,
|
||||
5,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('derives a bounded sticky todo item count from terminal height', () => {
|
||||
expect(getStickyTodoMaxVisibleItems(8)).toBe(1);
|
||||
expect(getStickyTodoMaxVisibleItems(15)).toBe(3);
|
||||
expect(getStickyTodoMaxVisibleItems(80)).toBe(5);
|
||||
});
|
||||
|
||||
it('falls back to the maximum sticky todo item count for invalid terminal heights', () => {
|
||||
expect(getStickyTodoMaxVisibleItems(Number.NaN)).toBe(
|
||||
STICKY_TODO_MAX_VISIBLE_ITEMS,
|
||||
);
|
||||
expect(getStickyTodoMaxVisibleItems(-1)).toBe(
|
||||
STICKY_TODO_MAX_VISIBLE_ITEMS,
|
||||
);
|
||||
expect(getStickyTodoMaxVisibleItems(0)).toBe(STICKY_TODO_MAX_VISIBLE_ITEMS);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,13 +12,40 @@ import type {
|
|||
} from '../types.js';
|
||||
|
||||
type HistoryLikeItem = HistoryItem | HistoryItemWithoutId;
|
||||
type SnapshotSearchResult = TodoItem[] | null | undefined;
|
||||
interface TodoSnapshotSearchResult {
|
||||
itemIndex: number;
|
||||
todos: TodoItem[] | null;
|
||||
}
|
||||
|
||||
type SnapshotSearchResult = TodoSnapshotSearchResult | undefined;
|
||||
|
||||
// This threshold is item-count based, not line-count based. A single long
|
||||
// response can fill the viewport while still counting as one item, so the
|
||||
// sticky panel may stay hidden longer than strictly necessary. That is
|
||||
// preferable to duplicating a recently committed inline TodoWrite result.
|
||||
// On tall terminals, TodoWrite -> short text -> small tool call can still
|
||||
// leave the inline result visible when the sticky panel appears.
|
||||
const MIN_HISTORY_ITEMS_AFTER_TODO_BEFORE_STICKY = 2;
|
||||
export const STICKY_TODO_MAX_VISIBLE_ITEMS = 5;
|
||||
const STICKY_TODO_ROWS_PER_VISIBLE_ITEM = 5;
|
||||
|
||||
const STICKY_TODO_STATUS_PRIORITY: Record<TodoItem['status'], number> = {
|
||||
in_progress: 0,
|
||||
pending: 1,
|
||||
completed: 2,
|
||||
};
|
||||
|
||||
function clampStickyTodoVisibleItems(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return STICKY_TODO_MAX_VISIBLE_ITEMS;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(STICKY_TODO_MAX_VISIBLE_ITEMS, Math.floor(value)),
|
||||
);
|
||||
}
|
||||
|
||||
function extractTodosFromResultDisplay(
|
||||
resultDisplay: unknown,
|
||||
): TodoItem[] | null {
|
||||
|
|
@ -67,7 +94,10 @@ function findLatestTodoSnapshot(
|
|||
const tool = item.tools[toolIndex] as IndividualToolCallDisplay;
|
||||
const todos = extractTodosFromResultDisplay(tool.resultDisplay);
|
||||
if (todos) {
|
||||
return todos.length > 0 ? todos : null;
|
||||
return {
|
||||
itemIndex,
|
||||
todos: todos.length > 0 ? todos : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,21 +109,42 @@ function areAllTodosCompleted(todos: readonly TodoItem[]): boolean {
|
|||
return todos.length > 0 && todos.every((todo) => todo.status === 'completed');
|
||||
}
|
||||
|
||||
function isRecentHistoryTodoSnapshot(
|
||||
snapshotItemIndex: number,
|
||||
historyLength: number,
|
||||
): boolean {
|
||||
const historyItemsAfterSnapshot = historyLength - snapshotItemIndex - 1;
|
||||
return historyItemsAfterSnapshot < MIN_HISTORY_ITEMS_AFTER_TODO_BEFORE_STICKY;
|
||||
}
|
||||
|
||||
export function getStickyTodos(
|
||||
history: readonly HistoryItem[],
|
||||
pendingHistoryItems: readonly HistoryItemWithoutId[],
|
||||
): TodoItem[] | null {
|
||||
const pendingSnapshot = findLatestTodoSnapshot(pendingHistoryItems);
|
||||
if (pendingSnapshot !== undefined) {
|
||||
return pendingSnapshot;
|
||||
}
|
||||
|
||||
const historySnapshot = findLatestTodoSnapshot(history);
|
||||
if (historySnapshot && areAllTodosCompleted(historySnapshot)) {
|
||||
// The pending TodoWrite result is already rendered inline above the
|
||||
// composer, so defer the sticky panel until the turn commits to history.
|
||||
return null;
|
||||
}
|
||||
|
||||
return historySnapshot ?? null;
|
||||
const historySnapshot = findLatestTodoSnapshot(history);
|
||||
if (historySnapshot === undefined || historySnapshot.todos === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ink Static writes committed history to scrollback, and does not expose a
|
||||
// reliable per-item viewport API. Treat very recent TodoWrite snapshots as
|
||||
// still visible so the footer does not duplicate the inline result.
|
||||
if (isRecentHistoryTodoSnapshot(historySnapshot.itemIndex, history.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (areAllTodosCompleted(historySnapshot.todos)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return historySnapshot.todos;
|
||||
}
|
||||
|
||||
export function getOrderedStickyTodos(todos: readonly TodoItem[]): TodoItem[] {
|
||||
|
|
@ -107,3 +158,46 @@ export function getOrderedStickyTodos(todos: readonly TodoItem[]): TodoItem[] {
|
|||
)
|
||||
.map(({ todo }) => todo);
|
||||
}
|
||||
|
||||
export function getStickyTodosRenderKey(
|
||||
todos: readonly TodoItem[] | null,
|
||||
): string {
|
||||
if (!todos) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
todos.map((todo) => [todo.id, todo.content, todo.status]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getStickyTodosLayoutKey(
|
||||
todos: readonly TodoItem[] | null,
|
||||
width: number,
|
||||
maxVisibleItems: number,
|
||||
): string {
|
||||
if (!todos) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
const visibleTodoCount = clampStickyTodoVisibleItems(maxVisibleItems);
|
||||
const visibleTodos = todos.slice(0, visibleTodoCount);
|
||||
const hasHiddenTodos = todos.length > visibleTodos.length;
|
||||
|
||||
return JSON.stringify({
|
||||
width,
|
||||
maxVisibleItems: visibleTodoCount,
|
||||
hasHiddenTodos,
|
||||
todos: visibleTodos.map((todo) => [todo.id, todo.content]),
|
||||
});
|
||||
}
|
||||
|
||||
export function getStickyTodoMaxVisibleItems(terminalHeight: number): number {
|
||||
if (!Number.isFinite(terminalHeight) || terminalHeight <= 0) {
|
||||
return STICKY_TODO_MAX_VISIBLE_ITEMS;
|
||||
}
|
||||
|
||||
return clampStickyTodoVisibleItems(
|
||||
terminalHeight / STICKY_TODO_ROWS_PER_VISIBLE_ITEM,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue