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,759 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.document import Document
|
|
9
|
+
|
|
10
|
+
from comate_agent_sdk.agent.llm_levels import ALL_LEVELS
|
|
11
|
+
from comate_agent_sdk.context.items import ItemType
|
|
12
|
+
from comate_agent_sdk.llm.messages import UserMessage
|
|
13
|
+
|
|
14
|
+
from comate_cli.terminal_agent.rewind_store import RewindCheckpoint, RewindRestorePlan, RewindStore
|
|
15
|
+
from comate_cli.terminal_agent.selection_menu import SelectionResult, build_model_level_options
|
|
16
|
+
from comate_cli.terminal_agent.slash_commands import parse_slash_command_call
|
|
17
|
+
from comate_cli.terminal_agent.tui_parts.ui_mode import UIMode
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandsMixin:
|
|
23
|
+
def _cycle_agent_mode(self) -> None:
|
|
24
|
+
if self._busy:
|
|
25
|
+
self._renderer.append_system_message(
|
|
26
|
+
"当前任务运行中,请在本轮结束后再切换模式。",
|
|
27
|
+
)
|
|
28
|
+
self._refresh_layers()
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
mode = self._session.cycle_mode()
|
|
32
|
+
self._status_bar.set_mode(mode)
|
|
33
|
+
self._refresh_layers()
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
logger.exception("Failed to cycle mode")
|
|
36
|
+
self._renderer.append_system_message(
|
|
37
|
+
f"Failed to switch mode: {exc}",
|
|
38
|
+
severity="error",
|
|
39
|
+
)
|
|
40
|
+
self._refresh_layers()
|
|
41
|
+
|
|
42
|
+
async def _execute_command(self, command: str) -> None:
|
|
43
|
+
parsed = parse_slash_command_call(command)
|
|
44
|
+
normalized = command.strip()
|
|
45
|
+
if parsed is None:
|
|
46
|
+
self._renderer.append_system_message(
|
|
47
|
+
f"Unknown command: {normalized}",
|
|
48
|
+
severity="error",
|
|
49
|
+
)
|
|
50
|
+
self._refresh_layers()
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
entry = self._slash_registry.resolve(parsed.name)
|
|
54
|
+
if entry is None:
|
|
55
|
+
self._renderer.append_system_message(
|
|
56
|
+
f"Unknown command: {normalized}",
|
|
57
|
+
severity="error",
|
|
58
|
+
)
|
|
59
|
+
self._refresh_layers()
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
is_busy = self._busy or self._initializing
|
|
63
|
+
if is_busy and not entry.allow_when_busy:
|
|
64
|
+
self._renderer.append_system_message(
|
|
65
|
+
f"当前任务运行中,暂不可执行 /{entry.spec.name}。",
|
|
66
|
+
severity="error",
|
|
67
|
+
)
|
|
68
|
+
self._refresh_layers()
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
handler = entry.handler
|
|
72
|
+
result = handler(parsed.args)
|
|
73
|
+
if asyncio.iscoroutine(result):
|
|
74
|
+
await result
|
|
75
|
+
self._refresh_layers()
|
|
76
|
+
|
|
77
|
+
def _slash_help(self, _args: str) -> None:
|
|
78
|
+
lines = []
|
|
79
|
+
for spec in self._slash_registry.command_specs():
|
|
80
|
+
alias_text = ""
|
|
81
|
+
if spec.aliases:
|
|
82
|
+
alias_text = f" ({', '.join(f'/{alias}' for alias in spec.aliases)})"
|
|
83
|
+
lines.append(f"/{spec.name}{alias_text} - {spec.description}")
|
|
84
|
+
self._renderer.append_system_message("\n".join(lines))
|
|
85
|
+
|
|
86
|
+
def _slash_session(self, _args: str) -> None:
|
|
87
|
+
self._renderer.append_system_message(f"Session ID: {self._session.session_id}")
|
|
88
|
+
|
|
89
|
+
async def _slash_usage(self, _args: str) -> None:
|
|
90
|
+
await self._append_usage_snapshot()
|
|
91
|
+
|
|
92
|
+
async def _slash_context(self, args: str) -> None:
|
|
93
|
+
normalized = args.strip()
|
|
94
|
+
show_details = False
|
|
95
|
+
if normalized:
|
|
96
|
+
if normalized in {"--details", "-d"}:
|
|
97
|
+
show_details = True
|
|
98
|
+
else:
|
|
99
|
+
self._renderer.append_system_message(
|
|
100
|
+
"Usage: /context [--details]",
|
|
101
|
+
severity="error",
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
await self._append_context_snapshot(show_details=show_details)
|
|
105
|
+
|
|
106
|
+
async def _slash_compact(self, args: str) -> None:
|
|
107
|
+
if args.strip():
|
|
108
|
+
self._renderer.append_system_message(
|
|
109
|
+
"Usage: /compact",
|
|
110
|
+
severity="error",
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
if self._is_compacting:
|
|
114
|
+
self._renderer.append_system_message(
|
|
115
|
+
"当前正在执行 /compact,请稍候。",
|
|
116
|
+
severity="warning",
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
await self._run_manual_compaction()
|
|
120
|
+
|
|
121
|
+
async def _run_manual_compaction(self) -> None:
|
|
122
|
+
exit_after_cancel = False
|
|
123
|
+
self._set_busy(True)
|
|
124
|
+
self._is_compacting = True
|
|
125
|
+
self._compact_cancel_requested = False
|
|
126
|
+
self._compact_task = asyncio.current_task()
|
|
127
|
+
self._renderer.append_system_message("/compact")
|
|
128
|
+
self._refresh_layers()
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
result = await self._session.compact_context_manual()
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.exception("manual compaction failed")
|
|
134
|
+
self._renderer.append_system_message(
|
|
135
|
+
f"/compact 执行失败: {exc}",
|
|
136
|
+
severity="error",
|
|
137
|
+
)
|
|
138
|
+
return
|
|
139
|
+
finally:
|
|
140
|
+
self._compact_task = None
|
|
141
|
+
self._is_compacting = False
|
|
142
|
+
self._compact_cancel_requested = False
|
|
143
|
+
self._set_busy(False)
|
|
144
|
+
try:
|
|
145
|
+
await self._status_bar.refresh()
|
|
146
|
+
except Exception:
|
|
147
|
+
logger.exception("Failed to refresh status bar after /compact")
|
|
148
|
+
self._refresh_layers()
|
|
149
|
+
|
|
150
|
+
if self._pending_exit_after_compact_cancel:
|
|
151
|
+
self._pending_exit_after_compact_cancel = False
|
|
152
|
+
exit_after_cancel = True
|
|
153
|
+
self._exit_app()
|
|
154
|
+
|
|
155
|
+
if exit_after_cancel:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if result.cancelled:
|
|
159
|
+
self._renderer.append_system_message(
|
|
160
|
+
"手动压缩已取消,改动已回滚。",
|
|
161
|
+
severity="warning",
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if result.compacted:
|
|
166
|
+
self._renderer.append_system_message(
|
|
167
|
+
f"/compact 完成: {result.tokens_before:,} → {result.tokens_after:,} tokens",
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
if not result.attempted:
|
|
172
|
+
self._renderer.append_system_message(
|
|
173
|
+
f"/compact 未执行: {result.reason}",
|
|
174
|
+
severity="warning",
|
|
175
|
+
)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
self._renderer.append_system_message(
|
|
179
|
+
f"/compact 未生效: {result.reason}",
|
|
180
|
+
severity="warning",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def _slash_rewind(self, args: str) -> None:
|
|
184
|
+
if args.strip():
|
|
185
|
+
self._renderer.append_system_message(
|
|
186
|
+
"Usage: /rewind",
|
|
187
|
+
severity="error",
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
if self._busy:
|
|
191
|
+
self._renderer.append_system_message(
|
|
192
|
+
"当前已有任务在运行,请稍后再执行 /rewind。",
|
|
193
|
+
severity="error",
|
|
194
|
+
)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
checkpoints = self._rewind_store.list_checkpoints()
|
|
198
|
+
if not checkpoints:
|
|
199
|
+
self._renderer.append_system_message(
|
|
200
|
+
"No checkpoints yet. 先发起至少一轮用户消息再执行 /rewind。"
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
self._show_rewind_checkpoint_menu(checkpoints)
|
|
204
|
+
|
|
205
|
+
def _slash_exit(self, _args: str) -> None:
|
|
206
|
+
self._request_exit()
|
|
207
|
+
|
|
208
|
+
def _slash_model(self, args: str) -> None:
|
|
209
|
+
"""Handle /model command - switch model level."""
|
|
210
|
+
# If args provided, try to use it directly
|
|
211
|
+
if args.strip():
|
|
212
|
+
level = args.strip().upper()
|
|
213
|
+
if level in ALL_LEVELS:
|
|
214
|
+
self._switch_model_level(level)
|
|
215
|
+
return
|
|
216
|
+
# Invalid level, show error and open menu
|
|
217
|
+
self._renderer.append_system_message(
|
|
218
|
+
f"Invalid model level: {args.strip()}. Use LOW, MID, or HIGH.",
|
|
219
|
+
severity="error",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Open selection menu
|
|
223
|
+
self._enter_selection_mode()
|
|
224
|
+
|
|
225
|
+
def _switch_model_level(self, level: str) -> None:
|
|
226
|
+
"""Switch to the specified model level."""
|
|
227
|
+
try:
|
|
228
|
+
llm_level = level # type: ignore[assignment]
|
|
229
|
+
event = self._session.set_level(llm_level)
|
|
230
|
+
|
|
231
|
+
# Get model names for display
|
|
232
|
+
prev_model = event.previous_model or "unknown"
|
|
233
|
+
new_model = event.new_model or "unknown"
|
|
234
|
+
|
|
235
|
+
self._renderer.append_system_message(
|
|
236
|
+
f"Model switched: {event.previous_level} → {event.new_level}\n"
|
|
237
|
+
f" ({prev_model} → {new_model})"
|
|
238
|
+
)
|
|
239
|
+
logger.info(f"Model level switched: {event}")
|
|
240
|
+
|
|
241
|
+
# Update status bar model name - 使用 event 中的新模型名
|
|
242
|
+
self._status_bar.set_model_name(new_model)
|
|
243
|
+
self._invalidate()
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.exception("Failed to switch model level")
|
|
246
|
+
self._renderer.append_system_message(
|
|
247
|
+
f"Failed to switch model: {e}",
|
|
248
|
+
severity="error",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _update_status_bar_model(self) -> None:
|
|
252
|
+
"""Update status bar with current model name from session."""
|
|
253
|
+
try:
|
|
254
|
+
agent = getattr(self._session, "_agent", None)
|
|
255
|
+
llm = getattr(agent, "llm", None)
|
|
256
|
+
model = getattr(llm, "model", "")
|
|
257
|
+
if model:
|
|
258
|
+
self._status_bar.set_model_name(str(model))
|
|
259
|
+
self._invalidate()
|
|
260
|
+
except Exception:
|
|
261
|
+
logger.exception("Failed to update status bar model name")
|
|
262
|
+
|
|
263
|
+
def _enter_selection_mode(self) -> None:
|
|
264
|
+
"""Enter model selection menu mode."""
|
|
265
|
+
# Get current level and llm_levels
|
|
266
|
+
# Default to MID if level is not set
|
|
267
|
+
current_level = "MID"
|
|
268
|
+
llm_levels = None
|
|
269
|
+
try:
|
|
270
|
+
agent_level = self._session._agent.level
|
|
271
|
+
if agent_level:
|
|
272
|
+
current_level = agent_level
|
|
273
|
+
llm_levels = self._session._agent.options.llm_levels
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
# Setup selection menu
|
|
278
|
+
def on_confirm(value: str) -> None:
|
|
279
|
+
self._exit_selection_mode()
|
|
280
|
+
self._switch_model_level(value)
|
|
281
|
+
self._refresh_layers()
|
|
282
|
+
|
|
283
|
+
def on_cancel() -> None:
|
|
284
|
+
self._exit_selection_mode()
|
|
285
|
+
self._renderer.append_system_message("Model switch cancelled.")
|
|
286
|
+
self._refresh_layers()
|
|
287
|
+
|
|
288
|
+
title, options = build_model_level_options(
|
|
289
|
+
current_level=current_level,
|
|
290
|
+
llm_levels=llm_levels,
|
|
291
|
+
)
|
|
292
|
+
ok = self._selection_ui.set_options(
|
|
293
|
+
title=title,
|
|
294
|
+
options=options,
|
|
295
|
+
on_confirm=on_confirm,
|
|
296
|
+
on_cancel=on_cancel,
|
|
297
|
+
)
|
|
298
|
+
if not ok:
|
|
299
|
+
self._renderer.append_system_message(
|
|
300
|
+
"No model options available.",
|
|
301
|
+
severity="error",
|
|
302
|
+
)
|
|
303
|
+
self._refresh_layers()
|
|
304
|
+
return
|
|
305
|
+
self._selection_ui.refresh()
|
|
306
|
+
|
|
307
|
+
# Enter selection mode
|
|
308
|
+
self._ui_mode = UIMode.SELECTION
|
|
309
|
+
self._sync_focus_for_mode()
|
|
310
|
+
self._invalidate()
|
|
311
|
+
|
|
312
|
+
def _show_rewind_checkpoint_menu(self, checkpoints: list[RewindCheckpoint]) -> None:
|
|
313
|
+
options: list[dict[str, str]] = []
|
|
314
|
+
for cp in checkpoints:
|
|
315
|
+
preview = cp.user_preview or "(empty)"
|
|
316
|
+
label = f"#{cp.checkpoint_id} turn={cp.turn_number}: {preview}"
|
|
317
|
+
desc = cp.created_at
|
|
318
|
+
options.append(
|
|
319
|
+
{
|
|
320
|
+
"value": str(cp.checkpoint_id),
|
|
321
|
+
"label": label,
|
|
322
|
+
"description": desc,
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def on_confirm(value: str) -> None:
|
|
327
|
+
checkpoint = next(
|
|
328
|
+
(cp for cp in checkpoints if str(cp.checkpoint_id) == value),
|
|
329
|
+
None,
|
|
330
|
+
)
|
|
331
|
+
if checkpoint is None:
|
|
332
|
+
self._renderer.append_system_message(
|
|
333
|
+
f"Checkpoint not found: {value}",
|
|
334
|
+
severity="error",
|
|
335
|
+
)
|
|
336
|
+
return
|
|
337
|
+
self._schedule_background(self._open_rewind_mode_menu_async(checkpoint))
|
|
338
|
+
|
|
339
|
+
def on_cancel() -> None:
|
|
340
|
+
self._renderer.append_system_message("Rewind cancelled.")
|
|
341
|
+
|
|
342
|
+
ok = self._selection_ui.set_options(
|
|
343
|
+
title="Select checkpoint",
|
|
344
|
+
options=options,
|
|
345
|
+
on_confirm=on_confirm,
|
|
346
|
+
on_cancel=on_cancel,
|
|
347
|
+
)
|
|
348
|
+
if not ok:
|
|
349
|
+
self._renderer.append_system_message(
|
|
350
|
+
"No checkpoint options available.",
|
|
351
|
+
severity="error",
|
|
352
|
+
)
|
|
353
|
+
return
|
|
354
|
+
self._selection_ui.refresh()
|
|
355
|
+
self._ui_mode = UIMode.SELECTION
|
|
356
|
+
self._sync_focus_for_mode()
|
|
357
|
+
self._invalidate()
|
|
358
|
+
|
|
359
|
+
async def _open_rewind_mode_menu_async(self, checkpoint: RewindCheckpoint) -> None:
|
|
360
|
+
await asyncio.sleep(0)
|
|
361
|
+
try:
|
|
362
|
+
plan = self._rewind_store.build_restore_plan(
|
|
363
|
+
checkpoint_id=checkpoint.checkpoint_id,
|
|
364
|
+
)
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
self._renderer.append_system_message(
|
|
367
|
+
f"Failed to prepare rewind preview: {exc}",
|
|
368
|
+
severity="error",
|
|
369
|
+
)
|
|
370
|
+
self._refresh_layers()
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
summary = self._format_restore_plan_summary(plan)
|
|
374
|
+
mode_options = [
|
|
375
|
+
{
|
|
376
|
+
"value": "restore_both",
|
|
377
|
+
"label": "Restore code and conversation",
|
|
378
|
+
"description": (
|
|
379
|
+
"The conversation will be forked. "
|
|
380
|
+
f"The code will be restored {summary}."
|
|
381
|
+
),
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"value": "restore_conversation",
|
|
385
|
+
"label": "Restore conversation",
|
|
386
|
+
"description": (
|
|
387
|
+
"The conversation will be forked. "
|
|
388
|
+
"The code will be unchanged."
|
|
389
|
+
),
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
"value": "restore_code",
|
|
393
|
+
"label": "Restore code",
|
|
394
|
+
"description": (
|
|
395
|
+
"The conversation will be unchanged. "
|
|
396
|
+
f"The code will be restored {summary}."
|
|
397
|
+
),
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
"value": "never_mind",
|
|
401
|
+
"label": "Never mind",
|
|
402
|
+
"description": "The conversation and code will be unchanged.",
|
|
403
|
+
},
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
def on_confirm(mode_value: str) -> None:
|
|
407
|
+
self._schedule_background(
|
|
408
|
+
self._execute_rewind_mode(
|
|
409
|
+
checkpoint=checkpoint,
|
|
410
|
+
mode=mode_value,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def on_cancel() -> None:
|
|
415
|
+
self._renderer.append_system_message("Rewind cancelled.")
|
|
416
|
+
|
|
417
|
+
ok = self._selection_ui.set_options(
|
|
418
|
+
title=f"Checkpoint #{checkpoint.checkpoint_id} (turn={checkpoint.turn_number})",
|
|
419
|
+
options=mode_options,
|
|
420
|
+
on_confirm=on_confirm,
|
|
421
|
+
on_cancel=on_cancel,
|
|
422
|
+
)
|
|
423
|
+
if not ok:
|
|
424
|
+
self._renderer.append_system_message(
|
|
425
|
+
"No rewind mode options available.",
|
|
426
|
+
severity="error",
|
|
427
|
+
)
|
|
428
|
+
self._refresh_layers()
|
|
429
|
+
return
|
|
430
|
+
self._selection_ui.refresh()
|
|
431
|
+
self._ui_mode = UIMode.SELECTION
|
|
432
|
+
self._sync_focus_for_mode()
|
|
433
|
+
self._invalidate()
|
|
434
|
+
|
|
435
|
+
async def _execute_rewind_mode(
|
|
436
|
+
self,
|
|
437
|
+
*,
|
|
438
|
+
checkpoint: RewindCheckpoint,
|
|
439
|
+
mode: str,
|
|
440
|
+
) -> None:
|
|
441
|
+
prefill_text: str | None = None
|
|
442
|
+
rewind_succeeded = False
|
|
443
|
+
if mode == "never_mind":
|
|
444
|
+
self._renderer.append_system_message("Rewind cancelled.")
|
|
445
|
+
self._refresh_layers()
|
|
446
|
+
return
|
|
447
|
+
if self._busy:
|
|
448
|
+
self._renderer.append_system_message(
|
|
449
|
+
"当前已有任务在运行,请稍后再执行 /rewind。",
|
|
450
|
+
severity="error",
|
|
451
|
+
)
|
|
452
|
+
self._refresh_layers()
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
self._set_busy(True)
|
|
456
|
+
try:
|
|
457
|
+
if mode == "restore_code":
|
|
458
|
+
self._rewind_store.restore_code_before_checkpoint(
|
|
459
|
+
checkpoint_id=checkpoint.checkpoint_id
|
|
460
|
+
)
|
|
461
|
+
self._rewind_store.prune_after_checkpoint(
|
|
462
|
+
checkpoint_id=checkpoint.checkpoint_id
|
|
463
|
+
)
|
|
464
|
+
prefill_text = self._resolve_checkpoint_user_text(
|
|
465
|
+
checkpoint=checkpoint,
|
|
466
|
+
fallback=checkpoint.user_message,
|
|
467
|
+
)
|
|
468
|
+
await self._status_bar.refresh()
|
|
469
|
+
rewind_succeeded = True
|
|
470
|
+
|
|
471
|
+
elif mode == "restore_conversation":
|
|
472
|
+
new_session = self._session.fork_session()
|
|
473
|
+
fork_store = RewindStore(
|
|
474
|
+
session=new_session,
|
|
475
|
+
project_root=Path.cwd(),
|
|
476
|
+
)
|
|
477
|
+
try:
|
|
478
|
+
rewind_turn = self._rewind_turn_before_checkpoint(checkpoint.turn_number)
|
|
479
|
+
new_session.restore_conversation_to_turn(
|
|
480
|
+
target_turn=rewind_turn
|
|
481
|
+
)
|
|
482
|
+
fork_store.prune_after_checkpoint(
|
|
483
|
+
checkpoint_id=checkpoint.checkpoint_id
|
|
484
|
+
)
|
|
485
|
+
await self._replace_session(
|
|
486
|
+
new_session,
|
|
487
|
+
close_old=True,
|
|
488
|
+
replay_history=False,
|
|
489
|
+
)
|
|
490
|
+
except Exception:
|
|
491
|
+
await new_session.close()
|
|
492
|
+
raise
|
|
493
|
+
|
|
494
|
+
prefill_text = self._resolve_checkpoint_user_text(
|
|
495
|
+
checkpoint=checkpoint,
|
|
496
|
+
fallback=checkpoint.user_message,
|
|
497
|
+
)
|
|
498
|
+
rewind_succeeded = True
|
|
499
|
+
|
|
500
|
+
elif mode == "restore_both":
|
|
501
|
+
new_session = self._session.fork_session()
|
|
502
|
+
fork_store = RewindStore(
|
|
503
|
+
session=new_session,
|
|
504
|
+
project_root=Path.cwd(),
|
|
505
|
+
)
|
|
506
|
+
try:
|
|
507
|
+
rewind_turn = self._rewind_turn_before_checkpoint(checkpoint.turn_number)
|
|
508
|
+
new_session.restore_conversation_to_turn(
|
|
509
|
+
target_turn=rewind_turn
|
|
510
|
+
)
|
|
511
|
+
fork_store.restore_code_before_checkpoint(
|
|
512
|
+
checkpoint_id=checkpoint.checkpoint_id
|
|
513
|
+
)
|
|
514
|
+
fork_store.prune_after_checkpoint(
|
|
515
|
+
checkpoint_id=checkpoint.checkpoint_id
|
|
516
|
+
)
|
|
517
|
+
await self._replace_session(
|
|
518
|
+
new_session,
|
|
519
|
+
close_old=True,
|
|
520
|
+
replay_history=False,
|
|
521
|
+
)
|
|
522
|
+
except Exception:
|
|
523
|
+
await new_session.close()
|
|
524
|
+
raise
|
|
525
|
+
prefill_text = self._resolve_checkpoint_user_text(
|
|
526
|
+
checkpoint=checkpoint,
|
|
527
|
+
fallback=checkpoint.user_message,
|
|
528
|
+
)
|
|
529
|
+
rewind_succeeded = True
|
|
530
|
+
|
|
531
|
+
else:
|
|
532
|
+
self._renderer.append_system_message(
|
|
533
|
+
f"Unknown rewind mode: {mode}",
|
|
534
|
+
severity="error",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
if rewind_succeeded:
|
|
538
|
+
await self._replay_scrollback_after_rewind()
|
|
539
|
+
except Exception as exc:
|
|
540
|
+
logger.exception("rewind failed")
|
|
541
|
+
self._renderer.append_system_message(
|
|
542
|
+
f"Rewind failed: {exc}",
|
|
543
|
+
severity="error",
|
|
544
|
+
)
|
|
545
|
+
finally:
|
|
546
|
+
self._set_busy(False)
|
|
547
|
+
if prefill_text:
|
|
548
|
+
self._prefill_user_input(prefill_text)
|
|
549
|
+
self._refresh_layers()
|
|
550
|
+
|
|
551
|
+
def _resolve_checkpoint_user_text(
|
|
552
|
+
self,
|
|
553
|
+
*,
|
|
554
|
+
checkpoint: RewindCheckpoint,
|
|
555
|
+
fallback: str,
|
|
556
|
+
) -> str:
|
|
557
|
+
items = getattr(self._session._agent._context.conversation, "items", [])
|
|
558
|
+
for item in reversed(items):
|
|
559
|
+
if item.item_type != ItemType.USER_MESSAGE:
|
|
560
|
+
continue
|
|
561
|
+
if int(getattr(item, "created_turn", 0) or 0) != checkpoint.turn_number:
|
|
562
|
+
continue
|
|
563
|
+
message = getattr(item, "message", None)
|
|
564
|
+
if isinstance(message, UserMessage) and not bool(getattr(message, "is_meta", False)):
|
|
565
|
+
text = str(message.text or "").strip()
|
|
566
|
+
if text:
|
|
567
|
+
return text
|
|
568
|
+
content_text = str(getattr(item, "content_text", "")).strip()
|
|
569
|
+
if content_text:
|
|
570
|
+
return content_text
|
|
571
|
+
return fallback.strip()
|
|
572
|
+
|
|
573
|
+
def _prefill_user_input(self, text: str) -> None:
|
|
574
|
+
normalized = str(text).strip()
|
|
575
|
+
if not normalized:
|
|
576
|
+
return
|
|
577
|
+
self._clear_paste_state()
|
|
578
|
+
self._last_input_len = len(normalized)
|
|
579
|
+
self._last_input_text = normalized
|
|
580
|
+
buffer = self._input_area.buffer
|
|
581
|
+
buffer.cancel_completion()
|
|
582
|
+
self._suppress_input_change_hook = True
|
|
583
|
+
try:
|
|
584
|
+
buffer.set_document(
|
|
585
|
+
Document(normalized, cursor_position=len(normalized)),
|
|
586
|
+
bypass_readonly=True,
|
|
587
|
+
)
|
|
588
|
+
finally:
|
|
589
|
+
self._suppress_input_change_hook = False
|
|
590
|
+
|
|
591
|
+
@staticmethod
|
|
592
|
+
def _rewind_turn_before_checkpoint(turn_number: int) -> int:
|
|
593
|
+
return max(0, int(turn_number) - 1)
|
|
594
|
+
|
|
595
|
+
@staticmethod
|
|
596
|
+
def _format_restore_plan_summary(plan: RewindRestorePlan) -> str:
|
|
597
|
+
return (
|
|
598
|
+
f"+{plan.total_added_lines} -{plan.total_removed_lines} "
|
|
599
|
+
f"in {plan.writable_files_count} file(s)"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def _render_rewind_done_message(
|
|
603
|
+
self,
|
|
604
|
+
*,
|
|
605
|
+
mode: str,
|
|
606
|
+
checkpoint: RewindCheckpoint,
|
|
607
|
+
plan: RewindRestorePlan,
|
|
608
|
+
new_session_id: str | None,
|
|
609
|
+
dropped_checkpoints: int,
|
|
610
|
+
) -> str:
|
|
611
|
+
lines = [
|
|
612
|
+
f"Rewind done: checkpoint #{checkpoint.checkpoint_id} (turn={checkpoint.turn_number})",
|
|
613
|
+
]
|
|
614
|
+
if mode == "restore_both":
|
|
615
|
+
lines.append(f"- conversation: forked to session {new_session_id}")
|
|
616
|
+
lines.append(f"- code: restored {self._format_restore_plan_summary(plan)}")
|
|
617
|
+
elif mode == "restore_conversation":
|
|
618
|
+
lines.append(f"- conversation: forked to session {new_session_id}")
|
|
619
|
+
lines.append("- code: unchanged")
|
|
620
|
+
elif mode == "restore_code":
|
|
621
|
+
lines.append("- conversation: unchanged")
|
|
622
|
+
lines.append(f"- code: restored {self._format_restore_plan_summary(plan)}")
|
|
623
|
+
|
|
624
|
+
if plan.skipped_binary_count > 0:
|
|
625
|
+
lines.append(f"- skipped(binary): {plan.skipped_binary_count}")
|
|
626
|
+
if plan.skipped_unknown_count > 0:
|
|
627
|
+
lines.append(f"- skipped(unknown): {plan.skipped_unknown_count}")
|
|
628
|
+
lines.append(f"- dropped_checkpoints_after_target: {dropped_checkpoints}")
|
|
629
|
+
return "\n".join(lines)
|
|
630
|
+
|
|
631
|
+
def _exit_selection_mode(self) -> None:
|
|
632
|
+
"""Exit selection menu mode."""
|
|
633
|
+
self._ui_mode = UIMode.NORMAL
|
|
634
|
+
self._selection_ui.clear()
|
|
635
|
+
self._sync_focus_for_mode()
|
|
636
|
+
self._invalidate()
|
|
637
|
+
|
|
638
|
+
def _handle_selection_result(self, result: SelectionResult | None) -> None:
|
|
639
|
+
"""Handle selection result."""
|
|
640
|
+
if result is None:
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
if not result.confirmed:
|
|
644
|
+
self._exit_selection_mode()
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
# The callback handles the actual switch
|
|
648
|
+
self._exit_selection_mode()
|
|
649
|
+
|
|
650
|
+
async def _append_usage_snapshot(self) -> None:
|
|
651
|
+
usage = await self._session.get_usage()
|
|
652
|
+
include_cost = bool(getattr(self._session._agent, "include_cost", False))
|
|
653
|
+
prompt_new_tokens = max(
|
|
654
|
+
usage.total_prompt_tokens - usage.total_prompt_cached_tokens,
|
|
655
|
+
0,
|
|
656
|
+
)
|
|
657
|
+
total_tokens = prompt_new_tokens + usage.total_completion_tokens
|
|
658
|
+
|
|
659
|
+
lines = [
|
|
660
|
+
"Token Usage",
|
|
661
|
+
f"- total: {total_tokens:,}",
|
|
662
|
+
f"- entries: {usage.entry_count}",
|
|
663
|
+
f"- prompt: {usage.total_prompt_tokens:,}",
|
|
664
|
+
f"- prompt_cached: {usage.total_prompt_cached_tokens:,}",
|
|
665
|
+
f"- prompt_new: {prompt_new_tokens:,}",
|
|
666
|
+
f"- completion: {usage.total_completion_tokens:,}",
|
|
667
|
+
]
|
|
668
|
+
|
|
669
|
+
if include_cost:
|
|
670
|
+
lines.extend(
|
|
671
|
+
[
|
|
672
|
+
f"- prompt_cost: ${usage.total_prompt_cost:.4f}",
|
|
673
|
+
f"- completion_cost: ${usage.total_completion_cost:.4f}",
|
|
674
|
+
f"- total_cost: ${usage.total_cost:.4f}",
|
|
675
|
+
]
|
|
676
|
+
)
|
|
677
|
+
self._renderer.append_system_message("\n".join(lines))
|
|
678
|
+
|
|
679
|
+
async def _append_context_snapshot(self, *, show_details: bool = False) -> None:
|
|
680
|
+
info = await self._session.get_context_info()
|
|
681
|
+
context_limit = int(getattr(info, "context_limit", 0) or 0)
|
|
682
|
+
next_step_estimated_tokens = int(getattr(info, "next_step_estimated_tokens", 0) or 0)
|
|
683
|
+
last_step_reported_tokens = int(getattr(info, "last_step_reported_tokens", 0) or 0)
|
|
684
|
+
ir_used_tokens = int(getattr(info, "used_tokens", 0) or 0)
|
|
685
|
+
|
|
686
|
+
headroom_left_percent = 0.0
|
|
687
|
+
next_step_used_percent = 0.0
|
|
688
|
+
actual_used_percent = 0.0
|
|
689
|
+
if context_limit > 0:
|
|
690
|
+
next_step_used_percent = max(
|
|
691
|
+
0.0,
|
|
692
|
+
min((next_step_estimated_tokens / context_limit) * 100.0, 100.0),
|
|
693
|
+
)
|
|
694
|
+
headroom_left_percent = max(0.0, 100.0 - next_step_used_percent)
|
|
695
|
+
if last_step_reported_tokens > 0:
|
|
696
|
+
actual_used_percent = max(
|
|
697
|
+
0.0,
|
|
698
|
+
min((last_step_reported_tokens / context_limit) * 100.0, 100.0),
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
lines = ["Context Usage"]
|
|
702
|
+
lines.append(
|
|
703
|
+
f"- Headroom (est): {headroom_left_percent:.1f}% left "
|
|
704
|
+
f"(est={next_step_estimated_tokens:,}, limit={context_limit:,}; includes buffer)"
|
|
705
|
+
)
|
|
706
|
+
if last_step_reported_tokens > 0:
|
|
707
|
+
lines.append(
|
|
708
|
+
f"- Last call (actual): {actual_used_percent:.1f}% used "
|
|
709
|
+
f"(actual={last_step_reported_tokens:,})"
|
|
710
|
+
)
|
|
711
|
+
else:
|
|
712
|
+
lines.append("- Last call (actual): unavailable")
|
|
713
|
+
|
|
714
|
+
if show_details:
|
|
715
|
+
lines.append("Context Details")
|
|
716
|
+
lines.append(f"- next_step_estimated_tokens: {next_step_estimated_tokens:,}")
|
|
717
|
+
lines.append(f"- last_step_reported_tokens: {last_step_reported_tokens:,}")
|
|
718
|
+
lines.append(f"- ir_used_tokens: {ir_used_tokens:,}")
|
|
719
|
+
lines.append(f"- delta_ir_vs_actual: {ir_used_tokens - last_step_reported_tokens:+,}")
|
|
720
|
+
lines.extend(self._build_last_usage_breakdown_lines())
|
|
721
|
+
|
|
722
|
+
self._renderer.append_system_message("\n".join(lines))
|
|
723
|
+
|
|
724
|
+
def _build_last_usage_breakdown_lines(self) -> list[str]:
|
|
725
|
+
"""Build I/R/W/O breakdown lines from the latest usage entry."""
|
|
726
|
+
token_cost = getattr(getattr(self._session, "_agent", None), "_token_cost", None)
|
|
727
|
+
usage_history = getattr(token_cost, "usage_history", None)
|
|
728
|
+
if not usage_history:
|
|
729
|
+
return ["- breakdown(last call): unavailable"]
|
|
730
|
+
|
|
731
|
+
latest = usage_history[-1]
|
|
732
|
+
usage = getattr(latest, "usage", None)
|
|
733
|
+
if usage is None:
|
|
734
|
+
return ["- breakdown(last call): unavailable"]
|
|
735
|
+
|
|
736
|
+
output_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
|
|
737
|
+
cache_read_tokens = int(getattr(usage, "prompt_cached_tokens", 0) or 0)
|
|
738
|
+
cache_creation_tokens = int(getattr(usage, "prompt_cache_creation_tokens", 0) or 0)
|
|
739
|
+
prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
740
|
+
input_tokens = max(prompt_tokens - cache_read_tokens - cache_creation_tokens, 0)
|
|
741
|
+
|
|
742
|
+
lines = [
|
|
743
|
+
(
|
|
744
|
+
"- breakdown(last call): "
|
|
745
|
+
f"I={input_tokens:,} (derived), "
|
|
746
|
+
f"R={cache_read_tokens:,}, "
|
|
747
|
+
f"W={cache_creation_tokens:,}, "
|
|
748
|
+
f"O={output_tokens:,}"
|
|
749
|
+
)
|
|
750
|
+
]
|
|
751
|
+
|
|
752
|
+
prefix_total = cache_read_tokens + cache_creation_tokens
|
|
753
|
+
if prefix_total > 0:
|
|
754
|
+
cache_hit_ratio = (cache_read_tokens / prefix_total) * 100.0
|
|
755
|
+
lines.append(f"- cache_hit_prefix: {cache_hit_ratio:.1f}% (R / (R + W))")
|
|
756
|
+
else:
|
|
757
|
+
lines.append("- cache_hit_prefix: n/a")
|
|
758
|
+
|
|
759
|
+
return lines
|