Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ca.js
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,7 @@ export default {
'Found {{count}} TOML command file:': '{{count}} TOML-Befehlsdatei gefunden:',
'Found {{count}} TOML command files:':
'{{count}} TOML-Befehlsdateien gefunden:',
'Current tasks': 'Aktuelle Aufgaben',
'... and {{count}} more': '... und {{count}} weitere',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'Das TOML-Format ist veraltet. Möchten Sie sie ins Markdown-Format migrieren?',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1689,6 +1689,7 @@ export default {
'Command Format Migration': 'Command Format Migration',
'Found {{count}} TOML command file:': 'Found {{count}} TOML command file:',
'Found {{count}} TOML command files:': 'Found {{count}} TOML command files:',
'Current tasks': 'Current tasks',
'... and {{count}} more': '... and {{count}} more',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'The TOML format is deprecated. Would you like to migrate them to Markdown format?',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -1671,6 +1671,7 @@ export default {
'Trouvé {{count}} fichier de commande TOML :',
'Found {{count}} TOML command files:':
'Trouvé {{count}} fichiers de commande TOML :',
'Current tasks': 'Tâches actuelles',
'... and {{count}} more': '... et {{count}} de plus',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'Le format TOML est obsolète. Souhaitez-vous les migrer vers le format Markdown ?',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:':
'セッションの続行方法を選択してください:',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -1643,6 +1643,7 @@ export default {
'Encontrado {{count}} arquivo de comando TOML:',
'Found {{count}} TOML command files:':
'Encontrados {{count}} arquivos de comando TOML:',
'Current tasks': 'Tarefas atuais',
'... and {{count}} more': '... e mais {{count}}',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'O formato TOML está obsoleto. Você gostaria de migrá-los para o formato Markdown?',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ru.js
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,7 @@ export default {
'Found {{count}} TOML command file:': 'Найден {{count}} файл команд TOML:',
'Found {{count}} TOML command files:':
'Найдено {{count}} файлов команд TOML:',
'Current tasks': 'Текущие задачи',
'... and {{count}} more': '... и ещё {{count}}',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'Формат TOML устарел. Хотите перенести их в формат Markdown?',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,7 @@ export default {
'Command Format Migration': '命令格式遷移',
'Found {{count}} TOML command file:': '發現 {{count}} 個 TOML 命令文件:',
'Found {{count}} TOML command files:': '發現 {{count}} 個 TOML 命令文件:',
'Current tasks': '目前任務',
'... and {{count}} more': '... 以及其他 {{count}} 個',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'TOML 格式已棄用。是否將它們遷移到 Markdown 格式?',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,7 @@ export default {
'Command Format Migration': '命令格式迁移',
'Found {{count}} TOML command file:': '发现 {{count}} 个 TOML 命令文件:',
'Found {{count}} TOML command files:': '发现 {{count}} 个 TOML 命令文件:',
'Current tasks': '当前任务',
'... and {{count}} more': '... 以及其他 {{count}} 个',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'TOML 格式已弃用。是否将它们迁移到 Markdown 格式?',
Expand Down
77 changes: 76 additions & 1 deletion packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -1351,6 +1351,43 @@ describe('AppContainer State Management', () => {
describe('Terminal Height Calculation', () => {
const mockedMeasureElement = measureElement as Mock;
const mockedUseTerminalSize = useTerminalSize as Mock;
const makeTodoHistory = (
status: 'pending' | 'in_progress' | 'completed',
): HistoryItem[] => [
{
type: 'tool_group',
id: 1,
tools: [
{
callId: 'todo-1',
name: 'TodoWrite',
description: 'Update todos',
resultDisplay: {
type: 'todo_list',
todos: [
{
id: 'todo-1',
content: 'Run focused tests',
status,
},
],
},
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
},
{
type: 'gemini',
id: 2,
text: 'First response after todo',
},
{
type: 'gemini',
id: 3,
text: 'Second response after todo',
},
];

it('should prevent terminal height from being less than 1', () => {
const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty');
Expand Down Expand Up @@ -1386,6 +1423,44 @@ describe('AppContainer State Management', () => {
// Check the height argument specifically
expect(lastCall[2]).toBe(1);
});

it('does not remeasure footer height for sticky todo status-only updates', () => {
const historyManager = {
history: makeTodoHistory('pending'),
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
truncateToItem: vi.fn(),
};
mockedUseHistory.mockReturnValue(historyManager);
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 24 });
mockedMeasureElement.mockReturnValue({ width: 80, height: 4 });

const view = render(
<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', () => {
Expand Down
48 changes: 42 additions & 6 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -160,6 +166,20 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
});
}

function useStableStickyTodos(todos: TodoItem[] | null): TodoItem[] | null {
const renderKey = getStickyTodosRenderKey(todos);
const stableTodosRef = useRef<{
renderKey: string;
todos: TodoItem[] | null;
} | null>(null);

if (stableTodosRef.current?.renderKey !== renderKey) {
stableTodosRef.current = { renderKey, todos };
}

return stableTodosRef.current.todos;
}

// Exported for tests. Given a newest-first list of messages, return a list
// with duplicates removed, keeping the first (newest) occurrence of each.
export function dedupeNewestFirst(messages: readonly string[]): string[] {
Expand Down Expand Up @@ -1230,10 +1250,11 @@ export const AppContainer = (props: AppContainerProps) => {
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const stickyTodos = useMemo(
const rawStickyTodos = useMemo(
() => getStickyTodos(historyManager.history, pendingHistoryItems),
[historyManager.history, pendingHistoryItems],
);
const stickyTodos = useStableStickyTodos(rawStickyTodos);

// Terminal tab progress bar (OSC 9;4) for iTerm2/Ghostty
useTerminalProgress(streamingState, isToolExecuting(pendingHistoryItems));
Expand Down Expand Up @@ -1573,24 +1594,39 @@ export const AppContainer = (props: AppContainerProps) => {
!dialogsVisible &&
!isFeedbackDialogOpen &&
streamingState !== StreamingState.WaitingForConfirmation;
const stickyTodoWidth = Math.min(mainAreaWidth, 64);
const stickyTodoMaxVisibleItems =
getStickyTodoMaxVisibleItems(terminalHeight);
const stickyTodosLayoutKey = shouldShowStickyTodos
? getStickyTodosLayoutKey(
stickyTodos,
stickyTodoWidth,
stickyTodoMaxVisibleItems,
)
: 'hidden';
const [controlsHeight, setControlsHeight] = useState(0);

useLayoutEffect(() => {
if (!mainControlsRef.current) {
setControlsHeight(0);
setControlsHeight((previousHeight) =>
previousHeight === 0 ? previousHeight : 0,
);
return;
}

const fullFooterMeasurement = measureElement(mainControlsRef.current);
setControlsHeight(fullFooterMeasurement.height);
setControlsHeight((previousHeight) =>
previousHeight === fullFooterMeasurement.height
? previousHeight
: fullFooterMeasurement.height,
);
}, [
buffer,
terminalWidth,
terminalHeight,
btwItem,
dialogsVisible,
shouldShowStickyTodos,
stickyTodos,
stickyTodosLayoutKey,
]);

// agentViewState is declared earlier (before handleFinalSubmit) so it
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/src/ui/components/StickyTodoList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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();
}
});
});
Loading
Loading