From ac4d0c20d868db042bc356d509522f3375a800c0 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 27 Feb 2026 07:34:49 -0800 Subject: [PATCH] fix(cli): hide shortcuts hint while model is thinking or the user has typed a prompt + add debounce to avoid flicker (#19389) --- .../cli/src/ui/components/Composer.test.tsx | 65 +++++++++++++++++-- packages/cli/src/ui/components/Composer.tsx | 23 ++++++- .../__snapshots__/Composer.test.tsx.snap | 2 +- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 12deda3e76..330e615cfa 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -6,8 +6,8 @@ import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import { render } from '../../test-utils/render.js'; +import { act, useEffect } from 'react'; import { Box, Text } from 'ink'; -import { useEffect } from 'react'; import { Composer } from './Composer.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { @@ -34,6 +34,7 @@ import { StreamingState } from '../types.js'; import { TransientMessageType } from '../../utils/events.js'; import type { LoadedSettings } from '../../config/settings.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; +import type { TextBuffer } from './shared/text-buffer.js'; const composerTestControls = vi.hoisted(() => ({ suggestionsVisible: false, @@ -263,16 +264,26 @@ const renderComposer = async ( , ); await result.waitUntilReady(); + + // Wait for shortcuts hint debounce if using fake timers + if (vi.isFakeTimers()) { + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + return result; }; describe('Composer', () => { beforeEach(() => { + vi.useFakeTimers(); composerTestControls.suggestionsVisible = false; composerTestControls.isAlternateBuffer = false; }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -809,6 +820,28 @@ describe('Composer', () => { }); describe('Shortcuts Hint', () => { + it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => { + const { lastFrame } = await renderComposer( + createMockUIState({ + buffer: { text: '' } as unknown as TextBuffer, + cleanUiDetailsVisible: false, + }), + ); + + expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); + }); + + it('does not show shortcuts hint immediately when buffer has text', async () => { + const uiState = createMockUIState({ + buffer: { text: 'hello' } as unknown as TextBuffer, + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + it('hides shortcuts hint when showShortcutsHint setting is false', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ @@ -857,6 +890,27 @@ describe('Composer', () => { expect(lastFrame()).toContain('ShortcutsHint'); }); + it('hides shortcuts hint while loading when full UI details are visible', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: true, + streamingState: StreamingState.Responding, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + + it('hides shortcuts hint when text is typed in buffer', async () => { + const uiState = createMockUIState({ + buffer: { text: 'hello' } as unknown as TextBuffer, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + it('hides shortcuts hint while loading in minimal mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, @@ -930,9 +984,10 @@ describe('Composer', () => { streamingState: StreamingState.Idle, }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHelp'); + unmount(); }); it('hides shortcuts help while streaming', async () => { @@ -941,9 +996,10 @@ describe('Composer', () => { streamingState: StreamingState.Responding, }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); + unmount(); }); it('hides shortcuts help when action is required', async () => { @@ -956,9 +1012,10 @@ describe('Composer', () => { ), }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 2adc370ed5..98a465fa39 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -151,11 +151,30 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : undefined, ); const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions; + const isModelIdle = uiState.streamingState === StreamingState.Idle; + const isBufferEmpty = uiState.buffer.text.length === 0; + const canShowShortcutsHint = + isModelIdle && isBufferEmpty && !hasPendingActionRequired; + const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] = + useState(canShowShortcutsHint); + + useEffect(() => { + if (!canShowShortcutsHint) { + setShowShortcutsHintDebounced(false); + return; + } + + const timeout = setTimeout(() => { + setShowShortcutsHintDebounced(true); + }, 200); + + return () => clearTimeout(timeout); + }, [canShowShortcutsHint]); + const showShortcutsHint = settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions && - !hideMinimalModeHintWhileBusy && - !hasPendingActionRequired; + showShortcutsHintDebounced; const showMinimalModeBleedThrough = !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; diff --git a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap index 2ba370a000..452663d719 100644 --- a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap @@ -35,7 +35,7 @@ Footer `; exports[`Composer > Snapshots > matches snapshot while streaming 1`] = ` -" LoadingIndicator: Thinking ShortcutsHint +" LoadingIndicator: Thinking ──────────────────────────────────────────────────────────────────────────────────────────────────── ApprovalModeIndicator InputPrompt: Type your message or @path/to/file