diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index df4217b3571..dea6eaa438e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -4100,6 +4100,12 @@ export const AppContainer = (props: AppContainerProps) => { streamingState, updateInfo, agentViewState.activeView, + // The agent tab footer grows with its own status row / queued messages / + // input text, none of which the deps above track; AgentComposer syncs + // this key to AgentViewContext whenever they change so the footer is + // re-measured and the transcript viewport does not stay stale-high + // (#9507). Mirrors the LiveAgentPanel layout key (#5798). + agentViewState.agentComposerLayoutKey, embeddedShellFocused, messageQueue.length, isInputActive, diff --git a/packages/cli/src/ui/app-container-controls-dep.test.ts b/packages/cli/src/ui/app-container-controls-dep.test.ts index c80077db9c3..829881b3416 100644 --- a/packages/cli/src/ui/app-container-controls-dep.test.ts +++ b/packages/cli/src/ui/app-container-controls-dep.test.ts @@ -22,6 +22,12 @@ * leaves the mechanism tests (which use a stand-in component) green. This guard * pins the dependency directly, so a deps-array cleanup or an `exhaustive-deps` * autofix cannot quietly delete the fix. + * + * The same reasoning extends to the #9507 provider link: the composer layout + * key must survive in the dependency arrays of the AgentViewContext state and + * actions memos, or setAgentComposerLayoutKey updates never reach the + * AppContainer measure effect guarded above. useMemo deps are invisible to + * TypeScript, so they are pinned here at the source level as well. */ import { describe, it, expect } from 'vitest'; @@ -33,6 +39,16 @@ const source = readFileSync( 'utf8', ); +const composerSource = readFileSync( + join(import.meta.dirname, 'components', 'agent-view', 'AgentComposer.tsx'), + 'utf8', +); + +const agentViewContextSource = readFileSync( + join(import.meta.dirname, 'contexts', 'AgentViewContext.tsx'), + 'utf8', +); + /** Extract the dependency array of the controls-height measurement effect. */ function controlsHeightEffectDeps(): string { const measureAt = source.indexOf('measureElement(mainControlsRef.current)'); @@ -44,6 +60,24 @@ function controlsHeightEffectDeps(): string { return source.slice(depsOpen, depsClose); } +/** + * Extract the dependency array of a useMemo in AgentViewContext.tsx. The + * slice runs from the deps-array opening bracket to its closing bracket, so + * it covers only the deps list, not the memoized object literal above it. + * That is deliberate: the guarded mutation keeps the value in the literal + * while dropping it from the deps, which must fail the assertion below even + * though the literal still mentions the identifier. + */ +function agentViewMemoDeps(memoAnchor: string): string { + const memoAt = agentViewContextSource.indexOf(memoAnchor); + expect(memoAt).toBeGreaterThan(-1); + const depsOpen = agentViewContextSource.indexOf('\n [', memoAt); + expect(depsOpen).toBeGreaterThan(-1); + const depsClose = agentViewContextSource.indexOf('\n ],', depsOpen); + expect(depsClose).toBeGreaterThan(depsOpen); + return agentViewContextSource.slice(depsOpen, depsClose); +} + describe('AppContainer controls-height measurement wiring', () => { it('measures controls height from mainControlsRef', () => { // Sanity: the effect we are guarding still exists and is shaped as expected. @@ -67,4 +101,55 @@ describe('AppContainer controls-height measurement wiring', () => { /liveAgentPanelLayoutKey\s*=\s*getLiveAgentPanelLayoutKey\(\s*bgTaskEntries\s*,\s*bgLivePanelFocused\s*,?\s*\)/, ); }); + + it('lists the agent composer layout key in the measurement effect deps', () => { + const deps = controlsHeightEffectDeps(); + // The #9507 fix: the agent tab footer grows with its own status row / + // queued messages / input text, none of which the other deps track; + // dropping this entry leaves controlsHeight stale-high on the agent tab + // and the viewport pushes the composer and tab bar off the terminal. + expect(deps).toContain('agentViewState.agentComposerLayoutKey'); + }); + + it('derives the composer layout key from the footer height-shifting state', () => { + // The key must cover every row-count factor the agent footer renders: + // loading row (streamingState), terminal status row, queued messages and + // input text. Whitespace-tolerant so prettier can't break the guard. + expect(composerSource).toMatch( + /getAgentComposerLayoutKey\(\{\s*streamingState\s*,\s*statusLabel:\s*statusLabel\?\.text\s*\?\?\s*''\s*,\s*queuedMessageCount:\s*messageQueue\.length\s*,\s*inputText:\s*buffer\.text\s*,?\s*\}\)/, + ); + }); + + it('syncs the composer layout key to context for the measure effect', () => { + expect(composerSource).toMatch( + /useEffect\(\(\)\s*=>\s*\{\s*setAgentComposerLayoutKey\(composerLayoutKey\);\s*\},\s*\[composerLayoutKey,\s*setAgentComposerLayoutKey\]\)/, + ); + }); +}); + +describe('AgentViewContext propagation of the composer layout key (#9507)', () => { + it('lists agentComposerLayoutKey in the state memo dependencies', () => { + const deps = agentViewMemoDeps('const state: AgentViewState = useMemo('); + // Sanity: this is the state memo's deps array (the literal above it + // mirrors the same identifiers, so anchor on entries plus position). + expect(deps).toContain('activeView'); + expect(deps).toContain('agentMessageQueues'); + // The #9507 provider link: the object literal still lists the key when + // this dep is dropped, so nothing looks wrong — but the memoized context + // value goes stale, setAgentComposerLayoutKey updates stop reaching the + // AppContainer controls-height measure effect, and the composer/tab bar + // overflow fixed in round 2 returns. TypeScript cannot see memo deps, so + // this source guard is the only protection. + expect(deps).toContain('agentComposerLayoutKey'); + }); + + it('lists setAgentComposerLayoutKey in the actions memo dependencies', () => { + const deps = agentViewMemoDeps( + 'const actions: AgentViewActions = useMemo(', + ); + expect(deps).toContain('setAgentShellFocused'); + // Dropping this entry hands consumers a stale actions object whose + // setAgentComposerLayoutKey identity predates the provider re-render. + expect(deps).toContain('setAgentComposerLayoutKey'); + }); }); diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx new file mode 100644 index 00000000000..0cc8df45110 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx @@ -0,0 +1,587 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Teammate-tab transcript scrolling (#9507). + * + * In Virtualized History mode (`ui.useTerminalBuffer`, the default) the + * interactive UI takes over the whole terminal, so there is no + * terminal-native scrollback. The main conversation view renders its + * transcript in a scrollable virtual viewport (Page Up/Page Down + mouse + * wheel), but the teammate tab used ink's `` directly — content + * that scrolled off screen was unrecoverable. These tests pin the + * viewport contract for the teammate tab: + * + * - VP mode: the transcript lives in a scrollable viewport pinned to + * the tail; Page Up reveals earlier output (and the tail leaves the + * visible window). + * - Legacy mode (`useTerminalBuffer: false`): the transcript keeps + * flowing through `` into the terminal's native scrollback — + * every line is emitted and Page Up must not hide anything. + * - VP mode keeps the executing/confirming split: items from an + * executing tool group onward render through the pending (interactive) + * path so confirmation dialogs keep working inside the viewport. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type React from 'react'; +import { render } from 'ink-testing-library'; +import { act } from '@testing-library/react'; +import { Text } from 'ink'; +import { AgentChatContent } from './AgentChatContent.js'; +import { UIStateContext, type UIState } from '../../contexts/UIStateContext.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { ThoughtExpandedProvider } from '../../contexts/ThoughtExpandedContext.js'; +import { + ContextMenuProvider, + useContextMenu, + type ContextMenuContextValue, +} from '../../context-menu/ContextMenuContext.js'; +import { AgentStatus } from '@qwen-code/qwen-code-core'; +import type { AgentMessage } from '@qwen-code/qwen-code-core'; + +vi.mock('../../utils/measure-element-position.js', () => ({ + measureElementPosition: () => ({ x: 0, y: 0, width: 80, height: 8 }), +})); + +// ink-testing-library's fake stdout has no `isTTY`; ScrollableList's +// `useMouseEvents` gates SGR mouse mode on it. Report a TTY so the +// viewport arms as in a real terminal (mirrors ScrollableList.test). +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useStdout: () => ({ + stdout: { write: vi.fn(), isTTY: true }, + writeToStdout: vi.fn(), + }), + }; +}); + +const historyItemDisplaySpy = vi.hoisted(() => vi.fn()); +type HistoryItemDisplayProps = { + id: number; + isPending?: boolean; + fullDetail?: boolean; +}; +/** Props of every HistoryItemDisplay render recorded so far. */ +const historyItemDisplayCalls = (): HistoryItemDisplayProps[] => + historyItemDisplaySpy.mock.calls.map( + (args: unknown[]) => args[0] as HistoryItemDisplayProps, + ); +vi.mock('../HistoryItemDisplay.js', () => ({ + HistoryItemDisplay: ({ + item, + isPending, + fullDetail, + }: { + item: { id: number; type: string; text?: string }; + isPending?: boolean; + fullDetail?: boolean; + }) => { + historyItemDisplaySpy({ id: item.id, isPending, fullDetail }); + return ( + + {item.type === 'tool_group' + ? `TOOLGROUP:${item.id}` + : (item.text ?? `ITEM:${item.id}`)} + {isPending ? ':pending' : ''} + + ); + }, +})); + +vi.mock('./AgentHeader.js', () => ({ + AgentHeader: () => AGENT_HEADER, +})); + +vi.mock('../RespondingSpinner.js', () => ({ + RespondingSpinner: () => SPINNER, +})); + +// Props spy that still renders the real viewport: the scrolling cases need +// genuine Page Up behavior, the context-menu case needs the wiring. +const scrollableListSpy = vi.hoisted(() => vi.fn()); +vi.mock('../shared/ScrollableList.js', async (importOriginal) => { + const actual = + await importOriginal(); + const { createElement, forwardRef } = await import('react'); + const RealList = actual.ScrollableList as unknown as React.ComponentType< + Record + >; + const ScrollableListSpy = forwardRef( + (props: Record, ref: React.Ref) => { + scrollableListSpy(props); + return createElement(RealList, { ...props, ref }); + }, + ); + return { ...actual, ScrollableList: ScrollableListSpy }; +}); + +/** Props of the most recent ScrollableList render. */ +const latestScrollableListProps = (): { hasFocus?: boolean } => + scrollableListSpy.mock.calls.at(-1)![0] as { hasFocus?: boolean }; + +const textSelectionControllerSpy = vi.hoisted(() => vi.fn()); +vi.mock('../../selection/use-text-selection.js', () => ({ + TextSelectionController: (props: { + isActive: boolean; + eventsPaused?: boolean; + }) => { + textSelectionControllerSpy(props); + return null; + }, +})); + +// The VP path arms SGR mouse capture through ScrollableList, which takes the +// mouse away from the terminal. ContentMouseController is what hands back +// OSC 8 link clicks and the right-click menu, so pin that it is mounted. +const contentMouseControllerSpy = vi.hoisted(() => vi.fn()); +vi.mock('../../context-menu/ContentMouseController.js', () => ({ + ContentMouseController: (props: { isActive: boolean }) => { + contentMouseControllerSpy(props); + return null; + }, +})); + +const setAgentShellFocusedSpy = vi.hoisted(() => vi.fn()); +vi.mock('../../contexts/AgentViewContext.js', () => ({ + useAgentViewActions: () => ({ + setAgentShellFocused: setAgentShellFocusedSpy, + }), +})); + +const createUIState = (overrides: Partial = {}): UIState => + ({ + history: [], + historyManager: {} as UIState['historyManager'], + isThemeDialogOpen: false, + themeError: null, + auth: { + authError: null, + isAuthDialogOpen: false, + isAuthenticating: false, + pendingAuthType: undefined, + externalAuthState: null, + qwenAuthState: { + deviceAuth: null, + authStatus: 'idle', + authMessage: null, + }, + }, + isConfigInitialized: true, + editorError: null, + isEditorDialogOpen: false, + debugMessage: '', + quittingMessages: null, + isSettingsDialogOpen: false, + isStatusLineDialogOpen: false, + isMemoryDialogOpen: false, + isModelDialogOpen: false, + isFastModelMode: false, + isTrustDialogOpen: false, + activeArenaDialog: null, + isPermissionsDialogOpen: false, + isApprovalModeDialogOpen: false, + isResumeDialogOpen: false, + resumeMatchedSessions: undefined, + isDeleteDialogOpen: false, + slashCommands: [], + pendingSlashCommandHistoryItems: [], + commandContext: {} as UIState['commandContext'], + shellConfirmationRequest: null, + confirmationRequest: null, + confirmUpdateExtensionRequests: [], + providerUpdateRequest: undefined, + settingInputRequests: [], + pluginChoiceRequests: [], + loopDetectionConfirmationRequest: null, + geminiMdFileCount: 0, + streamingState: {} as UIState['streamingState'], + initError: null, + pendingGeminiHistoryItems: [], + thought: null, + shellModeActive: false, + userMessages: [], + buffer: {} as UIState['buffer'], + inputWidth: 80, + suggestionsWidth: 80, + isInputActive: true, + shouldShowIdePrompt: false, + shouldShowCommandMigrationNudge: false, + commandMigrationTomlFiles: [], + isFolderTrustDialogOpen: false, + isTrustedFolder: true, + constrainHeight: true, + ideContextState: undefined, + showToolDescriptions: false, + ctrlCPressedOnce: false, + ctrlDPressedOnce: false, + showEscapePrompt: false, + elapsedTime: 0, + currentLoadingPhrase: '', + historyRemountKey: 1, + messageQueue: [], + showAutoAcceptIndicator: {} as UIState['showAutoAcceptIndicator'], + currentModel: 'test-model', + contextFileNames: [], + availableTerminalHeight: 8, + mainAreaWidth: 76, + staticAreaMaxItemHeight: 100, + staticExtraHeight: 0, + dialogsVisible: false, + pendingHistoryItems: [], + stickyTodos: null, + btwItem: null, + setBtwItem: vi.fn(), + cancelBtw: vi.fn(), + nightly: false, + branchName: 'main', + sessionStats: { lastPromptTokenCount: 0 } as UIState['sessionStats'], + terminalWidth: 80, + terminalHeight: 24, + mainControlsRef: { current: null }, + currentIDE: null, + updateInfo: null, + showIdeRestartPrompt: false, + ideTrustRestartReason: {} as UIState['ideTrustRestartReason'], + isRestarting: false, + extensionsUpdateState: new Map(), + activePtyId: undefined, + embeddedShellFocused: false, + showWelcomeBackDialog: false, + welcomeBackInfo: null, + welcomeBackChoice: null, + isSubagentCreateDialogOpen: false, + isAgentsManagerDialogOpen: false, + isExtensionsManagerDialogOpen: false, + isMcpDialogOpen: false, + isHooksDialogOpen: false, + isFeedbackDialogOpen: false, + taskStartTokens: 0, + taskStartStreamingChars: 0, + responseCandidateTokens: 0, + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + sessionName: null, + setSessionName: vi.fn(), + promptSuggestion: null, + abortPromptSuggestion: vi.fn(), + isRewindSelectorOpen: false, + rewindEscPending: false, + useTerminalBuffer: true, + showScrollbar: false, + ...overrides, + }) as UIState; + +const makeMessages = (n: number): AgentMessage[] => + Array.from({ length: n }, (_, i) => ({ + role: 'user' as const, + content: `user-msg-${i}`, + timestamp: Date.now(), + })); + +const makeCore = ( + messages: AgentMessage[], + pendingApprovals: ReadonlyMap = new Map(), +) => { + const emitter = { on: vi.fn(), off: vi.fn() }; + return { + getEventEmitter: () => emitter, + getMessages: () => messages, + getPendingApprovals: () => pendingApprovals, + getLiveOutputs: () => new Map(), + getShellPids: () => new Map(), + runtimeContext: { getTargetDir: () => '' }, + modelConfig: { model: 'test-model' }, + } as never; +}; + +const makeInteractiveAgent = () => + ({ + getStatus: () => AgentStatus.COMPLETED, + getExecutionStartTimes: () => new Map(), + }) as never; + +// `allExpanded` is the app-wide Ctrl+O/Alt+T full-detail switch that +// AppContainer flips and HistoryItemDisplay consumes as `fullDetail`. +// Default it to ON so the forwarded value is observable (a dropped prop +// reads as `undefined`, not `false`). +// +// The tree is wrapped in the real ContextMenuProvider (as DefaultAppLayout +// does in the app) and `MenuProbe` captures its API so a case can open the +// menu the way ContentMouseController does on a right-click. +let menuApi: ContextMenuContextValue | null = null; +const MenuProbe = () => { + menuApi = useContextMenu(); + return null; +}; + +const contentElement = ( + uiState: UIState, + core: unknown, + { allExpanded = true }: { allExpanded?: boolean } = {}, +) => ( + + + + (), + toggle: () => {}, + }} + > + + + + + + +); + +const renderContent = ( + uiState: UIState, + core: unknown, + options?: { allExpanded?: boolean }, +) => render(contentElement(uiState, core, options)); + +const PAGE_UP = '\x1b[5~'; +const settle = () => act(async () => {}); + +// Page Ups one at a time with a settle between them. Each press moves +// roughly one viewport page, but newly revealed items shrink from their +// estimated height to their measured height as they render, so a single +// press advances fewer items than `containerHeight` — keep pressing +// until the head is guaranteed to be in view. +const pageUpToTop = async (view: { stdin: { write: (s: string) => void } }) => { + for (let i = 0; i < 20; i++) { + await act(async () => { + view.stdin.write(PAGE_UP); + }); + await settle(); + } +}; + +describe('AgentChatContent teammate-tab scrolling (#9507)', () => { + beforeEach(() => { + setAgentShellFocusedSpy.mockClear(); + textSelectionControllerSpy.mockClear(); + contentMouseControllerSpy.mockClear(); + historyItemDisplaySpy.mockClear(); + scrollableListSpy.mockClear(); + menuApi = null; + }); + + it('VP mode: Page Up scrolls the teammate transcript back to earlier output', async () => { + const messages = makeMessages(40); + const view = renderContent(createUIState(), makeCore(messages)); + await settle(); + + expect(textSelectionControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true }), + ); + + // The viewport starts pinned to the tail, like the main view. + expect(view.lastFrame()).toContain('user-msg-39'); + // Earlier output must NOT be on screen before scrolling — this is + // what fails with the old always-`` renderer, which emits + // every line unconditionally and has no viewport to scroll. + expect(view.lastFrame()).not.toContain('user-msg-0'); + + // Page Up until the head of the transcript is back in view. + await pageUpToTop(view); + + expect(view.lastFrame()).toContain('user-msg-0'); + // Scrolled away from the tail: the newest line left the viewport. + expect(view.lastFrame()).not.toContain('user-msg-39'); + }); + + it('legacy mode: transcript still flows to terminal scrollback and Page Up hides nothing', async () => { + const messages = makeMessages(40); + const view = renderContent( + createUIState({ useTerminalBuffer: false }), + makeCore(messages), + ); + await settle(); + + // `` emits everything — native terminal scrollback works here. + expect(view.lastFrame()).toContain('user-msg-0'); + expect(view.lastFrame()).toContain('user-msg-39'); + + await pageUpToTop(view); + + // No in-app viewport exists in legacy mode; nothing may disappear. + expect(view.lastFrame()).toContain('user-msg-0'); + expect(view.lastFrame()).toContain('user-msg-39'); + }); + + it('VP mode: executing tool groups stay on the interactive (pending) render path', async () => { + // tool_call without a matching tool_result → Executing → must render + // through the pending path so confirmation dialogs remain input- + // capable inside the viewport (mirrors the ``-mode split). + const messages: AgentMessage[] = [ + ...makeMessages(20), + { + role: 'tool_call', + content: '', + timestamp: Date.now(), + metadata: { callId: 'call-1', toolName: 'run_shell_command' }, + }, + ]; + const view = renderContent(createUIState(), makeCore(messages)); + await settle(); + + // The viewport is pinned to the tail, where the executing tool group + // lives: it must render through the pending (interactive) path. + expect(view.lastFrame()).toContain('TOOLGROUP:20:pending'); + + // Scrolling away must not flip it to the committed path when it + // re-enters the window: scroll to the head and back to the tail. + await pageUpToTop(view); + expect(view.lastFrame()).toContain('user-msg-0'); + }); + + it('VP mode: forwards the Ctrl+O full-detail toggle on both render paths', async () => { + // Truncated tool rows advertise "… +N chars (ctrl+o)", so the toggle has + // to reach HistoryItemDisplay through the virtual viewport too — on the + // committed branch AND the pending branch, or the key does nothing here. + const messages: AgentMessage[] = [ + ...makeMessages(20), + { + role: 'tool_call', + content: '', + timestamp: Date.now(), + metadata: { callId: 'call-1', toolName: 'run_shell_command' }, + }, + ]; + renderContent(createUIState(), makeCore(messages)); + await settle(); + + const calls = historyItemDisplayCalls(); + expect(calls.length).toBeGreaterThan(0); + + // Both branches rendered, and neither dropped the prop. A VP path that + // forgets `fullDetail` reports `undefined` here, not `false`. + expect(calls.some((c) => c.isPending === true)).toBe(true); + expect(calls.some((c) => c.isPending === false)).toBe(true); + expect(calls.every((c) => c.fullDetail === true)).toBe(true); + }); + + it('VP mode: full-detail forwarding tracks the toggle instead of being hardcoded', async () => { + renderContent(createUIState(), makeCore(makeMessages(20)), { + allExpanded: false, + }); + await settle(); + + const calls = historyItemDisplayCalls(); + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((c) => c.fullDetail === false)).toBe(true); + }); + + it('VP mode: mounts ContentMouseController so link clicks and the right-click menu survive SGR capture', async () => { + // ScrollableList arms SGR mouse tracking (bypassVpGate), which makes the + // terminal stop handling the mouse natively. Without this controller OSC 8 + // link clicks and the right-click menu silently do nothing on this tab — + // MainContent mounts it for exactly this reason. + renderContent(createUIState(), makeCore(makeMessages(40))); + await settle(); + + expect(contentMouseControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true }), + ); + }); + + it('VP mode: an open context menu quiets the viewport and pauses selection', async () => { + // Mounting ContentMouseController makes the right-click menu openable on + // this tab for the first time, so the menu's quiescence has to come with + // it (MainContent's VP path gates both). Without it Page Up slides the + // transcript under a pinned menu — the highlight row then labels content + // the user is not looking at — and a press/drag on a menu item anchors or + // clears a transcript selection under the overlay. + renderContent(createUIState(), makeCore(makeMessages(40))); + await settle(); + + // Baseline: the viewport owns the keys and selection handling is live. + expect(latestScrollableListProps().hasFocus).toBe(true); + expect(textSelectionControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true, eventsPaused: false }), + ); + + await act(async () => { + menuApi?.openMenu( + [{ id: 'copy-link', label: 'Copy Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + await settle(); + expect(menuApi?.menu).not.toBeNull(); + + expect(latestScrollableListProps().hasFocus).toBe(false); + // Paused, not deactivated: deactivating clears the selection the menu's + // Copy Selection offers. + expect(textSelectionControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true, eventsPaused: true }), + ); + // The mouse controller owns the open menu's pointer interaction and its + // deactivate effect closes the menu, so it must stay active here. + expect(contentMouseControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true }), + ); + }); + + it('VP mode: a pending teammate approval pulls the tail back and keeps the viewport quiet', async () => { + // On the VP path a teammate's pending approval is a tail item inside the + // windowed list, and `dialogsVisible` is derived from main-app dialog state + // only — a scheduler approval never reaches it. Scrolling that tail out of + // the render range unmounts the confirmation dialog together with its key + // handler, which blocks the agent round with nothing on screen to answer + // and no timeout to recover it. The legacy `` path renders pending + // items outside ``, so they cannot scroll away there. + const approvals = new Map(); + const core = makeCore(makeMessages(40), approvals); + const uiState = createUIState(); + const view = render(contentElement(uiState, core)); + await settle(); + + // Control: with nothing pending the viewport owns the scroll keys. + expect(latestScrollableListProps().hasFocus).toBe(true); + await pageUpToTop(view); + expect(view.lastFrame()).not.toContain('user-msg-39'); + + // The teammate now needs an approval while the user is scrolled up. + approvals.set('call-1', {}); + view.rerender(contentElement(uiState, core)); + await settle(); + + // Half one: the tail is pulled back into view, so the dialog is mounted + // before the keys are taken away. Without this a user who scrolled up + // BEFORE the approval arrived would be locked out of ever scrolling back. + expect(view.lastFrame()).toContain('user-msg-39'); + // Half two: the viewport goes quiet, so the tail cannot be scrolled away. + expect(latestScrollableListProps().hasFocus).toBe(false); + + await pageUpToTop(view); + expect(view.lastFrame()).toContain('user-msg-39'); + }); + + it('legacy mode: does not mount ContentMouseController (terminal still owns the mouse)', async () => { + // No SGR tracking is armed on the `` path, so the terminal handles + // clicks itself; mounting the controller here would double-handle them. + renderContent( + createUIState({ useTerminalBuffer: false }), + makeCore(makeMessages(40)), + ); + await settle(); + + expect(contentMouseControllerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx index 26f9674926c..ed8eab0b6a5 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx @@ -24,12 +24,44 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { useAgentViewActions } from '../../contexts/AgentViewContext.js'; import { HistoryItemDisplay } from '../HistoryItemDisplay.js'; import { useThoughtExpanded } from '../../contexts/ThoughtExpandedContext.js'; -import { ToolCallStatus } from '../../types.js'; +import { ToolCallStatus, type HistoryItem } from '../../types.js'; import { theme } from '../../semantic-colors.js'; import { RespondingSpinner } from '../RespondingSpinner.js'; import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; import { AgentHeader } from './AgentHeader.js'; import { buildThoughtHeadIdMap } from '../../utils/historyUtils.js'; +import { + ScrollableList, + SCROLL_TO_ITEM_END, + type ScrollableListRef, +} from '../shared/ScrollableList.js'; +import { TextSelectionController } from '../../selection/use-text-selection.js'; +import { ContentMouseController } from '../../context-menu/ContentMouseController.js'; +import { useContextMenu } from '../../context-menu/ContextMenuContext.js'; + +// Virtual-viewport item wrapper for the VP scroll path. `pending` preserves +// the committed/live split: items from an executing/confirming tool group +// onward render through the interactive path so approval dialogs keep +// receiving input inside the viewport (same contract as the `` path). +type AgentVpItem = + | { kind: 'header' } + | { kind: 'history'; item: HistoryItem; pending: boolean } + | { kind: 'spinner' }; + +// Pure functions with no closure deps — defined outside the component so +// they are stable references and never invalidate useMemo deps (mirrors +// MainContent's virtual-path helpers). +const agentVpEstimatedHeight = (index: number) => (index === 0 ? 4 : 3); +const agentVpKeyExtractor = (vpItem: AgentVpItem) => + vpItem.kind === 'header' + ? 'agent-header' + : vpItem.kind === 'spinner' + ? 'agent-spinner' + : // Adapter ids are index-stable across the pending→committed + // transition, so the key must not encode pending-ness. + `h-${vpItem.item.id}`; +const agentVpIsStatic = (vpItem: AgentVpItem) => + vpItem.kind === 'header' || (vpItem.kind === 'history' && !vpItem.pending); export interface AgentChatContentProps { /** The agent's AgentCore — the source of truth for transcript state. */ @@ -57,15 +89,30 @@ export const AgentChatContent = ({ }: AgentChatContentProps) => { const readonly = !interactiveAgent; const uiState = useUIState(); - const { historyRemountKey, availableTerminalHeight, constrainHeight } = - uiState; + const { + historyRemountKey, + availableTerminalHeight, + constrainHeight, + dialogsVisible, + // VP mode owns the whole screen — no terminal-native scrollback — so + // the transcript gets the same scrollable virtual viewport the main + // conversation view uses (#9507). + useTerminalBuffer: useVirtualScroll, + } = uiState; const { columns: terminalWidth } = useTerminalSize(); // Ctrl+O full-detail, matching MainContent. Thinking blocks in this view // already honored the toggle (HistoryItemDisplay reads the context itself), // but the tool side never received it — so a truncated args row could // advertise `(ctrl+o)` for a key that did nothing here. const { allExpanded: fullDetail } = useThoughtExpanded(); + // An open right-click menu owns the pointer and keyboard: the viewport goes + // quiet so scroll keys and wheel ticks don't leak into the content under it. + // The selection controller only PAUSES — deactivating clears the selection + // the menu's Copy Selection offers. The mouse controller itself stays + // ungated: it owns the open menu's pointer interaction and closes it. + const { menu: contextMenuOpen } = useContextMenu(); const contentWidth = terminalWidth - 4; + const scrollRef = useRef>(null); // Force re-render on message updates and status changes. // STREAM_TEXT is deliberately excluded — model text is shown only after @@ -153,7 +200,11 @@ export const AgentChatContent = ({ } } }, - { isActive: !readonly }, + // An open right-click menu owns the keys, and KeypressContext broadcasts + // without consuming, so this subscriber has to go quiet itself: flipping + // embedded-shell focus deactivates both controllers below, which closes + // the menu and CLEARS the selection its Copy Selection item offers. + { isActive: !readonly && contextMenuOpen === null }, ); // tickRef.current in deps ensures we rebuild when events fire even if @@ -219,6 +270,168 @@ export const AgentChatContent = ({ const agentModelId = core.modelConfig.model ?? ''; + // ── VP (Virtualized History) path ─────────────────────────────────── + // The whole transcript — header, committed items, live items, spinner — + // lives in one scrollable viewport pinned to the tail, so Page Up / + // Page Down / wheel can reach earlier output even though the app owns + // the screen. The legacy `` path below is unchanged for + // `useTerminalBuffer: false`, where terminal-native scrollback works. + const virtualItems = useMemo( + (): AgentVpItem[] => [ + { kind: 'header' }, + ...allItems.map((item, idx) => ({ + kind: 'history' as const, + item, + pending: idx >= splitIndex, + })), + ...(isRunning ? [{ kind: 'spinner' as const }] : []), + ], + [allItems, splitIndex, isRunning], + ); + + const renderVirtualItem = useCallback( + ({ item: vpItem }: { item: AgentVpItem }) => { + if (vpItem.kind === 'header') { + return ( + + ); + } + if (vpItem.kind === 'spinner') { + return ( + + + + ); + } + if (vpItem.pending) { + return ( + + ); + } + return ( + + ); + }, + [ + agentModelId, + modelName, + agentWorkingDir, + agentGitBranch, + terminalWidth, + contentWidth, + constrainHeight, + availableTerminalHeight, + readonly, + fullDetail, + activePtyId, + embeddedShellFocused, + thoughtHeadIdByItem, + ], + ); + + // A teammate's pending tool approval renders its ToolConfirmationMessage as + // a tail item INSIDE this windowed viewport, and `dialogsVisible` is derived + // from main-app dialog state only (AppContainer.tsx) — a scheduler approval + // never reaches it. On the legacy `` path below, pending items are + // rendered outside `` so they can never scroll away; the VP path has + // no such guarantee. Scrolling the tail out of `[renderRangeStart, + // renderRangeEnd]` unmounts the dialog together with its `useKeypress` + // subscription, which blocks the agent round with nothing on screen to + // answer and no timeout to recover it. + const approvalPending = pendingApprovals.size > 0; + + // Two halves, both required. Quieting the viewport alone would trap a user + // who scrolled up BEFORE the approval arrived — the dialog is already out of + // the render window and, with the scroll keys now dead, could never be + // brought back. So pull the tail into view first, then take the keys away. + const approvalScrolledRef = useRef(false); + useEffect(() => { + if (!useVirtualScroll) return; + if (!approvalPending) { + approvalScrolledRef.current = false; + return; + } + if (approvalScrolledRef.current) return; + approvalScrolledRef.current = true; + scrollRef.current?.scrollToEnd(); + }, [approvalPending, useVirtualScroll]); + + if (useVirtualScroll) { + return ( + <> + + scrollRef.current?.getViewportRect() ?? null} + getScrollState={() => + scrollRef.current?.getScrollState() ?? { + scrollTop: 0, + scrollHeight: 0, + innerHeight: 0, + } + } + hitTestScrollbar={(location) => + scrollRef.current?.hitTestScrollbar(location) ?? false + } + /> + {/* SGR mouse tracking is armed by ScrollableList above, which takes + the mouse away from the terminal. Mount the controller that gives + back what tracking costs — OSC 8 link clicks and the right-click + context menu — exactly as MainContent does on its VP path. The + menu itself is painted by the shared in + DefaultAppLayout, which sits above this tab. */} + scrollRef.current?.getViewportRect() ?? null} + hitTestScrollbar={(location) => + scrollRef.current?.hitTestScrollbar(location) ?? false + } + /> + + ); + } + return ( {/* Committed message history. diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.test.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.test.tsx index 9b442647cee..f8f19bed901 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.test.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.test.tsx @@ -5,12 +5,18 @@ */ import { render } from 'ink-testing-library'; +import { act } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AgentStatus, ApprovalMode } from '@qwen-code/qwen-code-core'; import { useAgentViewActions, useAgentViewState, } from '../../contexts/AgentViewContext.js'; +import { + ContextMenuProvider, + useContextMenu, + type ContextMenuContextValue, +} from '../../context-menu/ContextMenuContext.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; @@ -27,7 +33,24 @@ vi.mock('../../hooks/useKeypress.js'); vi.mock('../../hooks/usePreferredEditor.js'); vi.mock('../../hooks/useTerminalSize.js'); vi.mock('../shared/text-buffer.js'); -vi.mock('../BaseTextInput.js', () => ({ BaseTextInput: () => null })); +// Capture the props instead of dropping them: `onKeypress` is the seam where +// the composer decides whether BaseTextInput may act on a key. +const { baseTextInputProps } = vi.hoisted(() => ({ + baseTextInputProps: {} as { + onKeypress?: (key: Key) => boolean; + onSubmit?: (text: string) => void; + }, +})); +vi.mock('../BaseTextInput.js', () => ({ + BaseTextInput: (props: { + onKeypress?: (key: Key) => boolean; + onSubmit?: (text: string) => void; + }) => { + baseTextInputProps.onKeypress = props.onKeypress; + baseTextInputProps.onSubmit = props.onSubmit; + return null; + }, +})); vi.mock('../LoadingIndicator.js', () => ({ LoadingIndicator: () => null })); vi.mock('../QueuedMessageDisplay.js', () => ({ QueuedMessageDisplay: () => null, @@ -36,15 +59,38 @@ vi.mock('./AgentFooter.js', () => ({ AgentFooter: () => null })); type KeypressHandler = (key: Key) => void; +// `AgentComposer` calls useKeypress twice per render, in a fixed order: +// [0] Escape-to-cancel, [1] Shift+Tab approval-mode cycler. +const ESCAPE_CANCEL = 0; +const SHIFT_TAB_CYCLE = 1; + +const MenuProbe = () => { + menuApi = useContextMenu(); + return null; +}; +let menuApi: ContextMenuContextValue | null = null; + +const renderWithMenu = () => + render( + + + + , + ); + describe('AgentComposer', () => { const setAgentInputBufferText = vi.fn(); + const setAgentComposerLayoutKey = vi.fn(); const setAgentTabBarFocused = vi.fn(); const setAgentApprovalMode = vi.fn(); let capturedKeypressHandlers: KeypressHandler[]; + let capturedKeypressOptions: Array<{ isActive: boolean }>; beforeEach(() => { vi.clearAllMocks(); + menuApi = null; capturedKeypressHandlers = []; + capturedKeypressOptions = []; vi.mocked(useAgentViewState).mockReturnValue({ activeView: 'agent-1', @@ -71,6 +117,7 @@ describe('AgentComposer', () => { } as never); vi.mocked(useAgentViewActions).mockReturnValue({ setAgentInputBufferText, + setAgentComposerLayoutKey, setAgentTabBarFocused, setAgentApprovalMode, setAgentMessageQueue: vi.fn(), @@ -81,8 +128,9 @@ describe('AgentComposer', () => { vi.mocked(usePreferredEditor).mockReturnValue(undefined); vi.mocked(useTerminalSize).mockReturnValue({ columns: 80, rows: 24 }); vi.mocked(useKeypress).mockImplementation( - (handler: KeypressHandler, _options) => { + (handler: KeypressHandler, options: { isActive: boolean }) => { capturedKeypressHandlers.push(handler); + capturedKeypressOptions.push(options); }, ); vi.mocked(useAgentStreamingState).mockReturnValue({ @@ -110,6 +158,33 @@ describe('AgentComposer', () => { expect(setAgentInputBufferText).not.toHaveBeenCalled(); }); + it('syncs the footer layout key and updates it when the agent completes', () => { + // AppContainer's controls-height measure effect depends on this key; it + // is the only re-measure trigger for the agent footer (#9507). + const { rerender } = render(); + + expect(setAgentComposerLayoutKey).toHaveBeenCalled(); + const initialKey = String(setAgentComposerLayoutKey.mock.calls.at(-1)?.[0]); + // The input text is part of the key (input wrapping shifts the height). + expect(initialKey).toContain('draft'); + + vi.mocked(useAgentStreamingState).mockReturnValue({ + status: AgentStatus.COMPLETED, + streamingState: StreamingState.Idle, + isInputActive: false, + elapsedTime: 0, + lastPromptTokenCount: 0, + } as never); + rerender(); + + // The terminal-status row appeared, so the key must change to trigger a + // re-measure of the grown footer. + const completedKey = String( + setAgentComposerLayoutKey.mock.calls.at(-1)?.[0], + ); + expect(completedKey).not.toBe(initialKey); + }); + // The second useKeypress call is the Shift+Tab approval-mode cycler. const getShiftTabHandler = (): KeypressHandler => { render(); @@ -134,4 +209,83 @@ describe('AgentComposer', () => { expect(setAgentApprovalMode).not.toHaveBeenCalled(); }); + + // The real useKeypress hook only subscribes while `isActive` is true, so the + // registration from the most recent render is the gate in force right now. + const latestOptions = (callSite: number): { isActive: boolean } => + capturedKeypressOptions[capturedKeypressOptions.length - 2 + callSite]!; + + it('releases same-chunk keys only after input dismisses the menu', async () => { + renderWithMenu(); + const items = [{ id: 'open-link', label: 'Open Link', onSelect: vi.fn() }]; + const open = () => + act(async () => { + menuApi?.openMenu(items, { x: 0, y: 0 }); + }); + await open(); + + await act(async () => { + // Hold the same callback: no render may occur between these keys. + const handleKey = baseTextInputProps.onKeypress!; + expect(handleKey({ name: 'a', sequence: 'a' } as Key)).toBe(false); + expect(handleKey({ name: 'return', sequence: '\r' } as Key)).toBe(false); + }); + + await open(); + await act(async () => { + const handleKey = baseTextInputProps.onKeypress!; + // A fresh menu owns Enter even after the previous menu was dismissed. + expect(handleKey({ name: 'return', sequence: '\r' } as Key)).toBe(true); + menuApi?.executeIndex(0); + // Even if the overlay executes first, the same Enter cannot submit. + expect(handleKey({ name: 'return', sequence: '\r' } as Key)).toBe(true); + }); + expect(items[0]!.onSelect).toHaveBeenCalledOnce(); + }); + + it('goes quiet while the right-click context menu is open', async () => { + // AgentChatContent mounts ContentMouseController on the teammate tab, so + // this composer is now reachable with the menu open. KeypressContext + // broadcasts to every subscriber and discards return values (the overlay + // cannot consume a key for us), so each consumer has to gate itself or a + // key aimed at the menu also cancels the round and steals tab-bar focus. + vi.mocked(useAgentStreamingState).mockReturnValue({ + status: AgentStatus.IDLE, + streamingState: StreamingState.Responding, + isInputActive: false, + elapsedTime: 0, + lastPromptTokenCount: 0, + } as never); + + renderWithMenu(); + + // Control: with no menu open the composer still owns its keys. + expect(latestOptions(ESCAPE_CANCEL).isActive).toBe(true); + expect(latestOptions(SHIFT_TAB_CYCLE).isActive).toBe(true); + expect(baseTextInputProps.onKeypress?.({ name: 'down' } as Key)).toBe(true); + expect(setAgentTabBarFocused).toHaveBeenCalledWith(true); + + setAgentTabBarFocused.mockClear(); + await act(async () => { + menuApi?.openMenu( + [{ id: 'open-link', label: 'Open Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + expect(menuApi?.menu).not.toBeNull(); + + // Escape no longer reaches the cancel-round handler at all... + expect(latestOptions(ESCAPE_CANCEL).isActive).toBe(false); + // ...and neither does Shift+Tab reach the approval-mode cycler, which + // writes straight through to the agent runtime's tool-scheduling policy: + // a key aimed at the menu must not silently change the approval mode of + // the teammate the user is deciding about. + expect(latestOptions(SHIFT_TAB_CYCLE).isActive).toBe(false); + // ...and BaseTextInput never sees the menu's navigation keys, so the draft + // survives and the tab bar keeps its focus. + for (const name of ['up', 'down', 'return', 'escape']) { + expect(baseTextInputProps.onKeypress?.({ name } as Key)).toBe(true); + } + expect(setAgentTabBarFocused).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index fe72a059b88..fc9bacb9ae4 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -18,7 +18,7 @@ */ import { Box, Text, useStdin } from 'ink'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { AgentStatus } from '@qwen-code/qwen-code-core/agents/runtime/agent-types.js'; import { ApprovalMode, @@ -34,6 +34,7 @@ import { StreamingState } from '../../types.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import { useContextMenu } from '../../context-menu/ContextMenuContext.js'; import { useTextBuffer } from '../shared/text-buffer.js'; import { calculatePromptWidths } from '../../utils/layoutUtils.js'; import { BaseTextInput } from '../BaseTextInput.js'; @@ -52,6 +53,31 @@ interface AgentComposerProps { agentId: string; } +// ─── Layout key ───────────────────────────────────────────── + +/** + * Build the layout key AppContainer's controls-height measure effect depends + * on to re-measure the agent footer. The footer's height shifts with the + * agent's streaming state (LoadingIndicator row), terminal status row + * (Completed/Failed/Cancelled), queued-message display, and input text + * wrapping — none of which AppContainer can observe through its own state. + * Syncing this key to AgentViewContext is what triggers the re-measure; + * mirrors getLiveAgentPanelLayoutKey for the LiveAgentPanel. + */ +export function getAgentComposerLayoutKey(parts: { + streamingState: StreamingState; + statusLabel: string; + queuedMessageCount: number; + inputText: string; +}): string { + return [ + parts.streamingState, + parts.statusLabel, + parts.queuedMessageCount, + parts.inputText, + ].join('|'); +} + // ─── Component ────────────────────────────────────────────── // Shared empty queue identity so unregistered agents don't allocate on @@ -68,6 +94,7 @@ export const AgentComposer: React.FC = ({ agentId }) => { } = useAgentViewState(); const { setAgentInputBufferText, + setAgentComposerLayoutKey, setAgentTabBarFocused, setAgentApprovalMode, appendToAgentMessageQueue, @@ -89,6 +116,14 @@ export const AgentComposer: React.FC = ({ agentId }) => { lastPromptTokenCount, } = useAgentStreamingState(interactiveAgent); + // An open right-click context menu owns the keyboard while it is up + // (mounted on this tab by AgentChatContent's ContentMouseController). + // KeypressContext broadcasts to every subscriber and discards return + // values, so the menu cannot consume a key for us — each consumer has to go + // quiet itself, exactly as InputPrompt does for the main view. + const { menu: contextMenu, closeMenu } = useContextMenu(); + const inputDismissedMenuRef = useRef(null); + // ── Escape to cancel the active agent round ── useKeypress( @@ -102,7 +137,9 @@ export const AgentComposer: React.FC = ({ agentId }) => { }, { isActive: - streamingState === StreamingState.Responding && !agentShellFocused, + streamingState === StreamingState.Responding && + !agentShellFocused && + contextMenu === null, }, ); @@ -126,7 +163,12 @@ export const AgentComposer: React.FC = ({ agentId }) => { setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!); } }, - { isActive: !agentShellFocused }, + // Same broadcast rule as the Escape subscriber above: an open right-click + // menu cannot consume a key for us, so this subscriber has to go quiet + // itself. This one writes straight through to the agent runtime's + // tool-scheduling policy, so a Shift+Tab aimed at the menu would silently + // change the approval mode of the very teammate being decided about. + { isActive: !agentShellFocused && contextMenu === null }, ); // ── Input buffer (independent from main agent) ── @@ -164,6 +206,29 @@ export const AgentComposer: React.FC = ({ agentId }) => { const handleKeypress = useCallback( (key: Key): boolean => { + // While the right-click context menu is open it owns the navigation + // keys (the overlay handles them); any other key closes the menu and is + // then processed normally, mirroring click-away dismissal. The + // dismissing key must fall through rather than be swallowed, since + // BaseTextInput still owns every non-navigation key. + if ( + contextMenu !== null && + inputDismissedMenuRef.current !== contextMenu + ) { + if ( + key.name === 'up' || + key.name === 'down' || + key.name === 'return' || + key.name === 'escape' + ) { + return true; + } + // Only input dismissal releases subsequent keys before a render. + // Menu activation must not also submit the draft on that same Enter. + inputDismissedMenuRef.current = contextMenu; + closeMenu(); + } + // When tab bar has focus, block all non-printable keys so they don't // act on the hidden buffer. Printable characters fall through to // BaseTextInput naturally; the tab bar handler releases focus on the @@ -193,7 +258,7 @@ export const AgentComposer: React.FC = ({ agentId }) => { } return false; }, - [buffer, agentTabBarFocused, setAgentTabBarFocused], + [buffer, agentTabBarFocused, setAgentTabBarFocused, contextMenu, closeMenu], ); // ── Message queue display ── @@ -242,6 +307,22 @@ export const AgentComposer: React.FC = ({ agentId }) => { } }, [status, interactiveAgent]); + // Sync a layout key that changes whenever this footer's height can change + // (loading row toggling, status row appearing, queued messages growing, + // input wrapping). AppContainer's controls-height measure effect depends on + // it via AgentViewContext; without the sync, controlsHeight stays + // stale-high after the footer grows and the transcript viewport pushes the + // composer and tab bar past the terminal bottom (#9507). + const composerLayoutKey = getAgentComposerLayoutKey({ + streamingState, + statusLabel: statusLabel?.text ?? '', + queuedMessageCount: messageQueue.length, + inputText: buffer.text, + }); + useEffect(() => { + setAgentComposerLayoutKey(composerLayoutKey); + }, [composerLayoutKey, setAgentComposerLayoutKey]); + // ── Approval-mode styling (mirrors main InputPrompt) ── const approvalModePromptStyle = getApprovalModePromptStyle(agentApprovalMode); diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.test.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.test.tsx index 7abfaa41835..fffb4a7979a 100644 --- a/packages/cli/src/ui/components/agent-view/AgentTabBar.test.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.test.tsx @@ -5,9 +5,15 @@ */ import { render } from 'ink-testing-library'; +import { act } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AgentStatus } from '@qwen-code/qwen-code-core'; import type { KeypressHandler, Key } from '../../contexts/KeypressContext.js'; +import { + ContextMenuProvider, + useContextMenu, + type ContextMenuContextValue, +} from '../../context-menu/ContextMenuContext.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { useAgentViewActions, @@ -26,6 +32,15 @@ vi.mock('../../contexts/BackgroundTaskViewContext.js'); vi.mock('../../contexts/UIStateContext.js'); let activeKeypressHandler: KeypressHandler | null = null; +let latestKeypressActive = false; +// Module scope (as in AgentComposer.test) so TS does not narrow the probe's +// assignment away at the assertion site. +let menuApi: ContextMenuContextValue | null = null; + +const MenuProbe = () => { + menuApi = useContextMenu(); + return null; +}; const createKey = (overrides: Partial): Key => ({ name: '', @@ -73,11 +88,15 @@ describe('AgentTabBar', () => { beforeEach(() => { vi.clearAllMocks(); activeKeypressHandler = null; + latestKeypressActive = false; + menuApi = null; vi.mocked(useKeypress).mockImplementation((handler, { isActive }) => { - if (isActive) { - activeKeypressHandler = handler; - } + // Mirror the real hook: it subscribes only while `isActive`, so a + // deactivated call site must stop answering keys rather than keep the + // last registered handler alive. + latestKeypressActive = isActive; + activeKeypressHandler = isActive ? handler : null; }); setActiveView('agent-1'); vi.mocked(useAgentViewActions).mockReturnValue({ @@ -196,4 +215,36 @@ describe('AgentTabBar', () => { expect(setLivePanelFocused).not.toHaveBeenCalled(); expect(setAgentTabBarFocused).not.toHaveBeenCalled(); }); + + it('goes quiet while the right-click context menu is open', async () => { + // AgentChatContent mounts ContentMouseController on the teammate tab, so + // this bar is now reachable with the menu open. KeypressContext broadcasts + // to every subscriber and discards return values, so the overlay cannot + // consume a key for us: ungated, ←/→ still switch tabs (remounting the + // active tab away from a teammate awaiting approval) and ↑/↓ still release + // tab-bar focus, which nothing restores after the menu closes. + render( + + + + , + ); + + // Control: with no menu open the bar owns the arrows. + expect(latestKeypressActive).toBe(true); + pressKey({ name: 'up', sequence: '[A' }); + expect(setAgentTabBarFocused).toHaveBeenCalledWith(false); + + await act(async () => { + menuApi?.openMenu( + [{ id: 'open-link', label: 'Open Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + expect(menuApi?.menu).not.toBeNull(); + + // The subscription is gone, so the menu's navigation keys cannot reach it. + expect(latestKeypressActive).toBe(false); + expect(activeKeypressHandler).toBeNull(); + }); }); diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx index ae54bba8520..86da2029b78 100644 --- a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx @@ -37,6 +37,7 @@ import { } from '../../contexts/BackgroundTaskViewContext.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { useContextMenu } from '../../context-menu/ContextMenuContext.js'; import { theme } from '../../semantic-colors.js'; import { isLiveAgentPanelVisibleEntry } from '../background-view/liveAgentPanelVisibility.js'; @@ -75,6 +76,12 @@ export const AgentTabBar: React.FC = () => { const { setLivePanelFocused, setPillFocused } = useBackgroundTaskViewActions(); const { embeddedShellFocused } = useUIState(); + // An open right-click menu owns the arrow keys. KeypressContext broadcasts to + // every subscriber and discards return values, so the overlay cannot consume + // a key for us — without this the tab bar keeps switching tabs (remounting + // the active tab, away from a teammate awaiting approval) and keeps releasing + // its own focus under the menu, and nothing restores it afterwards. + const { menu: contextMenu } = useContextMenu(); const hasVisibleBgAgentRoster = () => bgEntries.some((e) => isLiveAgentPanelVisibleEntry(e, Date.now())); @@ -118,7 +125,7 @@ export const AgentTabBar: React.FC = () => { setAgentTabBarFocused(false); } }, - { isActive: true }, + { isActive: contextMenu === null }, ); // Subscribe to STATUS_CHANGE events from all agents so the tab bar diff --git a/packages/cli/src/ui/components/agent-view/agentComposerLayout.measurement.test.tsx b/packages/cli/src/ui/components/agent-view/agentComposerLayout.measurement.test.tsx new file mode 100644 index 00000000000..4635445b79d --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/agentComposerLayout.measurement.test.tsx @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Runtime reproduction of the agent-tab stale-viewport root cause (#9507). + * + * On an agent tab AppContainer sizes the transcript viewport from + * availableTerminalHeight = terminalHeight - controlsHeight - tabBarHeight + * where `controlsHeight` is measured from the controls box (which contains + * AgentComposer) inside a useLayoutEffect gated by a dependency array. The + * agent footer grows when the agent reaches a terminal status (the + * Completed/Failed row appears) or messages are queued while it streams, but + * those changes live inside AgentComposer (agent events / local queue state) + * and never touch AppContainer's own deps. So unless the composer-synced + * layout key is part of the deps, the effect does not re-run: controlsHeight + * stays stale-high and — once the transcript fills the viewport — the column + * (viewport + grown composer + tab bar) lands past the terminal's last row. + * + * This test mirrors that measurement contract on a minimal component — real + * ink render, real measureElement, real getAgentComposerLayoutKey — and shows + * that WITHOUT the key in the deps a growing footer leaves the available + * height stale, while WITH it the re-measure shrinks the reserved room by + * exactly the growth. A real AppContainer cannot be exercised here because + * ink-testing-library's rerender remounts it (re-running every mount effect + * regardless of deps); see app-container-controls-dep.test.ts for the + * source-level guard on the actual deps array. + */ + +import { describe, it, expect } from 'vitest'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { render } from 'ink-testing-library'; +import { Box, Text, measureElement, type DOMElement } from 'ink'; +import { StreamingState } from '../../types.js'; +import { getAgentComposerLayoutKey } from './AgentComposer.js'; + +const TERMINAL_HEIGHT = 24; +// The agent tab always renders a 1-row tab bar under the controls box; +// AppContainer subtracts it separately from availableTerminalHeight. +const TAB_BAR_HEIGHT = 1; + +interface FooterShape { + streamingState: StreamingState; + statusLabel: string; + queuedMessageCount: number; + inputText: string; +} + +/** Rows the stand-in renders for the queued-message display. */ +function queuedRows(count: number): number { + if (count === 0) return 0; + // 1 margin row + up to 3 previews + a "more" row, as in QueuedMessageDisplay + return 1 + Math.min(count, 3) + (count > 3 ? 1 : 0); +} + +/** + * Minimal stand-in for AppContainer's footer-measurement contract on the + * agent tab. The controls box renders one row per height-shifting part of + * AgentComposer (loading row, terminal status row, queued-message rows, + * input line, footer row), measures itself into `controlsHeight` via a + * useLayoutEffect, and reports the resulting available height. + * `wireComposerKeyDep` toggles whether the composer layout key is part of + * the effect deps — i.e. buggy vs fixed. + */ +function AgentFooterMeasured({ + shape, + wireComposerKeyDep, + report, +}: { + shape: FooterShape; + wireComposerKeyDep: boolean; + report: (availableHeight: number) => void; +}) { + const ref = useRef(null); + const [controlsHeight, setControlsHeight] = useState(0); + + const composerKey = getAgentComposerLayoutKey(shape); + const deps = wireComposerKeyDep ? [composerKey] : []; + + useLayoutEffect(() => { + if (!ref.current) return; + const { height } = measureElement(ref.current); + setControlsHeight((prev) => (prev === height ? prev : height)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + report(Math.max(0, TERMINAL_HEIGHT - controlsHeight - TAB_BAR_HEIGHT)); + + return ( + + {shape.streamingState !== StreamingState.Idle && Thinking…} + {shape.statusLabel !== '' && {shape.statusLabel}} + {Array.from({ length: queuedRows(shape.queuedMessageCount) }, (_, i) => ( + queued + ))} + {shape.inputText} + footer + + ); +} + +async function availableAfterGrowth( + wireComposerKeyDep: boolean, +): Promise<{ before: number; after: number }> { + let availableHeight = -1; + const report = (v: number) => { + availableHeight = v; + }; + + const streamingShape: FooterShape = { + streamingState: StreamingState.Responding, + statusLabel: '', + queuedMessageCount: 0, + inputText: 'hi', + }; + + const { rerender, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 20)); + const before = availableHeight; + + // The agent finishes (Completed status row appears) and two messages were + // queued while it streamed: the footer grows by 1 + queuedRows(2) rows. + rerender( + , + ); + await new Promise((r) => setTimeout(r, 20)); + const after = availableHeight; + unmount(); + return { before, after }; +} + +describe('agent composer footer re-measurement (#9507)', () => { + it('leaves the available height stale without the composer key dep', async () => { + const { before, after } = await availableAfterGrowth(false); + // The footer objectively grew, but the measurement effect never + // re-runs: the reserved room stays overestimated by exactly the growth. + expect(after).toBe(before); + }); + + it('re-measures and shrinks the reserved room with the composer key dep', async () => { + const growth = 1 + queuedRows(2); // Completed row + queued display + const { before, after } = await availableAfterGrowth(true); + expect(after).toBe(before - growth); + }); +}); + +describe('getAgentComposerLayoutKey', () => { + const base: FooterShape = { + streamingState: StreamingState.Idle, + statusLabel: '', + queuedMessageCount: 0, + inputText: '', + }; + + it('is stable for unchanged footer state', () => { + expect(getAgentComposerLayoutKey(base)).toBe( + getAgentComposerLayoutKey({ ...base }), + ); + }); + + it('changes when the terminal status row appears', () => { + expect( + getAgentComposerLayoutKey({ ...base, statusLabel: 'Completed' }), + ).not.toBe(getAgentComposerLayoutKey(base)); + }); + + it('changes when messages are queued', () => { + expect( + getAgentComposerLayoutKey({ ...base, queuedMessageCount: 2 }), + ).not.toBe(getAgentComposerLayoutKey(base)); + }); + + it('changes when the input text grows', () => { + expect(getAgentComposerLayoutKey({ ...base, inputText: 'hello' })).not.toBe( + getAgentComposerLayoutKey(base), + ); + }); + + it('changes when the loading row toggles (streaming state)', () => { + expect( + getAgentComposerLayoutKey({ + ...base, + streamingState: StreamingState.Responding, + }), + ).not.toBe(getAgentComposerLayoutKey(base)); + }); +}); diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx index d15ea39fb73..a64c93f1051 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx @@ -561,7 +561,7 @@ export const AskUserQuestionDialog: React.FC = ({ }} onSubmit={handleCustomInputSubmit} placeholder={t('Type something...')} - isActive={true} + isActive={isFocused} inputWidth={50} /> diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 0ec4a61adfd..6ab31cfea82 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -45,8 +45,27 @@ import { withProviders, } from '../../../test-utils/render.js'; import type { LoadedSettings } from '../../../config/settings.js'; +import { render } from 'ink-testing-library'; +import { act, useEffect } from 'react'; +import { useKeypressContext } from '../../contexts/KeypressContext.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { ContextMenuOverlay } from '../../context-menu/ContextMenuOverlay.js'; +import { + ContextMenuProvider, + useContextMenu, + type ContextMenuContextValue, +} from '../../context-menu/ContextMenuContext.js'; describe('ToolConfirmationMessage', () => { + // The tree is wrapped in the real ContextMenuProvider (as DefaultAppLayout + // does) and `MenuProbe` captures its API so a case can open the menu the way + // ContentMouseController does on a right-click. + let menuApi: ContextMenuContextValue | null = null; + const MenuProbe = () => { + menuApi = useContextMenu(); + return null; + }; + const mockConfig = { isTrustedFolder: () => true, getIdeMode: () => false, @@ -1380,4 +1399,225 @@ describe('ToolConfirmationMessage', () => { expect(frame).toMatch(/\.{3} last \d+ lines hidden \.{3}/); }); }); + + it.each([true, false])( + 'handles a right-click and keys in one stdin chunk (menu items: %s)', + async (hasItems) => { + const onConfirm = vi.fn().mockResolvedValue(undefined); + const onMenuSelect = vi.fn(); + const onUnderlyingKey = vi.fn(); + const onMenuChange = vi.fn(); + const OpenOnRightPress = () => { + const { subscribeMouse, unsubscribeMouse } = useKeypressContext(); + const { menu, openMenu } = useContextMenu(); + useKeypress(onUnderlyingKey, { isActive: menu === null }); + useEffect(() => { + const handler = () => { + openMenu( + hasItems + ? [ + { + id: 'open-link', + label: 'Open Link', + onSelect: onMenuSelect, + }, + ] + : [], + { x: 0, y: 0 }, + ); + // AppContainer is outside the provider and relies on this callback. + expect(onMenuChange).toHaveBeenLastCalledWith(hasItems); + }; + subscribeMouse(handler); + return () => unsubscribeMouse(handler); + }, [subscribeMouse, unsubscribeMouse, openMenu]); + return null; + }; + const { stdin } = render( + withProviders( + + + + + , + ), + ); + await act(async () => {}); + await act(async () => { + stdin.write('\x1b[<2;10;5M\x1b[Z\x1b[C\r'); + }); + if (hasItems) { + expect(onUnderlyingKey).not.toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onMenuSelect).not.toHaveBeenCalled(); + // Once rendered, Enter belongs to the menu, then to the dialog again. + await act(async () => { + stdin.write('\r'); + }); + expect(onMenuSelect).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + await act(async () => { + stdin.write('\r'); + }); + } else { + expect(onUnderlyingKey).toHaveBeenCalled(); + } + await vi.waitFor(() => + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ), + ); + }, + ); + + it('does not act on a key aimed at an open context menu', async () => { + // The teammate tab mounts this dialog while AgentChatContent's + // ContentMouseController makes the right-click menu openable there. + // KeypressContext broadcasts to every subscriber and discards return + // values (the overlay cannot consume a key for us), so the dialog has to + // go quiet itself — RadioButtonSelect's initialIndex is 0 with + // "allow once" first, so one Enter aimed at "Open Link" would otherwise + // approve the pending call and Esc would silently deny it. + const onConfirm = vi.fn().mockResolvedValue(undefined); + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'touch /tmp/marker', + rootCommand: 'touch', + onConfirm, + }; + + const { stdin } = render( + withProviders( + + + + , + ), + ); + + // Control: with no menu open, Enter still approves the pending call. + stdin.write('\r'); + await vi.waitFor(() => + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ), + ); + onConfirm.mockClear(); + + await act(async () => { + menuApi?.openMenu( + [{ id: 'open-link', label: 'Open Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + expect(menuApi?.menu).not.toBeNull(); + + await act(async () => { + stdin.write('\r'); // would approve (ProceedOnce is index 0) + stdin.write('\x1b'); // would deny (handleConfirm(Cancel)) + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('does not submit a nested custom answer aimed at an open context menu', async () => { + // The `ask_user_question` flavour nests a free-text TextInput one level + // below the dialog's own subscriber. Quieting only the dialog left that + // input hot, so one Enter aimed at "Open Link" submitted the half-typed + // draft as the answer and the teammate round proceeded on it. + const onConfirm = vi.fn().mockResolvedValue(undefined); + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'ask_user_question', + title: 'Question', + questions: [ + { + question: 'What is your favorite color?', + header: 'Color', + options: [ + { label: 'Red', description: 'A warm color' }, + { label: 'Blue', description: 'A cool color' }, + { label: 'Green', description: '' }, + ], + multiSelect: false, + }, + ], + onConfirm, + }; + + const { stdin } = render( + withProviders( + + + + , + ), + ); + + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + }); + + // Row 4 is the custom answer; typing into it mounts the nested input. + await settle(); + await act(async () => { + stdin.write('4'); + }); + await settle(); + await act(async () => { + stdin.write('draft'); + }); + await settle(); + + // Control: with no menu open, Enter submits exactly that draft — which is + // also the proof the nested input is mounted and live. + await act(async () => { + stdin.write('\r'); + }); + await vi.waitFor(() => + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'draft' } }, + ), + ); + onConfirm.mockClear(); + + await act(async () => { + menuApi?.openMenu( + [{ id: 'open-link', label: 'Open Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + expect(menuApi?.menu).not.toBeNull(); + + await act(async () => { + stdin.write('\r'); // aimed at "Open Link", not at the draft + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(onConfirm).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index a88331f7762..34b47dcde1c 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -29,6 +29,7 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; import { useKeypress } from '../../hooks/useKeypress.js'; +import { useContextMenu } from '../../context-menu/ContextMenuContext.js'; import { useLaunchEditor } from '../../hooks/useLaunchEditor.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { theme } from '../../semantic-colors.js'; @@ -68,6 +69,15 @@ export const ToolConfirmationMessage: React.FC< 'hideAlwaysAllow' in confirmationDetails && confirmationDetails.hideAlwaysAllow === true; + // An open right-click context menu owns the keyboard while it is up. The + // dispatch in KeypressContext is a broadcast that discards return values, + // so an overlay cannot consume a key for the dialog — the dialog has to go + // quiet itself, or one Enter aimed at "Open Link" also approves the + // pending tool call. Mirrors the main view's InputPrompt gate; the + // teammate tab renders this same dialog (via HistoryItemDisplay). + const { menu: contextMenu } = useContextMenu(); + const inputActive = isFocused && contextMenu === null; + const settings = useSettings(); const preferredEditor = settings.merged.general?.preferredEditor as | EditorType @@ -165,7 +175,7 @@ export const ToolConfirmationMessage: React.FC< useKeypress( (key) => { - if (!isFocused) return; + if (!inputActive) return; if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { handleConfirm(ToolConfirmationOutcome.Cancel); return; @@ -179,7 +189,7 @@ export const ToolConfirmationMessage: React.FC< openFullPlanInEditor(); } }, - { isActive: isFocused }, + { isActive: inputActive }, ); const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item); @@ -643,7 +653,7 @@ export const ToolConfirmationMessage: React.FC< return ( @@ -811,7 +821,7 @@ export const ToolConfirmationMessage: React.FC< diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index e034e4aca26..3b7a2954154 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -324,6 +324,45 @@ describe(' mouse scrolling', () => { expect(lastFrame()).toContain('item-0'); }); + it('a focus loss cancels a still-pending wheel flush', async () => { + // Regression: whoever takes focus may have just pulled the tail into view + // (a pending approval dialog at the end of the transcript). A burst armed + // before that flip must not land after it — with both subscribers now off, + // it scrolls the answering surface out of the render window and nothing on + // this list can bring it back. + const renderItem = ({ item }: { item: Item }) => {item.label}; + const Wrapper = ({ focused }: { focused: boolean }) => ( + + hasFocus={focused} + data={makeItems(50)} + renderItem={renderItem} + estimatedItemHeight={estimatedItemHeight} + keyExtractor={keyExtractor} + initialScrollIndex={0} + containerHeight={5} + width={40} + showScrollbar={false} + /> + ); + + const { stdin, lastFrame, rerender } = render( + withKeypress(), + ); + rerender(withKeypress()); + await act(async () => {}); + expect(lastFrame()).toContain('item-0'); + + // Queue a burst, then take focus away inside the same coalescing window. + await act(async () => { + stdin.write(wheelDown(5, 5)); + stdin.write(wheelDown(5, 5)); + rerender(withKeypress()); + }); + await flushScrollFrame(); + + expect(lastFrame()).toContain('item-0'); + }); + it('does not start a scrollbar drag when content fits the viewport', async () => { const renderItem = ({ item }: { item: Item }) => {item.label}; const Wrapper = () => ( diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index ec160ab3186..e883db56294 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useRef, forwardRef, useImperativeHandle, useCallback } from 'react'; +import { + useRef, + forwardRef, + useImperativeHandle, + useCallback, + useEffect, +} from 'react'; import type React from 'react'; import { VirtualizedList, @@ -147,6 +153,13 @@ function ScrollableList( cancelScrollFlush(); }, [cancelScrollFlush]); + // Losing focus unsubscribes the keypress and mouse handlers, so a still-armed + // flush belongs to a gesture the user can no longer steer. Letting it fire + // after the flip scrolls the surface out from under whatever took focus. + useEffect(() => { + if (!hasFocus) cancelPendingScroll(); + }, [hasFocus, cancelPendingScroll]); + const handleMouseEvent = useCallback( (event: MouseEvent) => { if (!virtualizedListRef.current) return; diff --git a/packages/cli/src/ui/context-menu/ContextMenuContext.tsx b/packages/cli/src/ui/context-menu/ContextMenuContext.tsx index 369b1aefd2c..2dc0a24988b 100644 --- a/packages/cli/src/ui/context-menu/ContextMenuContext.tsx +++ b/packages/cli/src/ui/context-menu/ContextMenuContext.tsx @@ -34,6 +34,7 @@ export interface ContextMenuState { export interface ContextMenuContextValue { menu: ContextMenuState | null; + isMenuOpen: () => boolean; selectedIndex: number; openMenu: ( items: ContextMenuItem[], @@ -47,6 +48,7 @@ export interface ContextMenuContextValue { const ContextMenuContext = createContext({ menu: null, + isMenuOpen: () => false, selectedIndex: 0, openMenu: () => {}, closeMenu: () => {}, @@ -71,14 +73,18 @@ export const ContextMenuProvider: React.FC<{ // effects inside a state updater, which StrictMode double-invokes). const menuRef = useRef(null); menuRef.current = menu; + const isMenuOpen = useCallback(() => menuRef.current !== null, []); const openMenu = useCallback( (items: ContextMenuItem[], position: { x: number; y: number }) => { if (items.length === 0) return; - setMenu({ items, position }); + menuRef.current = { items, position }; + // The app-level key handler sits outside this provider. + onMenuChange?.(true); + setMenu(menuRef.current); setSelectedIndexState(0); }, - [], + [onMenuChange], ); const closeMenu = useCallback(() => { @@ -110,13 +116,22 @@ export const ContextMenuProvider: React.FC<{ const value = useMemo( () => ({ menu, + isMenuOpen, selectedIndex, openMenu, closeMenu, setSelectedIndex, executeIndex, }), - [menu, selectedIndex, openMenu, closeMenu, setSelectedIndex, executeIndex], + [ + menu, + isMenuOpen, + selectedIndex, + openMenu, + closeMenu, + setSelectedIndex, + executeIndex, + ], ); return ( diff --git a/packages/cli/src/ui/contexts/AgentViewContext.tsx b/packages/cli/src/ui/contexts/AgentViewContext.tsx index aa87639c3f7..d6bfd208940 100644 --- a/packages/cli/src/ui/contexts/AgentViewContext.tsx +++ b/packages/cli/src/ui/contexts/AgentViewContext.tsx @@ -55,6 +55,14 @@ export interface AgentViewState { agentShellFocused: boolean; /** Last synced text from the active agent tab's input buffer. */ agentInputBufferText: string; + /** + * Layout key synced by the active AgentComposer whenever its footer's + * height-relevant state changes (status row, queued messages, input text, + * streaming state). Consumed by AppContainer's controls-height measure + * effect so the agent footer is re-measured on growth; '' before any + * agent composer has synced. + */ + agentComposerLayoutKey: string; /** Whether the tab bar has keyboard focus (vs the agent input). */ agentTabBarFocused: boolean; /** Per-agent approval modes (keyed by agentId). */ @@ -85,6 +93,7 @@ export interface AgentViewActions { unregisterAll(): void; setAgentShellFocused(focused: boolean): void; setAgentInputBufferText(text: string): void; + setAgentComposerLayoutKey(key: string): void; setAgentTabBarFocused(focused: boolean): void; setAgentApprovalMode(agentId: string, mode: ApprovalMode): void; /** Replace the queued follow-up messages for an agent (see state docs). */ @@ -105,6 +114,7 @@ const DEFAULT_STATE: AgentViewState = { agents: new Map(), agentShellFocused: false, agentInputBufferText: '', + agentComposerLayoutKey: '', agentTabBarFocused: false, agentApprovalModes: new Map(), agentMessageQueues: new Map(), @@ -121,6 +131,7 @@ const DEFAULT_ACTIONS: AgentViewActions = { unregisterAll: noop, setAgentShellFocused: noop, setAgentInputBufferText: noop, + setAgentComposerLayoutKey: noop, setAgentTabBarFocused: noop, setAgentApprovalMode: noop, setAgentMessageQueue: noop, @@ -240,6 +251,7 @@ export function AgentViewProvider({ ); const [agentShellFocused, setAgentShellFocused] = useState(false); const [agentInputBufferText, setAgentInputBufferText] = useState(''); + const [agentComposerLayoutKey, setAgentComposerLayoutKey] = useState(''); const [agentTabBarFocused, setAgentTabBarFocused] = useState(false); const [agentApprovalModes, setAgentApprovalModes] = useState< Map @@ -418,6 +430,7 @@ export function AgentViewProvider({ agents, agentShellFocused, agentInputBufferText, + agentComposerLayoutKey, agentTabBarFocused, agentApprovalModes, agentMessageQueues, @@ -427,6 +440,7 @@ export function AgentViewProvider({ agents, agentShellFocused, agentInputBufferText, + agentComposerLayoutKey, agentTabBarFocused, agentApprovalModes, agentMessageQueues, @@ -443,6 +457,7 @@ export function AgentViewProvider({ unregisterAll, setAgentShellFocused, setAgentInputBufferText, + setAgentComposerLayoutKey, setAgentTabBarFocused, setAgentApprovalMode, setAgentMessageQueue, @@ -457,6 +472,7 @@ export function AgentViewProvider({ unregisterAll, setAgentShellFocused, setAgentInputBufferText, + setAgentComposerLayoutKey, setAgentTabBarFocused, setAgentApprovalMode, setAgentMessageQueue, diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index 2ed1bc550ed..d0b1a2aef3e 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef } from 'react'; import type { KeypressHandler, Key } from '../contexts/KeypressContext.js'; import { useKeypressContext } from '../contexts/KeypressContext.js'; +import { useContextMenu } from '../context-menu/ContextMenuContext.js'; export type { Key }; @@ -22,9 +23,15 @@ export function useKeypress( { isActive }: { isActive: boolean }, ) { const { subscribe, unsubscribe } = useKeypressContext(); + const { menu, isMenuOpen } = useContextMenu(); const onKeypressRef = useRef(onKeypress); - onKeypressRef.current = onKeypress; + onKeypressRef.current = (key) => { + // A right-click and key can share one stdin chunk. Until the menu renders, + // existing subscribers still hold the previous view's focus and callbacks. + if (menu === null && isMenuOpen()) return; + onKeypress(key); + }; const handleKeypress = useCallback((key) => { onKeypressRef.current(key); diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index 72d0baba8c8..9fece93d4ba 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -687,6 +687,27 @@ describe('TextSelectionController', () => { expect(setSelection).toHaveBeenCalledWith(null); }); + it('clears the painted highlight when the controller unmounts', () => { + const handler = mount(); + selectHello(handler); + // The painted range lives on the per-stdout frame controller, which outlives + // this component. Without an unmount teardown, a view that replaces this one + // (switching away from a teammate tab, or between teammates) re-stamps the + // highlight over cells the user never selected there — and no successor can + // repair it, because `clearSelection()` early-returns on an empty own + // selection, which is what every fresh controller starts with. + expect(setSelection).toHaveBeenLastCalledWith({ + sx: 0, + sy: 0, + ex: 4, + ey: 0, + }); + + cleanup(); + + expect(setSelection).toHaveBeenLastCalledWith(null); + }); + it('keeps a selection across its own highlight repaint', () => { const handler = mount(); selectHello(handler); diff --git a/packages/cli/src/ui/selection/use-text-selection.tsx b/packages/cli/src/ui/selection/use-text-selection.tsx index 43a3b3c6a96..ed55791c2eb 100644 --- a/packages/cli/src/ui/selection/use-text-selection.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.tsx @@ -480,6 +480,19 @@ export function TextSelectionController( } }, [props.isActive, clearSelection]); + // The painted range lives on the per-stdout frame controller, outside React, + // so it outlives this controller: ink re-reads it on every serialization pass + // and stamps the highlight onto whatever cells now occupy those coordinates. + // A view that unmounts with a selection on screen (switching away from a + // teammate tab, or between teammates) would therefore leave a highlight band + // over content the user never selected there. `clearSelection()` cannot do + // this teardown: it early-returns while its own selection is empty, and a + // successor controller always starts empty, so nothing downstream repairs it + // either. Unmount only — never `eventsPaused`, which must keep the selection + // the open context menu's "Copy Selection" offers. `getBuffer` is a + // `useCallback` keyed on `[stdout]`, so this cleanup runs once, on unmount. + useEffect(() => () => getBuffer()?.setSelection(null), [getBuffer]); + // Expose the live selection to an external query ref (context menu "Copy // Selection"). A collapsed char-mode selection is a bare click (cleared on // release anyway) and carries no text; a collapsed word/line span still