Skip to content

CommandPalette late result refresh resets keyboard selection before Enter #6701

Description

@rmusser01

Description

While command-palette providers are still yielding results, a keyboard selection can race a batched list refresh. The refresh clears and rebuilds CommandList and resets highlighted to 0, so Down can visibly highlight one command but a later provider result moves the highlight before Enter. The wrong command then runs.

I can reproduce this deterministically with Textual 8.2.8 on Python 3.12.11 by controlling the provider gates and the clock used for result batching.

Minimal reproduction

Save as repro.py and run it with Textual 8.2.8 (python repro.py):

from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator, Callable

from textual.app import App, ComposeResult
from textual.command import CommandList, CommandPalette, Hit, Provider
from textual.widgets import Static

import textual.command


class Clock:
    now = 0.0

    def __call__(self) -> float:
        return self.now

    def advance(self) -> None:
        self.now += 1.0


class Probe:
    def __init__(self) -> None:
        self.calls: list[str] = []
        self.release_batch = asyncio.Event()
        self.release_late = asyncio.Event()
        self.batch_waiting = asyncio.Event()
        self.late_waiting = asyncio.Event()

    def callback(self, name: str) -> Callable[[], None]:
        return lambda: self.calls.append(name)


probe = Probe()


class ControlledProvider(Provider):
    async def search(self, query: str) -> AsyncIterator[Hit]:
        if query != "logs":
            return
        yield Hit(0.90, "first", probe.callback("first"))
        yield Hit(0.80, "second", probe.callback("second"))
        probe.batch_waiting.set()
        await probe.release_batch.wait()
        yield Hit(0.70, "batch", probe.callback("batch"))
        probe.late_waiting.set()
        await probe.release_late.wait()
        yield Hit(0.60, "late", probe.callback("late"))


class Repro(App[None]):
    COMMANDS = {ControlledProvider}

    def compose(self) -> ComposeResult:
        yield Static("calling screen")

    def on_mount(self) -> None:
        self.push_screen(CommandPalette(id="--command-palette"))


async def wait_until(predicate, attempts: int = 50) -> None:
    for _ in range(attempts):
        if predicate():
            return
        await asyncio.sleep(0)
    raise AssertionError("condition not reached")


async def main() -> None:
    clock = Clock()
    textual.command.monotonic = clock
    app = Repro()

    async with app.run_test() as pilot:
        await pilot.press("l", "o", "g", "s")
        await asyncio.wait_for(probe.batch_waiting.wait(), timeout=1)

        clock.advance()
        probe.release_batch.set()
        commands = app.screen.query_one(CommandList)
        await wait_until(lambda: commands.option_count == 3)
        assert probe.late_waiting.is_set()

        await pilot.press("down")
        print("after Down:", commands.highlighted)  # 1 (second)

        clock.advance()
        probe.release_late.set()
        await wait_until(lambda: commands.option_count == 4)
        print("after late refresh:", commands.highlighted)  # 0 (first)

        await pilot.press("enter")
        await wait_until(lambda: probe.calls)
        print("callback:", probe.calls)


asyncio.run(main())

Output:

after Down: 1
after late refresh: 0
callback: ['first']

Expected behavior

Once Down visibly moves the command-list highlight to second, pressing Enter should run second exactly once, even if another provider result arrives between those key presses.

Actual behavior

The late result causes _refresh_command_list() to call clear_options().add_options(...) and assign highlighted = 0. Enter consequently runs first exactly once, not the visibly selected second.

This deterministic reproducer confirms a wrong-command selection race. It does not reproduce the original live report in which the palette dismissed without running any command, so I am not claiming that narrower symptom here.

App-side workaround

As a narrow compatibility workaround, our app subclasses CommandPalette and overrides _action_command_list(). Before delegating any keyboard list action, it cancels the active gather worker only when the real CommandList is visible, contains at least one option, and the first option is not the disabled _NO_MATCHES placeholder. This freezes the actionable snapshot after navigation begins while still allowing initial results and replacement-query results to arrive.

The relevant protected seams are _action_command_list() and _cancel_gather_commands(); no provider behavior or timing constants are changed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions