Files
deepagents/libs/code/deepagents_code/widgets/loading.py
T
github-actions[bot] f63a7b56f9 release(deepagents-code): 0.1.21 (#4091)
> [!CAUTION]
> Merging this PR will automatically publish to **PyPI** and create a
**GitHub release**.

For the full release process, see
[`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md).

---

_Everything below this line will be the GitHub release body._

---


##
[0.1.21](https://github.com/langchain-ai/deepagents/compare/deepagents-code==0.1.20...deepagents-code==0.1.21)
(2026-06-23)

### Features

* `dcode doctor` diagnostics command
([#4148](https://github.com/langchain-ai/deepagents/issues/4148))
([8179731](https://github.com/langchain-ai/deepagents/commit/81797312c7d857e7d94d03c9c695cd3c8d88799a))
* Add structured TUI display for `js_eval`
([#4151](https://github.com/langchain-ai/deepagents/issues/4151))
([91c0dae](https://github.com/langchain-ai/deepagents/commit/91c0dae3fe0253f02a5926fcd3c6f796cd8d11fe))
* Allow dependency updates without requiring release
([#4157](https://github.com/langchain-ai/deepagents/issues/4157))
([7beb97a](https://github.com/langchain-ai/deepagents/commit/7beb97a2b02e2fd238baf3b6f05d43a4accf3f42))
* Clear chat input via `esc+esc`, add `[ X ]/[ COPY ]` buttons
([#4000](https://github.com/langchain-ai/deepagents/issues/4000))
([c20546f](https://github.com/langchain-ai/deepagents/commit/c20546feac7876786e6816776d1ccfa5fcd4b2c8))
* Confirm "Launched" after auto-update restart
([#4098](https://github.com/langchain-ai/deepagents/issues/4098))
([df8db8a](https://github.com/langchain-ai/deepagents/commit/df8db8af6a7cbfc2ab535020b951d73759da73dd))
* Surface tracing in `doctor` and `config show`
([#4163](https://github.com/langchain-ai/deepagents/issues/4163))
([2bb3e44](https://github.com/langchain-ai/deepagents/commit/2bb3e44243553a5f2954a0f3ec42364563842a87))

### Bug Fixes

* Handle LangSmith project-not-found and default tracing project
([#4153](https://github.com/langchain-ai/deepagents/issues/4153))
([e303ce9](https://github.com/langchain-ai/deepagents/commit/e303ce986a3595f0cf458e796d857f7c8f5f8b5c))
* Make `/timestamps` toggle instant via per-footer class
([#4095](https://github.com/langchain-ai/deepagents/issues/4095))
([7ae32b0](https://github.com/langchain-ai/deepagents/commit/7ae32b0a606cc200d4311e11036a65f17e8282b3))
* Refocus `/mcp` filter input after in-place refresh
([#4080](https://github.com/langchain-ai/deepagents/issues/4080))
([d79cd74](https://github.com/langchain-ai/deepagents/commit/d79cd74cb8a44c300c3bbad712fe77e709f9221a))
* Report same-version dependency updates
([#4146](https://github.com/langchain-ai/deepagents/issues/4146))
([156e118](https://github.com/langchain-ai/deepagents/commit/156e1185242a19746f8c268904637c73f07b9a10))
* Show "Loading..." in `/threads` agent dropdown while loading
([#4101](https://github.com/langchain-ai/deepagents/issues/4101))
([c2d949e](https://github.com/langchain-ai/deepagents/commit/c2d949e8765fbbbdb81e5a70125932842358099f))
* Skip tool interrupts once auto-approve is set
([#4092](https://github.com/langchain-ai/deepagents/issues/4092))
([9e21c34](https://github.com/langchain-ai/deepagents/commit/9e21c346a6eb8ad25b9cc671f24527b07732e2b7))
* Word-delete backspace parity in ask-user text area
([#4079](https://github.com/langchain-ai/deepagents/issues/4079))
([ed3c499](https://github.com/langchain-ai/deepagents/commit/ed3c499354467bc5e8476e5c7cdf0cd5f8b6aec1))

---

_Everything above this line will be the GitHub release body._

---

> [!NOTE]
> A **New Contributors** section is appended to the GitHub release notes
automatically at publish time (see [Release
Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline),
step 2).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
2026-06-23 02:08:21 -04:00

228 lines
7.3 KiB
Python

"""Loading widget with animated spinner for agent activity."""
from __future__ import annotations
from time import time
from typing import TYPE_CHECKING
from textual.containers import Horizontal
from textual.content import Content
from textual.widgets import Static
from deepagents_code.config import get_glyphs
from deepagents_code.formatting import format_duration
if TYPE_CHECKING:
from textual.app import ComposeResult
from textual.await_remove import AwaitRemove
from textual.timer import Timer
class Spinner:
"""Animated spinner using charset-appropriate frames."""
def __init__(self) -> None:
"""Initialize spinner."""
self._position = 0
@property
def frames(self) -> tuple[str, ...]:
"""Spinner frames from glyphs config."""
return get_glyphs().spinner_frames
def next_frame(self) -> str:
"""Get next animation frame.
Returns:
The next spinner character in the animation sequence.
"""
frames = self.frames
frame = frames[self._position]
self._position = (self._position + 1) % len(frames)
return frame
def current_frame(self) -> str:
"""Get current frame without advancing.
Returns:
The current spinner character.
"""
return self.frames[self._position]
class LoadingWidget(Static):
"""Animated loading indicator with status text and elapsed time.
Displays: <spinner> Thinking... (3s, esc to interrupt)
"""
DEFAULT_CSS = """
LoadingWidget {
height: auto;
padding: 0 1;
margin: 0 0 1 0;
}
LoadingWidget .loading-container {
height: auto;
width: 100%;
}
LoadingWidget .loading-spinner {
width: auto;
color: $primary;
}
LoadingWidget .loading-status {
width: auto;
color: $primary;
}
LoadingWidget .loading-hint {
width: auto;
color: $text-muted;
margin-left: 1;
}
"""
def __init__(self, status: str = "Thinking") -> None:
"""Initialize loading widget.
Args:
status: Initial status text to display
"""
super().__init__()
self._status = status
self._spinner = Spinner()
self._start_time: float | None = None
self._spinner_widget: Static | None = None
self._status_widget: Static | None = None
self._hint_widget: Static | None = None
self._animation_timer: Timer | None = None
self._paused = False
self._paused_elapsed: float = 0.0
def compose(self) -> ComposeResult:
"""Compose the loading widget layout.
Yields:
Widgets for spinner, status text, and hint.
"""
with Horizontal(classes="loading-container"):
self._spinner_widget = Static(
self._spinner.current_frame(), classes="loading-spinner"
)
yield self._spinner_widget
self._status_widget = Static(
f" {self._status}... ", classes="loading-status"
)
yield self._status_widget
self._hint_widget = Static("(0s, esc to interrupt)", classes="loading-hint")
yield self._hint_widget
def on_mount(self) -> None:
"""Start animation on mount.
Preserves `_start_time` when the widget is remounted (e.g., after
being removed and re-added for repositioning) so the elapsed-time
counter doesn't reset. Repositioning via `move_child` avoids the
remount path entirely, but this guard keeps the behavior correct
if any caller ever falls back to remove + mount.
"""
if self._start_time is None:
self._start_time = time()
self._animation_timer = self.set_interval(0.1, self._update_animation)
def on_unmount(self) -> None:
"""Stop the animation timer when the widget leaves the DOM."""
self._stop_timer()
def remove(self) -> AwaitRemove:
"""Stop animation before delegating DOM removal to Textual.
Returns:
Awaitable that completes once the widget is removed from the DOM.
"""
self._stop_timer()
return super().remove()
def _stop_timer(self) -> None:
"""Stop the animation timer if it is running."""
if self._animation_timer is not None:
self._animation_timer.stop()
self._animation_timer = None
def _update_animation(self) -> None:
"""Update spinner and elapsed time."""
if self._paused:
return
if self._spinner_widget:
frame = self._spinner.next_frame()
self._spinner_widget.update(frame)
if self._hint_widget and self._start_time is not None:
elapsed = int(time() - self._start_time)
self._hint_widget.update(f"({format_duration(elapsed)}, esc to interrupt)")
def set_status(self, status: str) -> None:
"""Update the status text.
Args:
status: New status text
"""
self._status = status
if self._status_widget:
self._status_widget.update(f" {self._status}... ")
def pause(self, status: str = "Awaiting decision") -> None:
"""Pause the animation and update status.
Args:
status: Status to show while paused
"""
self._paused = True
if self._start_time is not None:
self._paused_elapsed = time() - self._start_time
self._status = status
if self._status_widget:
self._status_widget.update(f" {status}... ")
if self._hint_widget:
# Display whole seconds to match the live counter in
# `_update_animation`; `_paused_elapsed` stays a float only so
# `resume()` can rebase `_start_time` with sub-second precision.
self._hint_widget.update(
f"(paused at {format_duration(int(self._paused_elapsed))})"
)
if self._spinner_widget:
self._spinner_widget.update(Content.styled(get_glyphs().pause, "dim"))
def resume(self) -> None:
"""Resume the animation, excluding the paused interval from elapsed time.
Rebases `_start_time` forward by the paused duration so the elapsed-time
counter continues from where it paused rather than counting the wait.
No-op when not currently paused. This method is wired both as a
`Future.add_done_callback` and as a self-healing net in the app's
`_set_spinner`, so it can fire on a widget that was never paused (e.g.
one created to replace the paused spinner mid-approval). Returning early
there avoids rebasing the start time or clobbering that widget's status.
"""
if not self._paused:
# Load-bearing guard: resuming a never-paused (or replacement)
# widget must not rebase `_start_time` with a stale
# `_paused_elapsed`, which would silently jump its timer.
return
self._start_time = time() - self._paused_elapsed
self._paused = False
self._status = "Thinking"
if self._status_widget:
self._status_widget.update(f" {self._status}... ")
def stop(self) -> None:
"""Stop the animation (widget will be removed by caller)."""
self._stop_timer()