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
|
@@ -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()
|