mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(cli): tighten VP-mode controls footprint and fix shell tool indicator overlap (#6931)
* fix(cli): fix VP-mode controls footprint, indicator overlap, transcript crash, and scroll clipping
VP mode is slated to become the default renderer. Several UX issues block that:
- Shell tool status indicator overlapped / sat flush against the command text: the indicator Box used minWidth without flexShrink, so Yoga could compress it below its reserved column when the flexGrow'd command sibling applied pressure. Add flexShrink={0} (mirrors ToolElapsedTime in the same row) and reserve 3 columns (glyph + two trailing spaces) so the tool name never sits flush against the glyph.
- Sticky todo panel + live agent panel together consumed the whole viewport, leaving no room for the conversation. In VP mode cap the sticky todo list to 2 items and the live agent panel to 3 rows, freeing ~3 extra rows for the conversation on a 24-row terminal. Introduce LIVE_AGENT_PANEL_VP_MAX_ROWS as the single source of truth for the VP panel row budget, referenced by both the layout (rendered maxRows) and the composer keyboard handler (its candidate window), so the two can't drift. Keep the AppContainer layout-key todo cap in lockstep with the rendered value.
- Ctrl+O transcript crashed ("useStreamingContext must be used within a StreamingContextProvider") whenever the frozen snapshot held a tool in the Executing state: TranscriptView renders as a sibling of <App/>, which owns the StreamingContext.Provider, so the transcript subtree had no provider and the executing tool's spinner threw. Wrap the TranscriptView branch in its own StreamingContext.Provider.
- VP viewport bottom was clipped during a pure streaming turn: the controls height is measured one frame late, and its measure effect's deps omitted the composer's own dynamic height inputs (streamingState, embeddedShellFocused, queued messages, input-area mount). With buffer/todo/agent all steady, the loading block appearing left controlsHeight too small and availableTerminalHeight too large until an unrelated dep re-triggered the measure. Add those deps (excluding the per-second elapsedTime / loading phrase, which don't change row count).
All rendering changes gate on useTerminalBuffer; non-VP rendering is unchanged.
Generated with AI
Co-authored-by: Qwen-Coder <[email protected]>
* fix(cli): extract shared VP todo cap constant and fix test mock drift
Review feedback follow-up:
- Extract STICKY_TODO_VP_MAX_VISIBLE_ITEMS (= 2) in todoSnapshot.ts
and add getStickyTodoMaxVisibleItemsForMode() as single source of
truth for both DefaultAppLayout (render) and AppContainer (layout
key), preventing silent drift between the two sites.
- CompactToolGroupDisplay.test.tsx mock now uses importOriginal() to
keep STATUS_INDICATOR_WIDTH in sync with production.
- Add tests for getStickyTodoMaxVisibleItemsForMode (VP cap, non-VP
passthrough, floor behavior).
- Update PR body to accurately describe which changes are VP-gated
vs. apply to both renderers.
* fix(cli): migrate ScreenReaderAppLayout to shared VP todo cap
ScreenReaderAppLayout still called getStickyTodoMaxVisibleItems directly,
missing the VP-mode cap. VP + screen-reader is a reachable combination,
so migrate to getStickyTodoMaxVisibleItemsForMode for consistency.
* fix(cli): make VP panel caps height-aware instead of hardcoded
Replace static VP-mode caps with height-aware functions:
- Sticky todo panel: was flat 2 rows, now clamp(floor(h/12), 2, 4).
Short terminals (<=24 rows) still get 2; tall ones (>=48) get up to 4.
- Live agent panel: was flat 3 rows, now clamp(floor(h/10), 3, 5).
Short terminals (<=30 rows) still get 3; tall ones (>=50) get up to 5.
Both caps stay in lockstep between render and keyboard-nav sites via
the same shared functions (getStickyTodoMaxVisibleItemsForMode and
getLiveAgentPanelVpMaxRows).
* fix(cli): allow Shift+Enter for newline in VP mode input box
Two key bindings intercepted Shift+Enter before BaseTextInput's NEWLINE
handler could run:
1. ACCEPT_SUGGESTION's return binding lacked shift: false, so Shift+Enter
was captured as 'accept completion suggestion' whenever the slash-command
or @-file completion dropdown was visible. Added shift: false.
2. The isPerfectMatch check used Command.RETURN (no modifier constraints)
instead of Command.SUBMIT (which requires shift: false), causing
Shift+Enter to submit when the buffer exactly matched a slash command.
Changed to Command.SUBMIT.
* test(cli): add unit tests for getLiveAgentPanelVpMaxRows
Cover min rows on short terminals, growth with height, max cap on
tall terminals, and invalid height fallback.
* test(cli): sync keyMatchers.test.ts with ACCEPT_SUGGESTION shift: false
1. Fix stale originalMatchers reference to include !key.shift
2. Add createKey('return', { shift: true }) as negative test case
---------
Co-authored-by: 秦奇 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
This commit is contained in:
12 files changed
+187
-25
No files matched your search
@@ -172,7 +172,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
],
|
||||
|
||||
// Auto-completion
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
[Command.ACCEPT_SUGGESTION]: [
|
||||
{ key: 'tab' },
|
||||
{ key: 'return', ctrl: false, shift: false },
|
||||
],
|
||||
// Completion navigation: arrows + readline/Vim-style Ctrl+P/Ctrl+N
|
||||
[Command.COMPLETION_UP]: [
|
||||
{ key: 'up', shift: false },
|
||||
|
||||
@@ -81,7 +81,7 @@ import {
|
||||
import { loadLowlight } from './utils/lowlightLoader.js';
|
||||
import {
|
||||
getStickyTodos,
|
||||
getStickyTodoMaxVisibleItems,
|
||||
getStickyTodoMaxVisibleItemsForMode,
|
||||
getStickyTodosLayoutKey,
|
||||
getStickyTodosRenderKey,
|
||||
} from './utils/todoSnapshot.js';
|
||||
@@ -202,6 +202,7 @@ import {
|
||||
} from './hooks/useExtensionUpdates.js';
|
||||
import { useProviderUpdates } from './hooks/useProviderUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
import {
|
||||
RenderModeProvider,
|
||||
type RenderMode,
|
||||
@@ -2984,8 +2985,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
!isFeedbackDialogOpen &&
|
||||
streamingState === StreamingState.Responding;
|
||||
const stickyTodoWidth = Math.min(mainAreaWidth, 64);
|
||||
const stickyTodoMaxVisibleItems =
|
||||
getStickyTodoMaxVisibleItems(terminalHeight);
|
||||
const stickyTodoMaxVisibleItems = getStickyTodoMaxVisibleItemsForMode(
|
||||
terminalHeight,
|
||||
useTerminalBuffer,
|
||||
);
|
||||
const stickyTodosLayoutKey = shouldShowStickyTodos
|
||||
? getStickyTodosLayoutKey(
|
||||
stickyTodos,
|
||||
@@ -3026,6 +3029,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
dialogsVisible,
|
||||
stickyTodosLayoutKey,
|
||||
liveAgentPanelLayoutKey,
|
||||
// Composer height also shifts with these; without them the footer isn't
|
||||
// re-measured during a streaming turn and the VP viewport bottom clips.
|
||||
// (elapsedTime/currentLoadingPhrase excluded: they tick without changing rows.)
|
||||
streamingState,
|
||||
embeddedShellFocused,
|
||||
messageQueue.length,
|
||||
isInputActive,
|
||||
]);
|
||||
|
||||
// agentViewState is declared earlier (before handleFinalSubmit) so it
|
||||
@@ -4510,10 +4520,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
<TerminalOutputProvider value={writeRaw}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
{transcriptFreeze ? (
|
||||
<TranscriptView
|
||||
items={transcriptItems}
|
||||
useAlternateScreen={!useTerminalBuffer}
|
||||
/>
|
||||
// TranscriptView renders as a sibling of <App/>, which
|
||||
// owns the StreamingContext.Provider — so the frozen
|
||||
// transcript subtree has no provider of its own. A
|
||||
// pending tool group captured in the snapshot can hold a
|
||||
// tool in the Executing state, whose spinner calls
|
||||
// useStreamingContext and would otherwise throw. Provide
|
||||
// the context here so the transcript renders.
|
||||
<StreamingContext.Provider value={streamingState}>
|
||||
<TranscriptView
|
||||
items={transcriptItems}
|
||||
useAlternateScreen={!useTerminalBuffer}
|
||||
/>
|
||||
</StreamingContext.Provider>
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import {
|
||||
isLiveAgentPanelVisibleEntry,
|
||||
LIVE_AGENT_PANEL_MAX_ROWS,
|
||||
getLiveAgentPanelVpMaxRows,
|
||||
} from './background-view/liveAgentPanelVisibility.js';
|
||||
import { panelDisplayOrder } from './background-view/agent-forest.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
@@ -271,12 +272,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// `livePanelSelectedIndex - 1` indexes the same agent the user sees
|
||||
// highlighted. Filtering alone (snapshot order, newest-first, unsliced)
|
||||
// opened the wrong agent's detail on Enter.
|
||||
// Window by the same cap the panel actually renders (VP mode uses a
|
||||
// height-aware cap via getLiveAgentPanelVpMaxRows in DefaultAppLayout)
|
||||
// so the keyboard selection can't address a row that is scrolled off.
|
||||
const liveAgentPanelMaxRows = settings.merged.ui?.useTerminalBuffer
|
||||
? getLiveAgentPanelVpMaxRows(uiState.terminalHeight)
|
||||
: LIVE_AGENT_PANEL_MAX_ROWS;
|
||||
const getVisibleBgAgents = useCallback(
|
||||
() =>
|
||||
panelDisplayOrder(
|
||||
bgEntries.filter((e) => isLiveAgentPanelVisibleEntry(e, Date.now())),
|
||||
).slice(-LIVE_AGENT_PANEL_MAX_ROWS),
|
||||
[bgEntries],
|
||||
).slice(-liveAgentPanelMaxRows),
|
||||
[bgEntries, liveAgentPanelMaxRows],
|
||||
);
|
||||
const hasActiveToolConfirmation = useMemo(
|
||||
() =>
|
||||
@@ -1334,7 +1341,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
};
|
||||
|
||||
// If the command is a perfect match, pressing enter should execute it.
|
||||
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
|
||||
// Use SUBMIT (which requires shift: false) instead of RETURN to avoid
|
||||
// intercepting Shift+Enter as submit when the user wants a newline.
|
||||
if (completion.isPerfectMatch && keyMatchers[Command.SUBMIT](key)) {
|
||||
if (
|
||||
showCompletionSuggestions &&
|
||||
exportCompletion.navigatedRef.current &&
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getLiveAgentPanelLayoutKey,
|
||||
getLiveAgentPanelVpMaxRows,
|
||||
isLiveAgentPanelVisibleEntry,
|
||||
TERMINAL_VISIBLE_MS,
|
||||
} from './liveAgentPanelVisibility.js';
|
||||
@@ -137,3 +138,28 @@ describe('isLiveAgentPanelVisibleEntry (eviction window)', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLiveAgentPanelVpMaxRows', () => {
|
||||
it('returns min rows (3) for short terminals', () => {
|
||||
expect(getLiveAgentPanelVpMaxRows(15)).toBe(3);
|
||||
expect(getLiveAgentPanelVpMaxRows(24)).toBe(3);
|
||||
});
|
||||
|
||||
it('grows with terminal height', () => {
|
||||
// clamp(floor(h/10), 3, 5)
|
||||
expect(getLiveAgentPanelVpMaxRows(30)).toBe(3);
|
||||
expect(getLiveAgentPanelVpMaxRows(40)).toBe(4);
|
||||
expect(getLiveAgentPanelVpMaxRows(50)).toBe(5);
|
||||
});
|
||||
|
||||
it('caps at max rows (5) for tall terminals', () => {
|
||||
expect(getLiveAgentPanelVpMaxRows(80)).toBe(5);
|
||||
expect(getLiveAgentPanelVpMaxRows(100)).toBe(5);
|
||||
});
|
||||
|
||||
it('returns min rows for invalid heights', () => {
|
||||
expect(getLiveAgentPanelVpMaxRows(0)).toBe(3);
|
||||
expect(getLiveAgentPanelVpMaxRows(-1)).toBe(3);
|
||||
expect(getLiveAgentPanelVpMaxRows(Number.NaN)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,32 @@ export const TERMINAL_VISIBLE_MS = 8000;
|
||||
// to the keyboard handler.
|
||||
export const LIVE_AGENT_PANEL_MAX_ROWS = 12;
|
||||
|
||||
// VP (virtualized-viewport) mode shares the terminal height with the
|
||||
// conversation, so the panel uses a height-aware cap (tighter than the
|
||||
// non-VP budget) to leave room for history. Referenced by both
|
||||
// DefaultAppLayout (the rendered `maxRows`) and InputPrompt (the keyboard
|
||||
// candidate window) so the two stay in lockstep — see
|
||||
// LIVE_AGENT_PANEL_MAX_ROWS's note on the shared-window contract.
|
||||
//
|
||||
// Clamped to [3, 5]: at least 3 rows so the panel is useful on short
|
||||
// terminals, at most 5 on tall ones.
|
||||
const LIVE_AGENT_PANEL_VP_ROWS_PER_ROW = 10;
|
||||
const LIVE_AGENT_PANEL_VP_MIN_ROWS = 3;
|
||||
const LIVE_AGENT_PANEL_VP_MAX_ROWS_CAP = 5;
|
||||
|
||||
export function getLiveAgentPanelVpMaxRows(terminalHeight: number): number {
|
||||
if (!Number.isFinite(terminalHeight) || terminalHeight <= 0) {
|
||||
return LIVE_AGENT_PANEL_VP_MIN_ROWS;
|
||||
}
|
||||
return Math.max(
|
||||
LIVE_AGENT_PANEL_VP_MIN_ROWS,
|
||||
Math.min(
|
||||
LIVE_AGENT_PANEL_VP_MAX_ROWS_CAP,
|
||||
Math.floor(terminalHeight / LIVE_AGENT_PANEL_VP_ROWS_PER_ROW),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function isLiveAgentPanelVisibleEntry(
|
||||
entry: DialogEntry,
|
||||
nowMs: number,
|
||||
|
||||
@@ -17,11 +17,11 @@ import { ToolCallStatus } from '../../types.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
|
||||
// ToolStatusIndicator pulls in GeminiRespondingSpinner which requires
|
||||
// StreamingContext; stub it out so we can test the elapsed/timeout
|
||||
// plumbing in isolation.
|
||||
vi.mock('../shared/ToolStatusIndicator.js', () => ({
|
||||
// StreamingContext; stub the component but keep the real constant so
|
||||
// height-estimation tests stay in sync with production.
|
||||
vi.mock('../shared/ToolStatusIndicator.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
ToolStatusIndicator: () => <Text>•</Text>,
|
||||
STATUS_INDICATOR_WIDTH: 2,
|
||||
}));
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
@@ -555,7 +555,7 @@ describe('estimateCompactToolGroupHeight', () => {
|
||||
description: '中文中文中文中文',
|
||||
});
|
||||
|
||||
expect(estimateCompactToolGroupHeight([tool], 12)).toBe(3);
|
||||
expect(estimateCompactToolGroupHeight([tool], 12)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
} from '../../constants.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export const STATUS_INDICATOR_WIDTH = 2;
|
||||
// One column for the status glyph plus two trailing columns so the tool
|
||||
// name never sits flush against the indicator. Paired with flexShrink={0}
|
||||
// on the indicator Box so the reservation survives a tight header row.
|
||||
export const STATUS_INDICATOR_WIDTH = 3;
|
||||
|
||||
type ToolStatusIndicatorProps = {
|
||||
status: ToolCallStatus;
|
||||
@@ -30,7 +33,7 @@ export const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
|
||||
const statusColor = isShell ? theme.ui.symbol : theme.status.warning;
|
||||
|
||||
return (
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH} flexShrink={0}>
|
||||
{status === ToolCallStatus.Pending && (
|
||||
<Text color={theme.status.success}>{TOOL_STATUS.PENDING}</Text>
|
||||
)}
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('keyMatchers', () => {
|
||||
[Command.NAVIGATION_UP]: (key: Key) => key.name === 'up' && !key.shift,
|
||||
[Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down' && !key.shift,
|
||||
[Command.ACCEPT_SUGGESTION]: (key: Key) =>
|
||||
key.name === 'tab' || (key.name === 'return' && !key.ctrl),
|
||||
key.name === 'tab' || (key.name === 'return' && !key.ctrl && !key.shift),
|
||||
// Completion navigation uses arrows plus readline/Vim-style Ctrl+P/N.
|
||||
[Command.COMPLETION_UP]: (key: Key) =>
|
||||
(key.name === 'up' && !key.shift) || (key.ctrl && key.name === 'p'),
|
||||
@@ -204,7 +204,11 @@ describe('keyMatchers', () => {
|
||||
{
|
||||
command: Command.ACCEPT_SUGGESTION,
|
||||
positive: [createKey('tab'), createKey('return')],
|
||||
negative: [createKey('return', { ctrl: true }), createKey('space')],
|
||||
negative: [
|
||||
createKey('return', { ctrl: true }),
|
||||
createKey('return', { shift: true }),
|
||||
createKey('space'),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Completion navigation uses arrows plus readline/Vim-style Ctrl+P.
|
||||
|
||||
@@ -17,12 +17,13 @@ import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
|
||||
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
|
||||
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
|
||||
import { LiveAgentPanel } from '../components/background-view/LiveAgentPanel.js';
|
||||
import { getLiveAgentPanelVpMaxRows } from '../components/background-view/liveAgentPanelVisibility.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
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';
|
||||
import { getStickyTodoMaxVisibleItemsForMode } from '../utils/todoSnapshot.js';
|
||||
import { getDialogMaxHeight } from '../utils/layoutUtils.js';
|
||||
|
||||
export const DefaultAppLayout: React.FC = () => {
|
||||
@@ -33,8 +34,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(
|
||||
const stickyTodoMaxVisibleItems = getStickyTodoMaxVisibleItemsForMode(
|
||||
uiState.terminalHeight,
|
||||
uiState.useTerminalBuffer,
|
||||
);
|
||||
const dialogMaxHeight = getDialogMaxHeight(
|
||||
uiState.terminalHeight,
|
||||
@@ -135,7 +137,14 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
panel wants the full terminal width.
|
||||
*/}
|
||||
{!uiState.dialogsVisible && (
|
||||
<LiveAgentPanel width={uiState.terminalWidth} />
|
||||
<LiveAgentPanel
|
||||
width={uiState.terminalWidth}
|
||||
maxRows={
|
||||
uiState.useTerminalBuffer
|
||||
? getLiveAgentPanelVpMaxRows(uiState.terminalHeight)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -16,14 +16,15 @@ 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';
|
||||
import { getStickyTodoMaxVisibleItemsForMode } from '../utils/todoSnapshot.js';
|
||||
import { getDialogMaxHeight } from '../utils/layoutUtils.js';
|
||||
|
||||
export const ScreenReaderAppLayout: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const stickyTodoWidth = Math.min(uiState.mainAreaWidth, 64);
|
||||
const stickyTodoMaxVisibleItems = getStickyTodoMaxVisibleItems(
|
||||
const stickyTodoMaxVisibleItems = getStickyTodoMaxVisibleItemsForMode(
|
||||
uiState.terminalHeight,
|
||||
uiState.useTerminalBuffer,
|
||||
);
|
||||
const dialogMaxHeight = getDialogMaxHeight(
|
||||
uiState.terminalHeight,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ToolCallStatus } from '../types.js';
|
||||
import {
|
||||
STICKY_TODO_MAX_VISIBLE_ITEMS,
|
||||
getStickyTodoMaxVisibleItems,
|
||||
getStickyTodoMaxVisibleItemsForMode,
|
||||
getStickyTodos,
|
||||
getStickyTodosLayoutKey,
|
||||
getStickyTodosRenderKey,
|
||||
@@ -376,4 +377,32 @@ describe('sticky todo layout helpers', () => {
|
||||
);
|
||||
expect(getStickyTodoMaxVisibleItems(0)).toBe(STICKY_TODO_MAX_VISIBLE_ITEMS);
|
||||
});
|
||||
|
||||
describe('getStickyTodoMaxVisibleItemsForMode', () => {
|
||||
it('uses a height-aware VP cap that grows with terminal height', () => {
|
||||
// VP cap = clamp(floor(h/12), 2, 4), then min(vpCap, base).
|
||||
// h=80: vpCap=4, base=5 → 4
|
||||
expect(getStickyTodoMaxVisibleItemsForMode(80, true)).toBe(4);
|
||||
// h=40: vpCap=3, base=5 → 3
|
||||
expect(getStickyTodoMaxVisibleItemsForMode(40, true)).toBe(3);
|
||||
// h=24: vpCap=2, base=4 → 2
|
||||
expect(getStickyTodoMaxVisibleItemsForMode(24, true)).toBe(2);
|
||||
});
|
||||
|
||||
it('floors the VP cap at 2 on short terminals', () => {
|
||||
// h=15: vpCap=clamp(1,2,4)=2, base=3 → min(2,3)=2
|
||||
expect(getStickyTodoMaxVisibleItemsForMode(15, true)).toBe(2);
|
||||
});
|
||||
|
||||
it('uses height-derived count in non-VP mode', () => {
|
||||
expect(getStickyTodoMaxVisibleItemsForMode(80, false)).toBe(
|
||||
getStickyTodoMaxVisibleItems(80),
|
||||
);
|
||||
});
|
||||
|
||||
it('respects height-derived floor when VP cap exceeds it', () => {
|
||||
// Very short terminal: base is 1, vpCap is 2, so min(2,1)=1.
|
||||
expect(getStickyTodoMaxVisibleItemsForMode(8, true)).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,9 @@ type SnapshotSearchResult = TodoSnapshotSearchResult | undefined;
|
||||
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_VP_ROWS_PER_VISIBLE_ITEM = 12;
|
||||
const STICKY_TODO_VP_MIN_VISIBLE_ITEMS = 2;
|
||||
const STICKY_TODO_VP_MAX_VISIBLE_ITEMS = 4;
|
||||
|
||||
const STICKY_TODO_STATUS_PRIORITY: Record<TodoItem['status'], number> = {
|
||||
in_progress: 0,
|
||||
@@ -204,3 +207,33 @@ export function getStickyTodoMaxVisibleItems(terminalHeight: number): number {
|
||||
terminalHeight / STICKY_TODO_ROWS_PER_VISIBLE_ITEM,
|
||||
);
|
||||
}
|
||||
|
||||
// VP mode uses a tighter per-item row budget (12 rows/item vs 5) so the
|
||||
// panel leaves more room for the conversation. Clamped to [2, 4] so very
|
||||
// short terminals still show a useful panel and very tall ones don't
|
||||
// over-allocate.
|
||||
function getStickyTodoVpCap(terminalHeight: number): number {
|
||||
if (!Number.isFinite(terminalHeight) || terminalHeight <= 0) {
|
||||
return STICKY_TODO_VP_MIN_VISIBLE_ITEMS;
|
||||
}
|
||||
return Math.max(
|
||||
STICKY_TODO_VP_MIN_VISIBLE_ITEMS,
|
||||
Math.min(
|
||||
STICKY_TODO_VP_MAX_VISIBLE_ITEMS,
|
||||
Math.floor(terminalHeight / STICKY_TODO_VP_ROWS_PER_VISIBLE_ITEM),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Single source of truth for the sticky-todo cap across render + layout-key
|
||||
// sites. VP mode uses a height-aware tighter cap; other modes use the
|
||||
// height-derived default.
|
||||
export function getStickyTodoMaxVisibleItemsForMode(
|
||||
terminalHeight: number,
|
||||
useTerminalBuffer: boolean,
|
||||
): number {
|
||||
const base = getStickyTodoMaxVisibleItems(terminalHeight);
|
||||
return useTerminalBuffer
|
||||
? Math.min(getStickyTodoVpCap(terminalHeight), base)
|
||||
: base;
|
||||
}
|
||||
Reference in new issue
Block a user