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
81 changes: 81 additions & 0 deletions packages/cli/src/ui/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
streamingResponseLengthRef: { current: 0 },
isReceivingContent: false,
pendingGeminiHistoryItems: [],
terminalWidth: 80,
...overrides,
}) as UIState;

Expand Down Expand Up @@ -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,
Expand Down
24 changes: 22 additions & 2 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Magic number 30 — the narrow-terminal suppression threshold is a bare literal with no named constant or rationale comment. LoadingIndicator independently uses 80 for its internal isNarrowWidth() threshold. If LoadingIndicator is later restyled and grows wider than 30 columns, the suppression threshold silently becomes too aggressive.

Suggested change
uiState.terminalWidth <= 30;
const SUPPRESS_LOADING_INDICATOR_MAX_WIDTH = 30;
const suppressBottomLoadingIndicator =
uiState.streamingState === StreamingState.Responding &&
uiState.terminalWidth <= SUPPRESS_LOADING_INDICATOR_MAX_WIDTH;

— deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 7f77d8d. Extracted to SUPPRESS_LOADING_INDICATOR_MAX_WIDTH with a JSDoc that flags the relationship to LoadingIndicator's independent internal isNarrowWidth() threshold (80) so a future LoadingIndicator restyle has a single, documented knob to adjust.


// Aggregate agent tool tokens from executing tool calls. Only changes when
// a subagent reports progress, so it doesn't drive the animation loop.
Expand Down Expand Up @@ -80,7 +88,7 @@ export const Composer = () => {

return (
<Box flexDirection="column" marginTop={1}>
{!uiState.embeddedShellFocused && (
{!uiState.embeddedShellFocused && !suppressBottomLoadingIndicator && (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This suppresses the whole LoadingIndicator on ultra-narrow terminals while responding. That component also carries the visible esc to cancel affordance, so this removes the only on-screen cancel hint during the active long-running state.

Consider preserving a compact fallback such as esc to cancel, or suppressing only the animation/phrase/timer-heavy parts while keeping the cancel hint visible.

— gpt-5.5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — adopted the minimal-fallback approach. Composer now renders a compact (esc to cancel) text line at paddingLeft={2} whenever the full LoadingIndicator is suppressed (Responding on ≤30 cols). The spinner/phrase/timer remains hidden to avoid layout breakage on a 25-col terminal, but the cancel affordance stays visible. Added two Composer tests asserting the fallback shows when (and only when) the indicator is suppressed. Fixed in 169031d.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in cada422. Switched to the existing t('Esc to cancel') key (option 1) and moved the parentheses outside the t() call so they are layout-only, not translatable. All 9 locales now resolve correctly.

<LoadingIndicator
// Hide loading phrases when enableLoadingPhrases is explicitly false.
// Using === false ensures phrases show by default when undefined.
Expand All @@ -102,6 +110,18 @@ export const Composer = () => {
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 && (
<Box paddingLeft={2}>
<Text color={theme.text.secondary}>({t('Esc to cancel')})</Text>
</Box>
)}

<QueuedMessageDisplay messageQueue={uiState.messageQueue} />

Expand Down
99 changes: 95 additions & 4 deletions packages/cli/src/ui/utils/TableRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('<TableRenderer />', () => {
const output = renderTable(
['项目', 'ANSI', 'Markdown'],
[['中文内容', '\u001b[31mRed\u001b[0m Blue', '**bold** and `code`']],
42,
80,
['left', 'center', 'right'],
);
expectAllLinesToHaveSameVisibleWidth(output);
Expand Down Expand Up @@ -110,19 +110,27 @@ describe('<TableRenderer />', () => {
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', () => {
Expand Down Expand Up @@ -458,6 +466,89 @@ describe('<TableRenderer />', () => {
expect(output).toContain('很长的值一');
});

// ─── Narrow-terminal vertical fallback ───

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The tests cover widths below and above the new strict contentWidth < minHorizontalTableWidth threshold, but not equality. A future off-by-one regression from < to <= would force vertical rendering at the documented threshold while these tests still pass.

Consider adding boundary assertions that exactly 24 for two short columns and exactly 35 for five short columns still render horizontally, optionally paired with 23/34 vertical assertions.

— gpt-5.5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted. Added equality boundary tests at contentWidth=24 (2-col absolute floor — ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH) and contentWidth=35 (5-col column-budget threshold), paired with 23/34 vertical assertions. With the strict < comparator equality must still render horizontally, so a future <<= flip is now caught. Fixed in 169031d.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] 缺少 ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH(24)处的相等边界测试。当前测试覆盖了 contentWidth=20(低于)和 contentWidth=30(高于),但跳过了 contentWidth=24(等于)。如果 < 意外改为 <=,此边界是唯一能捕获的地方。同样缺少 5 列表在 contentWidth=35(列预算阈值)的相等测试。

Suggested change
// ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH is 24.
it('uses horizontal at absolute floor boundary (2 cols, 24 cols)', () => {
const output = renderTable(['A', 'B'], [['x', 'y']], 24);
expect(output).toContain('┌');
});

— deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted. Added the 2-col equality test at contentWidth=24 plus the 5-col equality test at contentWidth=35 (the column-budget threshold), each paired with one-below vertical assertions (23 and 34). All four guard the strict < comparator. Fixed in 169031d.

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(
Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/ui/utils/TableRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -324,7 +331,10 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
});

// ── 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,
Expand Down Expand Up @@ -392,7 +402,18 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
}

const maxRowLines = calculateMaxRowLines();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] calculateMaxRowLines() (iterates every cell through wrapText) runs before the minHorizontalTableWidth check. When contentWidth < minHorizontalTableWidth triggers vertical mode, all prior column-width allocation and wrapping work is discarded. The new column-aware threshold intends to provide a fast-path but doesn't actually short-circuit the expensive computation.

Consider hoisting the threshold check before column-width allocation:

Suggested change
const maxRowLines = calculateMaxRowLines();
const minHorizontalTableWidth = Math.max(
ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH,
colCount * MIN_COLUMN_WIDTH + borderOverhead + SAFETY_MARGIN,
);
if (contentWidth < minHorizontalTableWidth) {
return <Box marginY={1}><Text>{renderVerticalFormat()}</Text></Box>;
}
// Only compute columnWidths and maxRowLines for the horizontal path

— deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 7f77d8d. Hoisted the minHorizontalTableWidth check above calculateMaxRowLines() and early-return to the vertical format. The per-cell wrapText pass (and the column-width allocation right before it) is now skipped whenever the terminal is already too narrow for horizontal layout — that work was discarded on the prior code path. Behavior is unchanged: same threshold, same vertical fallback, same secondary MAX_ROW_LINES check still gates the horizontal path. All 71 tests in Composer.test.tsx + TableRenderer.test.tsx pass; the existing equality-boundary tests at contentWidth 24/34/35 continue to pin the strict < comparator.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] borderOverhead(第 335 行定义为 1 + colCount * 3)在 75 行后被复用于语义不同的目的(格式阈值 vs 可用宽度计算)。仅有一条散文注释连接两者,未直接命名变量。对 borderOverhead 看似无害的样式更改会静默改变水平/垂直格式决策阈值。

Suggested change
colCount * MIN_COLUMN_WIDTH + borderOverhead + SAFETY_MARGIN,
// borderOverhead is also used by minHorizontalTableWidth below for format selection.
const borderOverhead = 1 + colCount * 3;

— deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted via expanded comment rather than a new constant. The current usage isn't a separate magic threshold — it really is the same border overhead being added to the column-width budget — so introducing MIN_HORIZONTAL_TABLE_BORDER_OVERHEAD would just alias the existing name. Instead I expanded the comment at the borderOverhead definition to flag the dual usage and require updating both call sites together. Fixed in 169031d.

);
const useVerticalFormat =
contentWidth < minHorizontalTableWidth || maxRowLines > MAX_ROW_LINES;

// ── Helper: Get alignment for a column ──
const getAlign = (colIndex: number): ColumnAlign =>
Expand Down
Loading