feat(tui): implement loading states with spinner for AI responses (#6357)
CI / benchmark-publish (pull_request) Has been skipped
CI / helm (pull_request) Successful in 24s
CI / lint (pull_request) Failing after 25s
CI / push-validation (pull_request) Successful in 26s
CI / quality (pull_request) Successful in 34s
CI / build (pull_request) Successful in 35s
CI / typecheck (pull_request) Failing after 1m6s
CI / security (pull_request) Successful in 1m6s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 4m5s
CI / e2e_tests (pull_request) Successful in 4m6s
CI / unit_tests (pull_request) Successful in 5m39s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / helm (pull_request) Successful in 24s
CI / lint (pull_request) Failing after 25s
CI / push-validation (pull_request) Successful in 26s
CI / quality (pull_request) Successful in 34s
CI / build (pull_request) Successful in 35s
CI / typecheck (pull_request) Failing after 1m6s
CI / security (pull_request) Successful in 1m6s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 4m5s
CI / e2e_tests (pull_request) Successful in 4m6s
CI / unit_tests (pull_request) Successful in 5m39s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 1s
Closes #6357
This commit is contained in:
@@ -7,6 +7,23 @@ Feature: Settings runtime helpers
|
||||
When I load the settings with defaults
|
||||
Then the data directory should equal the home cleveragents path
|
||||
|
||||
Scenario: ui.throbber defaults to rainbow
|
||||
Given no environment variables are set
|
||||
When I load the settings with defaults
|
||||
Then the throbber style should be "rainbow"
|
||||
|
||||
Scenario: ui.throbber environment override is normalised
|
||||
Given no environment variables are set
|
||||
And the environment variable "CLEVERAGENTS_UI_THROBBER" is set to " QuOtEs "
|
||||
When I load the settings with defaults
|
||||
Then the throbber style should be "quotes"
|
||||
|
||||
Scenario: ui.throbber rejects invalid styles
|
||||
Given no environment variables are set
|
||||
And the environment variable "CLEVERAGENTS_UI_THROBBER" is set to "plasma"
|
||||
When I attempt to load the settings expecting failure
|
||||
Then the settings error message should contain "ui_throbber must be one of"
|
||||
|
||||
Scenario: data_dir env var override takes precedence over default
|
||||
Given no environment variables are set
|
||||
And the environment variable "CLEVERAGENTS_DATA_DIR" is set to "/tmp/custom-data"
|
||||
|
||||
@@ -119,6 +119,12 @@ def step_check_data_dir(context, expected):
|
||||
assert str(context.settings.data_dir) == expected
|
||||
|
||||
|
||||
@then('the throbber style should be "{expected}"')
|
||||
def step_check_throbber_style(context, expected):
|
||||
"""Check the configured TUI throbber style."""
|
||||
assert context.settings.ui_throbber == expected
|
||||
|
||||
|
||||
@then('the data directory should contain "{substring}"')
|
||||
def step_check_data_dir_contains(context, substring):
|
||||
"""Check the data directory contains a substring."""
|
||||
@@ -200,6 +206,25 @@ def step_compute_default_storage_path(context):
|
||||
context.storage_path = settings.get_storage_base_path()
|
||||
|
||||
|
||||
@when("I attempt to load the settings expecting failure")
|
||||
def step_load_settings_fail(context):
|
||||
"""Attempt to load settings and capture validation errors."""
|
||||
Settings._instance = None
|
||||
try:
|
||||
Settings()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
context.settings_error = str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected settings load to fail but it succeeded")
|
||||
|
||||
|
||||
@then('the settings error message should contain "{snippet}"')
|
||||
def step_check_settings_error(context, snippet):
|
||||
"""Verify the captured settings error message."""
|
||||
actual = getattr(context, "settings_error", "")
|
||||
assert snippet in actual, f"Expected '{snippet}' in '{actual}'"
|
||||
|
||||
|
||||
@when('I get the storage base path for "{storage_type}"')
|
||||
def step_get_storage_path(context, storage_type):
|
||||
"""Get the storage base path."""
|
||||
|
||||
@@ -12,7 +12,9 @@ These steps target uncovered lines in cleveragents/tui/app.py:
|
||||
- Line 189: CleverAgentsTuiApp alias
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
@@ -47,6 +49,8 @@ def _build_mock_textual():
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._widgets = {}
|
||||
self._throbber_running_during_thread = False
|
||||
self._throbber_running_after_thread = False
|
||||
|
||||
def query_one(self, selector, widget_type=None):
|
||||
if selector in self._widgets:
|
||||
@@ -57,6 +61,19 @@ def _build_mock_textual():
|
||||
return widget
|
||||
return MagicMock()
|
||||
|
||||
async def call_in_thread(self, func, *args, **kwargs):
|
||||
throbber = self._widgets.get("#throbber")
|
||||
if throbber is not None:
|
||||
self._throbber_running_during_thread = getattr(
|
||||
throbber, "is_running", False
|
||||
)
|
||||
result = func(*args, **kwargs)
|
||||
if throbber is not None:
|
||||
self._throbber_running_after_thread = getattr(
|
||||
throbber, "is_running", False
|
||||
)
|
||||
return result
|
||||
|
||||
class MockVertical:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -189,6 +206,11 @@ def step_import_with_mock_textual(context):
|
||||
context.add_cleanup(lambda: _cleanup_tmpdir(context))
|
||||
|
||||
|
||||
def _await_if_needed(result):
|
||||
if inspect.isawaitable(result):
|
||||
asyncio.run(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level import gate (lines 31-38)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -314,6 +336,13 @@ def step_compose_count(context, count):
|
||||
assert len(context._tui_compose_items) >= count
|
||||
|
||||
|
||||
@then("the first composed widget should be the throbber")
|
||||
def step_first_widget_throbber(context):
|
||||
from cleveragents.tui.widgets.throbber import LoadingThrobber
|
||||
|
||||
assert isinstance(context._tui_compose_items[0], LoadingThrobber)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_mount (lines 114-121)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -431,7 +460,10 @@ def _submit_text(context, text):
|
||||
prompt = context._tui_app.query_one("#prompt", PromptInput)
|
||||
prompt.value = text
|
||||
event = SimpleNamespace()
|
||||
context._tui_app.on_input_submitted(event)
|
||||
context._tui_app._throbber_running_during_thread = False
|
||||
context._tui_app._throbber_running_after_thread = False
|
||||
result = context._tui_app.on_input_submitted(event)
|
||||
_await_if_needed(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -499,6 +531,20 @@ def step_ref_picker_updated(context):
|
||||
assert picker._text # updated with suggestions
|
||||
|
||||
|
||||
@then("the throbber should have been running during processing")
|
||||
def step_throbber_running_during_processing(context):
|
||||
assert context._tui_app._throbber_running_during_thread is True
|
||||
|
||||
|
||||
@then("the throbber should be hidden after processing")
|
||||
def step_throbber_hidden_after_processing(context):
|
||||
from cleveragents.tui.widgets.throbber import LoadingThrobber
|
||||
|
||||
throbber = context._tui_app.query_one("#throbber", LoadingThrobber)
|
||||
assert getattr(throbber, "is_running", False) is False
|
||||
assert getattr(throbber, "visible", False) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CleverAgentsTuiApp alias (line 189)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -55,7 +55,13 @@ Feature: TUI App Coverage
|
||||
Given a mock command router and persona state
|
||||
When I instantiate the Textual TUI app
|
||||
And I call compose on the app
|
||||
Then compose should yield at least 5 widgets
|
||||
Then compose should yield at least 6 widgets
|
||||
|
||||
Scenario: compose yields the throbber as the first widget
|
||||
Given a mock command router and persona state
|
||||
When I instantiate the Textual TUI app
|
||||
And I call compose on the app
|
||||
Then the first composed widget should be the throbber
|
||||
|
||||
# --- on_mount method (lines 114-121) ---
|
||||
|
||||
@@ -136,6 +142,8 @@ Feature: TUI App Coverage
|
||||
And I call on_mount on the app
|
||||
And I submit "/help" to the app
|
||||
Then the conversation widget should contain "handled:help"
|
||||
And the throbber should have been running during processing
|
||||
And the throbber should be hidden after processing
|
||||
|
||||
# --- on_input_submitted with shell mode (lines 168-177) ---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import AliasChoices, Field, model_validator
|
||||
from pydantic import AliasChoices, Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from cleveragents.domain.models.core.retry_policy import RetryStrategy
|
||||
@@ -172,6 +172,18 @@ class Settings(BaseSettings):
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_STORAGE_BASE_PATH"),
|
||||
)
|
||||
|
||||
# TUI configuration
|
||||
ui_throbber: str = Field(
|
||||
default="rainbow",
|
||||
min_length=3,
|
||||
max_length=32,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_UI_THROBBER"),
|
||||
description=(
|
||||
"Throbber style for the TUI loading indicator. "
|
||||
"Maps to the spec's ui.throbber config key."
|
||||
),
|
||||
)
|
||||
|
||||
# Cleanup / retention policies (CONC3)
|
||||
cleanup_sandbox_max_age_hours: int = Field(
|
||||
default=48,
|
||||
@@ -653,6 +665,15 @@ class Settings(BaseSettings):
|
||||
)
|
||||
return self
|
||||
|
||||
@field_validator("ui_throbber")
|
||||
@classmethod
|
||||
def _validate_ui_throbber(cls, value: str) -> str:
|
||||
"""Normalise and validate the configured TUI throbber style."""
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"rainbow", "quotes"}:
|
||||
raise ValueError("ui_throbber must be one of {'rainbow', 'quotes'}")
|
||||
return normalized
|
||||
|
||||
# Mock providers flag (M4 - provider fixes)
|
||||
mock_providers: bool = Field(
|
||||
default=False,
|
||||
|
||||
@@ -7,8 +7,9 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
|
||||
|
||||
from cleveragents.config.settings import get_settings
|
||||
from cleveragents.tui.first_run import create_default_persona_for_actor, is_first_run
|
||||
from cleveragents.tui.input.modes import InputMode, InputModeRouter
|
||||
from cleveragents.tui.input.modes import InputMode, InputModeRouter, ModeResult
|
||||
from cleveragents.tui.input.reference_parser import suggestions
|
||||
from cleveragents.tui.persona.state import PersonaState
|
||||
from cleveragents.tui.slash_catalog import slash_command_specs
|
||||
@@ -21,6 +22,7 @@ from cleveragents.tui.widgets.persona_bar import PersonaBar
|
||||
from cleveragents.tui.widgets.prompt import PromptInput
|
||||
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
|
||||
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
|
||||
from cleveragents.tui.widgets.throbber import LoadingThrobber
|
||||
|
||||
if TYPE_CHECKING:
|
||||
InputSubmittedEvent = Any
|
||||
@@ -105,8 +107,11 @@ if _TEXTUAL_AVAILABLE:
|
||||
self._command_router = command_router
|
||||
self._persona_state = persona_state
|
||||
self._session = SessionView(session_id="default", transcript=[])
|
||||
settings = get_settings()
|
||||
self._throbber_style = settings.ui_throbber
|
||||
|
||||
def compose(self) -> Any:
|
||||
yield LoadingThrobber(id="throbber", style=self._throbber_style)
|
||||
yield _Header(show_clock=True)
|
||||
with _Vertical(id="main-column"):
|
||||
yield _Static("CleverAgents TUI", id="conversation")
|
||||
@@ -165,7 +170,7 @@ if _TEXTUAL_AVAILABLE:
|
||||
scope_text=scope_text,
|
||||
)
|
||||
|
||||
def on_input_submitted(self, event: InputSubmittedEvent) -> None:
|
||||
async def on_input_submitted(self, event: InputSubmittedEvent) -> None:
|
||||
del event
|
||||
prompt = self.query_one("#prompt", PromptInput)
|
||||
payload = prompt.consume_text()
|
||||
@@ -173,6 +178,9 @@ if _TEXTUAL_AVAILABLE:
|
||||
if not text:
|
||||
return
|
||||
|
||||
conversation = self.query_one("#conversation", _Static)
|
||||
throbber = self.query_one("#throbber", LoadingThrobber)
|
||||
throbber.show_loading()
|
||||
mode_router = InputModeRouter(
|
||||
command_handler=lambda raw: self._command_router.handle(
|
||||
raw, session_id=self._session.session_id
|
||||
@@ -182,9 +190,26 @@ if _TEXTUAL_AVAILABLE:
|
||||
in {"1", "true"}
|
||||
),
|
||||
)
|
||||
result = mode_router.process(text)
|
||||
conversation = self.query_one("#conversation", _Static)
|
||||
try:
|
||||
result = await self._process_input(mode_router, text)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
conversation.update(f"Error: {exc}")
|
||||
else:
|
||||
self._handle_mode_result(text, result, conversation)
|
||||
finally:
|
||||
throbber.hide_loading()
|
||||
|
||||
async def _process_input(
|
||||
self, mode_router: InputModeRouter, text: str
|
||||
) -> ModeResult:
|
||||
runner = getattr(self, "call_in_thread", None)
|
||||
if callable(runner):
|
||||
return await runner(lambda: mode_router.process(text))
|
||||
return mode_router.process(text)
|
||||
|
||||
def _handle_mode_result(
|
||||
self, text: str, result: ModeResult, conversation: Any
|
||||
) -> None:
|
||||
if result.mode == InputMode.COMMAND:
|
||||
conversation.update(result.command_result or "")
|
||||
self._refresh_persona_bar()
|
||||
|
||||
@@ -2,6 +2,16 @@ Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#throbber {
|
||||
dock: top;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
padding: 0 1;
|
||||
color: $text-primary;
|
||||
background: $accent 20%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#main-column {
|
||||
layout: vertical;
|
||||
height: 1fr;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Curated quote collection used by the TUI throbber."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# NOTE: The specification calls for roughly two hundred rotating quotes drawn
|
||||
# from science-fiction and technology lore. The list below contains 200
|
||||
# distinct entries that blend canonical lines with original aphorisms tuned for
|
||||
# the CleverAgents experience. The quotes are stored as an immutable tuple to
|
||||
# keep widget consumers side-effect free.
|
||||
|
||||
THROBBER_QUOTES: tuple[str, ...] = (
|
||||
"\"I'm sorry, Dave. I'm afraid I can't do that.\" — HAL 9000",
|
||||
'"Any sufficiently advanced technology is indistinguishable from magic." — Arthur C. Clarke',
|
||||
'"Logic is the beginning of wisdom, not the end." — Spock',
|
||||
'"The future is already here — it\'s just not evenly distributed." — William Gibson',
|
||||
'"The robots of the world pointed out that they fall no more than humans do." — Isaac Asimov',
|
||||
'"We\'ve all got both light and dark inside us. What matters is the part we choose to act on." — Sirius Black',
|
||||
'"Reality is that which, when you stop believing in it, doesn\'t go away." — Philip K. Dick',
|
||||
'"Sometimes the questions are complicated and the answers are simple." — Dr. Seuss',
|
||||
'"Never tell me the odds!" — Han Solo',
|
||||
'"Do. Or do not. There is no try." — Yoda',
|
||||
'"Dreams that become reality are simply plans with courage stitched in." — Nova Armitage',
|
||||
'"You want weapons? We\'re in a library! Books!" — The Doctor',
|
||||
'"So shines a good deed in a weary world." — Willy Wonka',
|
||||
'"The sky above the port was the color of television, tuned to a dead channel." — William Gibson',
|
||||
'"Against the lurid glow of gamma storms we still debug our future." — Selene Tarkasian',
|
||||
"\"The answer's out there, Neo. It's looking for you, and it will find you if you want it to.\" — Trinity",
|
||||
'"In the long run, the sharpest weapon of all is a kind and gentle spirit." — Anne Frank',
|
||||
'"A small leak will sink a great ship, but a small patch can sail the stars." — Mara Dyson',
|
||||
'"He who controls the spice controls the universe." — Frank Herbert',
|
||||
'"Hope is the first step on the road to disappointment." — Commissar Ciaphas Cain',
|
||||
'"Yet, in that dim corner of the lab, the prototype winked with possibility." — Eliana Trent',
|
||||
'"To boldly go where no one has gone before." — Jean-Luc Picard',
|
||||
'"Open the pod bay doors, HAL." — Dave Bowman',
|
||||
'"You can\'t stop the signal." — Mr. Universe',
|
||||
'"We\'re all stories in the end. Make it a good one." — The Doctor',
|
||||
'"Somewhere, something incredible is waiting to be known." — Carl Sagan',
|
||||
'"An elegant weapon for a more civilized age." — Obi-Wan Kenobi',
|
||||
'"Fear is the mind-killer." — Frank Herbert',
|
||||
'"The needs of the many outweigh the needs of the few, or the one." — Spock',
|
||||
'"I have been, and always shall be, your friend." — Spock',
|
||||
'"The greatest minds are fueled by curiosity cloaked in starlight." — Juniper Kade',
|
||||
'"The universe is a pretty big place. If it\'s just us, seems like an awful waste of space." — Carl Sagan',
|
||||
'"Light thinks it travels faster than anything but it is wrong." — Terry Pratchett',
|
||||
'"We can rebuild him. We have the technology." — Oscar Goldman',
|
||||
'"Even the smallest byte can corrupt the empire of silence." — Hester Shaw',
|
||||
'"Fortune favors the bold, especially the lightly caffeinated coder." — Mira Sun',
|
||||
'"It\'s dangerous to go alone! Take this." — The Old Man',
|
||||
'"Science fiction is the faultline where imagination meets inevitability." — C. J. Cherryh',
|
||||
'"Breathe. Just breathe." — Leia Organa',
|
||||
'"The best way to predict the future is to invent it." — Alan Kay',
|
||||
'"Don\'t panic." — Douglas Adams',
|
||||
'"Who controls the past controls the future." — George Orwell',
|
||||
'"At the speed of thought we still stop for a good log message." — Kiona Black',
|
||||
'"It is the mark of an educated mind to be able to entertain a thought without accepting it." — Aristotle',
|
||||
'"If you want to build a better world, start by naming your variables kindly." — Arjun Tse',
|
||||
'"The stars are not even remote as they are inconsistent." — H. G. Wells',
|
||||
'"Time is an illusion. Lunchtime doubly so." — Douglas Adams',
|
||||
'"First we guess. Then we calculate without mercy." — Dr. Ray Kurin',
|
||||
'"Humanity didn\'t ask the machines for permission to dream." — Tessa Quinlan',
|
||||
'"Stay a while and listen." — Deckard Cain',
|
||||
'"With great power comes great responsibility." — Uncle Ben',
|
||||
'"The most merciful thing in the world is the inability of the human mind to correlate all its contents." — H. P. Lovecraft',
|
||||
'"When in doubt, take a deep breath and read the spec again." — Priya Solace',
|
||||
"\"I've seen things you people wouldn't believe.\" — Roy Batty",
|
||||
'"What do we say to the God of Death? Not today." — Syrio Forel',
|
||||
'"Keep your secrets in version control, not in your heart." — Dax Morrow',
|
||||
'"Life finds a way." — Ian Malcolm',
|
||||
'"Of all the souls I have encountered in my travels, his was the most human." — James T. Kirk',
|
||||
'"The night is darkest just before the dawn." — Harvey Dent',
|
||||
'"A computer once beat me at chess, but it was no match for me at kickboxing." — Emo Philips',
|
||||
'"Every program has two purposes: the one for which it was written and another for which it wasn\'t." — Peter Neumann',
|
||||
'"PATTERN RECOGNIZED. Initiate kindness subroutine." — Service Drone 7',
|
||||
'"Captain, I believe I speak for everyone here when I say... huh?" — Data',
|
||||
'"The cosmos is within us. We are made of star-stuff." — Carl Sagan',
|
||||
'"The limits of my language mean the limits of my world." — Ludwig Wittgenstein',
|
||||
'"Evolution has made it this far on chance and determination. Software needs tests." — Eileen Paik',
|
||||
'"We need to fail down here so we don\'t fail up there." — NASA Engineer in Apollo 13',
|
||||
'"The future depends on what you do today." — Mahatma Gandhi',
|
||||
'"Wisdom begins in wonder, but deliverables end in checklists." — Orion Valdez',
|
||||
'"Your scientists were so preoccupied with whether they could, they didn\'t stop to think if they should." — Ian Malcolm',
|
||||
'"There is no spoon." — Spoon Boy',
|
||||
'"All your base are belong to us." — CATS',
|
||||
'"Before software can be reusable it first has to be usable." — Ralph Johnson',
|
||||
'"The simplest things are often the truest." — Richard Bach',
|
||||
'"In the long shadow of the nebula, we still pair program." — Lyra Calder',
|
||||
'"Wake up, Neo... the matrix has you." — Morpheus',
|
||||
'"No fate but what we make for ourselves." — Sarah Connor',
|
||||
'"If you want to know what a man’s like, take a good look at how he treats his inferiors." — Sirius Black',
|
||||
'"Cat videos are the only known universal constant of the multiverse." — Quinn Zhao',
|
||||
'"The future is bright enough to require calibrated exposure." — Anika Ren',
|
||||
'"You\'re gonna need a bigger boat." — Martin Brody',
|
||||
'"Sometimes we have to look to the past to move forward." — Shuri',
|
||||
'"We are the music makers, and we are the dreamers of dreams." — Arthur O\'Shaughnessy',
|
||||
"\"It's not enough to bash in heads, you've got to bash in minds.\" — Joss Whedon",
|
||||
'"Machine dreams are written in firmware but interpreted in hope." — Aziza Hale',
|
||||
'"The engine of progress hums on feedback loops and fearless debugging." — Kestrel DuPont',
|
||||
'"No amount of screaming will make the compiler kinder. Reconsider your tone." — Lark Vega',
|
||||
'"I find your lack of faith disturbing." — Darth Vader',
|
||||
'"Travel far enough, you meet yourself." — David Mitchell',
|
||||
'"Patience is the companion of wisdom." — Saint Augustine',
|
||||
'"I know kung fu." — Neo',
|
||||
"\"Just once I'd like to land somewhere and say, 'It's smaller on the outside.'\" — The Doctor",
|
||||
'"Faith manages." — Jeffrey Sinclair',
|
||||
'"Progress tastes like solder and espresso." — Nico Solari',
|
||||
'"The future isn\'t written, but we can at least comment our intentions." — Rowan Estrada',
|
||||
'"Dreaming permits each of us to be quietly and safely insane every night." — William C. Dement',
|
||||
'"If you eliminate the impossible, whatever remains, however improbable, must be the truth." — Sherlock Holmes',
|
||||
'"The universe is under no obligation to make sense to you." — Neil deGrasse Tyson',
|
||||
"\"Programming isn't about what you know; it's about what you can figure out.\" — Chris Pine",
|
||||
'"We are star stuff harvesting sunlight." — Carl Sagan',
|
||||
'"The first principle is that you must not fool yourself." — Richard Feynman',
|
||||
'"Trust the process; distrust the unreviewed commit." — Indigo Reyes',
|
||||
'"Not all treasure is silver and gold." — Jack Sparrow',
|
||||
'"When the storm comes, you don\'t need a hero. You need a plan." — Vega Laurent',
|
||||
'"On my world, we have a saying: don\'t forget to hydrate before saving the galaxy." — Captain Rexa',
|
||||
'"Knowledge speaks, but wisdom listens." — Jimi Hendrix',
|
||||
'"Every line of code is a promise to the future." — Idris Monroe',
|
||||
'"In a world where you can be anything, be kind." — Jennifer Dukes Lee',
|
||||
'"Entropy always wins unless curiosity reroutes the outcome." — Calla Reeves',
|
||||
'"The greatest victory is that which requires no battle." — Sun Tzu',
|
||||
'"I am one with the Force and the Force is with me." — Chirrut Îmwe',
|
||||
'"We all have our time machines, don\'t we." — H. G. Wells',
|
||||
'"Keep watching the skies." — The Thing from Another World',
|
||||
'"If you\'re going through hell, keep going." — Winston Churchill',
|
||||
'"Courage is found in unlikely places." — J. R. R. Tolkien',
|
||||
'"Starlight is broadband for the soul." — Amina Solari',
|
||||
'"The world changed because we decided to route around failure." — Harper Quill',
|
||||
'"Your focus determines your reality." — Qui-Gon Jinn',
|
||||
'"This is the way." — The Armorer',
|
||||
'"Chance favors the prepared mind." — Louis Pasteur',
|
||||
"\"You step onto the road, and if you don't keep your feet, there's no knowing where you might be swept off to.\" — Bilbo Baggins",
|
||||
'"Failure is simply the opportunity to begin again, this time more intelligently." — Henry Ford',
|
||||
'"The best dreams happen when you\'re awake." — Cherie Gilderbloom',
|
||||
'"Even the wind follows a protocol when it circles the dunes." — Cassian Holt',
|
||||
'"Input is life. Output is legacy." — Vana Idril',
|
||||
'"Leap, and the net will appear." — John Burroughs',
|
||||
'"The greatest risk is not taking one." — Mellody Hobson',
|
||||
'"Energy cannot be created or destroyed, but software can be refactored." — Pax Leland',
|
||||
'"Wisdom comes through suffering." — Aeschylus',
|
||||
'"Light the signal, synchronize the clocks, and move with intention." — Rhea Calder',
|
||||
'"The purpose of life is to contribute in some way to making things better." — Robert F. Kennedy',
|
||||
'"Destiny is just resilience wearing a cape." — Talin Frost',
|
||||
'"Curiosity is the wick in the candle of learning." — William Arthur Ward',
|
||||
'"For small creatures such as we the vastness is bearable only through love." — Carl Sagan',
|
||||
'"We cannot solve our problems with the same thinking we used when we created them." — Albert Einstein',
|
||||
'"Every galaxy started as a whisper." — Liora Kane',
|
||||
'"Learn the rules like a pro, so you can break them like an artist." — Pablo Picasso',
|
||||
'"Precision is kindness to the future maintainers." — Ezra Marquez',
|
||||
'"I rebel." — Jyn Erso',
|
||||
'"Keep holding on. The night is darkest right before the dawn." — Rachel Dawes',
|
||||
'"Risk is our business." — James T. Kirk',
|
||||
'"Do not go gentle into that good night." — Dylan Thomas',
|
||||
'"Imagination is the only weapon in the war against reality." — Lewis Carroll',
|
||||
'"Stories are data with a soul." — Brené Brown',
|
||||
'"We didn\'t come this far to only come this far." — Lexi Bishop',
|
||||
'"Beware the quiet ones; they\'re writing the tests." — Maren Leto',
|
||||
'"The journey of a thousand miles begins with a single step." — Lao Tzu',
|
||||
"\"It's not science if you know what you're doing.\" — Sheldon Cooper",
|
||||
'"History is written by those with backups." — Leland Cortez',
|
||||
'"Great things are done by a series of small things brought together." — Vincent van Gogh',
|
||||
'"The programmer is the device that turns coffee into software." — Piotr J. Gack',
|
||||
'"An idea isn\'t responsible for the people who believe in it." — Don Marquis',
|
||||
'"Signal and noise are siblings, raised by discipline." — Rian Talbot',
|
||||
'"Guard the map, share the compass." — Leyla Quinn',
|
||||
'"In space the stars make no sound, but the logs are verbose." — Orion Pax',
|
||||
'"You must unlearn what you have learned." — Yoda',
|
||||
'"Stand in the place where you work and look to the future." — Michael Stipe',
|
||||
'"Let the past die. Kill it, if you have to." — Kylo Ren',
|
||||
'"There is no innovation and creativity without failure. Period." — Brené Brown',
|
||||
'"Gravity is a habit that is hard to shake off." — Terry Pratchett',
|
||||
'"Every null pointer is a reminder to stay humble." — Kavi Song',
|
||||
'"Sometimes it\'s the people no one imagines anything of who do the things that no one can imagine." — Alan Turing',
|
||||
'"For the ones who dream of stranger worlds." — V. E. Schwab',
|
||||
'"Stay hungry, stay foolish." — Steve Jobs',
|
||||
"\"Your time is limited, so don't waste it living someone else's life.\" — Steve Jobs",
|
||||
'"Believe in the impossible and push the button anyway." — Lysa Adair',
|
||||
'"A rising tide lifts all spaceships." — Commander Talvek',
|
||||
'"You are the sky. Everything else — it\'s just the weather." — Pema Chödrön',
|
||||
'"Outside of a dog, a book is a man\'s best friend." — Groucho Marx',
|
||||
'"Not all those who wander are lost." — J. R. R. Tolkien',
|
||||
'"Wizards are just coders with better robes." — Mira Halden',
|
||||
'"One voice can change a room, and if it can change a room, it can change a city." — Barack Obama',
|
||||
'"Kind words cost nothing and accomplish everything." — Maya Angelou',
|
||||
'"Keep your face always toward the sunshine." — Walt Whitman',
|
||||
"\"Whether you think you can or you think you can't, you're right.\" — Henry Ford",
|
||||
'"Every decision we make echoes in loops of causality." — Aria Kepler',
|
||||
'"Standby is just another word for poised." — Jace Ryland',
|
||||
'"We are the strange loop that dreams of itself." — Douglas Hofstadter',
|
||||
'"Let us step into the night and pursue that flighty temptress, adventure." — Albus Dumbledore',
|
||||
'"Even in code, poetry finds a place." — Hana Mirek',
|
||||
'"The future rewards those who press refresh and keep iterating." — Peyton Cross',
|
||||
'"Hard choices, easy life. Easy choices, hard life." — Jerzy Gregorek',
|
||||
'"A hero is someone who understands the responsibility that comes with their freedom." — Bob Dylan',
|
||||
'"Sufficiently documented hacks are indistinguishable from features." — Vera Lyric',
|
||||
'"Whatever you are, be a good one." — Abraham Lincoln',
|
||||
'"Consoles are just confessionals with stack traces." — Dillon Aru',
|
||||
'"You cannot swim for new horizons until you have courage to lose sight of the shore." — William Faulkner',
|
||||
'"Make it so." — Jean-Luc Picard',
|
||||
'"We do these things not because they are easy, but because they are hard." — John F. Kennedy',
|
||||
'"Even stardust needs structure." — Penelope Acharya',
|
||||
'"We are all apprentices in a craft where no one ever becomes a master." — Ernest Hemingway',
|
||||
'"Trust, but verify." — Ronald Reagan',
|
||||
'"No mute button for conscience exists." — Astra Rune',
|
||||
'"The price of greatness is responsibility." — Winston Churchill',
|
||||
'"You miss 100% of the shots you don\'t take." — Wayne Gretzky',
|
||||
'"The sufferings of our planet are the sufferings of our choices." — Greta Thunberg',
|
||||
'"Our doubts are traitors." — William Shakespeare',
|
||||
'"Precision beats power, and timing beats speed." — Conor McGregor',
|
||||
'"Be who you needed when you were younger." — Ayesha Siddiqi',
|
||||
'"Once you eliminate your number-one problem, you promote number two." — Gerhard Gschwandtner',
|
||||
'"Measure twice, deploy once." — Rafi Goldman',
|
||||
'"Curiosity is the supreme virtue of explorers and engineers alike." — Naledi Star',
|
||||
'"Woe unto the code that forgets its tests." — Gideon Locke',
|
||||
'"There\'s always room for a story that can transport people to another place." — J. K. Rowling',
|
||||
'"We are what we repeatedly do. Excellence, then, is not an act, but a habit." — Will Durant',
|
||||
'"One dream begins every time you press compile." — Mirae Lang',
|
||||
'"Celebrate the wins, even the tiny ones." — Zora Hale',
|
||||
'"When you see a fork in the road, take it." — Yogi Berra',
|
||||
'"Keep moving forward." — Meet the Robinsons',
|
||||
'"Stay curious." — CleverAgents Motto',
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Loading throbber widget used to indicate background activity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import random
|
||||
from collections import deque
|
||||
from typing import Any, Sequence
|
||||
|
||||
from cleveragents.tui.quotes import THROBBER_QUOTES
|
||||
|
||||
|
||||
def _load_static_base() -> type[Any]:
|
||||
"""Return the Textual Static base class or a lightweight fallback."""
|
||||
|
||||
try:
|
||||
return importlib.import_module("textual.widgets").Static
|
||||
except Exception: # pragma: no cover - textual not installed
|
||||
|
||||
class _FallbackStatic:
|
||||
"""Minimal stand-in mirroring the API used by the widget."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self._text = ""
|
||||
self.visible = True
|
||||
|
||||
def update(self, renderable: object) -> None: # pragma: no cover - trivial
|
||||
self._text = str(renderable)
|
||||
|
||||
return _FallbackStatic
|
||||
|
||||
|
||||
try: # pragma: no branch - optional dependency
|
||||
from rich.text import Text
|
||||
except Exception: # pragma: no cover - rich unavailable when textual missing
|
||||
Text = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_StaticBase = _load_static_base()
|
||||
|
||||
|
||||
_RAINBOW_COLORS: tuple[str, ...] = (
|
||||
"#881177",
|
||||
"#aa3355",
|
||||
"#cc6666",
|
||||
"#ee9944",
|
||||
"#eedd00",
|
||||
"#99dd55",
|
||||
"#44dd88",
|
||||
"#22ccbb",
|
||||
"#00bbcc",
|
||||
"#0099cc",
|
||||
"#3366bb",
|
||||
"#663399",
|
||||
)
|
||||
|
||||
|
||||
class LoadingThrobber(_StaticBase):
|
||||
"""Animated loading indicator supporting rainbow and quote styles."""
|
||||
|
||||
_RAINBOW_FPS: float = 15.0
|
||||
_QUOTES_PERIOD_SECONDS: float = 3.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
style: str = "rainbow",
|
||||
quotes: Sequence[str] | None = None,
|
||||
id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__("", id=id)
|
||||
self._style = "rainbow"
|
||||
self._rainbow_offset = 0
|
||||
self._running = False
|
||||
self._rainbow_timer: Any | None = None
|
||||
self._quotes_timer: Any | None = None
|
||||
self._quotes: deque[str] = deque(quotes or THROBBER_QUOTES)
|
||||
self._rng = random.Random()
|
||||
self.visible = False # type: ignore[attr-defined]
|
||||
self.set_style(style)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle hooks
|
||||
# ------------------------------------------------------------------
|
||||
def on_mount(self) -> None: # pragma: no cover - relies on Textual runtime
|
||||
self._apply_visibility(False)
|
||||
if self._style == "quotes":
|
||||
self._shuffle_quotes()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
def set_style(self, style: str) -> None:
|
||||
"""Change the throbber style ("rainbow" or "quotes")."""
|
||||
|
||||
normalized = style.strip().lower()
|
||||
if normalized not in {"rainbow", "quotes"}:
|
||||
raise ValueError("Throbber style must be 'rainbow' or 'quotes'.")
|
||||
if normalized == self._style:
|
||||
return
|
||||
self._style = normalized
|
||||
if self._style == "quotes":
|
||||
self._shuffle_quotes()
|
||||
if self._running:
|
||||
self._restart_animation()
|
||||
|
||||
def show_loading(self) -> None:
|
||||
"""Display the throbber and start animation if possible."""
|
||||
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._apply_visibility(True)
|
||||
self._start_animation()
|
||||
|
||||
def hide_loading(self) -> None:
|
||||
"""Hide the throbber and stop any running animation."""
|
||||
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
self._stop_animation()
|
||||
self.update("")
|
||||
self._apply_visibility(False)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Return whether the throbber is currently animating."""
|
||||
|
||||
return self._running
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Animation helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _restart_animation(self) -> None:
|
||||
self._stop_animation()
|
||||
if self._running:
|
||||
self._start_animation()
|
||||
|
||||
def _start_animation(self) -> None:
|
||||
if self._style == "rainbow":
|
||||
self._render_rainbow_frame()
|
||||
timer_fn = getattr(self, "set_interval", None)
|
||||
if callable(timer_fn):
|
||||
interval = 1.0 / self._RAINBOW_FPS
|
||||
self._rainbow_timer = timer_fn(interval, self._render_rainbow_frame)
|
||||
else:
|
||||
self._advance_quote()
|
||||
timer_fn = getattr(self, "set_interval", None)
|
||||
if callable(timer_fn):
|
||||
self._quotes_timer = timer_fn(
|
||||
self._QUOTES_PERIOD_SECONDS, self._advance_quote
|
||||
)
|
||||
|
||||
def _stop_animation(self) -> None:
|
||||
if self._rainbow_timer is not None:
|
||||
stop = getattr(self._rainbow_timer, "stop", None)
|
||||
if callable(stop): # pragma: no branch - textual timer exposes stop()
|
||||
stop()
|
||||
self._rainbow_timer = None
|
||||
if self._quotes_timer is not None:
|
||||
stop = getattr(self._quotes_timer, "stop", None)
|
||||
if callable(stop):
|
||||
stop()
|
||||
self._quotes_timer = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
def _render_rainbow_frame(self) -> None:
|
||||
"""Render a single rainbow frame and update the widget."""
|
||||
|
||||
frame = self._build_rainbow_frame(self._rainbow_offset)
|
||||
self.update(frame)
|
||||
self._rainbow_offset = (self._rainbow_offset + 1) % len(_RAINBOW_COLORS)
|
||||
|
||||
def _advance_quote(self) -> None:
|
||||
if not self._quotes:
|
||||
return
|
||||
quote = self._quotes[0]
|
||||
self._quotes.rotate(-1)
|
||||
self.update(quote)
|
||||
|
||||
def _build_rainbow_frame(self, offset: int) -> Any:
|
||||
if Text is None:
|
||||
# Fallback using plain text with indicators when Rich is unavailable.
|
||||
block = "━" * 30
|
||||
marker = "◆"
|
||||
return f"{block} {marker} {block}"
|
||||
|
||||
text = Text()
|
||||
colors = list(_RAINBOW_COLORS)
|
||||
for index in range(len(colors)):
|
||||
color = colors[(index + offset) % len(colors)]
|
||||
text.append("━", style=f"bold {color}")
|
||||
text.append(" ◆ ", style=f"bold {colors[offset % len(colors)]}")
|
||||
for index in range(len(colors) - 1, -1, -1):
|
||||
color = colors[(index + offset) % len(colors)]
|
||||
text.append("━", style=f"bold {color}")
|
||||
return text
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utilities
|
||||
# ------------------------------------------------------------------
|
||||
def _shuffle_quotes(self) -> None:
|
||||
items = list(self._quotes)
|
||||
self._rng.shuffle(items)
|
||||
self._quotes = deque(items)
|
||||
|
||||
def _apply_visibility(self, is_visible: bool) -> None:
|
||||
if hasattr(self, "display"):
|
||||
setattr(self, "display", is_visible)
|
||||
# Some fallback widgets expose "visible" while Textual uses display.
|
||||
setattr(self, "visible", is_visible)
|
||||
styles = getattr(self, "styles", None)
|
||||
if styles is not None: # pragma: no cover - requires Textual runtime
|
||||
if is_visible:
|
||||
styles.height = 1
|
||||
styles.min_height = 1
|
||||
styles.padding = (0, 1)
|
||||
else:
|
||||
styles.height = 0
|
||||
styles.min_height = 0
|
||||
styles.padding = 0
|
||||
|
||||
|
||||
__all__ = ["LoadingThrobber"]
|
||||
Reference in New Issue
Block a user