From e44a16d8b5705a9030bcfd78033c4253c8d3f3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 21 Aug 2026 16:47:15 +0000 Subject: [PATCH] fix(cli): keep slash menu selection stable during streaming (#9494) (#9508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a response streams, session stats / pending-item updates rebuild commandContext. useCommandSuggestions listed commandContext in its effect deps even though only the async argument-completion callback consumes it, so every context rebuild re-ran the search and replaced the suggestions array with an identical-content copy. useCommandCompletion resets the active index to 0 whenever the suggestions array identity changes, which snapped the user's menu selection back to the first item mid-navigation. Read the context through a ref (same pattern as historyRef in slashCommandProcessor) and drop it from the deps so only real inputs — query, command list, recent commands — rebuild the suggestions. Adds hook-level regression tests wiring real useCommandCompletion + useSlashCompletion: selection survives context churn (red before the fix), query/command-list changes still reset it, and argument completion still observes the latest context. --- .../useCommandCompletion.slashChurn.test.ts | 222 ++++++++++++++++++ .../cli/src/ui/hooks/useSlashCompletion.ts | 26 +- 2 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useCommandCompletion.slashChurn.test.ts diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.slashChurn.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.slashChurn.test.ts new file mode 100644 index 0000000000..f2cfad040b --- /dev/null +++ b/packages/cli/src/ui/hooks/useCommandCompletion.slashChurn.test.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +// Regression tests for #9494: while a response streams, AppContainer state +// (session stats, pending item) changes identity, which rebuilds +// `commandContext`. That churn must NOT rebuild the slash suggestion list — +// rebuilding replaces the suggestions array, and the reset effect in +// useCommandCompletion then snaps the user's menu selection back to the +// first item. + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useCommandCompletion } from './useCommandCompletion.js'; +import type { CommandContext, SlashCommand } from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; +import { useTextBuffer } from '../components/shared/text-buffer.js'; + +vi.mock('./useAtCompletion', () => ({ + useAtCompletion: vi.fn(), +})); + +function createTestCommand(command: Partial): SlashCommand { + return { + kind: CommandKind.BUILT_IN, + name: 'test', + description: 'test command', + ...command, + } as SlashCommand; +} + +const defaultCommands: readonly SlashCommand[] = [ + createTestCommand({ name: 'memory', description: 'Manage memory' }), + createTestCommand({ name: 'model', description: 'Switch the model' }), + createTestCommand({ name: 'stats', description: 'Show session stats' }), +]; + +function makeContext(marker: string): CommandContext { + return { services: { marker } } as unknown as CommandContext; +} + +interface HarnessProps { + ctx: CommandContext; + commands?: readonly SlashCommand[]; +} + +function useHarness({ ctx, commands }: HarnessProps) { + const buffer = useTextBuffer({ + initialText: '/', + initialCursorOffset: 1, + viewport: { width: 80, height: 20 }, + isValidPath: () => false, + onChange: () => {}, + }); + const completion = useCommandCompletion( + buffer, + '/', + commands ?? defaultCommands, + ctx, + false, + ); + return { completion, buffer }; +} + +// Wait long enough for the async fuzzy-search pipeline (AsyncFzf) triggered +// by a dep change to settle. +async function flushCompletionPipeline() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); +} + +type HarnessResult = ReturnType; + +describe('slash completion during commandContext churn (#9494)', () => { + it('keeps the suggestions array stable when only commandContext identity changes', async () => { + const { result, rerender } = renderHook( + useHarness, + { + initialProps: { ctx: makeContext('A') }, + }, + ); + + await waitFor(() => { + expect(result.current.completion.suggestions.length).toBeGreaterThan(1); + }); + + const before = result.current.completion.suggestions; + + // Simulate streaming churn: the query is untouched, but the context + // object is rebuilt (session stats / pending item changed upstream). + rerender({ ctx: makeContext('B') }); + await flushCompletionPipeline(); + + expect(result.current.completion.suggestions).toBe(before); + }); + + it('preserves the user menu selection across commandContext rebuilds', async () => { + const { result, rerender } = renderHook( + useHarness, + { + initialProps: { ctx: makeContext('A') }, + }, + ); + + await waitFor(() => { + expect(result.current.completion.suggestions.length).toBeGreaterThan(1); + }); + + act(() => { + result.current.completion.navigateDown(); + }); + expect(result.current.completion.activeSuggestionIndex).toBe(1); + + rerender({ ctx: makeContext('B') }); + await flushCompletionPipeline(); + + expect(result.current.completion.activeSuggestionIndex).toBe(1); + }); + + it('still resets the selection when the query actually changes', async () => { + const { result } = renderHook(useHarness, { + initialProps: { ctx: makeContext('A') }, + }); + + await waitFor(() => { + expect(result.current.completion.suggestions.length).toBeGreaterThan(1); + }); + + act(() => { + result.current.completion.navigateDown(); + }); + expect(result.current.completion.activeSuggestionIndex).toBe(1); + + act(() => { + result.current.buffer.setText('/s'); + }); + // "/s" only matches the 'stats' command — wait for the rebuilt list. + await waitFor(() => { + expect(result.current.completion.suggestions.length).toBe(1); + }); + + expect(result.current.completion.activeSuggestionIndex).toBe(0); + }); + + it('still rebuilds suggestions when the command list changes', async () => { + const { result, rerender } = renderHook( + useHarness, + { + initialProps: { ctx: makeContext('A') }, + }, + ); + + await waitFor(() => { + expect(result.current.completion.suggestions.length).toBeGreaterThan(1); + }); + const before = result.current.completion.suggestions; + + rerender({ + ctx: makeContext('A'), + commands: [ + createTestCommand({ name: 'memory', description: 'Manage memory' }), + ], + }); + await waitFor(() => { + expect(result.current.completion.suggestions).not.toBe(before); + }); + expect(result.current.completion.suggestions.length).toBe(1); + }); + + it('argument completion still receives the latest commandContext', async () => { + const completionSpy = vi.fn< + (context: CommandContext, argString: string) => Promise + >().mockResolvedValue(['arg-a', 'arg-b']); + const markerOf = (callIndex: number): string => + ( + completionSpy.mock.calls[callIndex]![0] as unknown as { + services: { marker: string }; + } + ).services.marker; + const commands = [ + createTestCommand({ + name: 'deploy', + description: 'Deploy things', + completion: completionSpy, + }), + ]; + + const { result, rerender } = renderHook( + useHarness, + { + initialProps: { ctx: makeContext('A'), commands }, + }, + ); + + // Switch to an argument-completion query ("/deploy "). + act(() => { + result.current.buffer.setText('/deploy '); + }); + + await waitFor(() => { + expect(completionSpy).toHaveBeenCalled(); + }); + expect(markerOf(0)).toBe('A'); + + completionSpy.mockClear(); + // New context identity AND a changed argument query: the async + // completion must observe the new context, not a stale one. + rerender({ ctx: makeContext('B'), commands }); + act(() => { + result.current.buffer.setText('/deploy x'); + }); + await waitFor(() => { + expect(completionSpy).toHaveBeenCalled(); + }); + expect(markerOf(0)).toBe('B'); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 2b94d18748..6688c1566d 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef, useLayoutEffect } from 'react'; import { AsyncFzf } from 'fzf'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; @@ -336,6 +336,17 @@ function useCommandSuggestions( const [suggestions, setSuggestions] = useState([]); const [isLoading, setIsLoading] = useState(false); + // The context is only consumed by the async argument-completion callback. + // Reading it through a ref keeps unrelated context churn (session stats / + // pending item updates while a response streams rebuild commandContext) + // out of the effect deps below: re-running this effect replaces the + // suggestions array, which snaps the user's menu selection back to the + // first item (#9494). Same historyRef pattern as slashCommandProcessor. + const commandContextRef = useRef(commandContext); + useLayoutEffect(() => { + commandContextRef.current = commandContext; + }, [commandContext]); + useEffect(() => { const abortController = new AbortController(); const { signal } = abortController; @@ -369,7 +380,7 @@ function useCommandSuggestions( const results = (await leafCommand.completion( { - ...commandContext, + ...commandContextRef.current, invocation: { raw: `/${rawParts.join(' ')}`, name: leafCommand.name, @@ -508,13 +519,10 @@ function useCommandSuggestions( setSuggestions([]); return () => abortController.abort(); - }, [ - parserResult, - commandContext, - getFzfForCommands, - getPrefixSuggestions, - recentCommands, - ]); + // commandContext is deliberately absent: it is read through + // commandContextRef so context-identity churn alone never re-runs the + // search and rebuilds the suggestions array (#9494). + }, [parserResult, getFzfForCommands, getPrefixSuggestions, recentCommands]); return { suggestions, isLoading }; }