comate-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. comate_cli-0.1.0/.gitignore +221 -0
  2. comate_cli-0.1.0/PKG-INFO +37 -0
  3. comate_cli-0.1.0/README.md +16 -0
  4. comate_cli-0.1.0/comate_cli/__init__.py +5 -0
  5. comate_cli-0.1.0/comate_cli/__main__.py +5 -0
  6. comate_cli-0.1.0/comate_cli/main.py +128 -0
  7. comate_cli-0.1.0/comate_cli/terminal_agent/__init__.py +2 -0
  8. comate_cli-0.1.0/comate_cli/terminal_agent/animations.py +283 -0
  9. comate_cli-0.1.0/comate_cli/terminal_agent/app.py +261 -0
  10. comate_cli-0.1.0/comate_cli/terminal_agent/assistant_render.py +243 -0
  11. comate_cli-0.1.0/comate_cli/terminal_agent/env_utils.py +37 -0
  12. comate_cli-0.1.0/comate_cli/terminal_agent/error_display.py +46 -0
  13. comate_cli-0.1.0/comate_cli/terminal_agent/event_renderer.py +867 -0
  14. comate_cli-0.1.0/comate_cli/terminal_agent/fragment_utils.py +25 -0
  15. comate_cli-0.1.0/comate_cli/terminal_agent/history_printer.py +150 -0
  16. comate_cli-0.1.0/comate_cli/terminal_agent/input_geometry.py +92 -0
  17. comate_cli-0.1.0/comate_cli/terminal_agent/layout_coordinator.py +188 -0
  18. comate_cli-0.1.0/comate_cli/terminal_agent/logging_adapter.py +147 -0
  19. comate_cli-0.1.0/comate_cli/terminal_agent/logo.py +58 -0
  20. comate_cli-0.1.0/comate_cli/terminal_agent/markdown_render.py +24 -0
  21. comate_cli-0.1.0/comate_cli/terminal_agent/mention_completer.py +293 -0
  22. comate_cli-0.1.0/comate_cli/terminal_agent/message_style.py +33 -0
  23. comate_cli-0.1.0/comate_cli/terminal_agent/models.py +89 -0
  24. comate_cli-0.1.0/comate_cli/terminal_agent/question_view.py +584 -0
  25. comate_cli-0.1.0/comate_cli/terminal_agent/rewind_store.py +712 -0
  26. comate_cli-0.1.0/comate_cli/terminal_agent/rpc_protocol.py +103 -0
  27. comate_cli-0.1.0/comate_cli/terminal_agent/rpc_stdio.py +280 -0
  28. comate_cli-0.1.0/comate_cli/terminal_agent/selection_menu.py +305 -0
  29. comate_cli-0.1.0/comate_cli/terminal_agent/session_view.py +99 -0
  30. comate_cli-0.1.0/comate_cli/terminal_agent/slash_commands.py +142 -0
  31. comate_cli-0.1.0/comate_cli/terminal_agent/startup.py +77 -0
  32. comate_cli-0.1.0/comate_cli/terminal_agent/status_bar.py +258 -0
  33. comate_cli-0.1.0/comate_cli/terminal_agent/text_effects.py +30 -0
  34. comate_cli-0.1.0/comate_cli/terminal_agent/tool_view.py +584 -0
  35. comate_cli-0.1.0/comate_cli/terminal_agent/tui.py +1006 -0
  36. comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
  37. comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/commands.py +759 -0
  38. comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
  39. comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
  40. comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
  41. comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
  42. comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
  43. comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
  44. comate_cli-0.1.0/pyproject.toml +41 -0
@@ -0,0 +1,221 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+ .agent
6
+ .agents
7
+ set_env.sh
8
+ # C extensions
9
+ *.so
10
+
11
+ # Distribution / packaging
12
+ .Python
13
+ build/
14
+ develop-eggs/
15
+ dist/
16
+ downloads/
17
+ eggs/
18
+ .eggs/
19
+ lib/
20
+ lib64/
21
+ parts/
22
+ sdist/
23
+ var/
24
+ wheels/
25
+ share/python-wheels/
26
+ *.egg-info/
27
+ .installed.cfg
28
+ *.egg
29
+ MANIFEST
30
+
31
+ # PyInstaller
32
+ # Usually these files are written by a python script from a template
33
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .nox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ *.py.cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+ cover/
55
+
56
+ # Translations
57
+ *.mo
58
+ *.pot
59
+
60
+ # Django stuff:
61
+ *.log
62
+ local_settings.py
63
+ db.sqlite3
64
+ db.sqlite3-journal
65
+
66
+ # Flask stuff:
67
+ instance/
68
+ .webassets-cache
69
+
70
+ # Scrapy stuff:
71
+ .scrapy
72
+
73
+ # Sphinx documentation
74
+ docs/_build/
75
+
76
+ # PyBuilder
77
+ .pybuilder/
78
+ target/
79
+
80
+ # Jupyter Notebook
81
+ .ipynb_checkpoints
82
+
83
+ # IPython
84
+ profile_default/
85
+ ipython_config.py
86
+
87
+ # pyenv
88
+ # For a library or package, you might want to ignore these files since the code is
89
+ # intended to run in multiple environments; otherwise, check them in:
90
+ # .python-version
91
+
92
+ # pipenv
93
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
95
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
96
+ # install all needed dependencies.
97
+ # Pipfile.lock
98
+
99
+ # UV
100
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
101
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
102
+ # commonly ignored for libraries.
103
+ # uv.lock
104
+
105
+ # poetry
106
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
107
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
108
+ # commonly ignored for libraries.
109
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
110
+ # poetry.lock
111
+ # poetry.toml
112
+
113
+ # pdm
114
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
115
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
116
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
117
+ # pdm.lock
118
+ # pdm.toml
119
+ .pdm-python
120
+ .pdm-build/
121
+
122
+ # pixi
123
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
124
+ # pixi.lock
125
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
126
+ # in the .venv directory. It is recommended not to include this directory in version control.
127
+ .pixi
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule
134
+ celerybeat.pid
135
+
136
+ # Redis
137
+ *.rdb
138
+ *.aof
139
+ *.pid
140
+
141
+ # RabbitMQ
142
+ mnesia/
143
+ rabbitmq/
144
+ rabbitmq-data/
145
+
146
+ # ActiveMQ
147
+ activemq-data/
148
+
149
+ # SageMath parsed files
150
+ *.sage.py
151
+
152
+ # Environments
153
+ .env
154
+ .envrc
155
+ .venv
156
+ env/
157
+ venv/
158
+ ENV/
159
+ env.bak/
160
+ venv.bak/
161
+
162
+ # Spyder project settings
163
+ .spyderproject
164
+ .spyproject
165
+
166
+ # Rope project settings
167
+ .ropeproject
168
+
169
+ # mkdocs documentation
170
+ /site
171
+
172
+ # mypy
173
+ .mypy_cache/
174
+ .dmypy.json
175
+ dmypy.json
176
+
177
+ # Pyre type checker
178
+ .pyre/
179
+
180
+ # pytype static type analyzer
181
+ .pytype/
182
+
183
+ # Cython debug symbols
184
+ cython_debug/
185
+
186
+ # PyCharm
187
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
188
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
189
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
190
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
191
+ # .idea/
192
+
193
+ # Abstra
194
+ # Abstra is an AI-powered process automation framework.
195
+ # Ignore directories containing user credentials, local state, and settings.
196
+ # Learn more at https://abstra.io/docs
197
+ .abstra/
198
+
199
+ # Visual Studio Code
200
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
201
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
202
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
203
+ # you could uncomment the following to ignore the entire vscode folder
204
+ # .vscode/
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ .env
221
+ ./tmp/
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: comate-cli
3
+ Version: 0.1.0
4
+ Summary: Comate terminal CLI built on comate-agent-sdk
5
+ Project-URL: Homepage, https://github.com/AndyLee1024/agent-sdk
6
+ Project-URL: Repository, https://github.com/AndyLee1024/agent-sdk
7
+ Author-email: Andy <andy.dev@aliyun.com>
8
+ Keywords: agent,cli,comate,tui
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: comate-agent-sdk<1.0.0,>=0.0.1
18
+ Requires-Dist: prompt-toolkit>=3.0
19
+ Requires-Dist: rich>=14.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # comate-cli
23
+
24
+ Comate terminal CLI package.
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ uvx comate-cli
30
+ ```
31
+
32
+ Or install globally:
33
+
34
+ ```bash
35
+ uv tool install comate-cli
36
+ comate
37
+ ```
@@ -0,0 +1,16 @@
1
+ # comate-cli
2
+
3
+ Comate terminal CLI package.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ uvx comate-cli
9
+ ```
10
+
11
+ Or install globally:
12
+
13
+ ```bash
14
+ uv tool install comate-cli
15
+ comate
16
+ ```
@@ -0,0 +1,5 @@
1
+ """Comate CLI package."""
2
+
3
+ from comate_cli.main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,5 @@
1
+ from comate_cli.main import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import asyncio
5
+ import logging
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import termios
10
+ import threading
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class _TerminalStateGuard:
16
+ """Best-effort tty restore guard for abnormal shutdown paths."""
17
+
18
+ def __init__(self) -> None:
19
+ self._fd: int | None = None
20
+ self._attrs: list[int | list[int | bytes]] | None = None
21
+ self._enabled = False
22
+
23
+ stdin = sys.__stdin__
24
+ if stdin is None:
25
+ return
26
+ if not stdin.isatty():
27
+ return
28
+ try:
29
+ self._fd = stdin.fileno()
30
+ self._attrs = termios.tcgetattr(self._fd)
31
+ self._enabled = True
32
+ except Exception as exc:
33
+ logger.debug(f"Terminal guard disabled: failed to snapshot tty attrs: {exc}")
34
+
35
+ def restore(self, *, reason: str) -> None:
36
+ if not self._enabled or self._fd is None or self._attrs is None:
37
+ return
38
+
39
+ try:
40
+ termios.tcsetattr(self._fd, termios.TCSANOW, self._attrs)
41
+ return
42
+ except Exception as exc:
43
+ logger.warning(f"tty restore failed ({reason}): {exc}", exc_info=True)
44
+
45
+ try:
46
+ subprocess.run(
47
+ ["stty", "sane"],
48
+ stdin=sys.__stdin__,
49
+ stdout=subprocess.DEVNULL,
50
+ stderr=subprocess.DEVNULL,
51
+ check=False,
52
+ )
53
+ except Exception as exc:
54
+ logger.warning(f"stty sane fallback failed ({reason}): {exc}", exc_info=True)
55
+
56
+
57
+ class _ShutdownNoiseGuard:
58
+ """Suppress KeyboardInterrupt noise in interpreter shutdown phase."""
59
+
60
+ def __init__(self) -> None:
61
+ self._shutdown_armed = False
62
+ self._orig_unraisablehook = sys.unraisablehook
63
+
64
+ def install(self) -> None:
65
+ sys.unraisablehook = self._unraisablehook
66
+
67
+ def begin_shutdown(self) -> None:
68
+ if self._shutdown_armed:
69
+ return
70
+ self._shutdown_armed = True
71
+ self._ignore_sigint_until_exit()
72
+
73
+ def _ignore_sigint_until_exit(self) -> None:
74
+ if threading.current_thread() is not threading.main_thread():
75
+ return
76
+ try:
77
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
78
+ logger.debug("SIGINT is now ignored for final shutdown")
79
+ except Exception as exc:
80
+ logger.debug(f"Failed to switch SIGINT to SIG_IGN during shutdown: {exc}")
81
+
82
+ def _unraisablehook(self, unraisable: object) -> None:
83
+ exc_value = getattr(unraisable, "exc_value", None)
84
+ if self._shutdown_armed and isinstance(exc_value, KeyboardInterrupt):
85
+ logger.debug("Suppressed unraisable KeyboardInterrupt during shutdown")
86
+ return
87
+
88
+ self._orig_unraisablehook(unraisable)
89
+
90
+
91
+ def _parse_args(argv: list[str]) -> tuple[bool, str | None]:
92
+ rpc_stdio = False
93
+ session_id: str | None = None
94
+ for arg in argv:
95
+ if arg == "--rpc-stdio":
96
+ rpc_stdio = True
97
+ continue
98
+ if arg.startswith("-"):
99
+ continue
100
+ if session_id is None:
101
+ session_id = arg
102
+ return rpc_stdio, session_id
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> None:
106
+ run_argv = list(argv) if argv is not None else sys.argv[1:]
107
+
108
+ noise_guard = _ShutdownNoiseGuard()
109
+ noise_guard.install()
110
+
111
+ term_guard = _TerminalStateGuard()
112
+ atexit.register(term_guard.restore, reason="atexit")
113
+ atexit.register(noise_guard.begin_shutdown)
114
+
115
+ from comate_cli.terminal_agent.app import run
116
+
117
+ rpc_stdio, session_id = _parse_args(run_argv)
118
+ try:
119
+ asyncio.run(run(rpc_stdio=rpc_stdio, session_id=session_id))
120
+ except KeyboardInterrupt:
121
+ noise_guard.begin_shutdown()
122
+ finally:
123
+ noise_guard.begin_shutdown()
124
+ term_guard.restore(reason="main-finally")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,2 @@
1
+ """Terminal agent example package."""
2
+
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Sequence
6
+ from enum import Enum
7
+
8
+ from rich.console import RenderableType
9
+ from rich.text import Text
10
+
11
+ from comate_agent_sdk.agent.events import StopEvent, TextEvent, ToolCallEvent, ToolResultEvent, UserQuestionEvent
12
+
13
+ DEFAULT_STATUS_PHRASES: tuple[str, ...] = (
14
+ "Vibing...",
15
+ "Thinking...",
16
+ "Reasoning...",
17
+ "Planning next move...",
18
+ "Reading context...",
19
+ "Connecting dots...",
20
+ "Synthesizing signal...",
21
+ "Spotting edge cases...",
22
+ "Checking assumptions...",
23
+ "Tracing dependencies...",
24
+ "Drafting response...",
25
+ "Polishing details...",
26
+ "Validating flow...",
27
+ "Cross-checking facts...",
28
+ "Refining intent...",
29
+ "Mapping tools...",
30
+ "Building confidence...",
31
+ "Stitching answer...",
32
+ "Finalizing output...",
33
+ "Almost there...",
34
+ )
35
+
36
+ BREATH_DOT_COLORS: tuple[str, ...] = (
37
+ "#4B5563", # 暗灰
38
+ "#6B7280", # 灰
39
+ "#9CA3AF", # 浅灰
40
+ "#D1D5DB", # 极浅灰
41
+ "#9CA3AF", # 回退
42
+ "#6B7280", # 回退
43
+ )
44
+ BREATH_DOT_GLYPHS: tuple[str, ...] = (
45
+ "○",
46
+ "●",
47
+ )
48
+
49
+
50
+ def breathing_dot_color(frame: int) -> str:
51
+ """Return the breathing dot color for a given animation frame."""
52
+ phase = (frame // 4) % len(BREATH_DOT_COLORS)
53
+ return BREATH_DOT_COLORS[phase]
54
+
55
+
56
+ def breathing_dot_glyph(now_monotonic: float | None = None) -> str:
57
+ """Return the breathing dot glyph that switches once per second."""
58
+ now = time.monotonic() if now_monotonic is None else now_monotonic
59
+ phase = int(now) % len(BREATH_DOT_GLYPHS)
60
+ return BREATH_DOT_GLYPHS[phase]
61
+
62
+
63
+ def _lerp_rgb(
64
+ start_rgb: tuple[int, int, int],
65
+ end_rgb: tuple[int, int, int],
66
+ ratio: float,
67
+ ) -> tuple[int, int, int]:
68
+ clamped = max(0.0, min(1.0, ratio))
69
+ r = int(start_rgb[0] + (end_rgb[0] - start_rgb[0]) * clamped)
70
+ g = int(start_rgb[1] + (end_rgb[1] - start_rgb[1]) * clamped)
71
+ b = int(start_rgb[2] + (end_rgb[2] - start_rgb[2]) * clamped)
72
+ return r, g, b
73
+
74
+
75
+ def _cyan_sweep_text(
76
+ content: str,
77
+ frame: int,
78
+ ) -> Text:
79
+ """Create a cyan sweep (moving highlight) effect over text."""
80
+ text = Text()
81
+ if not content:
82
+ return text
83
+ total = len(content)
84
+ base_rgb = (95, 155, 190)
85
+ mid_rgb = (120, 200, 235)
86
+ high_rgb = (210, 245, 255)
87
+
88
+ window = max(3, total // 5)
89
+ cycle = max(total + window * 2, 16)
90
+ center = (frame % cycle) - window
91
+
92
+ for idx, ch in enumerate(content):
93
+ distance = abs(idx - center)
94
+ if distance <= window:
95
+ glow = 1.0 - (distance / window)
96
+ if glow >= 0.6:
97
+ r, g, b = _lerp_rgb(mid_rgb, high_rgb, (glow - 0.6) / 0.4)
98
+ else:
99
+ r, g, b = _lerp_rgb(base_rgb, mid_rgb, glow / 0.6)
100
+ else:
101
+ r, g, b = base_rgb
102
+ text.append(ch, style=f"bold rgb({r},{g},{b})")
103
+ return text
104
+
105
+
106
+ class SubmissionAnimator:
107
+ """Animated status line shown after user submits a message."""
108
+
109
+ def __init__(
110
+ self,
111
+ console: object | None = None,
112
+ phrases: Sequence[str] | None = None,
113
+ refresh_interval: float = 0.12,
114
+ min_phrase_seconds: float = 2.4,
115
+ max_phrase_seconds: float = 3.0,
116
+ ) -> None:
117
+ del console
118
+ self._phrases = tuple(phrases) if phrases else DEFAULT_STATUS_PHRASES
119
+ self._refresh_interval = refresh_interval
120
+ self._min_phrase_seconds = max(0.6, min_phrase_seconds)
121
+ self._max_phrase_seconds = max(self._min_phrase_seconds, max_phrase_seconds)
122
+ self._status_hint: str | None = None
123
+ self._task: asyncio.Task[None] | None = None
124
+ self._stop_event: asyncio.Event | None = None
125
+ self._frame = 0
126
+ self._phrase_idx = 0
127
+ self._phrase_started_at_monotonic = 0.0
128
+ self._phrase_duration_seconds = 0.0
129
+ self._dirty = False
130
+ self._is_active = False
131
+
132
+ def _compute_phrase_duration(self, phrase_idx: int) -> float:
133
+ # Deterministic per phrase, bounded by [min, max], and never exceeds 3s by default.
134
+ span = self._max_phrase_seconds - self._min_phrase_seconds
135
+ if span <= 0:
136
+ return self._max_phrase_seconds
137
+ step = (phrase_idx * 17 + 11) % 100
138
+ ratio = step / 100.0
139
+ return self._min_phrase_seconds + span * ratio
140
+
141
+ async def start(self) -> None:
142
+ if self._task is not None:
143
+ return
144
+ self._frame = 0
145
+ self._phrase_idx = 0
146
+ self._phrase_started_at_monotonic = time.monotonic()
147
+ self._phrase_duration_seconds = self._phrase_duration_for_idx(0)
148
+ self._is_active = True
149
+ self._dirty = True
150
+ self._stop_event = asyncio.Event()
151
+ self._task = asyncio.create_task(self._run(), name="submission-animator")
152
+
153
+ def set_status_hint(self, hint: str | None) -> None:
154
+ if hint is None:
155
+ if self._status_hint is not None:
156
+ self._dirty = True
157
+ self._status_hint = None
158
+ return
159
+ normalized = hint.strip()
160
+ new_value = normalized or None
161
+ if new_value != self._status_hint:
162
+ self._dirty = True
163
+ self._status_hint = new_value
164
+
165
+ async def stop(self) -> None:
166
+ if self._task is None:
167
+ return
168
+ assert self._stop_event is not None
169
+ self._stop_event.set()
170
+ try:
171
+ await self._task
172
+ finally:
173
+ self._task = None
174
+ self._stop_event = None
175
+ self._is_active = False
176
+ self._dirty = True
177
+
178
+ @property
179
+ def is_active(self) -> bool:
180
+ return self._is_active
181
+
182
+ def consume_dirty(self) -> bool:
183
+ dirty = self._dirty
184
+ self._dirty = False
185
+ return dirty
186
+
187
+ def renderable(self) -> RenderableType:
188
+ if not self._is_active:
189
+ return Text("")
190
+
191
+ phrase = self._status_hint if self._status_hint else self._phrases[self._phrase_idx]
192
+ dot_color = breathing_dot_color(self._frame)
193
+ now_monotonic = time.monotonic()
194
+ dot = Text(
195
+ f"{breathing_dot_glyph(now_monotonic)} ",
196
+ style=f"bold {dot_color}",
197
+ )
198
+ sweep = _cyan_sweep_text(phrase, frame=self._frame)
199
+ return Text.assemble(dot, sweep)
200
+
201
+ async def _run(self) -> None:
202
+ assert self._stop_event is not None
203
+ while not self._stop_event.is_set():
204
+ now = time.monotonic()
205
+ if now - self._phrase_started_at_monotonic >= self._phrase_duration_seconds:
206
+ self._phrase_idx = (self._phrase_idx + 1) % len(self._phrases)
207
+ self._phrase_started_at_monotonic = now
208
+ self._phrase_duration_seconds = self._phrase_duration_for_idx(self._phrase_idx)
209
+ self._frame += 1
210
+ self._dirty = True
211
+ try:
212
+ await asyncio.wait_for(self._stop_event.wait(), timeout=self._refresh_interval)
213
+ except TimeoutError:
214
+ continue
215
+
216
+ def _phrase_duration_for_idx(self, phrase_idx: int) -> float:
217
+ return self._compute_phrase_duration(phrase_idx)
218
+
219
+
220
+ class AnimationPhase(str, Enum):
221
+ IDLE = "idle"
222
+ SUBMITTING = "submitting"
223
+ TOOL_RUNNING = "tool_running"
224
+ ASSISTANT_STREAMING = "assistant_streaming"
225
+ DONE = "done"
226
+
227
+
228
+ class StreamAnimationController:
229
+ """Controls submission animation lifecycle across stream events."""
230
+
231
+ def __init__(
232
+ self,
233
+ animator: SubmissionAnimator,
234
+ *,
235
+ min_visible_seconds: float = 0.35,
236
+ ) -> None:
237
+ self._animator = animator
238
+ self._min_visible_seconds = max(0.0, float(min_visible_seconds))
239
+ self._phase = AnimationPhase.IDLE
240
+ self._started_at_monotonic = 0.0
241
+ self._stopped = True
242
+ self._active_tool_call_ids: set[str] = set()
243
+
244
+ @property
245
+ def phase(self) -> AnimationPhase:
246
+ return self._phase
247
+
248
+ async def start(self) -> None:
249
+ self._active_tool_call_ids.clear()
250
+ self._animator.set_status_hint(None)
251
+ self._started_at_monotonic = time.monotonic()
252
+ self._phase = AnimationPhase.SUBMITTING
253
+ self._stopped = False
254
+ await self._animator.start()
255
+
256
+ async def shutdown(self) -> None:
257
+ await self._stop_if_needed(AnimationPhase.DONE)
258
+
259
+ async def on_event(self, event: object) -> None:
260
+ if self._stopped:
261
+ return
262
+
263
+ if isinstance(event, ToolCallEvent):
264
+ self._active_tool_call_ids.add(event.tool_call_id)
265
+ return
266
+
267
+ if isinstance(event, ToolResultEvent):
268
+ self._active_tool_call_ids.discard(event.tool_call_id)
269
+ return
270
+
271
+ if isinstance(event, UserQuestionEvent) or isinstance(event, StopEvent):
272
+ await self._stop_if_needed(AnimationPhase.DONE)
273
+
274
+ async def _stop_if_needed(self, next_phase: AnimationPhase) -> None:
275
+ if self._stopped:
276
+ return
277
+ elapsed = time.monotonic() - self._started_at_monotonic
278
+ if elapsed < self._min_visible_seconds:
279
+ await asyncio.sleep(self._min_visible_seconds - elapsed)
280
+ self._animator.set_status_hint(None)
281
+ await self._animator.stop()
282
+ self._stopped = True
283
+ self._phase = next_phase