diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 449d042f8cc..ba1b706a458 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -115,6 +115,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => streamingResponseLengthRef: { current: 0 }, isReceivingContent: false, pendingGeminiHistoryItems: [], + terminalWidth: 80, ...overrides, }) as UIState; @@ -202,6 +203,86 @@ describe('Composer', () => { expect(output).not.toContain('Should not show'); }); + // ─── Narrow-terminal suppression (suppressBottomLoadingIndicator) ─── + // The indicator is hidden only when actively Responding on a terminal + // ≤ 30 cols wide. WaitingForConfirmation must NEVER be suppressed. + + it('hides LoadingIndicator when Responding on a 30-col terminal', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + terminalWidth: 30, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).not.toContain('LoadingIndicator'); + }); + + it('hides LoadingIndicator when Responding on a 25-col terminal', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + terminalWidth: 25, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).not.toContain('LoadingIndicator'); + }); + + it('preserves "esc to cancel" fallback when LoadingIndicator is suppressed', () => { + // Even when the full LoadingIndicator is hidden on ultra-narrow + // terminals, the cancel affordance must remain so users can abort. + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + terminalWidth: 25, + }); + + const { lastFrame } = renderComposer(uiState); + + const output = lastFrame(); + expect(output).not.toContain('LoadingIndicator'); + expect(output).toContain('Esc to cancel'); + }); + + it('does not render the esc fallback once the full indicator is visible', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + terminalWidth: 31, + }); + + const { lastFrame } = renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator'); + // The minimal fallback string only appears when the full indicator is + // suppressed — when LoadingIndicator renders, it owns the cancel hint. + expect(output).not.toContain('Esc to cancel'); + }); + + it('shows LoadingIndicator when Responding on a 31-col terminal', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + terminalWidth: 31, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('LoadingIndicator'); + }); + + it('shows LoadingIndicator when WaitingForConfirmation even on a 25-col terminal', () => { + // Confirmation prompts must remain visible regardless of width — the + // user needs to see something is awaiting their input. + const uiState = createMockUIState({ + streamingState: StreamingState.WaitingForConfirmation, + terminalWidth: 25, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('LoadingIndicator'); + }); + it('suppresses thought when waiting for confirmation', () => { const uiState = createMockUIState({ streamingState: StreamingState.WaitingForConfirmation, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index c5bda6bea92..b26aac50349 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, useIsScreenReaderEnabled } from 'ink'; +import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import { useCallback, useState } from 'react'; import { LoadingIndicator } from './LoadingIndicator.js'; import { InputPrompt } from './InputPrompt.js'; @@ -15,6 +15,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; +import { theme } from '../semantic-colors.js'; import { StreamingState, type HistoryItemToolGroup } from '../types.js'; import { FeedbackDialog } from '../FeedbackDialog.js'; import { t } from '../../i18n/index.js'; @@ -38,6 +39,13 @@ export const Composer = () => { const isStreaming = uiState.streamingState === StreamingState.Responding || uiState.streamingState === StreamingState.WaitingForConfirmation; + // `isStreaming` covers Responding|WaitingForConfirmation, but we only + // suppress during Responding (active token output). A confirmation prompt + // must remain visible regardless of width. Drop the redundant `isStreaming` + // guard so future expansions of `isStreaming` don't silently widen suppression. + const suppressBottomLoadingIndicator = + uiState.streamingState === StreamingState.Responding && + uiState.terminalWidth <= 30; // Aggregate agent tool tokens from executing tool calls. Only changes when // a subagent reports progress, so it doesn't drive the animation loop. @@ -80,7 +88,7 @@ export const Composer = () => { return ( - {!uiState.embeddedShellFocused && ( + {!uiState.embeddedShellFocused && !suppressBottomLoadingIndicator && ( { isReceivingContent={isReceivingContent} /> )} + {/* + * Narrow-terminal fallback: when the full LoadingIndicator is suppressed + * (≤30 cols, actively Responding) we still surface a minimal `esc to + * cancel` hint so users on ultra-narrow terminals retain the cancel + * affordance during long-running calls. The full timer/spinner/phrase + * UI is still suppressed to avoid layout breakage. + */} + {!uiState.embeddedShellFocused && suppressBottomLoadingIndicator && ( + + ({t('Esc to cancel')}) + + )} diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx index 51bc466d8b4..b5cc235f8b7 100644 --- a/packages/cli/src/ui/utils/TableRenderer.test.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -61,7 +61,7 @@ describe('', () => { const output = renderTable( ['项目', 'ANSI', 'Markdown'], [['中文内容', '\u001b[31mRed\u001b[0m Blue', '**bold** and `code`']], - 42, + 80, ['left', 'center', 'right'], ); expectAllLinesToHaveSameVisibleWidth(output); @@ -110,19 +110,27 @@ describe('', () => { expect(output).toContain('wrap'); }); + // Alignment tests use contentWidth ≥ 60 so horizontal mode is exercised + // (vertical mode renders key:value pairs and bypasses pad alignment). + it('respects left alignment', () => { - const output = renderTable(['Header'], [['left']], 30, ['left']); + const output = renderTable(['Header'], [['left']], 60, ['left']); expect(output).toContain('left'); + // Horizontal-mode guard so this test fails loudly if the threshold + // is bumped back above 60 and the test silently degrades to vertical. + expect(output).toContain('┌'); }); it('respects center alignment', () => { - const output = renderTable(['Header'], [['center']], 30, ['center']); + const output = renderTable(['Header'], [['center']], 60, ['center']); expect(output).toContain('center'); + expect(output).toContain('┌'); }); it('respects right alignment', () => { - const output = renderTable(['Header'], [['right']], 30, ['right']); + const output = renderTable(['Header'], [['right']], 60, ['right']); expect(output).toContain('right'); + expect(output).toContain('┌'); }); it('handles multiple columns with mixed alignment', () => { @@ -458,6 +466,89 @@ describe('', () => { expect(output).toContain('很长的值一'); }); + // ─── Narrow-terminal vertical fallback ─── + describe('horizontal/vertical mode threshold', () => { + it('uses horizontal mode at ample width (60 cols, 2 short cols)', () => { + const output = renderTable(['A', 'B'], [['x', 'y']], 60); + // Horizontal markers must be present. + expect(output).toContain('┌'); + expect(output).toContain('└'); + expect(output).toContain('│'); + }); + + it('falls back to vertical below the absolute floor (≤24 cols)', () => { + // ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH is 24. + const output = renderTable(['A', 'B'], [['x', 'y']], 20); + // No horizontal table border characters in vertical mode. + expect(output).not.toContain('┌'); + expect(output).not.toContain('└'); + // Vertical mode renders "label:" pairs. + expect(output).toContain('A:'); + expect(output).toContain('B:'); + expect(output).toContain('x'); + expect(output).toContain('y'); + }); + + it('promotes to horizontal once column-budget threshold is met (2 cols, ~30 cols)', () => { + // borderOverhead = 1 + 2*3 = 7; minHorizontal = max(24, 2*3 + 7 + 4) = 24 + // so 30 cols comfortably fits horizontal. + const output = renderTable(['A', 'B'], [['x', 'y']], 30); + expect(output).toContain('┌'); + }); + + // Boundary equality tests: the comparator is strict `<`, so the threshold + // value itself must still render horizontally. Without these, a future + // off-by-one change from `<` to `<=` would slip through the < / > pair. + it('renders horizontal at exact absolute floor (2 cols, contentWidth=24)', () => { + // ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH is 24. With strict `<`, equality + // means horizontal mode is selected. + const output = renderTable(['A', 'B'], [['x', 'y']], 24); + expect(output).toContain('┌'); + expect(output).toContain('└'); + }); + + it('falls back to vertical one below absolute floor (2 cols, contentWidth=23)', () => { + const output = renderTable(['A', 'B'], [['x', 'y']], 23); + expect(output).not.toContain('┌'); + expect(output).toContain('A:'); + }); + + it('renders horizontal at exact column-budget threshold (5 cols, contentWidth=35)', () => { + // 5 cols → minHorizontal = 5*3 + (1+5*3) + 4 = 35. Equality must still + // render horizontally under the strict `<` comparator. + const output = renderTable( + ['A', 'B', 'C', 'D', 'E'], + [['1', '2', '3', '4', '5']], + 35, + ); + expect(output).toContain('┌'); + }); + + it('falls back to vertical one below column-budget threshold (5 cols, contentWidth=34)', () => { + const output = renderTable( + ['A', 'B', 'C', 'D', 'E'], + [['1', '2', '3', '4', '5']], + 34, + ); + expect(output).not.toContain('┌'); + expect(output).toContain('A:'); + }); + + it('forces vertical for many-column tables on narrow terminals', () => { + // 5 cols → minHorizontal = 5*3 + (1+5*3) + 4 = 35; 30 cols is below that. + const output = renderTable( + ['A', 'B', 'C', 'D', 'E'], + [['1', '2', '3', '4', '5']], + 30, + ); + expect(output).not.toContain('┌'); + // Should still surface the data. + expect(output).toContain('A:'); + expect(output).toContain('1'); + expect(output).toContain('5'); + }); + }); + it('stays stable across multiple content widths', () => { for (const width of [8, 10, 12, 16, 20, 30, 40, 60]) { const output = renderTable( diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index e65ed9b1086..623ed99bf06 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -18,6 +18,13 @@ const MIN_COLUMN_WIDTH = 3; /** Maximum number of lines per row before switching to vertical format */ const MAX_ROW_LINES = 4; +/** + * Below this width the column-aware budget (see `minHorizontalTableWidth` + * below) is bypassed and we always switch to vertical: even a 1-column + * table is barely readable horizontally under ~24 cols of content. + */ +const ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH = 24; + /** Safety margin to account for terminal resize races */ const SAFETY_MARGIN = 4; @@ -324,7 +331,10 @@ export const TableRenderer: React.FC = ({ }); // ── Step 2: Calculate available space ── - // Border overhead: │ content │ content │ = 1 + (width + 3) per column + // Border overhead: │ content │ content │ = 1 + (width + 3) per column. + // NOTE: this value is reused below in the horizontal-vs-vertical threshold + // (`minHorizontalTableWidth`). Any change to this formula will silently + // shift the layout threshold — adjust both call sites together. const borderOverhead = 1 + colCount * 3; const availableWidth = Math.max( contentWidth - borderOverhead - SAFETY_MARGIN, @@ -392,7 +402,18 @@ export const TableRenderer: React.FC = ({ } const maxRowLines = calculateMaxRowLines(); - const useVerticalFormat = maxRowLines > MAX_ROW_LINES; + // Column-aware horizontal-vs-vertical decision: a horizontal table needs + // at least `MIN_COLUMN_WIDTH` per column plus the border overhead computed + // above, with a safety margin. This avoids the prior fixed 60-col floor + // that forced vertical mode for a 2-col table on a 50-col terminal even + // when content fit comfortably. The downstream `maxLineWidth` safety + // check still catches content that would actually overflow. + const minHorizontalTableWidth = Math.max( + ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH, + colCount * MIN_COLUMN_WIDTH + borderOverhead + SAFETY_MARGIN, + ); + const useVerticalFormat = + contentWidth < minHorizontalTableWidth || maxRowLines > MAX_ROW_LINES; // ── Helper: Get alignment for a column ── const getAlign = (colIndex: number): ColumnAlign =>