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,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