diff --git a/features/settings_configuration.feature b/features/settings_configuration.feature index 6e504ded4..bc2c63586 100644 --- a/features/settings_configuration.feature +++ b/features/settings_configuration.feature @@ -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" diff --git a/features/steps/settings_steps.py b/features/steps/settings_steps.py index b6a250604..8965389cb 100644 --- a/features/steps/settings_steps.py +++ b/features/steps/settings_steps.py @@ -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.""" diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index ad9227bd2..3f9ff09d1 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -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) # --------------------------------------------------------------------------- diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index 2392dca85..4fcf8f767 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -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) --- diff --git a/robot/common.resource b/robot/common.resource index 83ea2f5a8..c6c4362d1 100644 --- a/robot/common.resource +++ b/robot/common.resource @@ -52,6 +52,9 @@ Setup Test Environment # Get the actual Python executable being used ${python_exec}= Evaluate sys.executable sys Set Suite Variable ${PYTHON} ${python_exec} + ${existing_py_path}= Get Environment Variable PYTHONPATH default= + ${new_py_path}= Set Variable If '${existing_py_path}' == '' ${WORKSPACE}/src ${WORKSPACE}/src:${existing_py_path} + Set Environment Variable PYTHONPATH ${new_py_path} # Don't set AGENTS_EXECUTABLE as a single variable - we'll use ${PYTHON} -m cleveragents directly Setup Database Isolation diff --git a/robot/tui_throbber.robot b/robot/tui_throbber.robot new file mode 100644 index 000000000..89ec6054e --- /dev/null +++ b/robot/tui_throbber.robot @@ -0,0 +1,73 @@ +*** Settings *** +Documentation Integration coverage for the CleverAgents TUI loading throbber. +Resource ${CURDIR}/common.resource +Library Process +Library Collections +Suite Setup Setup Test Environment With Database Isolation +Suite Teardown Cleanup Test Environment + +*** Variables *** +${PYTHON} python + +*** Test Cases *** +Throbber Show Hide Cycle Updates Visibility + [Documentation] The throbber toggles visibility attributes when shown and hidden. + ${script}= Catenate SEPARATOR=\n + ... from types import SimpleNamespace + ... from cleveragents.tui.widgets.throbber import LoadingThrobber + ... throbber = LoadingThrobber(style="rainbow") + ... throbber.styles = SimpleNamespace(height=None, min_height=None, padding=None) + ... assert throbber.is_running is False, "Expected throbber idle on init" + ... throbber.show_loading() + ... assert throbber.is_running is True, "Expected throbber running after show_loading()" + ... assert getattr(throbber, "display", True) is True, "display should be True after show_loading()" + ... assert getattr(throbber.styles, "height", None) == 1, "height should be 1 when visible" + ... assert getattr(throbber.styles, "min_height", None) == 1, "min_height should be 1 when visible" + ... throbber.hide_loading() + ... assert throbber.is_running is False, "Expected throbber idle after hide_loading()" + ... assert getattr(throbber, "display", False) is False, "display should be False after hide_loading()" + ... assert getattr(throbber.styles, "height", None) == 0, "height should collapse when hidden" + ... assert getattr(throbber.styles, "min_height", None) == 0, "min_height should collapse when hidden" + ... print("throbber-visibility-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} throbber-visibility-ok + +Throbber Style Switch Preserves Running State + [Documentation] Switching styles while running keeps animation active and rotates quotes. + ${script}= Catenate SEPARATOR=\n + ... from types import SimpleNamespace + ... from cleveragents.tui.widgets.throbber import LoadingThrobber + ... throbber = LoadingThrobber(style="rainbow", quotes=("alpha", "beta", "gamma", "delta")) + ... throbber.styles = SimpleNamespace(height=None, min_height=None, padding=None) + ... throbber.show_loading() + ... assert throbber.is_running is True, "Expected throbber to run after show_loading()" + ... throbber.set_style("quotes") + ... assert throbber.is_running is True, "Expected throbber to remain running after style change" + ... quotes_before = list(throbber._quotes) + ... throbber.hide_loading() + ... throbber.show_loading() + ... quotes_after = list(throbber._quotes) + ... assert quotes_before != quotes_after, "Quotes deque should rotate between animations" + ... throbber.hide_loading() + ... print("throbber-style-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} throbber-style-ok + +Throbber Rejects Invalid Styles + [Documentation] Invalid styles raise ValueError to protect configuration flow. + ${script}= Catenate SEPARATOR=\n + ... from types import SimpleNamespace + ... from cleveragents.tui.widgets.throbber import LoadingThrobber + ... throbber = LoadingThrobber() + ... throbber.styles = SimpleNamespace(height=None, min_height=None, padding=None) + ... try: + ... throbber.set_style("blink") + ... except ValueError: + ... print("throbber-invalid-style-ok") + ... else: + ... raise AssertionError("Expected ValueError for invalid throbber style") + ${result}= Run Process ${PYTHON} -c ${script} shell=False timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} throbber-invalid-style-ok diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 4a8c66dcf..f7ff93569 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -4,11 +4,13 @@ from __future__ import annotations import importlib import os +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, ClassVar, Protocol +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast +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,12 +23,15 @@ 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 else: InputSubmittedEvent = Any +CallInThread = Callable[[Callable[[], ModeResult]], Awaitable[ModeResult] | ModeResult] + _TEXTUAL_AVAILABLE = False _TextualApp: type[Any] = object _Vertical: type[Any] = object @@ -105,8 +110,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 +173,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 +181,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 +193,31 @@ 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): + thread_runner = cast(CallInThread, runner) + result = thread_runner(lambda: mode_router.process(text)) + if isinstance(result, Awaitable): + awaited_result = await result + return cast(ModeResult, awaited_result) + return cast(ModeResult, result) + 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() diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss index 94438c763..e59fe9894 100644 --- a/src/cleveragents/tui/cleveragents.tcss +++ b/src/cleveragents/tui/cleveragents.tcss @@ -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; diff --git a/src/cleveragents/tui/data/throbber_quotes.txt b/src/cleveragents/tui/data/throbber_quotes.txt new file mode 100644 index 000000000..ea899c570 --- /dev/null +++ b/src/cleveragents/tui/data/throbber_quotes.txt @@ -0,0 +1,210 @@ +"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 diff --git a/src/cleveragents/tui/quotes.py b/src/cleveragents/tui/quotes.py new file mode 100644 index 000000000..56618b64c --- /dev/null +++ b/src/cleveragents/tui/quotes.py @@ -0,0 +1,37 @@ +"""Curated quote collection used by the TUI throbber.""" + +from pathlib import Path + +_DATA_PATH = Path(__file__).with_name("data").joinpath("throbber_quotes.txt") +_FALLBACK_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', +) + + +def _load_quotes() -> tuple[str, ...]: + """Load the throbber quotes from the data file. + + Falls back to a small default set if the data file cannot be read so the + widget continues to operate during packaging or test environments. + """ + + try: + lines = _DATA_PATH.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return _FALLBACK_QUOTES + + quotes = tuple(line.strip() for line in lines if line.strip()) + if quotes: + return quotes + return _FALLBACK_QUOTES + + +THROBBER_QUOTES: tuple[str, ...] = _load_quotes() + + +__all__ = ["THROBBER_QUOTES"] diff --git a/src/cleveragents/tui/widgets/throbber.py b/src/cleveragents/tui/widgets/throbber.py new file mode 100644 index 000000000..90f20279d --- /dev/null +++ b/src/cleveragents/tui/widgets/throbber.py @@ -0,0 +1,230 @@ +"""Loading throbber widget used to indicate background activity.""" + +from __future__ import annotations + +import importlib +import random +from collections import deque +from collections.abc import Sequence +from contextlib import suppress +from typing import Any, cast + +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 + _RICH_TEXT_CLS: type[Any] | None = importlib.import_module("rich.text").Text +except Exception: # pragma: no cover - rich unavailable when textual missing + _RICH_TEXT_CLS = None + + +_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.set_style(style) + self._apply_visibility(False) + + # ------------------------------------------------------------------ + # 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 _RICH_TEXT_CLS is None: + # Fallback using plain text with indicators when Rich is unavailable. + block = "━" * 30 + marker = "◆" + return f"{block} {marker} {block}" + + text = _RICH_TEXT_CLS() + 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: + target = cast(Any, self) + with suppress(Exception): + target.display = is_visible + with suppress(Exception): + target.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"]