-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(tui): skip cross-group tool merge in <Static> mode to eliminate screen flash #4795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
437f358
03b545a
f82f622
a74d3e8
5865d26
cda2c94
b8e3726
4b79738
f23ca79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { | |
| import { AppContext } from '../contexts/AppContext.js'; | ||
| import { CompactModeProvider } from '../contexts/CompactModeContext.js'; | ||
| import { OverflowProvider } from '../contexts/OverflowContext.js'; | ||
| import { ToolCallStatus } from '../types.js'; | ||
|
|
||
| const staticPropsSpy = vi.fn(); | ||
| const staticItemsSpy = vi.fn(); | ||
|
|
@@ -238,7 +239,7 @@ const createUIActions = (): UIActions => | |
| const renderMainContent = (uiState: UIState) => | ||
| render( | ||
| <AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}> | ||
| <CompactModeProvider value={{ compactMode: false }}> | ||
| <CompactModeProvider value={{ compactMode: false, compactInline: false }}> | ||
| <UIActionsContext.Provider value={createUIActions()}> | ||
| <UIStateContext.Provider value={uiState}> | ||
| <OverflowProvider> | ||
|
|
@@ -277,7 +278,9 @@ describe('<MainContent />', () => { | |
|
|
||
| rerender( | ||
| <AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}> | ||
| <CompactModeProvider value={{ compactMode: false }}> | ||
| <CompactModeProvider | ||
| value={{ compactMode: false, compactInline: false }} | ||
| > | ||
| <UIActionsContext.Provider value={createUIActions()}> | ||
| <UIStateContext.Provider | ||
| value={createUIState({ | ||
|
|
@@ -427,7 +430,9 @@ describe('<MainContent />', () => { | |
| staticItemsSpy.mockClear(); | ||
| rerender( | ||
| <AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}> | ||
| <CompactModeProvider value={{ compactMode: false }}> | ||
| <CompactModeProvider | ||
| value={{ compactMode: false, compactInline: false }} | ||
| > | ||
| <UIActionsContext.Provider value={createUIActions()}> | ||
| <UIStateContext.Provider | ||
| value={createUIState({ | ||
|
|
@@ -485,7 +490,9 @@ describe('<MainContent />', () => { | |
| // meant to avoid. | ||
| rerender( | ||
| <AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}> | ||
| <CompactModeProvider value={{ compactMode: false }}> | ||
| <CompactModeProvider | ||
| value={{ compactMode: false, compactInline: false }} | ||
| > | ||
| <UIActionsContext.Provider value={createUIActions()}> | ||
| <UIStateContext.Provider | ||
| value={createUIState({ history, historyRemountKey: 2 })} | ||
|
|
@@ -551,7 +558,9 @@ describe('<MainContent />', () => { | |
| // someone correctly drives the reset off the model dimension instead. | ||
| rerender( | ||
| <AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}> | ||
| <CompactModeProvider value={{ compactMode: false }}> | ||
| <CompactModeProvider | ||
| value={{ compactMode: false, compactInline: false }} | ||
| > | ||
| <UIActionsContext.Provider value={createUIActions()}> | ||
| <UIStateContext.Provider | ||
| value={createUIState({ | ||
|
|
@@ -573,6 +582,145 @@ describe('<MainContent />', () => { | |
| expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(TOTAL); | ||
| }); | ||
|
|
||
| describe('compact mode + Static path (useTerminalBuffer=false)', () => { | ||
|
zzhenyao marked this conversation as resolved.
|
||
| it('skips cross-group merge in Static mode to avoid screen flash (issue #4794)', () => { | ||
|
zzhenyao marked this conversation as resolved.
|
||
| staticItemsSpy.mockClear(); | ||
| historyItemDisplayPropsSpy.mockClear(); | ||
|
|
||
| // Two consecutive tool_groups that mergeCompactToolGroups would normally | ||
| // consolidate into a single item. In Static mode this merge MUST be | ||
| // skipped because Ink's <Static> is append-only and cannot handle | ||
| // item-count changes without a full clearTerminal + remount (flash). | ||
| const history = [ | ||
| { | ||
| id: 1, | ||
| type: 'tool_group' as const, | ||
| tools: [ | ||
| { | ||
| callId: 'a1', | ||
| name: 'bash', | ||
| description: 'run ls', | ||
| status: ToolCallStatus.Success, | ||
| resultDisplay: undefined, | ||
| confirmationDetails: undefined, | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| id: 2, | ||
| type: 'tool_group' as const, | ||
| tools: [ | ||
| { | ||
| callId: 'b1', | ||
| name: 'bash', | ||
| description: 'run wc', | ||
| status: ToolCallStatus.Success, | ||
| resultDisplay: undefined, | ||
| confirmationDetails: undefined, | ||
| }, | ||
| ], | ||
| }, | ||
| ]; | ||
|
|
||
| // Render with compactMode=true and useTerminalBuffer=false (default Static path). | ||
| render( | ||
| <AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}> | ||
| <CompactModeProvider | ||
| value={{ compactMode: true, compactInline: false }} | ||
| > | ||
| <UIActionsContext.Provider value={createUIActions()}> | ||
| <UIStateContext.Provider value={createUIState({ history })}> | ||
|
zzhenyao marked this conversation as resolved.
|
||
| <OverflowProvider> | ||
| <MainContent /> | ||
| </OverflowProvider> | ||
| </UIStateContext.Provider> | ||
| </UIActionsContext.Provider> | ||
| </CompactModeProvider> | ||
| </AppContext.Provider>, | ||
| ); | ||
|
|
||
| // 3 prefix items (header / debug / notifications) + 2 raw history items | ||
| // The 2 tool_groups should NOT be merged into 1. | ||
| expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(5); | ||
| // Verify both tool_group ids are present via historyItemDisplayPropsSpy. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This test (and the preceding one at line 585) asserts item count and IDs via Consider adding: // Verify compactLabel is undefined for all rendered tool_groups in Static mode.
const compactLabels = historyItemDisplayPropsSpy.mock.calls
.map((call) => call[0].compactLabel)
.filter((label) => label !== undefined);
expect(compactLabels).toHaveLength(0);— qwen3.7-max via Qwen Code /review |
||
| const renderedIds = historyItemDisplayPropsSpy.mock.calls | ||
| .map((call) => call[0].item.id) | ||
| .filter((id) => id === 1 || id === 2); | ||
| expect(renderedIds).toEqual([1, 2]); | ||
| }); | ||
|
|
||
| it('preserves tool_use_summary as standalone line when merge is skipped (Static mode)', () => { | ||
| staticItemsSpy.mockClear(); | ||
| historyItemDisplayPropsSpy.mockClear(); | ||
|
|
||
| // History with a tool_group followed by its tool_use_summary, then another tool_group. | ||
| // When merge is skipped (Static mode), absorbedCallIds returns EMPTY_ABSORBED_CALL_IDS | ||
| // so isSummaryAbsorbed returns false — the summary MUST pass through as a standalone | ||
| // item and render as `● <label>` line in HistoryItemDisplay. | ||
| const history = [ | ||
| { | ||
| id: 1, | ||
| type: 'tool_group' as const, | ||
| tools: [ | ||
| { | ||
| callId: 'a1', | ||
| name: 'bash', | ||
| description: 'run ls', | ||
| status: ToolCallStatus.Success, | ||
| resultDisplay: undefined, | ||
| confirmationDetails: undefined, | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| id: 2, | ||
| type: 'tool_use_summary' as const, | ||
| precedingToolUseIds: ['a1'], | ||
| summary: 'Searched in auth/', | ||
| }, | ||
| { | ||
| id: 3, | ||
| type: 'tool_group' as const, | ||
| tools: [ | ||
| { | ||
| callId: 'b1', | ||
| name: 'bash', | ||
| description: 'run wc', | ||
| status: ToolCallStatus.Success, | ||
| resultDisplay: undefined, | ||
| confirmationDetails: undefined, | ||
| }, | ||
| ], | ||
| }, | ||
| ]; | ||
|
|
||
| render( | ||
| <AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}> | ||
| <CompactModeProvider | ||
| value={{ compactMode: true, compactInline: false }} | ||
| > | ||
| <UIActionsContext.Provider value={createUIActions()}> | ||
| <UIStateContext.Provider value={createUIState({ history })}> | ||
| <OverflowProvider> | ||
| <MainContent /> | ||
| </OverflowProvider> | ||
| </UIStateContext.Provider> | ||
| </UIActionsContext.Provider> | ||
| </CompactModeProvider> | ||
| </AppContext.Provider>, | ||
| ); | ||
|
|
||
| // 3 prefix items (header / debug / notifications) + 3 raw history items | ||
| // (tool_group + tool_use_summary + tool_group). The summary must NOT be dropped. | ||
| expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(6); | ||
| // Verify all three history item ids are present. | ||
| const renderedIds = historyItemDisplayPropsSpy.mock.calls | ||
| .map((call) => call[0].item.id) | ||
| .filter((id) => id === 1 || id === 2 || id === 3); | ||
| expect(renderedIds).toEqual([1, 2, 3]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('virtual viewport path (ui.useTerminalBuffer)', () => { | ||
| it('renders ScrollableList and skips <Static> entirely when useTerminalBuffer is true', () => { | ||
| staticPropsSpy.mockClear(); | ||
|
|
@@ -689,7 +837,9 @@ describe('<MainContent />', () => { | |
| // Flip activePtyId; identical re-render except this one streaming-state field. | ||
| rerender( | ||
| <AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}> | ||
| <CompactModeProvider value={{ compactMode: false }}> | ||
| <CompactModeProvider | ||
| value={{ compactMode: false, compactInline: false }} | ||
| > | ||
| <UIActionsContext.Provider value={createUIActions()}> | ||
| <UIStateContext.Provider | ||
| value={createUIState({ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.