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.
- comate_cli-0.1.0/.gitignore +221 -0
- comate_cli-0.1.0/PKG-INFO +37 -0
- comate_cli-0.1.0/README.md +16 -0
- comate_cli-0.1.0/comate_cli/__init__.py +5 -0
- comate_cli-0.1.0/comate_cli/__main__.py +5 -0
- comate_cli-0.1.0/comate_cli/main.py +128 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/__init__.py +2 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/animations.py +283 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/app.py +261 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/assistant_render.py +243 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/env_utils.py +37 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/error_display.py +46 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/event_renderer.py +867 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/fragment_utils.py +25 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/history_printer.py +150 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/input_geometry.py +92 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/layout_coordinator.py +188 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/logging_adapter.py +147 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/logo.py +58 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/markdown_render.py +24 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/mention_completer.py +293 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/message_style.py +33 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/models.py +89 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/question_view.py +584 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/rewind_store.py +712 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/rpc_protocol.py +103 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/rpc_stdio.py +280 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/selection_menu.py +305 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/session_view.py +99 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/slash_commands.py +142 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/startup.py +77 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/status_bar.py +258 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/text_effects.py +30 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tool_view.py +584 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui.py +1006 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/commands.py +759 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
- comate_cli-0.1.0/comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
- 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,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,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
|