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.
Files changed (44) hide show
  1. comate_cli/__init__.py +5 -0
  2. comate_cli/__main__.py +5 -0
  3. comate_cli/main.py +128 -0
  4. comate_cli/terminal_agent/__init__.py +2 -0
  5. comate_cli/terminal_agent/animations.py +283 -0
  6. comate_cli/terminal_agent/app.py +261 -0
  7. comate_cli/terminal_agent/assistant_render.py +243 -0
  8. comate_cli/terminal_agent/env_utils.py +37 -0
  9. comate_cli/terminal_agent/error_display.py +46 -0
  10. comate_cli/terminal_agent/event_renderer.py +867 -0
  11. comate_cli/terminal_agent/fragment_utils.py +25 -0
  12. comate_cli/terminal_agent/history_printer.py +150 -0
  13. comate_cli/terminal_agent/input_geometry.py +92 -0
  14. comate_cli/terminal_agent/layout_coordinator.py +188 -0
  15. comate_cli/terminal_agent/logging_adapter.py +147 -0
  16. comate_cli/terminal_agent/logo.py +58 -0
  17. comate_cli/terminal_agent/markdown_render.py +24 -0
  18. comate_cli/terminal_agent/mention_completer.py +293 -0
  19. comate_cli/terminal_agent/message_style.py +33 -0
  20. comate_cli/terminal_agent/models.py +89 -0
  21. comate_cli/terminal_agent/question_view.py +584 -0
  22. comate_cli/terminal_agent/rewind_store.py +712 -0
  23. comate_cli/terminal_agent/rpc_protocol.py +103 -0
  24. comate_cli/terminal_agent/rpc_stdio.py +280 -0
  25. comate_cli/terminal_agent/selection_menu.py +305 -0
  26. comate_cli/terminal_agent/session_view.py +99 -0
  27. comate_cli/terminal_agent/slash_commands.py +142 -0
  28. comate_cli/terminal_agent/startup.py +77 -0
  29. comate_cli/terminal_agent/status_bar.py +258 -0
  30. comate_cli/terminal_agent/text_effects.py +30 -0
  31. comate_cli/terminal_agent/tool_view.py +584 -0
  32. comate_cli/terminal_agent/tui.py +1006 -0
  33. comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
  34. comate_cli/terminal_agent/tui_parts/commands.py +759 -0
  35. comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
  36. comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
  37. comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
  38. comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
  39. comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
  40. comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
  41. comate_cli-0.1.0.dist-info/METADATA +37 -0
  42. comate_cli-0.1.0.dist-info/RECORD +44 -0
  43. comate_cli-0.1.0.dist-info/WHEEL +4 -0
  44. comate_cli-0.1.0.dist-info/entry_points.txt +2 -0
comate_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Comate CLI package."""
2
+
3
+ from comate_cli.main import main
4
+
5
+ __all__ = ["main"]
comate_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from comate_cli.main import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
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,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
@@ -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))