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
@@ -0,0 +1,1006 @@
1
+ """Terminal Agent TUI implementation.
2
+
3
+ This module was split out from `terminal_agent.app` to keep the entrypoint small.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ import random
11
+ import time
12
+ from collections import deque
13
+ from collections.abc import Callable
14
+ from contextlib import suppress
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from prompt_toolkit.application import Application
19
+ from prompt_toolkit.completion import (
20
+ merge_completers,
21
+ )
22
+ from prompt_toolkit.filters import Condition, has_completions, has_focus
23
+ from prompt_toolkit.history import InMemoryHistory
24
+ from prompt_toolkit.layout import FloatContainer, HSplit, Layout, Window
25
+ from prompt_toolkit.layout.containers import ConditionalContainer
26
+ from prompt_toolkit.layout.controls import FormattedTextControl
27
+ from prompt_toolkit.patch_stdout import patch_stdout
28
+ from prompt_toolkit.styles import Style as PTStyle
29
+ from prompt_toolkit.utils import get_cwidth
30
+ from prompt_toolkit.widgets import TextArea
31
+
32
+ from comate_agent_sdk.agent import ChatSession
33
+ from comate_agent_sdk.agent.events import PlanApprovalRequiredEvent
34
+
35
+ from comate_cli.terminal_agent.animations import (
36
+ DEFAULT_STATUS_PHRASES,
37
+ StreamAnimationController,
38
+ SubmissionAnimator,
39
+ )
40
+ from comate_cli.terminal_agent.env_utils import read_env_float, read_env_int
41
+ from comate_cli.terminal_agent.event_renderer import EventRenderer
42
+ from comate_cli.terminal_agent.mention_completer import LocalFileMentionCompleter
43
+ from comate_cli.terminal_agent.question_view import AskUserQuestionUI
44
+ from comate_cli.terminal_agent.rewind_store import RewindStore
45
+ from comate_cli.terminal_agent.selection_menu import (
46
+ SelectionMenuUI,
47
+ )
48
+ from comate_cli.terminal_agent.slash_commands import (
49
+ SLASH_COMMAND_SPECS,
50
+ SlashCommandCompleter,
51
+ )
52
+ from comate_cli.terminal_agent.status_bar import StatusBar
53
+ from comate_cli.terminal_agent.tui_parts import (
54
+ CommandsMixin,
55
+ HistorySyncMixin,
56
+ InputBehaviorMixin,
57
+ KeyBindingsMixin,
58
+ RenderPanelsMixin,
59
+ SlashCommandRegistry,
60
+ UIMode,
61
+ )
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+
66
+ class TerminalAgentTUI(
67
+ KeyBindingsMixin,
68
+ InputBehaviorMixin,
69
+ CommandsMixin,
70
+ HistorySyncMixin,
71
+ RenderPanelsMixin,
72
+ ):
73
+ def __init__(
74
+ self,
75
+ session: ChatSession,
76
+ status_bar: StatusBar,
77
+ renderer: EventRenderer,
78
+ ) -> None:
79
+ self._session = session
80
+ self._status_bar = status_bar
81
+ try:
82
+ self._status_bar.set_mode(self._session.get_mode())
83
+ except Exception:
84
+ pass
85
+ self._renderer = renderer
86
+ self._rewind_store = RewindStore(session=self._session, project_root=Path.cwd())
87
+
88
+ self._tool_panel_max_lines = read_env_int(
89
+ "AGENT_SDK_TUI_TOOL_PANEL_MAX_LINES",
90
+ 4,
91
+ )
92
+ self._todo_panel_max_lines = read_env_int(
93
+ "AGENT_SDK_TUI_TODO_PANEL_MAX_LINES",
94
+ 6,
95
+ )
96
+ self._message_queue_max_size = read_env_int(
97
+ "AGENT_SDK_TUI_MESSAGE_QUEUE_MAX_SIZE",
98
+ 3,
99
+ )
100
+ self._queued_preview_max_chars = read_env_int(
101
+ "AGENT_SDK_TUI_QUEUED_PREVIEW_MAX_CHARS",
102
+ 24,
103
+ )
104
+
105
+ self._busy = False
106
+ self._initializing = False # MCP 加载中
107
+ self._is_compacting = False
108
+ self._compact_task: asyncio.Task[Any] | None = None
109
+ self._compact_cancel_requested = False
110
+ self._pending_exit_after_compact_cancel = False
111
+ self._queued_messages: deque[str] = deque() # busy 时入队的消息(FIFO)
112
+ self._waiting_for_input = False
113
+ self._pending_questions: list[dict[str, Any]] | None = None
114
+ self._pending_plan_approval: dict[str, str] | None = None
115
+ self._ui_mode = UIMode.NORMAL
116
+ self._show_thinking = True # Ctrl+T 开关,默认打开
117
+
118
+ self._slash_registry = SlashCommandRegistry()
119
+ self._build_slash_registry()
120
+ self._slash_completer = SlashCommandCompleter(
121
+ self._slash_registry.command_specs()
122
+ )
123
+ self._mention_completer = LocalFileMentionCompleter(Path.cwd())
124
+ self._input_completer = merge_completers(
125
+ [self._slash_completer, self._mention_completer],
126
+ deduplicate=True,
127
+ )
128
+ self._loading_frame = 0
129
+ self._fallback_loading_phrase = (
130
+ random.choice(DEFAULT_STATUS_PHRASES)
131
+ if DEFAULT_STATUS_PHRASES
132
+ else "Thinking…"
133
+ )
134
+ self._fallback_phrase_refresh_at = 0.0
135
+
136
+ self._tool_result_flash_seconds = read_env_float(
137
+ "AGENT_SDK_TUI_TOOL_RESULT_FLASH_SECONDS",
138
+ 0.55,
139
+ )
140
+ self._tool_result_flash_gen = 0
141
+ self._tool_result_flash_until_monotonic: float | None = None
142
+ self._tool_result_flash_active = False
143
+
144
+ # 初始化提交动画控制器
145
+ self._animator = SubmissionAnimator()
146
+ self._animation_controller = StreamAnimationController(self._animator)
147
+ self._tool_result_animator = SubmissionAnimator()
148
+
149
+ self._closing = False
150
+ self._printed_history_index = 0
151
+ self._render_dirty = True
152
+ self._diff_panel_visible = False
153
+ self._diff_panel_scroll = 0
154
+ self._last_loading_line = ""
155
+
156
+ self._app: Application[None] | None = None
157
+ self._stream_task: asyncio.Task[Any] | None = None
158
+ self._ui_tick_task: asyncio.Task[None] | None = None
159
+ self._mcp_init_task: asyncio.Task[None] | None = None
160
+ self._interrupt_requested_at: float | None = None
161
+ self._interrupt_force_window_seconds = 1.5
162
+
163
+ self._esc_last_pressed_at: float = 0.0
164
+ self._esc_press_count: int = 0
165
+ esc_window_ms = read_env_int("AGENT_SDK_TUI_ESC_CLEAR_WINDOW_MS", 700)
166
+ self._esc_clear_window_seconds = esc_window_ms / 1000.0
167
+
168
+ self._ctrl_c_last_pressed_at: float = 0.0
169
+ self._ctrl_c_press_count: int = 0
170
+ ctrl_c_window_ms = read_env_int("AGENT_SDK_TUI_CTRL_C_EXIT_WINDOW_MS", 700)
171
+ self._ctrl_c_exit_window_seconds = ctrl_c_window_ms / 1000.0
172
+
173
+ self._paste_threshold_chars = read_env_int(
174
+ "AGENT_SDK_TUI_PASTE_PLACEHOLDER_THRESHOLD_CHARS",
175
+ 500,
176
+ )
177
+ self._paste_placeholder_text: str | None = None
178
+ self._active_paste_token: str | None = None
179
+ self._paste_payload_by_token: dict[str, str] = {}
180
+ self._paste_token_seq = 0
181
+ self._suppress_input_change_hook = False
182
+ self._last_input_len = 0
183
+ self._last_input_text = ""
184
+
185
+ # Running 计时:记录本次 busy 开始的单调时间,结束后清空
186
+ self._run_start_time: float | None = None
187
+
188
+ self._input_prompt_text = "> "
189
+ self._input_prompt_width = max(1, get_cwidth(self._input_prompt_text))
190
+ self._queued_input_hint = "Press ↑ to edit"
191
+
192
+ def _input_line_prefix(
193
+ _line_number: int,
194
+ wrap_count: int,
195
+ ) -> list[tuple[str, str]]:
196
+ if wrap_count <= 0:
197
+ fragments: list[tuple[str, str]] = [
198
+ ("class:input.prompt", self._input_prompt_text)
199
+ ]
200
+ hint_text = self._input_placeholder_hint()
201
+ if hint_text:
202
+ fragments.append(("class:input.placeholder", f"{hint_text} "))
203
+ return fragments
204
+ return [("class:input.prompt", " " * self._input_prompt_width)]
205
+
206
+ self._input_area = TextArea(
207
+ text="",
208
+ multiline=True,
209
+ prompt="",
210
+ wrap_lines=True,
211
+ dont_extend_height=True,
212
+ completer=self._input_completer,
213
+ complete_while_typing=False, # 通过 Tab/上下键手动触发补全
214
+ history=InMemoryHistory(),
215
+ style="class:input.line",
216
+ get_line_prefix=_input_line_prefix,
217
+ )
218
+ # Fill the entire input area with styled spaces to avoid VT100
219
+ # erase-to-end-of-line resetting the background to the terminal default.
220
+ # (prompt_toolkit renderer resets attributes before erase_end_of_line.)
221
+ self._input_area.window.char = " "
222
+
223
+ @self._input_area.buffer.on_text_changed.add_handler
224
+ def _trigger_completion(_buffer) -> None:
225
+ if self._handle_large_paste(_buffer):
226
+ return
227
+ if self._busy:
228
+ return
229
+ doc = self._input_area.buffer.document
230
+ if self._completion_context_active(
231
+ doc.text_before_cursor,
232
+ doc.text_after_cursor,
233
+ ):
234
+ # 输入 / 或 @ 时自动弹出(不会选中第一项)
235
+ self._input_area.buffer.start_completion(select_first=False)
236
+
237
+ self._question_ui = AskUserQuestionUI()
238
+ self._selection_ui = SelectionMenuUI()
239
+ self._todo_control = FormattedTextControl(text=self._todo_text)
240
+ self._loading_control = FormattedTextControl(text=self._loading_text)
241
+ self._status_control = FormattedTextControl(text=self._status_text)
242
+ self._completion_status_control = FormattedTextControl(
243
+ text=self._completion_panel_text
244
+ )
245
+
246
+ self._todo_window = Window(
247
+ content=self._todo_control,
248
+ height=self._todo_height,
249
+ dont_extend_height=True,
250
+ style="class:loading",
251
+ )
252
+ self._todo_container = ConditionalContainer(
253
+ content=self._todo_window,
254
+ filter=Condition(lambda: self._renderer.has_active_todos()),
255
+ )
256
+
257
+ self._loading_window = Window(
258
+ content=self._loading_control,
259
+ height=self._loading_height,
260
+ dont_extend_height=True,
261
+ style="class:loading",
262
+ )
263
+ self._loading_placeholder_window = Window(
264
+ content=FormattedTextControl(text=[("", " ")]),
265
+ height=1,
266
+ dont_extend_height=True,
267
+ style="class:loading",
268
+ )
269
+ self._loading_container = ConditionalContainer(
270
+ content=self._loading_window,
271
+ filter=Condition(lambda: not self._should_hide_loading_panel()),
272
+ alternative_content=self._loading_placeholder_window,
273
+ )
274
+ self._queue_control = FormattedTextControl(text=self._queue_text)
275
+ self._queue_window = Window(
276
+ content=self._queue_control,
277
+ height=self._queue_height,
278
+ dont_extend_height=True,
279
+ style="class:queue",
280
+ )
281
+ self._queue_container = ConditionalContainer(
282
+ content=self._queue_window,
283
+ filter=Condition(self._should_show_queue_panel),
284
+ )
285
+
286
+ # Diff preview panel
287
+ self._diff_panel_control = FormattedTextControl(text=self._diff_panel_text)
288
+ self._diff_panel_window = Window(
289
+ content=self._diff_panel_control,
290
+ height=self._diff_panel_height,
291
+ dont_extend_height=True,
292
+ style="class:diff-panel",
293
+ wrap_lines=False,
294
+ )
295
+ self._diff_panel_container = ConditionalContainer(
296
+ content=self._diff_panel_window,
297
+ filter=Condition(
298
+ lambda: self._diff_panel_visible
299
+ and self._renderer.latest_diff_lines is not None
300
+ ),
301
+ )
302
+
303
+ self._status_window = Window(
304
+ content=self._status_control,
305
+ height=1,
306
+ dont_extend_height=True,
307
+ style="class:status",
308
+ )
309
+
310
+ self._completion_status_window = Window(
311
+ content=self._completion_status_control,
312
+ height=self._completion_panel_height,
313
+ dont_extend_height=True,
314
+ style="class:completion.status",
315
+ )
316
+
317
+ self._completion_visible = (
318
+ Condition(lambda: self._ui_mode == UIMode.NORMAL)
319
+ & has_focus(self._input_area)
320
+ & has_completions
321
+ )
322
+ self._bottom_container = ConditionalContainer(
323
+ content=self._completion_status_window,
324
+ filter=self._completion_visible,
325
+ alternative_content=self._status_window,
326
+ )
327
+
328
+ self._input_container = ConditionalContainer(
329
+ content=self._input_area,
330
+ filter=Condition(lambda: self._ui_mode == UIMode.NORMAL),
331
+ )
332
+ self._question_container = ConditionalContainer(
333
+ content=self._question_ui.container,
334
+ filter=Condition(lambda: self._ui_mode == UIMode.QUESTION),
335
+ )
336
+ self._selection_container = ConditionalContainer(
337
+ content=self._selection_ui.container,
338
+ filter=Condition(lambda: self._ui_mode == UIMode.SELECTION),
339
+ )
340
+
341
+ self._main_container = HSplit(
342
+ [
343
+ self._todo_container,
344
+ self._loading_container,
345
+ self._diff_panel_container,
346
+ self._queue_container,
347
+ Window(height=1, char=" ", style="class:input.pad"),
348
+ self._input_container,
349
+ self._question_container,
350
+ self._selection_container,
351
+ Window(height=1, char=" ", style="class:input.pad"),
352
+ self._bottom_container,
353
+ ]
354
+ )
355
+
356
+ self._root = FloatContainer(
357
+ content=self._main_container,
358
+ floats=[],
359
+ )
360
+
361
+ self._layout = Layout(self._root, focused_element=self._input_area.window)
362
+ self._bindings = self._build_key_bindings()
363
+ self._style = PTStyle.from_dict(
364
+ {
365
+ "": "bg:#1f232a #e5e9f0",
366
+ "history": "bg:#1a1e24 #d8dee9",
367
+ "input.pad": "fg:default bg:default",
368
+ "input.prompt": "bg:default #f2f4f8",
369
+ "input.line": "bg:default #f2f4f8",
370
+ "input-line": "bg:default #f2f4f8",
371
+ "status": "bg:#2d3138 #c3ccd8",
372
+ "status.mode.act": "bg:#2d3138 #60a5fa bold",
373
+ "status.mode.plan": "bg:#2d3138 #7AC9CA bold",
374
+ "status.hint": "bg:#2d3138 #6B7280",
375
+ "input.placeholder": "bg:default #9CA3AF",
376
+ "queue": "bg:#1d222a #d8dee9",
377
+ "queue.item": "bg:#1d222a #cbd5e1",
378
+ "git-diff.added": "#4ade80",
379
+ "git-diff.removed": "#f87171",
380
+ "question.tabs": "bg:#30353f #c7d2fe",
381
+ "question.tabs.nav": "bg:#30353f #93c5fd",
382
+ "question.tab": "bg:#374151 #cbd5e1",
383
+ "question.tab.submit": "bg:#14532d #86efac bold",
384
+ "question.tab.active": "bg:#1d4ed8 #eff6ff bold",
385
+ "question.divider": "fg:#4b5563",
386
+ "question.body": "bg:#252a33 #d8dee9",
387
+ "question.title": "fg:#f8fafc bold",
388
+ "question.hint": "fg:#94a3b8",
389
+ "question.option": "fg:#dbeafe",
390
+ "question.option.cursor": "bg:#1e3a8a #f8fafc bold",
391
+ "question.option.selected": "fg:#93c5fd bold",
392
+ "question.option.description": "fg:#9ca3af",
393
+ "question.custom_input": "bg:#0f172a #f8fafc",
394
+ "question.custom_input.border": "fg:#334155",
395
+ "question.preview.title": "fg:#e2e8f0 bold",
396
+ "question.preview.question": "fg:#bfdbfe",
397
+ "question.preview.answer": "fg:#f1f5f9",
398
+ "selection.title": "bg:#2d3138 #c3ccd8 bold",
399
+ "selection.divider": "fg:#4b5563",
400
+ "selection.body": "bg:#252a33 #d8dee9",
401
+ "selection.option": "fg:#dbeafe",
402
+ "selection.option.selected": "bg:#1e3a8a #f8fafc bold",
403
+ "selection.description": "fg:#9ca3af",
404
+ "selection.description.selected": "fg:#93c5fd",
405
+ "selection.hint": "fg:#94a3b8",
406
+ "completion.status": "bg:#2d3441 #d8dee9",
407
+ "completion.status.item": "bg:#2d3441 #d8dee9",
408
+ "completion.status.current": "bg:#5e81ac #eceff4 bold",
409
+ "completion.status.more": "bg:#2d3441 #81a1c1",
410
+ "loading": "fg:default bg:default",
411
+ }
412
+ )
413
+
414
+ self._app = Application(
415
+ layout=self._layout,
416
+ key_bindings=self._bindings,
417
+ style=self._style,
418
+ full_screen=False,
419
+ mouse_support=False,
420
+ )
421
+
422
+ def _should_show_queue_panel(self) -> bool:
423
+ return self._ui_mode == UIMode.NORMAL and len(self._queued_messages) >= 1
424
+
425
+ def _build_slash_registry(self) -> None:
426
+ handlers: dict[str, Callable[[str], Any]] = {
427
+ "help": self._slash_help,
428
+ "model": self._slash_model,
429
+ "session": self._slash_session,
430
+ "usage": self._slash_usage,
431
+ "context": self._slash_context,
432
+ "compact": self._slash_compact,
433
+ "rewind": self._slash_rewind,
434
+ "exit": self._slash_exit,
435
+ }
436
+ allow_when_busy = {"help", "session", "usage", "context", "exit"}
437
+
438
+ for spec in SLASH_COMMAND_SPECS:
439
+ handler = handlers.get(spec.name)
440
+ if handler is None:
441
+ logger.warning(f"missing slash command handler: {spec.name}")
442
+ continue
443
+ self._slash_registry.register(
444
+ spec=spec,
445
+ handler=handler,
446
+ allow_when_busy=spec.name in allow_when_busy,
447
+ )
448
+
449
+ def _input_placeholder_hint(self) -> str | None:
450
+ if self._ui_mode != UIMode.NORMAL:
451
+ return None
452
+ if not self._queued_messages:
453
+ return None
454
+ input_text = str(getattr(getattr(self, "_input_area", None), "text", ""))
455
+ if input_text.strip():
456
+ return None
457
+ if len(self._queued_messages) >= 2:
458
+ return self._queued_input_hint
459
+
460
+ preview = " ".join(self._queued_messages[0].split())
461
+ max_chars = max(8, int(self._queued_preview_max_chars))
462
+ if len(preview) > max_chars:
463
+ preview = f"{preview[: max_chars - 3]}..."
464
+ return f"已排队: {preview} | {self._queued_input_hint}"
465
+
466
+ # 极客风运行完成词语,随机选一个拼成 history 记录
467
+ _RUN_ELAPSED_VERBS: tuple[str, ...] = (
468
+ "Executed",
469
+ "Compiled",
470
+ "Dispatched",
471
+ "Processed",
472
+ "Computed",
473
+ "Resolved",
474
+ "Terminated",
475
+ "Committed",
476
+ "Deployed",
477
+ "Brewed",
478
+ "Flushed",
479
+ "Finalized",
480
+ "Propagated",
481
+ "Synchronized",
482
+ "Yielded",
483
+ "Emitted",
484
+ )
485
+
486
+ @staticmethod
487
+ def _format_run_elapsed(seconds: float) -> str:
488
+ """将秒数格式化为人类可读的时间字符串."""
489
+ if seconds < 10.0:
490
+ return f"{seconds:.1f}s"
491
+ if seconds < 60.0:
492
+ return f"{int(seconds)}s"
493
+ minutes = int(seconds) // 60
494
+ secs = int(seconds) % 60
495
+ return f"{minutes}m {secs}s"
496
+
497
+ def _append_run_elapsed_to_history(self) -> None:
498
+ """将本次 running 耗时以极客风格写入 history scrollback."""
499
+ if self._run_start_time is None:
500
+ return
501
+ elapsed = time.monotonic() - self._run_start_time
502
+ verb = random.choice(self._RUN_ELAPSED_VERBS)
503
+ duration_str = self._format_run_elapsed(elapsed)
504
+ self._renderer.append_elapsed_message(f"{verb} in {duration_str}")
505
+
506
+ def _set_busy(self, value: bool) -> None:
507
+ self._busy = value
508
+ self._render_dirty = True
509
+ self._invalidate()
510
+
511
+ async def _submit_user_message(
512
+ self,
513
+ text: str,
514
+ *,
515
+ display_text: str | None = None,
516
+ ) -> None:
517
+ if self._busy:
518
+ self._renderer.append_system_message(
519
+ "当前已有任务在运行,请稍候。", severity="error"
520
+ )
521
+ return
522
+
523
+ if self._ui_mode == UIMode.QUESTION:
524
+ self._exit_question_mode()
525
+
526
+ self._session.run_controller.clear()
527
+ self._interrupt_requested_at = None
528
+
529
+ self._set_busy(True)
530
+ self._run_start_time = time.monotonic()
531
+ self._waiting_for_input = False
532
+ self._pending_questions = None
533
+ # 本轮默认 loading 文案:避免出现 Working… 这种突兀 fallback
534
+ self._fallback_loading_phrase = (
535
+ random.choice(DEFAULT_STATUS_PHRASES)
536
+ if DEFAULT_STATUS_PHRASES
537
+ else "Thinking…"
538
+ )
539
+ self._fallback_phrase_refresh_at = time.monotonic() + 3.0 # 每 3 秒允许换一次
540
+
541
+ self._renderer.start_turn()
542
+ self._renderer.seed_user_message(
543
+ display_text if display_text is not None else text
544
+ )
545
+ self._refresh_layers()
546
+
547
+ # 启动提交动画
548
+ await self._animation_controller.start()
549
+
550
+ waiting_for_input = False
551
+ questions: list[dict[str, Any]] | None = None
552
+ plan_approval: dict[str, str] | None = None
553
+ stream_completed = False
554
+
555
+ stream_task = asyncio.create_task(
556
+ self._consume_stream(text),
557
+ name="terminal-tui-stream",
558
+ )
559
+ self._stream_task = stream_task
560
+ try:
561
+ waiting_for_input, questions, plan_approval = await stream_task
562
+ stream_completed = True
563
+ except asyncio.CancelledError:
564
+ logger.debug("stream task cancelled")
565
+ except Exception as exc:
566
+ logger.exception("stream failed")
567
+ from comate_cli.terminal_agent.error_display import format_error
568
+
569
+ error_msg, suggestion = format_error(exc)
570
+ self._renderer.append_system_message(error_msg, severity="error")
571
+ if suggestion:
572
+ self._renderer.append_system_message(f"💡 {suggestion}")
573
+
574
+ # 清理 UI 状态
575
+ self._renderer.interrupt_turn()
576
+ await self._animation_controller.shutdown()
577
+ finally:
578
+ if stream_completed:
579
+ self._maybe_capture_checkpoint(
580
+ user_preview=display_text if display_text is not None else text
581
+ )
582
+ self._stream_task = None
583
+ self._interrupt_requested_at = None
584
+ self._append_run_elapsed_to_history()
585
+ self._run_start_time = None
586
+ self._set_busy(False)
587
+ await self._status_bar.refresh()
588
+
589
+ if waiting_for_input:
590
+ self._waiting_for_input = True
591
+ self._pending_questions = questions
592
+ self._pending_plan_approval = None
593
+ if questions:
594
+ self._enter_question_mode(questions)
595
+ else:
596
+ self._renderer.append_system_message(
597
+ "请输入对上述问题的回答后回车提交。"
598
+ )
599
+ elif plan_approval is not None:
600
+ self._waiting_for_input = False
601
+ self._pending_questions = None
602
+ self._pending_plan_approval = plan_approval
603
+ self._exit_question_mode()
604
+ self._open_plan_approval_menu(plan_approval)
605
+ else:
606
+ self._waiting_for_input = False
607
+ self._pending_questions = None
608
+ self._pending_plan_approval = None
609
+ self._exit_question_mode()
610
+
611
+ self._refresh_layers()
612
+
613
+ # 正常结束后自动提交队列消息
614
+ if (
615
+ stream_completed
616
+ and self._queued_messages
617
+ and not waiting_for_input
618
+ and plan_approval is None
619
+ ):
620
+ queued = self._queued_messages.popleft()
621
+ self._schedule_background(self._submit_user_message(queued))
622
+
623
+ async def _consume_stream(
624
+ self,
625
+ text: str,
626
+ ) -> tuple[bool, list[dict[str, Any]] | None, dict[str, str] | None]:
627
+ waiting_for_input = False
628
+ questions: list[dict[str, Any]] | None = None
629
+ plan_approval: dict[str, str] | None = None
630
+
631
+ async for event in self._session.query_stream(text):
632
+ self._maybe_cancel_tool_result_flash(event)
633
+ # 将事件传递给动画控制器以控制动画生命周期
634
+ await self._animation_controller.on_event(event)
635
+ if isinstance(event, PlanApprovalRequiredEvent):
636
+ plan_approval = {
637
+ "plan_path": str(event.plan_path),
638
+ "summary": str(event.summary),
639
+ "execution_prompt": str(event.execution_prompt),
640
+ "context_utilization_pct": event.context_utilization_pct,
641
+ }
642
+ is_waiting, new_questions = self._renderer.handle_event(event)
643
+ self._maybe_flash_tool_result(event)
644
+ if is_waiting:
645
+ waiting_for_input = True
646
+ if new_questions is not None:
647
+ questions = new_questions
648
+ self._refresh_layers()
649
+
650
+ self._renderer.finalize_turn()
651
+ await self._animation_controller.shutdown()
652
+ return waiting_for_input, questions, plan_approval
653
+
654
+ def _open_plan_approval_menu(self, approval: dict[str, str]) -> None:
655
+ plan_path = str(approval.get("plan_path", "")).strip()
656
+ summary = str(approval.get("summary", "")).strip()
657
+ execution_prompt = str(approval.get("execution_prompt", "")).strip()
658
+ context_utilization_pct = int(approval.get("context_utilization_pct", 0))
659
+ clear_label = (
660
+ f"Yes, clear context ({context_utilization_pct}% used) and execute"
661
+ )
662
+
663
+ options = [
664
+ {
665
+ "value": "approve_execute",
666
+ "label": "Approve and execute",
667
+ "description": "Switch to Act Mode and execute immediately.",
668
+ },
669
+ {
670
+ "value": "approve_clear_execute",
671
+ "label": clear_label,
672
+ "description": "Clear conversation history, then switch to Act Mode and execute.",
673
+ },
674
+ {
675
+ "value": "reject_continue",
676
+ "label": "Reject and continue planning",
677
+ "description": "Stay in Plan Mode and continue refining the plan.",
678
+ },
679
+ ]
680
+
681
+ title = "Plan approval required"
682
+ if summary:
683
+ title = f"Plan approval: {summary}"
684
+
685
+ def on_confirm(value: str) -> None:
686
+ if value == "approve_execute":
687
+ self._schedule_background(
688
+ self._approve_plan_and_execute(
689
+ plan_path=plan_path,
690
+ execution_prompt=execution_prompt,
691
+ clear_context=False,
692
+ )
693
+ )
694
+ return
695
+ if value == "approve_clear_execute":
696
+ self._schedule_background(
697
+ self._approve_plan_and_execute(
698
+ plan_path=plan_path,
699
+ execution_prompt=execution_prompt,
700
+ clear_context=True,
701
+ )
702
+ )
703
+ return
704
+ if value == "reject_continue":
705
+ self._reject_plan_and_continue(plan_path=plan_path)
706
+
707
+ def on_cancel() -> None:
708
+ self._reject_plan_and_continue(plan_path=plan_path)
709
+
710
+ ok = self._selection_ui.set_options(
711
+ title=title,
712
+ options=options,
713
+ on_confirm=on_confirm,
714
+ on_cancel=on_cancel,
715
+ )
716
+ if not ok:
717
+ self._renderer.append_system_message(
718
+ "No plan approval options available.",
719
+ severity="error",
720
+ )
721
+ return
722
+ self._selection_ui.refresh()
723
+ self._ui_mode = UIMode.SELECTION
724
+ self._sync_focus_for_mode()
725
+ self._invalidate()
726
+
727
+ async def _approve_plan_and_execute(
728
+ self,
729
+ *,
730
+ plan_path: str,
731
+ execution_prompt: str,
732
+ clear_context: bool = False,
733
+ ) -> None:
734
+ prompt = self._session.approve_plan(clear_context=clear_context)
735
+ if execution_prompt.strip():
736
+ prompt = execution_prompt.strip()
737
+ self._pending_plan_approval = None
738
+ try:
739
+ self._status_bar.set_mode(self._session.get_mode())
740
+ except Exception:
741
+ pass
742
+ self._renderer.append_system_message(f"Plan approved: {plan_path}")
743
+ self._refresh_layers()
744
+ await self._submit_user_message(prompt, display_text="Execute approved plan")
745
+
746
+ def _reject_plan_and_continue(self, *, plan_path: str) -> None:
747
+ self._session.reject_plan()
748
+ self._pending_plan_approval = None
749
+ try:
750
+ self._status_bar.set_mode(self._session.get_mode())
751
+ except Exception:
752
+ pass
753
+ self._renderer.append_system_message(f"Plan rejected: {plan_path}")
754
+ self._refresh_layers()
755
+
756
+ def _maybe_cancel_tool_result_flash(self, event: object) -> None:
757
+ if not self._tool_result_flash_active:
758
+ return
759
+
760
+ from comate_agent_sdk.agent.events import StopEvent, TextEvent
761
+
762
+ if isinstance(event, TextEvent) or isinstance(event, StopEvent):
763
+ self._schedule_background(self._stop_tool_result_flash())
764
+
765
+ def _maybe_flash_tool_result(self, event: object) -> None:
766
+ from comate_agent_sdk.agent.events import ToolResultEvent
767
+
768
+ if not isinstance(event, ToolResultEvent):
769
+ return
770
+ tool_name = str(event.tool or "").strip()
771
+ if tool_name.lower() == "todowrite":
772
+ return
773
+ if self._renderer.has_running_tools():
774
+ return
775
+ self._trigger_tool_result_flash(
776
+ tool_name=tool_name or "Tool",
777
+ is_error=bool(event.is_error),
778
+ )
779
+
780
+ def _trigger_tool_result_flash(self, *, tool_name: str, is_error: bool) -> None:
781
+ del is_error, tool_name
782
+ phrase = (
783
+ random.choice(DEFAULT_STATUS_PHRASES)
784
+ if DEFAULT_STATUS_PHRASES
785
+ else "Vibing..."
786
+ )
787
+ duration_seconds = max(0.15, float(self._tool_result_flash_seconds))
788
+ self._tool_result_flash_gen += 1
789
+ self._tool_result_flash_active = True
790
+ self._tool_result_flash_until_monotonic = time.monotonic() + duration_seconds
791
+ gen = self._tool_result_flash_gen
792
+ self._schedule_background(self._start_tool_result_flash(gen=gen, hint=phrase))
793
+
794
+ async def _start_tool_result_flash(self, *, gen: int, hint: str) -> None:
795
+ if gen != self._tool_result_flash_gen:
796
+ return
797
+ await self._tool_result_animator.start()
798
+ if gen != self._tool_result_flash_gen:
799
+ return
800
+ self._tool_result_animator.set_status_hint(hint)
801
+ self._render_dirty = True
802
+ self._invalidate()
803
+
804
+ async def _stop_tool_result_flash(self) -> None:
805
+ if not self._tool_result_flash_active:
806
+ return
807
+ self._tool_result_flash_gen += 1
808
+ self._tool_result_flash_active = False
809
+ self._tool_result_flash_until_monotonic = None
810
+ self._tool_result_animator.set_status_hint(None)
811
+ await self._tool_result_animator.stop()
812
+ self._render_dirty = True
813
+ self._invalidate()
814
+
815
+ def _schedule_background(self, coroutine: Any) -> None:
816
+ task = asyncio.create_task(coroutine)
817
+
818
+ def _done(done_task: asyncio.Task[Any]) -> None:
819
+ try:
820
+ done_task.result()
821
+ except asyncio.CancelledError:
822
+ return
823
+ except Exception:
824
+ logger.exception("background task failed")
825
+
826
+ task.add_done_callback(_done)
827
+
828
+ def _refresh_layers(self) -> None:
829
+ self._sync_focus_for_mode()
830
+ self._render_dirty = True
831
+
832
+ async def _ui_tick(self) -> None:
833
+ try:
834
+ while not self._closing:
835
+ self._renderer.tick_progress()
836
+ self._loading_frame += 1
837
+ # busy 时每隔几秒换一个短语,让 loading 更“活”
838
+ if self._busy and time.monotonic() >= self._fallback_phrase_refresh_at:
839
+ self._fallback_loading_phrase = (
840
+ random.choice(DEFAULT_STATUS_PHRASES)
841
+ if DEFAULT_STATUS_PHRASES
842
+ else "Thinking…"
843
+ )
844
+ self._fallback_phrase_refresh_at = time.monotonic() + 3.0
845
+ self._render_dirty = True
846
+
847
+ await self._drain_history_async()
848
+
849
+ # 检查动画器是否需要刷新
850
+ if self._animator.consume_dirty():
851
+ self._render_dirty = True
852
+ if self._tool_result_animator.consume_dirty():
853
+ self._render_dirty = True
854
+
855
+ if self._tool_result_flash_active:
856
+ until = self._tool_result_flash_until_monotonic
857
+ if until is None or time.monotonic() >= until:
858
+ self._schedule_background(self._stop_tool_result_flash())
859
+ else:
860
+ self._render_dirty = True
861
+
862
+ loading_line = self._renderer.loading_line().strip()
863
+ loading_changed = loading_line != self._last_loading_line
864
+ if loading_changed:
865
+ self._last_loading_line = loading_line
866
+ self._render_dirty = True
867
+
868
+ if self._renderer.has_running_tools():
869
+ # Keep repainting for breathing animation.
870
+ self._render_dirty = True
871
+ elif self._busy or self._initializing:
872
+ self._render_dirty = True
873
+
874
+ if self._render_dirty:
875
+ self._invalidate()
876
+ self._render_dirty = False
877
+
878
+ # 动态帧率:busy/动画/initializing 时降为 6fps(缓解 scrollback 污染),idle 时 4fps
879
+ fast = (
880
+ self._busy
881
+ or self._initializing
882
+ or self._animator.is_active
883
+ or self._renderer.has_running_tools()
884
+ )
885
+ sleep_s = 1 / 6 if fast else 1 / 4
886
+ await asyncio.sleep(sleep_s)
887
+ except asyncio.CancelledError:
888
+ return
889
+
890
+ def _invalidate(self) -> None:
891
+ if self._app is None:
892
+ return
893
+ self._app.invalidate()
894
+
895
+ def _maybe_capture_checkpoint(self, *, user_preview: str) -> None:
896
+ try:
897
+ checkpoint = self._rewind_store.capture_checkpoint_for_latest_turn(
898
+ user_preview=user_preview,
899
+ )
900
+ if checkpoint is not None:
901
+ logger.info(
902
+ "checkpoint captured: session=%s id=%s turn=%s",
903
+ self._session.session_id,
904
+ checkpoint.checkpoint_id,
905
+ checkpoint.turn_number,
906
+ )
907
+ except Exception as exc:
908
+ logger.warning(f"failed to capture checkpoint: {exc}", exc_info=True)
909
+
910
+ async def _replace_session(
911
+ self,
912
+ new_session: ChatSession,
913
+ *,
914
+ close_old: bool = True,
915
+ replay_history: bool = True,
916
+ ) -> None:
917
+ old_session = self._session
918
+ self._session = new_session
919
+ self._status_bar.set_session(new_session)
920
+ self._rewind_store.bind_session(new_session)
921
+ await self._status_bar.refresh()
922
+ self._renderer.reset_history_view()
923
+ self._printed_history_index = 0
924
+ if replay_history:
925
+ self.add_resume_history("resume")
926
+ if close_old and old_session is not new_session:
927
+ await old_session.close()
928
+
929
+ @property
930
+ def session(self) -> ChatSession:
931
+ return self._session
932
+
933
+ def _request_exit(self) -> None:
934
+ if self._is_compacting and self._compact_task is not None:
935
+ self._pending_exit_after_compact_cancel = True
936
+ if not self._compact_cancel_requested:
937
+ self._compact_cancel_requested = True
938
+ self._renderer.append_system_message(
939
+ "正在取消 /compact,请等待回滚完成后退出。",
940
+ severity="warning",
941
+ )
942
+ self._compact_task.cancel()
943
+ else:
944
+ self._renderer.append_system_message(
945
+ "已请求取消 /compact,等待回滚完成。",
946
+ severity="warning",
947
+ )
948
+ self._refresh_layers()
949
+ return
950
+ self._exit_app()
951
+
952
+ def _exit_app(self) -> None:
953
+ self._closing = True
954
+ self._session.run_controller.clear()
955
+ if self._stream_task is not None:
956
+ self._stream_task.cancel()
957
+ if self._app is not None:
958
+ self._app.exit(result=None)
959
+
960
+ async def run(
961
+ self,
962
+ *,
963
+ mcp_init: Callable[[], Any] | None = None,
964
+ ) -> None:
965
+ if self._app is None:
966
+ return
967
+
968
+ self._refresh_layers()
969
+
970
+ if mcp_init is not None:
971
+ self._initializing = True
972
+
973
+ async def _do_init() -> None:
974
+ try:
975
+ await mcp_init()
976
+ except asyncio.CancelledError:
977
+ return
978
+ finally:
979
+ self._initializing = False
980
+ if self._queued_messages and not self._busy:
981
+ queued = self._queued_messages.popleft()
982
+ self._schedule_background(self._submit_user_message(queued))
983
+ self._refresh_layers()
984
+
985
+ self._mcp_init_task = asyncio.create_task(_do_init(), name="terminal-mcp-init")
986
+
987
+ self._ui_tick_task = asyncio.create_task(
988
+ self._ui_tick(),
989
+ name="terminal-ui-tick",
990
+ )
991
+ try:
992
+ # Ensure scrollback prints don't mix with the inline (full_screen=False) UI.
993
+ with patch_stdout(raw=True):
994
+ await self._app.run_async()
995
+ finally:
996
+ self._closing = True
997
+ if self._mcp_init_task is not None:
998
+ self._mcp_init_task.cancel()
999
+ with suppress(asyncio.CancelledError):
1000
+ await self._mcp_init_task
1001
+ self._mcp_init_task = None
1002
+ if self._ui_tick_task is not None:
1003
+ self._ui_tick_task.cancel()
1004
+ with suppress(asyncio.CancelledError):
1005
+ await self._ui_tick_task
1006
+ self._renderer.close()