comate-cli 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- comate_cli/__main__.py +5 -0
- comate_cli/main.py +128 -0
- comate_cli/terminal_agent/__init__.py +2 -0
- comate_cli/terminal_agent/animations.py +283 -0
- comate_cli/terminal_agent/app.py +261 -0
- comate_cli/terminal_agent/assistant_render.py +243 -0
- comate_cli/terminal_agent/env_utils.py +37 -0
- comate_cli/terminal_agent/error_display.py +46 -0
- comate_cli/terminal_agent/event_renderer.py +867 -0
- comate_cli/terminal_agent/fragment_utils.py +25 -0
- comate_cli/terminal_agent/history_printer.py +150 -0
- comate_cli/terminal_agent/input_geometry.py +92 -0
- comate_cli/terminal_agent/layout_coordinator.py +188 -0
- comate_cli/terminal_agent/logging_adapter.py +147 -0
- comate_cli/terminal_agent/logo.py +58 -0
- comate_cli/terminal_agent/markdown_render.py +24 -0
- comate_cli/terminal_agent/mention_completer.py +293 -0
- comate_cli/terminal_agent/message_style.py +33 -0
- comate_cli/terminal_agent/models.py +89 -0
- comate_cli/terminal_agent/question_view.py +584 -0
- comate_cli/terminal_agent/rewind_store.py +712 -0
- comate_cli/terminal_agent/rpc_protocol.py +103 -0
- comate_cli/terminal_agent/rpc_stdio.py +280 -0
- comate_cli/terminal_agent/selection_menu.py +305 -0
- comate_cli/terminal_agent/session_view.py +99 -0
- comate_cli/terminal_agent/slash_commands.py +142 -0
- comate_cli/terminal_agent/startup.py +77 -0
- comate_cli/terminal_agent/status_bar.py +258 -0
- comate_cli/terminal_agent/text_effects.py +30 -0
- comate_cli/terminal_agent/tool_view.py +584 -0
- comate_cli/terminal_agent/tui.py +1006 -0
- comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
- comate_cli/terminal_agent/tui_parts/commands.py +759 -0
- comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
- comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
- comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
- comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
- comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
- comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
- comate_cli-0.1.0.dist-info/METADATA +37 -0
- comate_cli-0.1.0.dist-info/RECORD +44 -0
- comate_cli-0.1.0.dist-info/WHEEL +4 -0
- comate_cli-0.1.0.dist-info/entry_points.txt +2 -0
comate_cli/__init__.py
ADDED
comate_cli/__main__.py
ADDED
comate_cli/main.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from comate_agent_sdk import Agent
|
|
17
|
+
from comate_agent_sdk.agent import AgentConfig, ChatSession
|
|
18
|
+
from comate_agent_sdk.context import EnvOptions
|
|
19
|
+
from comate_agent_sdk.tools import tool
|
|
20
|
+
|
|
21
|
+
from comate_cli.terminal_agent.event_renderer import EventRenderer
|
|
22
|
+
from comate_cli.terminal_agent.logo import print_logo
|
|
23
|
+
from comate_cli.terminal_agent.rpc_stdio import StdioRPCBridge
|
|
24
|
+
from comate_cli.terminal_agent.status_bar import StatusBar
|
|
25
|
+
from comate_cli.terminal_agent.tui import TerminalAgentTUI
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _flush_langfuse_if_configured() -> None:
|
|
32
|
+
"""Flush Langfuse pending events synchronously to prevent atexit thread-join errors on Ctrl+C."""
|
|
33
|
+
try:
|
|
34
|
+
from langfuse import get_client
|
|
35
|
+
get_client().flush()
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def _sigint_guard() -> Iterator[None]:
|
|
42
|
+
"""Temporarily ignore SIGINT in critical shutdown windows."""
|
|
43
|
+
if os.name == "nt":
|
|
44
|
+
yield
|
|
45
|
+
return
|
|
46
|
+
if threading.current_thread() is not threading.main_thread():
|
|
47
|
+
yield
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
previous_handler = signal.getsignal(signal.SIGINT)
|
|
52
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
53
|
+
logger.debug("SIGINT guard enabled for shutdown")
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.debug(f"Failed to enable SIGINT guard, fallback to unguarded shutdown: {exc}")
|
|
56
|
+
yield
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
yield
|
|
61
|
+
finally:
|
|
62
|
+
try:
|
|
63
|
+
signal.signal(signal.SIGINT, previous_handler)
|
|
64
|
+
logger.debug("SIGINT guard restored")
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
logger.warning(f"Failed to restore SIGINT handler: {exc}", exc_info=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _shutdown_session(session: ChatSession, *, label: str) -> None:
|
|
70
|
+
start = time.monotonic()
|
|
71
|
+
try:
|
|
72
|
+
shutdown = getattr(session, "shutdown", None)
|
|
73
|
+
if callable(shutdown):
|
|
74
|
+
await shutdown()
|
|
75
|
+
else:
|
|
76
|
+
await session.close()
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
logger.warning(f"Session shutdown failed ({label}): {exc}", exc_info=True)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
elapsed = time.monotonic() - start
|
|
82
|
+
logger.info(f"Session shutdown completed ({label}) in {elapsed:.3f}s")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _graceful_shutdown(*sessions: ChatSession) -> None:
|
|
86
|
+
unique_sessions: list[ChatSession] = []
|
|
87
|
+
seen_ids: set[int] = set()
|
|
88
|
+
for session in sessions:
|
|
89
|
+
sid = id(session)
|
|
90
|
+
if sid in seen_ids:
|
|
91
|
+
continue
|
|
92
|
+
seen_ids.add(sid)
|
|
93
|
+
unique_sessions.append(session)
|
|
94
|
+
|
|
95
|
+
start = time.monotonic()
|
|
96
|
+
with _sigint_guard():
|
|
97
|
+
for index, session in enumerate(unique_sessions, start=1):
|
|
98
|
+
label = f"{index}/{len(unique_sessions)} session_id={session.session_id}"
|
|
99
|
+
await _shutdown_session(session, label=label)
|
|
100
|
+
_flush_langfuse_if_configured()
|
|
101
|
+
|
|
102
|
+
elapsed = time.monotonic() - start
|
|
103
|
+
logger.info(f"Graceful shutdown completed in {elapsed:.3f}s")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@tool("Add two numbers 涉及到加法运算 必须使用这个工具")
|
|
107
|
+
async def add(a: int, b: int) -> int:
|
|
108
|
+
return a + b
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_agent() -> Agent:
|
|
112
|
+
context7_api_key = os.getenv("CONTEXT7_API_KEY")
|
|
113
|
+
exa_api_key = os.getenv("EXA_API_KEY")
|
|
114
|
+
|
|
115
|
+
exa_tools = (
|
|
116
|
+
"web_search_exa,"
|
|
117
|
+
"web_search_advanced_exa,"
|
|
118
|
+
"get_code_context_exa,"
|
|
119
|
+
"crawling_exa"
|
|
120
|
+
)
|
|
121
|
+
exa_url = "https://mcp.exa.ai/mcp"
|
|
122
|
+
if exa_api_key:
|
|
123
|
+
exa_url = f"{exa_url}?exaApiKey={exa_api_key}&tools={exa_tools}"
|
|
124
|
+
else:
|
|
125
|
+
exa_url = f"{exa_url}?tools={exa_tools}"
|
|
126
|
+
|
|
127
|
+
return Agent(
|
|
128
|
+
config=AgentConfig(
|
|
129
|
+
role="software_engineering",
|
|
130
|
+
env_options=EnvOptions(system_env=True, git_env=True),
|
|
131
|
+
use_streaming_task=True, # 启用流式 Task(实时显示嵌套工具调用)
|
|
132
|
+
mcp_servers={
|
|
133
|
+
"context7": {
|
|
134
|
+
"type": "http",
|
|
135
|
+
"url": "https://mcp.context7.com/mcp",
|
|
136
|
+
"headers": {
|
|
137
|
+
"CONTEXT7_API_KEY": context7_api_key,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
"wiretext": {
|
|
141
|
+
"command": "npx",
|
|
142
|
+
"args": ["-y", "@wiretext/mcp"]
|
|
143
|
+
},
|
|
144
|
+
"exa_search": {
|
|
145
|
+
"type": "http",
|
|
146
|
+
"url": exa_url,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_session(agent: Agent, session_id: str | None) -> tuple[ChatSession, str]:
|
|
154
|
+
if session_id:
|
|
155
|
+
return ChatSession.resume(agent, session_id=session_id), "resume"
|
|
156
|
+
return ChatSession(agent), "new"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _format_exit_usage_line(usage: object) -> str:
|
|
160
|
+
total_prompt_tokens = int(getattr(usage, "total_prompt_tokens", 0) or 0)
|
|
161
|
+
total_prompt_cached_tokens = int(
|
|
162
|
+
getattr(usage, "total_prompt_cached_tokens", 0) or 0
|
|
163
|
+
)
|
|
164
|
+
total_completion_tokens = int(getattr(usage, "total_completion_tokens", 0) or 0)
|
|
165
|
+
total_reasoning_tokens = int(getattr(usage, "total_reasoning_tokens", 0) or 0)
|
|
166
|
+
|
|
167
|
+
input_tokens = max(total_prompt_tokens - total_prompt_cached_tokens, 0)
|
|
168
|
+
total_tokens = input_tokens + total_completion_tokens
|
|
169
|
+
|
|
170
|
+
reasoning_part = f" (reasoning {total_reasoning_tokens:,})" if total_reasoning_tokens > 0 else ""
|
|
171
|
+
return (
|
|
172
|
+
f"Token usage: total={total_tokens:,} "
|
|
173
|
+
f"input={input_tokens:,} (+ {total_prompt_cached_tokens:,} cached) "
|
|
174
|
+
f"output={total_completion_tokens:,}{reasoning_part}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _preload_mcp_in_tui(session: ChatSession, renderer: EventRenderer) -> None:
|
|
179
|
+
"""在 TUI 内部异步加载 MCP,通过 renderer 输出状态消息."""
|
|
180
|
+
if not session._agent.options.mcp_servers:
|
|
181
|
+
return
|
|
182
|
+
try:
|
|
183
|
+
await session._agent.ensure_mcp_tools_loaded()
|
|
184
|
+
except Exception as e:
|
|
185
|
+
renderer.append_system_message(f"MCP init failed: {e}", severity="error")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
mgr = session._agent._mcp_manager
|
|
189
|
+
if mgr is None:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
loaded = mgr.tool_infos
|
|
193
|
+
if loaded:
|
|
194
|
+
count = len(loaded)
|
|
195
|
+
aliases = sorted({i.server_alias for i in loaded})
|
|
196
|
+
renderer.append_system_message(f"MCP loaded: {', '.join(aliases)} ({count} tools)")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def run(*, rpc_stdio: bool = False, session_id: str | None = None) -> None:
|
|
200
|
+
agent = _build_agent()
|
|
201
|
+
session, mode = _resolve_session(agent, session_id)
|
|
202
|
+
|
|
203
|
+
if rpc_stdio:
|
|
204
|
+
bridge = StdioRPCBridge(session)
|
|
205
|
+
try:
|
|
206
|
+
await bridge.run()
|
|
207
|
+
finally:
|
|
208
|
+
await _graceful_shutdown(session)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
print_logo(console)
|
|
212
|
+
# 不再阻塞等待 MCP,改为 TUI 内部异步加载
|
|
213
|
+
status_bar = StatusBar(session)
|
|
214
|
+
status_bar.set_mode(session.get_mode())
|
|
215
|
+
if mode == "resume":
|
|
216
|
+
await status_bar.refresh()
|
|
217
|
+
|
|
218
|
+
renderer = EventRenderer(project_root=Path.cwd())
|
|
219
|
+
|
|
220
|
+
# 配置 TUI logging handler(将 SDK 日志输出到 TUI)
|
|
221
|
+
from comate_cli.terminal_agent.logging_adapter import setup_tui_logging
|
|
222
|
+
setup_tui_logging(renderer)
|
|
223
|
+
|
|
224
|
+
tui = TerminalAgentTUI(session, status_bar, renderer)
|
|
225
|
+
tui.add_resume_history(mode)
|
|
226
|
+
|
|
227
|
+
# 把 MCP loading 作为 TUI 内部初始化任务
|
|
228
|
+
async def _mcp_loader() -> None:
|
|
229
|
+
await _preload_mcp_in_tui(session, renderer)
|
|
230
|
+
|
|
231
|
+
usage_line: str | None = None
|
|
232
|
+
active_session = session
|
|
233
|
+
try:
|
|
234
|
+
await tui.run(mcp_init=_mcp_loader)
|
|
235
|
+
active_session = tui.session
|
|
236
|
+
try:
|
|
237
|
+
usage = await active_session.get_usage()
|
|
238
|
+
usage_line = _format_exit_usage_line(usage)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
logger.warning(
|
|
241
|
+
f"Failed to collect usage for exit summary: {exc}",
|
|
242
|
+
exc_info=True,
|
|
243
|
+
)
|
|
244
|
+
finally:
|
|
245
|
+
if active_session is session:
|
|
246
|
+
await _graceful_shutdown(active_session)
|
|
247
|
+
else:
|
|
248
|
+
await _graceful_shutdown(session, active_session)
|
|
249
|
+
|
|
250
|
+
if usage_line:
|
|
251
|
+
console.print(f"[dim]{usage_line}[/]")
|
|
252
|
+
|
|
253
|
+
console.print(
|
|
254
|
+
f"[dim]To continue this session, run [bold cyan]comate resume "
|
|
255
|
+
f"{active_session.session_id}[/][/]"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__":
|
|
260
|
+
argv_session_id = sys.argv[1] if len(sys.argv) > 1 else None
|
|
261
|
+
asyncio.run(run(session_id=argv_session_id))
|