From 3c4935395e2fb0acc2bca3726993b80dff076a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 30 Jul 2026 10:35:24 +0800 Subject: [PATCH 1/8] fix(cli): hide streaming thinking preview, rebind Ctrl+O to inline fullDetail toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming thinking block showed a 4-line preview that varied in height due to empty lines in the model's reasoning output, causing constant page reflow and flicker during generation. Changes: - ThinkBody now renders nothing when collapsed (both streaming and committed), keeping the block at a stable 1-line header height. - Ctrl+O now toggles inline fullDetail mode (like Claude Code): all thinking blocks, tool groups, and tool results expand/collapse in the main conversation view — no alternate-screen overlay. - Alt+T preserved as hidden shortcut (same toggle, not shown in UI). - MainContent passes fullDetail to HistoryItemDisplay via the existing ThoughtExpandedContext, so the toggle works in both VP and Static rendering paths. - Removed TranscriptView overlay rendering, transcriptItems memo, StreamingContext import, and EMPTY_HISTORY_ITEMS constant. - Removed dead code: tailVisualLines, grow-only height tracker, MAX_STREAMING_THINKING_VISUAL_LINES, openTranscript callback. --- packages/cli/src/config/keyBindings.ts | 11 +- packages/cli/src/ui/AppContainer.test.tsx | 118 ------------------ packages/cli/src/ui/AppContainer.tsx | 88 +------------ .../cli/src/ui/components/MainContent.tsx | 7 ++ .../messages/ConversationMessages.test.tsx | 109 +++------------- .../messages/ConversationMessages.tsx | 65 +--------- packages/cli/src/ui/keyMatchers.test.ts | 19 ++- 7 files changed, 51 insertions(+), 366 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 833fa02e740..1c6cf4515ba 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -261,11 +261,14 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], - // Thinking expansion - [Command.TOGGLE_THINKING_EXPANDED]: [{ key: 't', meta: true }], + // Thinking expansion (Ctrl+O primary, Alt+T legacy) + [Command.TOGGLE_THINKING_EXPANDED]: [ + { key: 'o', ctrl: true }, + { key: 't', meta: true }, + ], - // Transcript full-detail screen - [Command.TOGGLE_TRANSCRIPT]: [{ key: 'o', ctrl: true }], + // Transcript overlay — unbound (replaced by inline thinking expansion) + [Command.TOGGLE_TRANSCRIPT]: [], // Scroll commands [Command.SCROLL_UP]: [{ key: 'up', shift: true }], diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index fe6154110cf..e6586086e04 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -4733,124 +4733,6 @@ describe('AppContainer State Management', () => { }); }); - describe('Transcript (Ctrl+O) integration', () => { - // The frozen transcript (TranscriptView) renders its title row as the - // literal "Transcript"; the main view never does, so its presence in the - // rendered frame is a reliable open/closed signal. - const TRANSCRIPT_MARKER = 'Transcript'; - - const makeKey = (overrides: Partial): Key => - ({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: '', - ...overrides, - }) as Key; - - // The global keypress handler owns Ctrl+O / the transcript close keys; it is - // the registered useKeypress handler whose body references TOGGLE_TRANSCRIPT. - const getGlobalKeypress = () => - mockedUseKeypress.mock.calls - .map((call) => call[0]) - .reverse() - .find( - (handler): handler is (key: Key) => void => - typeof handler === 'function' && - handler.toString().includes('TOGGLE_TRANSCRIPT'), - ) as ((key: Key) => void) | undefined; - - const ctrlO = makeKey({ name: 'o', ctrl: true, sequence: '\x0f' }); - - const renderApp = () => - render( - , - ); - - it('Ctrl+O installs the TranscriptView in the rendered tree', () => { - const { lastFrame } = renderApp(); - const handleKeypress = getGlobalKeypress(); - expect(handleKeypress).toBeDefined(); - // Baseline: the marker also confirms the main view doesn't render it. - expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER); - act(() => handleKeypress!(ctrlO)); - expect(lastFrame()).toContain(TRANSCRIPT_MARKER); - }); - - it.each([ - ['Esc', makeKey({ name: 'escape', sequence: '\x1b' })], - ['q', makeKey({ name: 'q', sequence: 'q' })], - ['Ctrl+C', makeKey({ name: 'c', ctrl: true, sequence: '\x03' })], - ['Ctrl+D', makeKey({ name: 'd', ctrl: true, sequence: '\x04' })], - // Ctrl+O is the toggle: pressing it again while open also closes. - ['Ctrl+O', ctrlO], - ])('%s while open removes the TranscriptView', (_label, closeKey) => { - const { lastFrame } = renderApp(); - const handleKeypress = getGlobalKeypress()!; - act(() => handleKeypress(ctrlO)); - expect(lastFrame()).toContain(TRANSCRIPT_MARKER); - act(() => handleKeypress(closeKey)); - expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER); - }); - - it.each([ - ['Ctrl+Q', makeKey({ name: 'q', ctrl: true, sequence: '\x11' })], - ['Alt+Q', makeKey({ name: 'q', meta: true, sequence: '\x1bq' })], - ['Shift+Q', makeKey({ name: 'q', shift: true, sequence: 'Q' })], - ])( - '%s does NOT close the transcript (bare-q modifier guard)', - (_l, modQ) => { - const { lastFrame } = renderApp(); - const handleKeypress = getGlobalKeypress()!; - act(() => handleKeypress(ctrlO)); - expect(lastFrame()).toContain(TRANSCRIPT_MARKER); - act(() => handleKeypress(modQ)); - // Only a bare `q` closes; modified variants stay swallowed but open. - expect(lastFrame()).toContain(TRANSCRIPT_MARKER); - }, - ); - - it('swallows arbitrary keys while open and keeps the transcript open', () => { - const { lastFrame } = renderApp(); - const handleKeypress = getGlobalKeypress()!; - act(() => handleKeypress(ctrlO)); - expect(lastFrame()).toContain(TRANSCRIPT_MARKER); - expect(() => - act(() => handleKeypress(makeKey({ name: 'x', sequence: 'x' }))), - ).not.toThrow(); - expect(lastFrame()).toContain(TRANSCRIPT_MARKER); - }); - - it('auto-closes when a blocking confirmation appears (anti-deadlock)', () => { - // A blocking prompt would be invisible behind the alt-screen transcript; - // the needsBlockingInput effect must close it on the same commit. - mockedUseGeminiStream.mockReturnValue({ - streamingState: 'waiting_for_confirmation', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - retryLastPrompt: vi.fn(), - streamingResponseLengthRef: { current: 0 }, - isReceivingContent: false, - }); - const { lastFrame } = renderApp(); - const handleKeypress = getGlobalKeypress()!; - act(() => handleKeypress(ctrlO)); - // openTranscript sets the freeze, but the anti-deadlock effect tears it - // back down on the same commit, so it never stays visible. - expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER); - }); - }); - describe('Model Dialog Integration', () => { it('should provide isModelDialogOpen in the UIStateContext', () => { mockedUseModelCommand.mockReturnValue({ diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f911d5c2ebb..6b90a3d3ce4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -31,11 +31,7 @@ import { type HistoryItemWithoutId, } from './types.js'; import type { RestoreOption } from './components/RewindSelector.js'; -import { - MessageType, - StreamingState, - isHistoryItemVisibleAfterRestore, -} from './types.js'; +import { MessageType, StreamingState } from './types.js'; import { type EditorType, type Config, @@ -212,13 +208,11 @@ 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, } from './contexts/RenderModeContext.js'; import { TerminalOutputProvider } from './contexts/TerminalOutputContext.js'; -import { TranscriptView } from './components/TranscriptView.js'; import { useAgentViewState } from './contexts/AgentViewContext.js'; import { useBackgroundTaskViewState, @@ -261,10 +255,6 @@ import { MAIN_CONTENT_HEIGHT_RESERVATION } from './utils/layoutUtils.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const debugLogger = createDebugLogger('APP_CONTAINER'); -// Stable empty reference for the transcript items memo when no snapshot is -// frozen, so the memo never hands TranscriptView a fresh [] each render. -const EMPTY_HISTORY_ITEMS: HistoryItem[] = []; - export function isRenderModeToggleKey(key: Key): boolean { return ( keyMatchers[Command.TOGGLE_RENDER_MODE](key) || @@ -625,7 +615,7 @@ export const AppContainer = (props: AppContainerProps) => { setTranscriptFreeze(null); }, []); - // Alt+T inline expansion toggle for thinking blocks (expands all at once). + // Ctrl+O / Alt+T inline expansion toggle for thinking blocks (expands all at once). const [thoughtExpanded, setThoughtExpanded] = useState(false); // Per-thought inline expansion: head ids the user expanded by clicking the // collapsed thinking line (VP mode). Replaces the old full-screen viewer — @@ -2521,47 +2511,6 @@ export const AppContainer = (props: AppContainerProps) => { () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], ); - // Read history/pending through refs so `openTranscript` stays referentially - // stable. Both arrays change identity on every streaming tick; capturing them - // as deps would rebuild this callback — and, since `handleGlobalKeypress` - // lists it in its deps, the entire keypress-handler closure — on every render - // during active streaming. The callback only ever runs on a Ctrl+O press, so - // reading the latest values via refs at call time is sufficient. - const historyForTranscriptRef = useRef(historyManager.history); - historyForTranscriptRef.current = historyManager.history; - const pendingForTranscriptRef = useRef(pendingHistoryItems); - pendingForTranscriptRef.current = pendingHistoryItems; - const openTranscript = useCallback(() => { - setTranscriptFreeze({ - // Share MainContent's visibility predicate so the transcript shows exactly - // what the main view shows. Items collapsed on session resume - // (ui.history.collapseOnResume) are represented by their collapse-summary - // row and must NOT be re-exposed in the Ctrl+O view. - committedItems: historyForTranscriptRef.current.filter( - isHistoryItemVisibleAfterRestore, - ), - pendingItems: [...pendingForTranscriptRef.current], - }); - }, []); - - // Build the transcript item list from the frozen snapshot only. Recomputes - // on open/close (when `transcriptFreeze` flips), not on every streaming tick, - // so the array reference stays stable while open — combined with the - // React.memo'd TranscriptView this avoids re-running its VirtualizedList - // offset/render memos on every AppContainer re-render during streaming. - const transcriptItems = useMemo(() => { - if (!transcriptFreeze) return EMPTY_HISTORY_ITEMS; - return [ - ...transcriptFreeze.committedItems, - // Pending snapshot gets negative ids (mirrors MainContent's `id: -(i+1)`) - // so keys never collide with committed history items. - ...transcriptFreeze.pendingItems.map((item, i) => ({ - ...item, - id: -(i + 1), - })), - ]; - }, [transcriptFreeze]); - const rawStickyTodos = useMemo( () => getStickyTodos(historyManager.history, pendingHistoryItems), [historyManager.history, pendingHistoryItems], @@ -3813,10 +3762,9 @@ export const AppContainer = (props: AppContainerProps) => { // a close request). (key.name === 'q' && !key.ctrl && !key.meta && !key.shift) || keyMatchers[Command.QUIT](key) || - keyMatchers[Command.EXIT](key) || - keyMatchers[Command.TOGGLE_TRANSCRIPT](key) + keyMatchers[Command.EXIT](key) ) { - // Esc / q / Ctrl+C / Ctrl+D / Ctrl+O all just close the transcript. + // Esc / q / Ctrl+C / Ctrl+D all just close the transcript. // EXIT (Ctrl+D) is included so it isn't silently swallowed by the // blanket return below — the transcript is a transient overlay, so we // close it rather than fall through to app exit. @@ -3825,20 +3773,13 @@ export const AppContainer = (props: AppContainerProps) => { return; } - // Alt+T: toggle inline expansion of thinking blocks. + // Ctrl+O / Alt+T: toggle inline expansion of thinking blocks. if (keyMatchers[Command.TOGGLE_THINKING_EXPANDED](key)) { setThoughtExpanded((prev) => !prev); refreshStatic(); return; } - // Ctrl+O: open the transcript full-detail screen. (Close while open is - // handled by the transcript guard at the very top of this handler.) - if (keyMatchers[Command.TOGGLE_TRANSCRIPT](key)) { - openTranscript(); - return; - } - if (keyMatchers[Command.QUIT](key)) { if (isAuthenticating) { return; @@ -4092,7 +4033,6 @@ export const AppContainer = (props: AppContainerProps) => { vimEnabled, vimMode, setThoughtExpanded, - openTranscript, closeTranscript, ], ); @@ -4691,23 +4631,7 @@ export const AppContainer = (props: AppContainerProps) => { - {transcriptFreeze ? ( - // TranscriptView renders as a sibling of , 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. - - - - ) : ( - - )} + diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index ec52bada53b..1a085068901 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -14,6 +14,7 @@ import { Notifications } from './Notifications.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useAppContext } from '../contexts/AppContext.js'; +import { useThoughtExpanded } from '../contexts/ThoughtExpandedContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; import { @@ -112,6 +113,7 @@ const virtualIsStaticItem = (item: VpItem) => export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); + const { allExpanded: fullDetail } = useThoughtExpanded(); const streamingState = uiState.streamingState; const showScrollbar = uiState.showScrollbar ?? true; const { @@ -384,6 +386,7 @@ export const MainContent = () => { embeddedShellFocused={ps.embeddedShellFocused} commands={uiState.slashCommands} sourceCopyIndexOffsets={sourceCopyIndexOffsets} + fullDetail={fullDetail} /> ); } @@ -398,6 +401,7 @@ export const MainContent = () => { commands={uiState.slashCommands} sourceCopyIndexOffsets={sourceCopyIndexOffsets} thoughtHeadId={thoughtHeadIdByItemRef.current.get(item)} + fullDetail={fullDetail} /> ); }, @@ -408,6 +412,7 @@ export const MainContent = () => { staticAreaMaxItemHeight, uiState.slashCommands, sourceCopyOffsetsByHistoryItem, + fullDetail, ], ); @@ -478,6 +483,7 @@ export const MainContent = () => { commands={uiState.slashCommands} sourceCopyIndexOffsets={sourceCopyIndexOffsets} thoughtHeadId={thoughtHeadIdByItem.get(h)} + fullDetail={fullDetail} /> ), ), @@ -541,6 +547,7 @@ export const MainContent = () => { activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} sourceCopyIndexOffsets={sourceCopyIndexOffsets} + fullDetail={fullDetail} /> ), )} diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx index 8068bf0730c..9020e8237d1 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx @@ -164,7 +164,7 @@ describe('', () => { expect(output).toContain('Line 4'); }); - it('should only show tail lines when pending and not expanded', () => { + it('should show only the header when pending and not expanded', () => { const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); const longText = lines.join('\n'); const { lastFrame } = render( @@ -178,103 +178,27 @@ describe('', () => { ); const output = lastFrame(); expect(output).toContain('Thinking'); - expect(output).toContain('Line 20'); - expect(output).not.toContain('Line 1\n'); + // No thinking body content when collapsed — prevents height flicker. + expect(output).not.toContain('Line 20'); + expect(output).not.toContain('Line 1'); }); - it('should not shrink the streaming window when the visible tail momentarily drops', () => { - // A long unbroken run pushes the tail window to its full height. When the - // next chunk introduces a newline near the char-budget boundary, the tail - // slice can momentarily collapse to a single line even though the buffer - // only grew. Grow-only height must pad that back up so the block does not - // flicker down and then up again. - const wide = 'X'.repeat(400); - const { lastFrame, rerender } = render( - , - ); - const tallHeight = (lastFrame() ?? '').split('\n').length; - - rerender( - , - ); - const afterFrame = lastFrame() ?? ''; - expect(afterFrame).toContain('Y'); - // Height is preserved (grow-only), not collapsed to the 1-line natural tail. - expect(afterFrame.split('\n').length).toBe(tallHeight); - }); - - it('should reset the grow-only window when a new thought replaces the buffer', () => { - const tall = Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`).join( - '\n', - ); - const { lastFrame, rerender } = render( - , - ); - const tallHeight = (lastFrame() ?? '').split('\n').length; - - // A shorter buffer signals a fresh thought; the window should shrink back. - rerender( - , - ); - expect((lastFrame() ?? '').split('\n').length).toBeLessThan(tallHeight); - }); - - it('keeps the streaming window height stable as availableTerminalHeight changes', () => { - // While a thought streams the terminal keeps constrainHeight on, so - // availableTerminalHeight drifts as sibling pending content grows. The - // streaming window must not track it, or the block flickers in height. - const tall = Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`).join( - '\n', - ); - const { lastFrame, rerender } = render( - , - ); - const heightBefore = (lastFrame() ?? '').split('\n').length; - - // Same thought, but availableTerminalHeight collapses to a value whose - // old maxLines = floor(6/3) = 2 would have shrunk the window. - rerender( + it('should show full content when pending and expanded', () => { + const lines = Array.from({ length: 5 }, (_, i) => `Line ${i + 1}`); + const text = lines.join('\n'); + const { lastFrame } = render( , ); - expect((lastFrame() ?? '').split('\n').length).toBe(heightBefore); + const output = lastFrame(); + expect(output).toContain('Thinking'); + expect(output).toContain('Line 1'); + expect(output).toContain('Line 5'); }); }); @@ -284,12 +208,11 @@ describe('', () => { contentWidth: 80, }; - it('should render when pending (streaming)', () => { + it('should render nothing when pending and not expanded', () => { const { lastFrame } = render( , ); - const output = lastFrame(); - expect(output).not.toBe(''); + expect(lastFrame()).toBe(''); }); it('should render nothing when committed and not expanded', () => { diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx index e90c4787841..2b9071e7fa9 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -5,7 +5,6 @@ */ import type React from 'react'; -import { useRef } from 'react'; import { Box, Text } from 'ink'; import stringWidth from 'string-width'; import { @@ -19,14 +18,12 @@ import { } from '../../textConstants.js'; import { t } from '../../../i18n/index.js'; import { ICON } from '../../constants.js'; -import { wrapToVisualLines } from '../../utils/textUtils.js'; import { formatDuration } from '../../utils/displayUtils.js'; export const THINKING_ICON = `${ICON.THEREFORE} `; export const THINKING_ICON_PENDING = `${ICON.BECAUSE} `; -export const toggleKeyHint = - process.platform === 'darwin' ? 'option+t' : 'alt+t'; +export const toggleKeyHint = 'ctrl+o'; interface UserMessageProps { text: string; @@ -270,26 +267,8 @@ export const AssistantMessageContent: React.FC< /> ); -const MAX_STREAMING_THINKING_VISUAL_LINES = 4; const BRIEF_THOUGHT_THRESHOLD_MS = 1_000; -function tailVisualLines( - text: string, - width: number, - maxLines: number, -): string[] { - const charBudget = maxLines * width * 2; - let sliceStart = Math.max(0, text.length - charBudget); - if (sliceStart > 0) { - const nl = text.indexOf('\n', sliceStart); - if (nl !== -1 && nl < text.length - 1) { - sliceStart = nl + 1; - } - } - const lines = wrapToVisualLines(text.slice(sliceStart), width); - return lines.slice(-maxLines); -} - const ThinkBody: React.FC<{ text: string; isPending: boolean; @@ -297,47 +276,7 @@ const ThinkBody: React.FC<{ availableTerminalHeight?: number; contentWidth: number; }> = ({ text, isPending, expanded, availableTerminalHeight, contentWidth }) => { - // Grow-only height tracker for the streaming window: the rendered block never - // shrinks below the tallest it has already reached for this thought, so a - // blank paragraph separator (`\n\n`) transiently entering/leaving the tail - // window can't make the block jump 2→3→5 rows and flicker. Reset when the - // block stops streaming or when the buffer shrinks (a new thought replaced it). - const maxSeenLinesRef = useRef(0); - const prevTextLenRef = useRef(0); - if (!isPending || text.length < prevTextLenRef.current) { - maxSeenLinesRef.current = 0; - } - prevTextLenRef.current = text.length; - - if (!isPending && !expanded) return null; - - if (isPending && !expanded) { - const innerWidth = Math.max(contentWidth - 2, 20); - // Use a constant window height rather than deriving it from - // availableTerminalHeight. While a thought streams the terminal keeps - // constrainHeight on, so availableTerminalHeight (and therefore a derived - // maxLines) drifts up and down as sibling pending content grows — which - // reintroduced the very height flicker this block is meant to remove. The - // window is at most a few lines, so a fixed cap can't meaningfully overflow - // (VP scrolls anyway), and it keeps the height stable. - const maxLines = MAX_STREAMING_THINKING_VISUAL_LINES; - const lines = tailVisualLines(text, innerWidth, maxLines); - const target = Math.max(lines.length, maxSeenLinesRef.current); - maxSeenLinesRef.current = target; - // Pad at the top so the newest line stays pinned to the bottom. - const padded = - lines.length < target - ? [...new Array(target - lines.length).fill(''), ...lines] - : lines; - const display = padded.join('\n'); - return ( - - - {display} - - - ); - } + if (!expanded) return null; return ( diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 6932dca55ba..b7427263ac6 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -98,8 +98,8 @@ describe('keyMatchers', () => { [Command.SCROLL_HOME]: (key: Key) => key.ctrl && key.name === 'home', [Command.SCROLL_END]: (key: Key) => key.ctrl && key.name === 'end', [Command.TOGGLE_THINKING_EXPANDED]: (key: Key) => - key.meta && key.name === 't', - [Command.TOGGLE_TRANSCRIPT]: (key: Key) => key.ctrl && key.name === 'o', + (key.ctrl && key.name === 'o') || (key.meta && key.name === 't'), + [Command.TOGGLE_TRANSCRIPT]: (_key: Key) => false, }; // Test data for each command with positive and negative test cases @@ -451,13 +451,20 @@ describe('keyMatchers', () => { }, { command: Command.TOGGLE_THINKING_EXPANDED, - positive: [createKey('t', { meta: true })], - negative: [createKey('t'), createKey('t', { ctrl: true })], + positive: [ + createKey('t', { meta: true }), + createKey('o', { ctrl: true }), + ], + negative: [ + createKey('t'), + createKey('t', { ctrl: true }), + createKey('o'), + ], }, { command: Command.TOGGLE_TRANSCRIPT, - positive: [createKey('o', { ctrl: true })], - negative: [createKey('o'), createKey('o', { meta: true })], + positive: [], + negative: [createKey('o', { ctrl: true }), createKey('o')], }, ]; From 949129137ebddcd68a9bbceb07371e43d681515c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 30 Jul 2026 06:47:27 +0000 Subject: [PATCH 2/8] refactor(cli): remove orphaned transcript full-detail infrastructure (#8077) --- packages/cli/src/ui/AppContainer.tsx | 129 ------------- .../TranscriptView.errorFallback.test.tsx | 55 ------ .../src/ui/components/TranscriptView.test.tsx | 110 ----------- .../cli/src/ui/components/TranscriptView.tsx | 174 ------------------ 4 files changed, 468 deletions(-) delete mode 100644 packages/cli/src/ui/components/TranscriptView.errorFallback.test.tsx delete mode 100644 packages/cli/src/ui/components/TranscriptView.test.tsx delete mode 100644 packages/cli/src/ui/components/TranscriptView.tsx diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 6b90a3d3ce4..3b5a2dce25d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -595,26 +595,6 @@ export const AppContainer = (props: AppContainerProps) => { const [userMessages, setUserMessages] = useState([]); - // Transcript full-detail screen (Ctrl+O). Freezes a snapshot of the - // conversation at entry time. Both committed history and the streaming - // `pendingHistoryItems` are stored as shallow copies (`.slice()` / spread): - // the snapshot must stay stable while open, but `useMemoryMonitor` → - // `compactOldItems` can replace `historyManager.history` with a rewritten - // array (collapsed tool groups, merged thoughts, shifted indices) mid-view. - // Re-slicing the live array at render would let that rewrite visibly corrupt - // the "frozen" transcript, so we pin the array of item references here. A - // shallow copy is cheap (references only) even for long sessions. - const [transcriptFreeze, setTranscriptFreeze] = useState<{ - committedItems: HistoryItem[]; - pendingItems: HistoryItemWithoutId[]; - } | null>(null); - const isTranscriptOpen = transcriptFreeze != null; - const isTranscriptOpenRef = useRef(isTranscriptOpen); - isTranscriptOpenRef.current = isTranscriptOpen; - const closeTranscript = useCallback(() => { - setTranscriptFreeze(null); - }, []); - // Ctrl+O / Alt+T inline expansion toggle for thinking blocks (expands all at once). const [thoughtExpanded, setThoughtExpanded] = useState(false); // Per-thought inline expansion: head ids the user expanded by clicking the @@ -1158,68 +1138,12 @@ export const AppContainer = (props: AppContainerProps) => { ); const showScrollbar = settings.merged.ui?.showScrollbar ?? true; const refreshStatic = useCallback(() => { - // While the transcript (alt-screen) owns the whole screen, suppress static - // refreshes (e.g. resize-settle repaints) so they don't write into / reorder - // the normal-buffer scrollback that is currently hidden behind the alt - // screen. On transcript close the - // AlternateScreen unmount restores the normal buffer; the next legitimate - // refreshStatic (model change, Alt+T, etc.) repaints as usual. - if (isTranscriptOpenRef.current) { - return; - } if (!useTerminalBuffer) { stdout.write(ansiEscapes.clearTerminal); } remountStaticHistory(); }, [useTerminalBuffer, remountStaticHistory, stdout]); - // Repaint the normal buffer once when the transcript (alt-screen) closes. - // In the legacy path the normal buffer still holds the pre-transcript - // frame; remounting the main tree would append the committed history a second - // time (the transcript's full-detail rows leaking into scrollback). Force one - // clear + Static remount AFTER the AlternateScreen's exit escape (?1049l) has - // flushed — deferred a tick so the buffer switch lands first, and run outside - // the during-transcript guard above (which has already cleared by now). VP - // mode keeps its own scrollback via the React tree, so this is non-VP only. - // Snapshot the previous-render value during render (not inside the effect), - // so React.StrictMode's double-invoke of the effect can't read a value the - // effect itself just wrote — `wasOpenPrevRender` is always a true previous - // render snapshot. - const prevTranscriptOpenRef = useRef(isTranscriptOpen); - const wasOpenPrevRender = prevTranscriptOpenRef.current; - prevTranscriptOpenRef.current = isTranscriptOpen; - // Bump a counter on each close transition and use IT — not `wasOpenPrevRender` - // / `isTranscriptOpen` — as the effect's only changing trigger. If those were - // in the dep array (as they were originally), the very next streaming - // re-render flips `wasOpenPrevRender` back to false, the deps change, cleanup - // runs, and `clearTimeout` cancels the pending repaint before it fires — - // leaving stale pre-transcript content in the normal buffer. With the counter, - // post-close re-renders don't change the deps, so the scheduled repaint - // survives and fires exactly once per close. - const transcriptCloseCountRef = useRef(0); - if (wasOpenPrevRender && !isTranscriptOpen) { - transcriptCloseCountRef.current += 1; - } - const transcriptCloseCount = transcriptCloseCountRef.current; - useEffect(() => { - if (transcriptCloseCount === 0 || useTerminalBuffer) { - return undefined; - } - // Guard the clear-screen write on stdout being a TTY: with stdout piped or - // redirected (`qwen | tee log`) the transcript degrades to in-buffer - // rendering (AlternateScreen skips its escapes on non-TTY), so emitting - // `clearTerminal` here would leak raw control bytes into the captured - // output without ever having taken over a screen to repaint. - if (!stdout.isTTY) { - return undefined; - } - const id = setTimeout(() => { - stdout.write(ansiEscapes.clearTerminal); - remountStaticHistory(); - }, 0); - return () => clearTimeout(id); - }, [transcriptCloseCount, useTerminalBuffer, stdout, remountStaticHistory]); - // Keep the static header in sync with model changes without polling. // Ink's output is append-only, so model changes must explicitly // clear and remount the static region to redraw the banner at the top. @@ -3067,26 +2991,6 @@ export const AppContainer = (props: AppContainerProps) => { !!(settings.corruptedPath && !settings.corruptionDialogDismissed); dialogsVisibleRef.current = dialogsVisible; - // Anti-deadlock: the transcript takes over the whole screen via alt-screen, - // so any blocking confirmation/dialog (or a tool awaiting confirmation) would - // be invisible and unanswerable behind it. Auto-close the transcript whenever - // one appears so the user can see and respond. `dialogsVisible` already - // aggregates every blocking request surfaced by DialogManager - // (shellConfirmationRequest / loopDetectionConfirmationRequest / - // confirmationRequest / confirmUpdateExtensionRequests / providerUpdateRequest - // and friends); WaitingForConfirmation covers the inline tool-approval path. - const needsBlockingInput = - dialogsVisible || streamingState === StreamingState.WaitingForConfirmation; - useEffect(() => { - if (needsBlockingInput && isTranscriptOpen) { - closeTranscript(); - } - // `isTranscriptOpen` must be a dependency (not just read via ref): if a - // blocking prompt is already visible when the user opens the transcript, - // `needsBlockingInput` doesn't change, so without this the effect wouldn't - // re-fire and the transcript would open over an invisible prompt. - }, [needsBlockingInput, isTranscriptOpen, closeTranscript]); - const shouldShowStickyTodos = stickyTodos !== null && !dialogsVisible && @@ -3747,32 +3651,6 @@ export const AppContainer = (props: AppContainerProps) => { debugLogger.debug('[DEBUG] Keystroke:', JSON.stringify(key)); } - // Transcript full-detail screen owns all input while open. This MUST be - // the first branch — earlier than QUIT(Ctrl+C) / EXIT(Ctrl+D) / ESCAPE - // (and its vim-INSERT guard) — so Ctrl+C/Esc close the transcript - // instead of triggering quit / being swallowed by vim. TranscriptView's - // own ScrollableList handles the scroll keys; we swallow everything else - // here so a single broadcast keypress isn't double-handled. - if (isTranscriptOpenRef.current) { - if ( - keyMatchers[Command.ESCAPE](key) || - // Bare `q` only — Ink reports Ctrl/Alt/Shift+Q as `{ name: 'q', … }` - // too (Alt arrives as `meta`), so guard every modifier to avoid those - // silently closing it (Shift+Q is the user typing a literal `Q`, not - // a close request). - (key.name === 'q' && !key.ctrl && !key.meta && !key.shift) || - keyMatchers[Command.QUIT](key) || - keyMatchers[Command.EXIT](key) - ) { - // Esc / q / Ctrl+C / Ctrl+D all just close the transcript. - // EXIT (Ctrl+D) is included so it isn't silently swallowed by the - // blanket return below — the transcript is a transient overlay, so we - // close it rather than fall through to app exit. - closeTranscript(); - } - return; - } - // Ctrl+O / Alt+T: toggle inline expansion of thinking blocks. if (keyMatchers[Command.TOGGLE_THINKING_EXPANDED](key)) { setThoughtExpanded((prev) => !prev); @@ -4033,7 +3911,6 @@ export const AppContainer = (props: AppContainerProps) => { vimEnabled, vimMode, setThoughtExpanded, - closeTranscript, ], ); @@ -4096,10 +3973,6 @@ export const AppContainer = (props: AppContainerProps) => { ) { return; } - // Don't silently auto-submit queued messages while the transcript is open - // (it isn't part of `dialogsVisible`). Resume draining once it closes. - if (isTranscriptOpenRef.current) return; - // Two-phase: batch plain prompts as one turn, else pop next slash command. const submission = popNextTurn(); if (submission === null) return; @@ -4114,8 +3987,6 @@ export const AppContainer = (props: AppContainerProps) => { streamingState, isProcessing, dialogsVisible, - // Re-run the drain when the transcript closes so queued messages resume. - isTranscriptOpen, messageQueue, popNextTurn, submitUserQuery, diff --git a/packages/cli/src/ui/components/TranscriptView.errorFallback.test.tsx b/packages/cli/src/ui/components/TranscriptView.errorFallback.test.tsx deleted file mode 100644 index ffb41722abe..00000000000 --- a/packages/cli/src/ui/components/TranscriptView.errorFallback.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ReactNode } from 'react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { HistoryItem } from '../types.js'; -import { renderWithProviders } from '../../test-utils/render.js'; - -// Force every item render to throw so the transcript's ErrorBoundary + -// `errorFallback` recovery path is exercised (the fullDetail render path hits -// code the normal view never does, so a throw here must show the fallback -// instead of crashing the CLI). Isolated in its own file so the throwing mock -// doesn't affect the main TranscriptView render tests. -vi.mock('./HistoryItemDisplay.js', () => ({ - HistoryItemDisplay: () => { - throw new Error('malformed history item'); - }, -})); - -vi.mock('../hooks/useMouseEvents.js', () => ({ - useMouseEvents: vi.fn(), -})); - -vi.mock('../contexts/TerminalOutputContext.js', () => ({ - useTerminalOutput: () => vi.fn(), - TerminalOutputProvider: ({ children }: { children?: ReactNode }) => children, -})); - -import { TranscriptView } from './TranscriptView.js'; - -describe(' error fallback', () => { - // React logs the caught render error to console.error; silence it. - let errorSpy: ReturnType; - beforeEach(() => { - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - afterEach(() => { - errorSpy.mockRestore(); - }); - - it('shows the recovery fallback (not a crash) when an item render throws', () => { - const items: HistoryItem[] = [{ id: 1, type: 'user', text: 'anything' }]; - const { lastFrame } = renderWithProviders( - , - ); - const frame = lastFrame() ?? ''; - // The ErrorBoundary's `errorFallback` renders its title + the Esc/q hint - // instead of letting the throw propagate and take the process down. - expect(frame).toContain('Failed to render transcript.'); - expect(frame).toContain('to close'); - }); -}); diff --git a/packages/cli/src/ui/components/TranscriptView.test.tsx b/packages/cli/src/ui/components/TranscriptView.test.tsx deleted file mode 100644 index 08d02cc4c51..00000000000 --- a/packages/cli/src/ui/components/TranscriptView.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ReactNode } from 'react'; -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { TranscriptView } from './TranscriptView.js'; -import type { HistoryItem } from '../types.js'; -import { renderWithProviders } from '../../test-utils/render.js'; - -// The transcript renders thinking blocks in full detail; the inline thinking -// block also installs mouse listeners — stub them out for a deterministic test. -vi.mock('../hooks/useMouseEvents.js', () => ({ - useMouseEvents: vi.fn(), -})); - -// Spy on the raw terminal writer so we can assert the alt-screen escapes that -// the default `useAlternateScreen` path emits via the AlternateScreen wrapper. -const writeRaw = vi.fn(); -vi.mock('../contexts/TerminalOutputContext.js', () => ({ - useTerminalOutput: () => writeRaw, - TerminalOutputProvider: ({ children }: { children?: ReactNode }) => children, -})); - -const ENTER_ALT_SCREEN = '\x1b[?1049h'; -const EXIT_ALT_SCREEN = '\x1b[?1049l'; - -describe('', () => { - const origIsTTY = process.stdout.isTTY; - const setTTY = (value: boolean) => - Object.defineProperty(process.stdout, 'isTTY', { - value, - configurable: true, - }); - - afterEach(() => { - writeRaw.mockClear(); - setTTY(origIsTTY); - }); - - const items: HistoryItem[] = [ - { id: 1, type: 'user', text: 'hello world' }, - { - id: 2, - type: 'gemini_thought', - text: 'a private reasoning step that is normally collapsed', - }, - { id: 3, type: 'gemini', text: 'the assistant reply' }, - ]; - - it('renders the frozen items with header and footer chrome', () => { - const { lastFrame } = renderWithProviders( - , - ); - const frame = lastFrame(); - expect(frame).toContain('Transcript'); - // Footer hints (Esc/q to close, scroll keys). - expect(frame).toContain('to close'); - expect(frame).toContain('to scroll'); - }); - - it('renders thinking blocks expanded (fullDetail) — full text, not a summary', () => { - const { lastFrame } = renderWithProviders( - , - ); - const frame = lastFrame(); - // The full thought text is shown (fullDetail forces expansion) rather than - // the collapsed single-line "Thought for …" summary. - expect(frame).toContain( - 'a private reasoning step that is normally collapsed', - ); - expect(frame).toContain('hello world'); - expect(frame).toContain('the assistant reply'); - }); - - it('enters and exits the alternate screen by default (useAlternateScreen defaults to true)', () => { - setTTY(true); - const { unmount } = renderWithProviders(); - // The default path drives AlternateScreen with disabled=false, which writes - // the enter-alt-screen escape on mount. - expect(writeRaw).toHaveBeenCalledWith( - expect.stringContaining(ENTER_ALT_SCREEN), - ); - - writeRaw.mockClear(); - unmount(); - expect(writeRaw).toHaveBeenCalledWith( - expect.stringContaining(EXIT_ALT_SCREEN), - ); - }); - - it('renders frozen pending items carrying negative ids without key collisions', () => { - // AppContainer assigns negative ids to the pending snapshot (`id: -(i+1)`), - // exercising keyExtractor's `tp-` branch alongside the committed `t-` items. - const withPending: HistoryItem[] = [ - { id: 1, type: 'user', text: 'committed question' }, - { id: -1, type: 'gemini', text: 'streaming pending reply' }, - { id: -2, type: 'gemini_content', text: 'second pending chunk' }, - ]; - const { lastFrame } = renderWithProviders( - , - ); - const frame = lastFrame(); - expect(frame).toContain('committed question'); - expect(frame).toContain('streaming pending reply'); - expect(frame).toContain('second pending chunk'); - }); -}); diff --git a/packages/cli/src/ui/components/TranscriptView.tsx b/packages/cli/src/ui/components/TranscriptView.tsx deleted file mode 100644 index 1fe67307955..00000000000 --- a/packages/cli/src/ui/components/TranscriptView.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { memo, useCallback, useMemo } from 'react'; -import type { ErrorInfo } from 'react'; -import { Box, Text } from 'ink'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { theme } from '../semantic-colors.js'; -import { t } from '../../i18n/index.js'; -import { AlternateScreen } from './AlternateScreen.js'; -import { HistoryItemDisplay } from './HistoryItemDisplay.js'; -import { ErrorBoundary } from './shared/ErrorBoundary.js'; -import { ScrollableList, SCROLL_TO_ITEM_END } from './shared/ScrollableList.js'; -import { sanitizeTerminalText } from '../utils/textUtils.js'; -import { OverflowProvider } from '../contexts/OverflowContext.js'; -import type { HistoryItem } from '../types.js'; - -const debugLogger = createDebugLogger('TRANSCRIPT_VIEW'); - -interface TranscriptViewProps { - /** Frozen snapshot of history + pending items, already stitched by the caller. */ - items: HistoryItem[]; - /** - * When false, Ink already owns the alternate screen (VP mode) — the - * AlternateScreen wrapper skips its escape writes to avoid double-enter. - */ - useAlternateScreen?: boolean; -} - -// Per-item virtual-scroll height estimate. The transcript renders every item -// with `fullDetail` (thinking full text, full tool output), so each item is -// far taller than MainContent's flat `() => 3`. A type-aware estimate keeps the -// scrollbar / PageUp-PageDown jump distances sane; VirtualizedList back-fills the -// real measured height once an item is rendered. -function estimateTranscriptItemHeight(item: HistoryItem): number { - switch (item.type) { - case 'gemini_thought': - case 'gemini_thought_content': - return 12; - case 'tool_group': - return 16; - case 'gemini': - case 'gemini_content': - return 8; - case 'user': - case 'user_shell': - return 2; - default: - return 4; - } -} - -const keyExtractor = (item: HistoryItem) => - item.id >= 0 ? `t-${item.id}` : `tp-${-item.id - 1}`; - -const TranscriptViewImpl = ({ - items, - useAlternateScreen = true, -}: TranscriptViewProps) => { - const { rows, columns } = useTerminalSize(); - - const headerHeight = 1; - const footerHeight = 1; - const contentHeight = Math.max(rows - headerHeight - footerHeight, 1); - - const estimatedItemHeight = useCallback( - (index: number) => estimateTranscriptItemHeight(items[index]), - [items], - ); - - const renderItem = useCallback( - ({ item }: { item: HistoryItem }) => ( - - ), - [columns], - ); - - const title = t('Transcript'); - - // Close keys (Esc / q / Ctrl+C / Ctrl+O) are owned exclusively by - // AppContainer's global keypress guard so a single broadcast keypress isn't - // handled twice — TranscriptView renders no close handler of its own. - - const content = useMemo( - () => ( - - - - ), - [items, renderItem, estimatedItemHeight, contentHeight], - ); - - // fullDetail rendering exercises paths the normal view never hits (forced - // thinking expansion, every tool group expanded, full result blocks). An - // unexpected item shape would otherwise throw uncaught and crash the CLI, so - // contain it: show a fallback and let the user press Esc/q to close. - const errorFallback = useCallback( - (error: Error) => ( - - - {t('Failed to render transcript.')} - - - {sanitizeTerminalText(error.message)} - - - Esc/q {t('to close')} - - - ), - [], - ); - - // Log caught render errors to the debug channel — the on-screen fallback is - // user-facing, but the fullDetail paths exercise rendering the normal view - // never hits, so a swallowed error must still leave a diagnostic trail. - const onRenderError = useCallback((error: Error, info: ErrorInfo) => { - debugLogger.error( - `render error: ${error.message}`, - info.componentStack ?? '', - ); - }, []); - - return ( - - - - - {title} - - - - - {content} - - - - - Esc/q {t('to close')} {' '}Shift+↑↓ {t('to scroll')} {' '} - PgUp/PgDn - {' '} - Ctrl+Home/End - - - - - ); -}; - -/** - * Memoized so the frozen transcript doesn't re-reconcile on every AppContainer - * re-render while streaming continues underneath. AppContainer hands a stable - * `items` reference (memoized from the freeze snapshot), so the default shallow - * prop compare is enough. - */ -export const TranscriptView = memo(TranscriptViewImpl); -TranscriptView.displayName = 'TranscriptView'; From 33a32108b51df5497f85d50ed96aa053be422024 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Thu, 30 Jul 2026 08:54:37 +0000 Subject: [PATCH 3/8] fix(cli): update Ctrl+O help text and add thinking-expansion integration test (#8077) --- packages/cli/src/i18n/locales/ca.js | 2 +- packages/cli/src/i18n/locales/de.js | 2 +- packages/cli/src/i18n/locales/en.js | 2 +- packages/cli/src/i18n/locales/fr.js | 2 +- packages/cli/src/i18n/locales/ja.js | 2 +- packages/cli/src/i18n/locales/pt.js | 2 +- packages/cli/src/i18n/locales/ru.js | 2 +- packages/cli/src/i18n/locales/zh-TW.js | 2 +- packages/cli/src/i18n/locales/zh.js | 2 +- packages/cli/src/ui/AppContainer.test.tsx | 57 +++++++++++++++++++ .../src/ui/components/KeyboardShortcuts.tsx | 2 +- 11 files changed, 67 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 177e08d0293..620207b76ef 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -58,7 +58,7 @@ export default { 'to search history': "per cercar a l'historial", 'to paste images': 'per enganxar imatges', 'for external editor': 'per a editor extern', - 'to view transcript': 'per veure la transcripció', + 'to expand thinking': 'per expandir el pensament', 'Jump through words in the input': "Salta entre paraules a l'entrada", 'Close dialogs, cancel requests, or quit application': "Tanca els diàlegs, cancel·la les peticions o surt de l'aplicació", diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 975d28c6b0e..f8f95a42359 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1911,7 +1911,7 @@ export default { 'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Ctrl+C zum Beenden)\n', - 'to view transcript': 'zum Anzeigen des Transkripts', + 'to expand thinking': 'zum Erweitern der Gedanken', 'Switch to plan mode or exit plan mode': 'In den Plan-Modus wechseln oder den Plan-Modus verlassen', 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 4ee231dbcb9..5db5ed2215c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -276,7 +276,7 @@ export default { 'to search history': 'to search history', 'to paste images': 'to paste images', 'for external editor': 'for external editor', - 'to view transcript': 'to view transcript', + 'to expand thinking': 'to expand thinking', 'Jump through words in the input': 'Jump through words in the input', 'Close dialogs, cancel requests, or quit application': 'Close dialogs, cancel requests, or quit application', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index d5bd122b1b2..c92848987a4 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2045,7 +2045,7 @@ export default { 'Afficher le détail de l’utilisation du contexte par élément.', // === Missing key backfill === - 'to view transcript': 'pour voir la transcription', + 'to expand thinking': 'pour développer la réflexion', 'The name of the extension to update.': "Le nom de l'extension à mettre à jour.", 'Session (temporary)': 'Session (temporaire)', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index d51e8895ee5..2014cb43529 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1449,7 +1449,7 @@ export default { 'Rawモードが利用できません。インタラクティブターミナルで実行してください。', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n', - 'to view transcript': 'トランスクリプトを表示', + 'to expand thinking': '思考を展開', 'Switch to plan mode or exit plan mode': 'プランモードに切り替えるか、プランモードを終了する', 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 5d981fc4668..307d00eaed1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -54,7 +54,7 @@ export default { 'to search history': 'para pesquisar no histórico', 'to paste images': 'para colar imagens', 'for external editor': 'para editor externo', - 'to view transcript': 'para ver a transcrição', + 'to expand thinking': 'para expandir o pensamento', 'Jump through words in the input': 'Pular palavras na entrada', 'Close dialogs, cancel requests, or quit application': 'Fechar diálogos, cancelar solicitações ou sair do aplicativo', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 9bb5904a385..672629f7855 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -89,7 +89,7 @@ export default { 'to search history': 'поиск в истории', 'to paste images': 'вставить изображения', 'for external editor': 'внешний редактор', - 'to view transcript': 'показать транскрипт', + 'to expand thinking': 'развернуть размышления', // ============================================================================ // Поля системной информации diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 08c5e41ad68..127dce13514 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -261,7 +261,7 @@ export default { 'to search history': '搜索歷史', 'to paste images': '粘貼圖片', 'for external editor': '外部編輯器', - 'to view transcript': '檢視完整記錄', + 'to expand thinking': '展開思考', 'Jump through words in the input': '在輸入中按單詞跳轉', 'Close dialogs, cancel requests, or quit application': '關閉對話框、取消請求或退出應用程序', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 95d293cece6..e2ab8a9d899 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -266,7 +266,7 @@ export default { 'to search history': '搜索历史', 'to paste images': '粘贴图片', 'for external editor': '外部编辑器', - 'to view transcript': '查看完整记录', + 'to expand thinking': '展开思考', 'Jump through words in the input': '在输入中按单词跳转', 'Close dialogs, cancel requests, or quit application': '关闭对话框、取消请求或退出应用程序', diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index e6586086e04..211216a93c3 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -4733,6 +4733,63 @@ describe('AppContainer State Management', () => { }); }); + describe('Thinking expansion (Ctrl+O) integration', () => { + const makeKey = (overrides: Partial): Key => + ({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + ...overrides, + }) as Key; + + const getGlobalKeypress = () => + mockedUseKeypress.mock.calls + .map((call) => call[0]) + .reverse() + .find( + (handler): handler is (key: Key) => void => + typeof handler === 'function' && + handler.toString().includes('TOGGLE_THINKING_EXPANDED'), + ) as ((key: Key) => void) | undefined; + + const ctrlO = makeKey({ name: 'o', ctrl: true, sequence: '\x0f' }); + + it('Ctrl+O is handled by the TOGGLE_THINKING_EXPANDED branch (calls refreshStatic)', () => { + render( + , + ); + const handleKeypress = getGlobalKeypress(); + expect(handleKeypress).toBeDefined(); + + // refreshStatic writes clearTerminal when not in VP mode — proves the + // TOGGLE_THINKING_EXPANDED branch executed (it calls setThoughtExpanded + // then refreshStatic). Without this branch the key would fall through + // to later handlers that do NOT call refreshStatic. + const writesBefore = mockStdout.write.mock.calls.length; + act(() => { + handleKeypress!(ctrlO); + }); + expect(mockStdout.write.mock.calls.length).toBeGreaterThan(writesBefore); + + // A second press also hits the same branch (toggle back). + const writesAfterFirst = mockStdout.write.mock.calls.length; + act(() => { + handleKeypress!(ctrlO); + }); + expect(mockStdout.write.mock.calls.length).toBeGreaterThan( + writesAfterFirst, + ); + }); + }); + describe('Model Dialog Integration', () => { it('should provide isModelDialogOpen in the UIStateContext', () => { mockedUseModelCommand.mockReturnValue({ diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index 628d9f0cd7e..adf0a91c7ac 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -38,7 +38,7 @@ const getShortcuts = (): Shortcut[] => [ { key: 'ctrl+c', description: t('to quit') }, { key: getNewlineKey(), description: t('for newline') + ' ⏎' }, { key: 'ctrl+l', description: t('to clear screen') }, - { key: 'ctrl+o', description: t('to view transcript') }, + { key: 'ctrl+o', description: t('to expand thinking') }, { key: 'ctrl+r', description: t('to search history') }, { key: 'ctrl+y', description: t('to retry last request') }, { key: 'ctrl+q', description: t('to queue for the next turn') }, From f07b027b88c31d92000add33362cfa1752eb40de Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Thu, 30 Jul 2026 10:30:52 +0000 Subject: [PATCH 4/8] fix(cli): update docs, help text, and remove transcript dead code (#8077) --- docs/users/reference/keyboard-shortcuts.md | 2 +- packages/cli/src/config/keyBindings.ts | 6 -- packages/cli/src/i18n/locales/ca.js | 3 +- packages/cli/src/i18n/locales/de.js | 3 +- packages/cli/src/i18n/locales/en.js | 3 +- packages/cli/src/i18n/locales/fr.js | 3 +- packages/cli/src/i18n/locales/ja.js | 3 +- packages/cli/src/i18n/locales/pt.js | 3 +- packages/cli/src/i18n/locales/ru.js | 3 +- packages/cli/src/i18n/locales/zh-TW.js | 3 +- packages/cli/src/i18n/locales/zh.js | 3 +- .../ui/components/AlternateScreen.test.tsx | 75 ------------------- .../cli/src/ui/components/AlternateScreen.tsx | 65 ---------------- .../src/ui/components/HistoryItemDisplay.tsx | 2 +- .../src/ui/components/KeyboardShortcuts.tsx | 2 +- .../messages/ToolGroupMessage.test.tsx | 2 +- .../components/messages/ToolGroupMessage.tsx | 10 +-- .../ui/components/messages/ToolMessage.tsx | 2 +- .../ui/contexts/ThoughtExpandedContext.tsx | 2 +- packages/cli/src/ui/hooks/useMouseEvents.ts | 6 +- packages/cli/src/ui/keyMatchers.test.ts | 6 -- packages/cli/src/ui/utils/textUtils.test.ts | 47 ------------ packages/cli/src/ui/utils/textUtils.ts | 47 ------------ 23 files changed, 23 insertions(+), 278 deletions(-) delete mode 100644 packages/cli/src/ui/components/AlternateScreen.test.tsx delete mode 100644 packages/cli/src/ui/components/AlternateScreen.tsx diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index 83f5f888531..322ec454889 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -10,7 +10,7 @@ This document lists the available keyboard shortcuts in Qwen Code. | `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. | | `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | | `Ctrl+L` | Clear the screen. | -| `Ctrl+O` | Open/close the full-detail transcript view (a scrollable, frozen snapshot showing every tool's complete output and full thinking). Press again, or `Esc`/`q`, to close. | +| `Ctrl+O` | Toggle expanded detail mode: expand or collapse all thinking blocks and tool outputs inline. Press again to collapse. | | `Ctrl+S` | Stashes non-empty input for the current project and restores it on the next launch. With empty input, allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | | `Ctrl+T` | Toggle the display of tool descriptions. | | `Ctrl+B` | While a foreground shell command is running: promote it to a background task. The child keeps running, the agent's turn unblocks, and the shell appears in `/tasks` + the Background tasks dialog. No-op when no shell is executing — Ctrl+B then falls through to its prompt-area binding (cursor-left). | diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 1c6cf4515ba..7424cad7f8e 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -82,9 +82,6 @@ export enum Command { // Thinking expansion TOGGLE_THINKING_EXPANDED = 'toggleThinkingExpanded', - // Transcript full-detail screen (Ctrl+O) - TOGGLE_TRANSCRIPT = 'toggleTranscript', - // Scroll commands SCROLL_UP = 'scrollUp', SCROLL_DOWN = 'scrollDown', @@ -267,9 +264,6 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 't', meta: true }, ], - // Transcript overlay — unbound (replaced by inline thinking expansion) - [Command.TOGGLE_TRANSCRIPT]: [], - // Scroll commands [Command.SCROLL_UP]: [{ key: 'up', shift: true }], [Command.SCROLL_DOWN]: [{ key: 'down', shift: true }], diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 620207b76ef..dfe8e0aad1f 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -58,7 +58,7 @@ export default { 'to search history': "per cercar a l'historial", 'to paste images': 'per enganxar imatges', 'for external editor': 'per a editor extern', - 'to expand thinking': 'per expandir el pensament', + 'to expand details': 'per expandir els detalls', 'Jump through words in the input': "Salta entre paraules a l'entrada", 'Close dialogs, cancel requests, or quit application': "Tanca els diàlegs, cancel·la les peticions o surt de l'aplicació", @@ -254,7 +254,6 @@ export default { Transcript: 'Transcripció', 'to close': 'per tancar', 'to scroll': 'per desplaçar', - 'Failed to render transcript.': 'Error en renderitzar la transcripció.', 'Read {{count}} file': 'Ha llegit {{count}} fitxer', 'Read {{count}} files': 'Ha llegit {{count}} fitxers', 'Reading {{count}} file': 'Llegint {{count}} fitxer', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index f8f95a42359..00a7c881227 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -234,7 +234,6 @@ export default { Transcript: 'Transkript', 'to close': 'zum Schließen', 'to scroll': 'zum Scrollen', - 'Failed to render transcript.': 'Transkript konnte nicht gerendert werden.', 'Read {{count}} file': '{{count}} Datei gelesen', 'Read {{count}} files': '{{count}} Dateien gelesen', 'Reading {{count}} file': 'Lese {{count}} Datei', @@ -1911,7 +1910,7 @@ export default { 'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Ctrl+C zum Beenden)\n', - 'to expand thinking': 'zum Erweitern der Gedanken', + 'to expand details': 'zum Erweitern der Details', 'Switch to plan mode or exit plan mode': 'In den Plan-Modus wechseln oder den Plan-Modus verlassen', 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 5db5ed2215c..fb6b3cf2089 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -276,7 +276,7 @@ export default { 'to search history': 'to search history', 'to paste images': 'to paste images', 'for external editor': 'for external editor', - 'to expand thinking': 'to expand thinking', + 'to expand details': 'to expand details', 'Jump through words in the input': 'Jump through words in the input', 'Close dialogs, cancel requests, or quit application': 'Close dialogs, cancel requests, or quit application', @@ -508,7 +508,6 @@ export default { Transcript: 'Transcript', 'to close': 'to close', 'to scroll': 'to scroll', - 'Failed to render transcript.': 'Failed to render transcript.', 'Read {{count}} file': 'Read {{count}} file', 'Read {{count}} files': 'Read {{count}} files', 'Reading {{count}} file': 'Reading {{count}} file', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index c92848987a4..7676a73ce45 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -258,7 +258,6 @@ export default { Transcript: 'Transcription', 'to close': 'pour fermer', 'to scroll': 'pour défiler', - 'Failed to render transcript.': 'Échec du rendu de la transcription.', 'Read {{count}} file': 'Lu {{count}} fichier', 'Read {{count}} files': 'Lu {{count}} fichiers', 'Reading {{count}} file': 'Lecture de {{count}} fichier', @@ -2045,7 +2044,7 @@ export default { 'Afficher le détail de l’utilisation du contexte par élément.', // === Missing key backfill === - 'to expand thinking': 'pour développer la réflexion', + 'to expand details': 'pour développer les détails', 'The name of the extension to update.': "Le nom de l'extension à mettre à jour.", 'Session (temporary)': 'Session (temporaire)', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 2014cb43529..fb87a5c3826 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -210,7 +210,6 @@ export default { Transcript: 'トランスクリプト', 'to close': '閉じる', 'to scroll': 'スクロール', - 'Failed to render transcript.': 'トランスクリプトの描画に失敗しました。', 'Read {{count}} file': '{{count}} 件のファイルを読み込みました', 'Read {{count}} files': '{{count}} 件のファイルを読み込みました', 'Reading {{count}} file': '{{count}} 件のファイルを読み込み中', @@ -1449,7 +1448,7 @@ export default { 'Rawモードが利用できません。インタラクティブターミナルで実行してください。', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n', - 'to expand thinking': '思考を展開', + 'to expand details': '詳細を展開', 'Switch to plan mode or exit plan mode': 'プランモードに切り替えるか、プランモードを終了する', 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 307d00eaed1..f412edc43bf 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -54,7 +54,7 @@ export default { 'to search history': 'para pesquisar no histórico', 'to paste images': 'para colar imagens', 'for external editor': 'para editor externo', - 'to expand thinking': 'para expandir o pensamento', + 'to expand details': 'para expandir os detalhes', 'Jump through words in the input': 'Pular palavras na entrada', 'Close dialogs, cancel requests, or quit application': 'Fechar diálogos, cancelar solicitações ou sair do aplicativo', @@ -250,7 +250,6 @@ export default { Transcript: 'Transcrição', 'to close': 'para fechar', 'to scroll': 'para rolar', - 'Failed to render transcript.': 'Falha ao renderizar a transcrição.', 'Read {{count}} file': 'Leu {{count}} arquivo', 'Read {{count}} files': 'Leu {{count}} arquivos', 'Reading {{count}} file': 'Lendo {{count}} arquivo', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 672629f7855..844aa484079 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -89,7 +89,7 @@ export default { 'to search history': 'поиск в истории', 'to paste images': 'вставить изображения', 'for external editor': 'внешний редактор', - 'to expand thinking': 'развернуть размышления', + 'to expand details': 'развернуть детали', // ============================================================================ // Поля системной информации @@ -257,7 +257,6 @@ export default { Transcript: 'Транскрипт', 'to close': 'закрыть', 'to scroll': 'прокрутить', - 'Failed to render transcript.': 'Не удалось отобразить транскрипт.', 'Read {{count}} file': 'Прочитано файлов: {{count}}', 'Read {{count}} files': 'Прочитано файлов: {{count}}', 'Reading {{count}} file': 'Чтение файлов: {{count}}', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 127dce13514..fc4c5ece8ca 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -261,7 +261,7 @@ export default { 'to search history': '搜索歷史', 'to paste images': '粘貼圖片', 'for external editor': '外部編輯器', - 'to expand thinking': '展開思考', + 'to expand details': '展開詳情', 'Jump through words in the input': '在輸入中按單詞跳轉', 'Close dialogs, cancel requests, or quit application': '關閉對話框、取消請求或退出應用程序', @@ -466,7 +466,6 @@ export default { Transcript: '完整記錄', 'to close': '關閉', 'to scroll': '捲動', - 'Failed to render transcript.': '無法呈現完整記錄。', 'Read {{count}} file': '讀取了 {{count}} 個檔案', 'Read {{count}} files': '讀取了 {{count}} 個檔案', 'Reading {{count}} file': '正在讀取 {{count}} 個檔案', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index e2ab8a9d899..506b1f14834 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -266,7 +266,7 @@ export default { 'to search history': '搜索历史', 'to paste images': '粘贴图片', 'for external editor': '外部编辑器', - 'to expand thinking': '展开思考', + 'to expand details': '展开详情', 'Jump through words in the input': '在输入中按单词跳转', 'Close dialogs, cancel requests, or quit application': '关闭对话框、取消请求或退出应用程序', @@ -491,7 +491,6 @@ export default { Transcript: '完整记录', 'to close': '关闭', 'to scroll': '滚动', - 'Failed to render transcript.': '无法渲染完整记录。', 'Read {{count}} file': '读取了 {{count}} 个文件', 'Read {{count}} files': '读取了 {{count}} 个文件', 'Reading {{count}} file': '正在读取 {{count}} 个文件', diff --git a/packages/cli/src/ui/components/AlternateScreen.test.tsx b/packages/cli/src/ui/components/AlternateScreen.test.tsx deleted file mode 100644 index e2c1e9238bc..00000000000 --- a/packages/cli/src/ui/components/AlternateScreen.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render } from 'ink-testing-library'; -import { Text } from 'ink'; -import { AlternateScreen } from './AlternateScreen.js'; - -const writeRaw = vi.fn(); -vi.mock('../contexts/TerminalOutputContext.js', () => ({ - useTerminalOutput: () => writeRaw, -})); -vi.mock('../hooks/useTerminalSize.js', () => ({ - useTerminalSize: () => ({ rows: 24, columns: 80 }), -})); - -const ENTER_ALT_SCREEN = '\x1b[?1049h'; -const EXIT_ALT_SCREEN = '\x1b[?1049l'; - -describe('', () => { - const origIsTTY = process.stdout.isTTY; - const setTTY = (value: boolean) => - Object.defineProperty(process.stdout, 'isTTY', { - value, - configurable: true, - }); - - afterEach(() => { - writeRaw.mockClear(); - setTTY(origIsTTY); - }); - - it('enters on mount and exits on unmount when stdout is a TTY', () => { - setTTY(true); - const { unmount } = render( - - x - , - ); - expect(writeRaw).toHaveBeenCalledWith( - expect.stringContaining(ENTER_ALT_SCREEN), - ); - - writeRaw.mockClear(); - unmount(); - expect(writeRaw).toHaveBeenCalledWith( - expect.stringContaining(EXIT_ALT_SCREEN), - ); - }); - - it('skips escape writes when disabled (VP mode owns the alt screen)', () => { - setTTY(true); - const { unmount } = render( - - x - , - ); - expect(writeRaw).not.toHaveBeenCalled(); - unmount(); - }); - - it('skips escape writes when stdout is not a TTY (piped/CI)', () => { - setTTY(false); - const { unmount } = render( - - x - , - ); - expect(writeRaw).not.toHaveBeenCalled(); - unmount(); - }); -}); diff --git a/packages/cli/src/ui/components/AlternateScreen.tsx b/packages/cli/src/ui/components/AlternateScreen.tsx deleted file mode 100644 index c3f0bbafe8f..00000000000 --- a/packages/cli/src/ui/components/AlternateScreen.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { FC, ReactNode } from 'react'; -import { useEffect } from 'react'; -import { Box } from 'ink'; -import { useTerminalOutput } from '../contexts/TerminalOutputContext.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; - -const ENTER_ALT_SCREEN = '\x1b[?1049h'; -const EXIT_ALT_SCREEN = '\x1b[?1049l'; -const CLEAR_SCREEN = '\x1b[2J\x1b[H'; -const HIDE_CURSOR = '\x1b[?25l'; -const SHOW_CURSOR = '\x1b[?25h'; - -interface AlternateScreenProps { - children: ReactNode; - /** Skip escape writes when the root Ink renderer already owns the alt screen (VP mode). */ - disabled?: boolean; -} - -export const AlternateScreen: FC = ({ - children, - disabled, -}) => { - const writeRaw = useTerminalOutput(); - const { rows } = useTerminalSize(); - - useEffect(() => { - // Skip when the root Ink renderer already owns the alt screen (VP mode), - // or when stdout is not a TTY (piped/redirected/CI): writing alt-screen - // escapes to a non-terminal would just emit garbage bytes. Mirrors the - // repo convention of guarding terminal-control writes on `isTTY` - // (see startInteractiveUI.tsx / notificationService.ts). On non-TTY the - // transcript degrades to in-buffer rendering (no full-screen takeover). - if (disabled || !process.stdout.isTTY) return; - // Guard the raw writes: stdout can throw synchronously (EPIPE when the - // terminal closes mid-render, EAGAIN under backpressure). An uncaught throw - // from this effect / its cleanup would crash the app or leave the terminal - // in a corrupt state; swallow it — a failed escape write is best-effort. - const safeWrite = (data: string) => { - try { - writeRaw(data); - } catch { - // best-effort terminal control; ignore transient I/O errors - } - }; - safeWrite(ENTER_ALT_SCREEN + CLEAR_SCREEN + HIDE_CURSOR); - const onExit = () => safeWrite(SHOW_CURSOR + EXIT_ALT_SCREEN); - process.on('exit', onExit); - return () => { - process.removeListener('exit', onExit); - safeWrite(SHOW_CURSOR + EXIT_ALT_SCREEN); - }; - }, [writeRaw, disabled]); - - return ( - - {children} - - ); -}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 621667273d9..43d55042149 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -86,7 +86,7 @@ interface HistoryItemDisplayProps { /** Force thinking blocks expanded (e.g. in SessionPreview). */ thoughtExpanded?: boolean; /** - * Transcript full-detail mode (Ctrl+O). When true, collapse is lifted: + * Full-detail mode (Ctrl+O). When true, collapse is lifted: * thinking blocks render expanded and tool groups force `forceExpandAll` * + `forceShowResult` (every tool with its full, untruncated result). * Default false (main view stays at the #5661 partition baseline). diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index adf0a91c7ac..684ed2fca95 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -38,7 +38,7 @@ const getShortcuts = (): Shortcut[] => [ { key: 'ctrl+c', description: t('to quit') }, { key: getNewlineKey(), description: t('for newline') + ' ⏎' }, { key: 'ctrl+l', description: t('to clear screen') }, - { key: 'ctrl+o', description: t('to expand thinking') }, + { key: 'ctrl+o', description: t('to expand details') }, { key: 'ctrl+r', description: t('to search history') }, { key: 'ctrl+y', description: t('to retry last request') }, { key: 'ctrl+q', description: t('to queue for the next turn') }, diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 1de8e46a67a..abc63d02e76 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -523,7 +523,7 @@ describe('', () => { }); }); - // Transcript full-detail mode must NOT be short-circuited by the + // Full-detail mode must NOT be short-circuited by the // memory-only / pure-parallel-agent early returns (which run before the // forceExpandAll computation). Each tool must render in full. describe('fullDetail bypasses compact early returns', () => { diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index f9b9e17a3b7..09488a37622 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -150,7 +150,7 @@ interface ToolGroupMessageProps { memoryReadCount?: number; isUserInitiated?: boolean; /** - * Transcript full-detail mode (Ctrl+O). When true, force `forceExpandAll` + * Full-detail mode (Ctrl+O). When true, force `forceExpandAll` * (skip the type-based partition so every tool renders individually), pass * `forceShowResult=true` to each `ToolMessage`, and lift the per-tool * terminal-height truncation. Default false (main view keeps the #5661 @@ -283,7 +283,7 @@ export const ToolGroupMessage: React.FC = ({ // header's "N · done/N" honest, and `availableTerminalHeight` is a hard cap // backstop for degenerate cases (many agents finishing at once). // - // Skipped in transcript full-detail mode (fullDetail) so every agent + // Skipped in full-detail mode (fullDetail) so every agent // falls through to its own full ToolMessage instead of the dense panel. if ( !fullDetail && @@ -332,7 +332,7 @@ export const ToolGroupMessage: React.FC = ({ // Memory-only groups get their own compact rendering with read/write // counts. Check BEFORE the partition logic so they aren't routed through - // the collapsible/non-collapsible split. Skipped in transcript full-detail + // the collapsible/non-collapsible split. Skipped in full-detail // mode (fullDetail) so each memory op renders as its own full ToolMessage // rather than collapsing to the "Recalled/Wrote N memories" badge. const allMemOpsComplete = @@ -367,7 +367,7 @@ export const ToolGroupMessage: React.FC = ({ // Force-expand ALL tools individually when the user must interact or // must see full details: confirmation prompts, errors, user-initiated - // batches, focused shells, terminal subagents. Transcript full-detail + // batches, focused shells, terminal subagents. Full-detail // mode (fullDetail) also forces it so every tool renders individually // instead of collapsing read/search into a partition summary. const hasTerminalSubagent = inlineToolCalls.some(isTerminalSubagentTool); @@ -454,7 +454,7 @@ export const ToolGroupMessage: React.FC = ({ } const countOneLineToolCalls = nonCollapsibleTools.length - countToolCallsWithResults; - // In transcript full-detail mode, lift the per-tool height truncation so + // In full-detail mode, lift the per-tool height truncation so // each tool's output renders in full (combined with forceShowResult below). const availableTerminalHeightPerToolMessage = fullDetail ? undefined diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 22c44d97dfe..d0c2ce052d3 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -803,7 +803,7 @@ export const ToolMessage: React.FC = ({ renderOutputAsMarkdown = false; } - // §4.9: in transcript full-detail mode, collapsible tools (read/search/list) + // §4.9: in full-detail mode, collapsible tools (read/search/list) // swap the summary `resultDisplay` for the complete `detailedDisplay` derived // from the persisted functionResponse. Only a non-empty string detail // qualifies; everything else (and all main-view rendering) keeps the summary. diff --git a/packages/cli/src/ui/contexts/ThoughtExpandedContext.tsx b/packages/cli/src/ui/contexts/ThoughtExpandedContext.tsx index bc0fae585a3..d93959ba6fb 100644 --- a/packages/cli/src/ui/contexts/ThoughtExpandedContext.tsx +++ b/packages/cli/src/ui/contexts/ThoughtExpandedContext.tsx @@ -7,7 +7,7 @@ import { createContext, useContext } from 'react'; export interface ThoughtExpandedValue { - /** Alt+T global toggle — expands every thinking block at once. */ + /** Ctrl+O / Alt+T global toggle — expands every thinking block and tool output at once. */ allExpanded: boolean; /** * Head ids of thoughts the user expanded individually (by clicking the diff --git a/packages/cli/src/ui/hooks/useMouseEvents.ts b/packages/cli/src/ui/hooks/useMouseEvents.ts index 2b5eddbbdc4..81a65cd1d79 100644 --- a/packages/cli/src/ui/hooks/useMouseEvents.ts +++ b/packages/cli/src/ui/hooks/useMouseEvents.ts @@ -161,9 +161,9 @@ export function useMouseEvents( // Never write SGR mouse-mode escapes (?1002h ?1006h) unless stdout is a TTY. // `isRawModeSupported` only reflects stdin; with stdout piped/redirected // (`qwen | tee log`) an active, raw-mode-capable surface — e.g. the non-TTY - // transcript's focused ScrollableList (`bypassVpGate`) — would otherwise emit - // raw control bytes into the captured output. Mirrors AlternateScreen's - // `process.stdout.isTTY` guard so the non-TTY fallback stays byte-clean. + // focused ScrollableList (`bypassVpGate`) — would otherwise emit + // raw control bytes into the captured output. Mirrors the repo-wide + // `process.stdout.isTTY` convention so the non-TTY fallback stays byte-clean. const enabled = isActive && isRawModeSupported && vpGateOpen && Boolean(stdout.isTTY); diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index b7427263ac6..5dbfe714a31 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -99,7 +99,6 @@ describe('keyMatchers', () => { [Command.SCROLL_END]: (key: Key) => key.ctrl && key.name === 'end', [Command.TOGGLE_THINKING_EXPANDED]: (key: Key) => (key.ctrl && key.name === 'o') || (key.meta && key.name === 't'), - [Command.TOGGLE_TRANSCRIPT]: (_key: Key) => false, }; // Test data for each command with positive and negative test cases @@ -461,11 +460,6 @@ describe('keyMatchers', () => { createKey('o'), ], }, - { - command: Command.TOGGLE_TRANSCRIPT, - positive: [], - negative: [createKey('o', { ctrl: true }), createKey('o')], - }, ]; describe('Data-driven key binding matches original logic', () => { diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index 51ae2b43227..addfd4fab4a 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -16,7 +16,6 @@ import { sanitizeSensitiveText, sliceTextByVisualHeight, truncateToWidth, - wrapToVisualLines, } from './textUtils.js'; describe('textUtils', () => { @@ -398,49 +397,3 @@ describe('textUtils', () => { }); }); }); - -describe('visual row counting agrees between wrap and slice', () => { - // Both functions are documented as measuring visual rows at a given width, - // and callers mix them (scroll offsets, pending-render height). They - // disagreed on anything `string-width` reports as zero width, because only - // one of them clamped the per-character width to 1. - // `hiddenLinesCount + visible` only recovers the true row count when the - // text actually overflows `visible`, so every case below is chosen to. - const rowsFromSlice = (text: string, width: number): number => { - const visible = 3; - return ( - sliceTextByVisualHeight(text, visible, width).hiddenLinesCount + visible - ); - }; - - it.each([ - ['tabs', '\t'.repeat(50)], - ['combining marks', '́'.repeat(50)], - ['zero-width joiners', '‍'.repeat(50)], - ['a letter then combining marks', 'e' + '́'.repeat(49)], - ])('agrees on a run of %s', (_label, text) => { - expect(wrapToVisualLines(text, 10).length).toBe(rowsFromSlice(text, 10)); - }); - - // Guards against over-correcting: ordinary and wide characters were always - // consistent and must stay so. These pass before and after. - it.each([ - ['ascii', 'a'.repeat(50), 10], - ['wide CJK', '漢'.repeat(25), 10], - ['mixed', 'ab漢cd'.repeat(10), 10], - ])('still agrees on %s', (_label, text, width) => { - expect(wrapToVisualLines(text, width).length).toBe( - rowsFromSlice(text, width), - ); - }); - - it('still wraps a string shorter than the width to one row', () => { - expect(wrapToVisualLines('abc', 10)).toEqual(['abc']); - }); - - it('counts a run of tabs as more than one row', () => { - // The concrete regression: 50 zero-width characters at width 10 used to - // wrap to a single row. - expect(wrapToVisualLines('\t'.repeat(50), 10).length).toBe(5); - }); -}); diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index b4bdc2bcee9..bbcff6e35eb 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -275,53 +275,6 @@ export function sliceTextByVisualHeight( }; } -/** - * Wrap text into the visual rows it occupies at `width` columns, accounting - * for both explicit newlines and code-point-width-aware soft wrapping. Unlike - * `sliceTextByVisualHeight` (which keeps only a head/tail window), this returns - * every visual row, so callers that scroll an arbitrary offset can slice the - * rows the user actually sees. - */ -export function wrapToVisualLines(text: string, width: number): string[] { - if (width <= 0) { - return ['']; - } - const visualLines: string[] = []; - for (const logicalLine of text.split('\n')) { - if (logicalLine === '') { - visualLines.push(''); - continue; - } - let currentLine = ''; - let currentWidth = 0; - for (const char of logicalLine) { - // Clamped to 1, matching sliceTextByVisualHeight. `string-width` reports - // 0 for TAB, ZWJ and combining marks, so without this a run of them was - // charged nothing and the whole run counted as a single row: 50 tabs at - // width 10 came back as 1 row here and 5 there, for the same input. Any - // caller mixing the two -- scroll offsets, pending-render height -- then - // disagreed with itself. Erring high is the safe direction for a - // terminal: reserving a row too many costs a blank line, while counting - // one too few overflows the region and pushes content off screen. - const charWidth = Math.max(getCachedStringWidth(char), 1); - if (currentWidth + charWidth > width && currentWidth > 0) { - visualLines.push(currentLine); - currentLine = ''; - currentWidth = 0; - } - currentLine += char; - currentWidth += charWidth; - } - if (currentLine) { - visualLines.push(currentLine); - } - } - if (visualLines.length === 0) { - visualLines.push(''); - } - return visualLines; -} - /** * Clear the string width cache */ From 94f30aeb175581cf809440d2db4c9cf7f056757f Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Thu, 30 Jul 2026 15:45:08 +0000 Subject: [PATCH 5/8] fix(cli): strengthen Ctrl+O full-detail tests and refresh stale docs (#8077) --- docs/design/ctrl-o-detail-expand/design.md | 2 + docs/users/configuration/settings.md | 2 +- docs/users/features/tool-use-summaries.md | 14 ++-- packages/cli/src/ui/AppContainer.test.tsx | 30 ++++---- .../src/ui/components/MainContent.test.tsx | 69 +++++++++++++++++++ .../ui/contexts/ThoughtExpandedContext.tsx | 7 +- packages/cli/src/ui/hooks/useMouseEvents.ts | 2 +- 7 files changed, 104 insertions(+), 22 deletions(-) diff --git a/docs/design/ctrl-o-detail-expand/design.md b/docs/design/ctrl-o-detail-expand/design.md index 352da0d92db..281437ba66d 100644 --- a/docs/design/ctrl-o-detail-expand/design.md +++ b/docs/design/ctrl-o-detail-expand/design.md @@ -1,5 +1,7 @@ # 设计方案:Ctrl+O 行为重构 —— 对齐 Claude Code 的 Transcript 模型 +> **⚠️ 已被取代(superseded)**:本文档记录的 **`TranscriptView` + `AlternateScreen` 全详情冻结快照屏** 方案已在后续重构(PR #8077)中**移除**。当前实现中,`Ctrl+O`(与 `Alt+T` 共用 `Command.TOGGLE_THINKING_EXPANDED`)不再打开独立的 transcript 屏,而是**就地切换 full-detail 模式**:通过 `ThoughtExpandedContext.allExpanded` 由 `MainContent` 以 `fullDetail` 下传给每个 `HistoryItemDisplay`,在主视图内联展开所有思考块与工具组(并解除工具结果截断),再按一次收起。下文凡描述独立 transcript 屏与 alt-screen 的章节(如 §3.2、§4.2–§4.5、§9 的栈式 commit 拆分)仅作**历史记录**保留,与现行代码不符。 + - 分支:`feat/ctrl-o-detail-expand` - worktree:`` - 状态:**实现进行中——本文档为当前 PR 实现的验收基线**(非 docs-only;当前 PR 已含实现文件改动) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 58f3d294da8..7b42481f1c5 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -125,7 +125,7 @@ Settings are organized into categories. Most settings should be placed within th | `ui.showCitations` | boolean | Show citations for generated text in the chat. | `false` | | `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` | | `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` | -| `ui.compactMode` | boolean | Retired in the terminal UI. The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to open the full-detail transcript instead of toggling a mode. Still honored by the web shell. | `false` | +| `ui.compactMode` | boolean | Retired in the terminal UI. The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to toggle expanded detail mode (expand or collapse all thinking blocks and tool outputs inline) instead of toggling a mode. Still honored by the web shell. | `false` | | `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` | | `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | | `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` | diff --git a/docs/users/features/tool-use-summaries.md b/docs/users/features/tool-use-summaries.md index 7f040d12155..f0eadc1556a 100644 --- a/docs/users/features/tool-use-summaries.md +++ b/docs/users/features/tool-use-summaries.md @@ -1,6 +1,6 @@ # Tool-Use Summaries -Qwen Code can generate a short, git-commit-subject-style label after each tool batch completes, summarizing what the batch accomplished. The label appears inline: for a completed tool group in the main view it replaces the generic `Tool × N` header; when the group is force-expanded (in the `Ctrl+O` full-detail transcript, or for error / user-initiated batches) it appears as a dim `●