fix(cli): keep Thinking spinner during edit_file approval (#3310)

The CLI hides the Thinking spinner as soon as a tool call is rendered,
which leaves a visual gap for `edit_file` — its argument streaming, HITL
interrupt, and user approval can take multiple seconds with no feedback.
Keep the spinner anchored beneath the tool call row for calls in the new
`_TOOL_CALLS_KEEP_THINKING_SPINNER` set (currently just `edit_file`),
and only clear it once the tool finishes.
This commit is contained in:
Mason Daugherty
2026-05-11 01:49:59 -04:00
committed by GitHub
parent dc36bd3333
commit d06133f8dd
2 changed files with 68 additions and 2 deletions
+15 -2
View File
@@ -77,6 +77,9 @@ _hitl_adapter_cache: TypeAdapter | None = None
_ASK_USER_UNSUPPORTED_ERROR = "ask_user not supported by this UI"
_TOOL_CALLS_KEEP_THINKING_SPINNER = frozenset({"edit_file"})
"""Tool calls whose argument/approval phase can be long enough to need feedback."""
def _get_hitl_request_adapter(hitl_request_type: type) -> TypeAdapter:
"""Return a cached `TypeAdapter(HITLRequest)`.
@@ -929,8 +932,16 @@ async def execute_task_textual(
buffer_name, parsed_args, buffer_id
)
# Hide spinner before showing tool call
if adapter._set_spinner:
keep_thinking_spinner = (
buffer_name in _TOOL_CALLS_KEEP_THINKING_SPINNER
)
# Hide spinner before showing most tool calls.
# `edit_file` can spend noticeable time between
# argument streaming, HITL interrupt delivery, and
# approval handling, so re-anchor Thinking below
# the row instead of leaving the UI visually idle.
if adapter._set_spinner and not keep_thinking_spinner:
await adapter._set_spinner(None)
# Mount tool call message
@@ -942,6 +953,8 @@ async def execute_task_textual(
tool_msg = ToolCallMessage(buffer_name, parsed_args)
await adapter._mount_message(tool_msg)
adapter._current_tool_messages[buffer_id] = tool_msg
if adapter._set_spinner and keep_thinking_spinner:
await adapter._set_spinner("Thinking")
tool_call_buffers.pop(buffer_key, None)
@@ -789,6 +789,59 @@ class TestExecuteTaskTextualParallelToolSpinner:
assert statuses[-1] == "Thinking"
async def test_edit_file_tool_keeps_thinking_spinner_while_pending(self) -> None:
"""`edit_file` should not leave a visual gap before approval/execution."""
statuses: list[str | None] = []
async def record_spinner(status: str | None) -> None:
await asyncio.sleep(0)
statuses.append(status)
chunks = [
(
(),
"messages",
(
_tool_call_message(
"edit_file",
{
"file_path": "example.py",
"old_string": "old",
"new_string": "new",
},
"tool-1",
),
{},
),
),
(
(),
"messages",
(
ToolMessage(content="edited", tool_call_id="tool-1"),
{},
),
),
]
adapter = TextualUIAdapter(
mount_message=_mock_mount,
update_status=_noop_status,
request_approval=_mock_approval,
set_spinner=record_spinner,
)
await execute_task_textual(
user_input="edit the file",
agent=_FakeAgent(chunks),
assistant_id="assistant",
session_state=SimpleNamespace(thread_id="thread-1", auto_approve=True),
adapter=adapter,
)
assert statuses[:2] == ["Thinking", "Thinking"]
assert None not in statuses
async def test_spinner_with_three_parallel_tools_out_of_order(self) -> None:
"""Three parallel tools completed out of order; Thinking after all."""
statuses: list[str | None] = []