crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
crabcode_cli/repl.py ADDED
@@ -0,0 +1,4841 @@
1
+ """Interactive REPL — rich terminal UI with streaming, Markdown, and tool rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import sys
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ from prompt_toolkit import PromptSession
19
+ from prompt_toolkit.application import get_app_or_none, in_terminal
20
+ from prompt_toolkit.buffer import Buffer
21
+ from prompt_toolkit.completion import Completer, Completion
22
+ from prompt_toolkit.enums import DEFAULT_BUFFER
23
+ from prompt_toolkit.filters import Condition, has_focus
24
+ from prompt_toolkit.formatted_text import HTML
25
+ from prompt_toolkit.history import InMemoryHistory
26
+ from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
27
+ from prompt_toolkit.input.vt100_parser import _IS_PREFIX_OF_LONGER_MATCH_CACHE
28
+ from prompt_toolkit.key_binding import KeyBindings
29
+ from prompt_toolkit.keys import Keys
30
+ from prompt_toolkit.layout import (
31
+ ConditionalContainer,
32
+ Dimension,
33
+ FloatContainer,
34
+ HSplit,
35
+ VerticalAlign,
36
+ Window,
37
+ )
38
+ from prompt_toolkit.layout.controls import FormattedTextControl
39
+ from prompt_toolkit.layout.margins import Margin
40
+ from prompt_toolkit.patch_stdout import patch_stdout
41
+ from prompt_toolkit.styles import Style
42
+ from prompt_toolkit.utils import get_cwidth
43
+ from rich.console import Console
44
+ from rich.markup import escape
45
+ from rich.panel import Panel
46
+ from rich.text import Text
47
+
48
+ from crabcode_cli.banner import print_banner
49
+ from crabcode_core.subprocess_utils import managed_process_command, shell_command
50
+ from crabcode_core.events import CoreSession
51
+ from crabcode_core.logging_utils import get_logger
52
+ from crabcode_core.paths import get_config_home
53
+ from crabcode_core.types.config import (
54
+ REASONING_EFFORT_LEVELS,
55
+ CrabCodeSettings,
56
+ DisplaySettings,
57
+ )
58
+ from crabcode_core.types.message import Message, MessageRole
59
+ from crabcode_core.utf8_sanitize import safe_utf8_str
60
+ from crabcode_core.types.event import (
61
+ AgentOutputEvent,
62
+ AgentStateEvent,
63
+ ChoiceRequestEvent,
64
+ ChoiceResponseEvent,
65
+ CompactEvent,
66
+ ErrorEvent,
67
+ ModeChangeEvent,
68
+ PermissionRequestEvent,
69
+ PermissionResponseEvent,
70
+ PeerMessageEvent,
71
+ PlanReadyEvent,
72
+ QueuedMessageStartedEvent,
73
+ ScheduleRunEvent,
74
+ StreamModeEvent,
75
+ StreamRetryEvent,
76
+ StreamTextEvent,
77
+ SteeringAppliedEvent,
78
+ TaskUpdateEvent,
79
+ TeamMessageEvent,
80
+ TeamStateEvent,
81
+ ThinkingEvent,
82
+ ToolResultEvent,
83
+ ToolUseEvent,
84
+ TurnCompleteEvent,
85
+ )
86
+
87
+ # Module-level display settings, set during run_repl()
88
+ _display_settings: DisplaySettings | None = None
89
+ logger = get_logger(__name__)
90
+
91
+
92
+ def _supports_ansi_output() -> bool:
93
+ """Return True when stdout is an interactive ANSI-capable terminal."""
94
+ if os.getenv("CRABCODE_PLAIN_OUTPUT") or os.getenv("NO_COLOR"):
95
+ return False
96
+ if os.getenv("TERM", "").lower() == "dumb":
97
+ return False
98
+ return bool(getattr(sys.stdin, "isatty", lambda: False)()) and bool(
99
+ getattr(sys.stdout, "isatty", lambda: False)()
100
+ )
101
+
102
+
103
+ _ANSI_ENABLED = _supports_ansi_output()
104
+ console = Console(no_color=not _ANSI_ENABLED, force_terminal=_ANSI_ENABLED)
105
+
106
+
107
+ def _format_token_count(tokens: int) -> str:
108
+ if tokens >= 1_000_000:
109
+ return f"{tokens / 1_000_000:.1f}M"
110
+ if tokens >= 1_000:
111
+ return f"{tokens // 1_000}k"
112
+ return str(tokens)
113
+
114
+
115
+ def _format_percent(percent: float) -> str:
116
+ rounded = round(percent)
117
+ if abs(percent - rounded) < 0.05:
118
+ return f"{rounded}%"
119
+ return f"{percent:.1f}%"
120
+
121
+
122
+ def _cache_usage_text(usage: dict[str, Any]) -> str | None:
123
+ if "cache_read_tokens" not in usage and "cache_write_tokens" not in usage:
124
+ return None
125
+ cache_read = max(0, int(usage.get("cache_read_tokens", 0) or 0))
126
+ cache_write = max(0, int(usage.get("cache_write_tokens", 0) or 0))
127
+ total_input = max(
128
+ 0,
129
+ int(usage.get("total_input_tokens", usage.get("input_tokens", 0)) or 0),
130
+ )
131
+ hit_rate = cache_read / total_input * 100 if total_input else 0.0
132
+ parts = [
133
+ f"Cache: {_format_percent(hit_rate)} hit",
134
+ f"read {_format_token_count(cache_read)}",
135
+ ]
136
+ if "cache_write_tokens" in usage:
137
+ parts.append(f"write {_format_token_count(cache_write)}")
138
+ return " · ".join(parts)
139
+
140
+
141
+ def _render_context_usage(event: TurnCompleteEvent) -> None:
142
+ used = max(0, int(getattr(event, "context_used_tokens", 0) or 0))
143
+ window = max(0, int(getattr(event, "context_window_tokens", 0) or 0))
144
+ cache_text = _cache_usage_text(event.usage)
145
+ if not used and not window and not cache_text:
146
+ return
147
+
148
+ parts: list[str] = []
149
+ if window:
150
+ used_percent = max(0.0, float(getattr(event, "context_used_percent", 0.0) or 0.0))
151
+ remaining_percent = max(0.0, 100.0 - used_percent)
152
+ parts.append(
153
+ "Context: "
154
+ f"{_format_percent(used_percent)} used "
155
+ f"({_format_percent(remaining_percent)} remaining) · "
156
+ f"{_format_token_count(used)} tokens used of {_format_token_count(window)}"
157
+ )
158
+ elif used:
159
+ parts.append(f"Context: {_format_token_count(used)} tokens used (window unknown)")
160
+ if (used or window) and getattr(event, "context_token_source", "estimated") == "estimated":
161
+ parts.append("Local estimate: server-side count not yet available")
162
+ if cache_text:
163
+ parts.append(cache_text)
164
+ console.print(f" [dim]{' · '.join(parts)}[/]")
165
+
166
+
167
+ # Slash commands with their arguments for auto-completion
168
+ _SLASH_COMMANDS: dict[str, list[str]] = {
169
+ "/help": [],
170
+ "/follow-up": ["queue", "steer"],
171
+ "/queue": ["steer"],
172
+ "/goal": ["set", "edit", "pause", "resume", "complete", "blocked", "clear"],
173
+ "/plan": [],
174
+ "/agent": [],
175
+ "/plan-status": [],
176
+ "/agents": [],
177
+ "/peers": [],
178
+ "/tasks": ["stop"],
179
+ "/agent-log": [],
180
+ "/agent-send": [],
181
+ "/wait": [],
182
+ "/cancel-agent": [],
183
+ "/team": [
184
+ "list",
185
+ "create",
186
+ "status",
187
+ "messages",
188
+ "tasks",
189
+ "spawn",
190
+ "message",
191
+ "broadcast",
192
+ "task-add",
193
+ "task-claim",
194
+ "task-complete",
195
+ "shutdown",
196
+ ],
197
+ "/schedule": [
198
+ "list",
199
+ "show",
200
+ "runs",
201
+ "create",
202
+ "pause",
203
+ "resume",
204
+ "run",
205
+ "cancel",
206
+ ],
207
+ "/status": [],
208
+ "/effort": ["auto", *REASONING_EFFORT_LEVELS],
209
+ "/ultra": ["true", "false"],
210
+ "/logs": ["-f", "--follow", "--clear", "--tail"],
211
+ "/model": [], # Dynamic: model names
212
+ "/add": ["model", "group"],
213
+ "/del": ["model", "group"],
214
+ "/new": [],
215
+ "/compact": [],
216
+ "/clear": [],
217
+ "/sessions": [],
218
+ "/recent": [],
219
+ "/search": [], # Dynamic: search query
220
+ "/archive": [],
221
+ "/export": [],
222
+ "/stats": [],
223
+ "/checkpoint": [],
224
+ "/checkpoints": [],
225
+ "/rollback": [],
226
+ "/revert": [],
227
+ "/undo": [],
228
+ "/resume": [], # Dynamic: session IDs
229
+ "/fork": [], # Dynamic: assistant message UUIDs
230
+ "/exit": [],
231
+ "/quit": [],
232
+ "/image": [], # Dynamic: file path
233
+ }
234
+
235
+
236
+ class _CrabCodeCompleter(Completer):
237
+ """Auto-completer for slash commands and their arguments."""
238
+
239
+ def __init__(self, session: "CoreSession" | None = None) -> None:
240
+ self._session = session
241
+
242
+ def get_completions(self, document, complete_event):
243
+ text = document.text_before_cursor.lstrip()
244
+ word_before_cursor = document.get_word_before_cursor(WORD=False)
245
+
246
+ # Only complete after /
247
+ if not text.startswith("/"):
248
+ return
249
+
250
+ parts = text.split()
251
+ cmd = parts[0].lower() if parts else ""
252
+
253
+ # First token must include leading "/"; get_word_before_cursor(WORD=False)
254
+ # treats "/" as a separator, so "/sta" was only replacing "sta" -> "//status".
255
+ if len(parts) <= 1 and not text.endswith(" "):
256
+ replace_len = len(parts[0])
257
+ for name in _SLASH_COMMANDS:
258
+ if name.startswith(cmd):
259
+ yield Completion(
260
+ name,
261
+ start_position=-replace_len,
262
+ display=name,
263
+ display_meta=self._get_command_description(name),
264
+ )
265
+ # Also complete skill names (skip names that clash with built-in commands)
266
+ if self._session:
267
+ skills = getattr(self._session, "skills", [])
268
+ builtin_names = set(_SLASH_COMMANDS)
269
+ for skill in skills:
270
+ skill_cmd = f"/{skill.name}"
271
+ if skill_cmd in builtin_names:
272
+ continue
273
+ if skill_cmd.startswith(cmd):
274
+ yield Completion(
275
+ skill_cmd,
276
+ start_position=-replace_len,
277
+ display=skill_cmd,
278
+ display_meta=skill.description or skill.when_to_use or "skill",
279
+ )
280
+ return
281
+
282
+ # Complete arguments for specific commands
283
+ if len(parts) >= 2 or text.endswith(" "):
284
+ # /model <name> — complete model names
285
+ if cmd == "/model":
286
+ if self._session:
287
+ models = self._session.list_models()
288
+ for name in models:
289
+ if name.startswith(word_before_cursor):
290
+ yield Completion(
291
+ name,
292
+ start_position=-len(word_before_cursor),
293
+ display=name,
294
+ )
295
+ return
296
+
297
+ if cmd in {"/effort", "/ultra", "/add", "/del"}:
298
+ for value in _SLASH_COMMANDS[cmd]:
299
+ if value.startswith(word_before_cursor):
300
+ yield Completion(
301
+ value,
302
+ start_position=-len(word_before_cursor),
303
+ display=value,
304
+ )
305
+ return
306
+
307
+ # /logs <name> — complete log names
308
+ if cmd == "/logs":
309
+ try:
310
+ from crabcode_search.background import list_background_logs
311
+ logs = list_background_logs(self._session.cwd if self._session else ".")
312
+ except Exception:
313
+ logger.debug("Failed to load log names for completion", exc_info=True)
314
+ logs = {}
315
+ for name in logs:
316
+ if name.startswith(word_before_cursor):
317
+ yield Completion(
318
+ name,
319
+ start_position=-len(word_before_cursor),
320
+ display=name,
321
+ )
322
+ # Also complete flags
323
+ for flag in _SLASH_COMMANDS.get("/logs", []):
324
+ if flag.startswith(word_before_cursor):
325
+ yield Completion(
326
+ flag,
327
+ start_position=-len(word_before_cursor),
328
+ display=flag,
329
+ )
330
+ return
331
+
332
+ # /resume <id> — complete session IDs
333
+ if cmd == "/resume":
334
+ from crabcode_core.session.storage import SessionStorage
335
+ sessions = SessionStorage.list_sessions(self._session.cwd if self._session else ".")
336
+ for s in sessions[:20]:
337
+ sid = s["session_id"]
338
+ if sid.startswith(word_before_cursor):
339
+ yield Completion(
340
+ sid,
341
+ start_position=-len(word_before_cursor),
342
+ display=sid[:12] + "…",
343
+ display_meta=s.get("preview", "")[:40],
344
+ )
345
+ return
346
+
347
+ # /fork [message-uuid] — complete assistant message UUIDs
348
+ if cmd == "/fork" and self._session:
349
+ for message in reversed(self._session.messages):
350
+ if message.role != MessageRole.ASSISTANT:
351
+ continue
352
+ message_id = str(message.uuid)
353
+ if message_id.startswith(word_before_cursor):
354
+ yield Completion(
355
+ message_id,
356
+ start_position=-len(word_before_cursor),
357
+ display=message_id[:12] + "…",
358
+ display_meta=message.text_content[:40],
359
+ )
360
+ return
361
+
362
+ if cmd in {"/agent", "/agent-log", "/agent-send", "/wait", "/cancel-agent"} and self._session:
363
+ for snapshot in self._session.list_agents()[:20]:
364
+ sid = snapshot.agent_id
365
+ if sid.startswith(word_before_cursor):
366
+ yield Completion(
367
+ sid,
368
+ start_position=-len(word_before_cursor),
369
+ display=sid[:12] + "…",
370
+ display_meta=f"{snapshot.status} · {snapshot.title[:40]}",
371
+ )
372
+ return
373
+
374
+ def _get_command_description(self, cmd: str) -> str:
375
+ descriptions = {
376
+ "/help": "show help",
377
+ "/follow-up": "choose queue or steer for follow-up messages",
378
+ "/queue": "list queued messages or steer one into the active turn",
379
+ "/goal": "set or manage the persistent task goal",
380
+ "/plan": "switch to plan mode (read-only analysis)",
381
+ "/agent": "switch to agent mode / show agent (<id>)",
382
+ "/plan-status": "show current plan status",
383
+ "/agents": "list managed agents",
384
+ "/peers": "list messageable CrabCode sessions",
385
+ "/tasks": "list/stop background agents and monitors",
386
+ "/agent-log": "show an agent transcript",
387
+ "/agent-send": "send input to an agent",
388
+ "/wait": "wait for an agent",
389
+ "/cancel-agent": "cancel an agent",
390
+ "/team": "team lifecycle, messaging, and task-board management",
391
+ "/schedule": "scheduled task lifecycle and execution history",
392
+ "/status": "show session status",
393
+ "/effort": "show/set reasoning effort",
394
+ "/ultra": "toggle/set ultra mode",
395
+ "/logs": "show background logs",
396
+ "/model": "show/switch model",
397
+ "/add": "add a model or group",
398
+ "/del": "delete a model or group",
399
+ "/new": "start new session",
400
+ "/compact": "compact conversation",
401
+ "/clear": "clear history",
402
+ "/sessions": "list sessions",
403
+ "/recent": "list recent sessions (all projects)",
404
+ "/search": "search sessions",
405
+ "/archive": "archive a session",
406
+ "/export": "export session (md/json)",
407
+ "/stats": "usage statistics",
408
+ "/checkpoint": "create checkpoint (with file snapshot)",
409
+ "/checkpoints": "list checkpoints",
410
+ "/rollback": "rollback conversation to checkpoint",
411
+ "/revert": "revert files + conversation to checkpoint",
412
+ "/undo": "undo last checkpoint (revert files + conversation)",
413
+ "/resume": "resume session",
414
+ "/fork": "fork session at an assistant reply",
415
+ "/image": "attach image to next message",
416
+ "/exit": "exit CrabCode",
417
+ "/quit": "exit CrabCode",
418
+ }
419
+ return descriptions.get(cmd, "")
420
+
421
+
422
+ _SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
423
+ _VERBS = ["Thinking", "Reasoning", "Analyzing", "Processing", "Understanding"]
424
+
425
+ _COMPOSER_STYLE = Style.from_dict(
426
+ {
427
+ # prompt_toolkit gives bottom toolbars reverse video by default. This
428
+ # toolbar is the lower edge of the frame, so retain the normal terminal
429
+ # background.
430
+ "bottom-toolbar": "noreverse",
431
+ }
432
+ )
433
+
434
+
435
+ _exit_terminal_state: tuple[int, Any] | None = None
436
+
437
+
438
+ def _capture_exit_terminal() -> None:
439
+ """Save terminal modes before prompt_toolkit enters raw input mode."""
440
+ global _exit_terminal_state
441
+ _exit_terminal_state = None
442
+ if os.name != "nt":
443
+ try:
444
+ import termios
445
+ fd = sys.stdin.fileno()
446
+ _exit_terminal_state = (fd, termios.tcgetattr(fd))
447
+ except (OSError, ValueError, AttributeError):
448
+ pass
449
+
450
+
451
+ def _force_exit(code: int = 130) -> None:
452
+ """Exit without session persistence, stdout flush, or executor joins."""
453
+ # Even terminal restoration must not become an unbounded prerequisite.
454
+ fallback = threading.Timer(0.5, os._exit, args=(code,))
455
+ fallback.daemon = True
456
+ fallback.start()
457
+ try:
458
+ from crabcode_core.io_worker import abort_io_workers
459
+ abort_io_workers()
460
+ if _exit_terminal_state is not None:
461
+ import termios
462
+ fd, attrs = _exit_terminal_state
463
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
464
+ except (OSError, ValueError):
465
+ pass
466
+ finally:
467
+ fallback.cancel()
468
+ os._exit(code)
469
+
470
+
471
+ _CTRL_C_EXIT_WINDOW_S = 5.0
472
+
473
+
474
+ @contextlib.contextmanager
475
+ def _shutdown_deadline(timeout: float = 5.0):
476
+ """Bound cleanup even when a synchronous call blocks the event loop."""
477
+ timer = threading.Timer(timeout, _force_exit)
478
+ timer.daemon = True
479
+ timer.start()
480
+ try:
481
+ yield
482
+ finally:
483
+ timer.cancel()
484
+
485
+
486
+ def _composer_columns() -> int:
487
+ """Return the live renderer width so frame edges follow terminal resizes."""
488
+ app = get_app_or_none()
489
+ if app is not None:
490
+ return max(4, app.output.get_size().columns)
491
+ try:
492
+ return max(4, os.get_terminal_size(sys.stdout.fileno()).columns)
493
+ except (OSError, ValueError):
494
+ return 80
495
+
496
+
497
+ def _composer_frame_parts(
498
+ left: str,
499
+ label: str,
500
+ right: str,
501
+ ) -> tuple[str, str]:
502
+ """Fit a label and return it with enough dashes to reach the right edge."""
503
+ available = max(
504
+ 0,
505
+ _composer_columns() - get_cwidth(left) - get_cwidth(right),
506
+ )
507
+ fitted: list[str] = []
508
+ used = 0
509
+ for char in label:
510
+ char_width = get_cwidth(char)
511
+ if used + char_width > available:
512
+ break
513
+ fitted.append(char)
514
+ used += char_width
515
+ return "".join(fitted), "─" * max(0, available - used)
516
+
517
+
518
+ def _composer_prompt(
519
+ session: CoreSession,
520
+ *,
521
+ pending_images: bool = False,
522
+ ) -> HTML:
523
+ """Render the persistent, framed prompt used by idle and active turns."""
524
+ mode = getattr(session, "_agent_mode", "agent")
525
+ if mode == "plan":
526
+ title = "CrabCode · plan"
527
+ else:
528
+ title = "CrabCode"
529
+ attachment = " 📎" if pending_images else ""
530
+ fitted_title, dashes = _composer_frame_parts("╭─ ", f"{title} ", "╮")
531
+ return HTML(
532
+ f"<ansicyan>╭─ {fitted_title}{dashes}╮</ansicyan>\n"
533
+ f"<ansicyan>│</ansicyan><b> ❯{attachment} </b>"
534
+ )
535
+
536
+
537
+ def _composer_toolbar(*, busy: bool = False, queued_count: int = 0, follow_up_mode: str = "queue") -> HTML:
538
+ if busy:
539
+ action, opposite = ("queues", "steers") if follow_up_mode == "queue" else ("steers", "queues")
540
+ prefix = f" {queued_count} pending ·" if queued_count else ""
541
+ hint = f"{prefix} Enter {action} · Ctrl+S {opposite} · Ctrl+J newline · Ctrl+C stops "
542
+ else:
543
+ hint = f" Enter sends · {_alt_enter_label()} / Ctrl+J newline · Ctrl+D exits "
544
+ fitted_hint, dashes = _composer_frame_parts("╰─", hint, "╯")
545
+ return HTML(
546
+ f"<ansicyan>╰─</ansicyan><gray>{fitted_hint}</gray>"
547
+ f"<ansicyan>{dashes}╯</ansicyan>"
548
+ )
549
+
550
+
551
+ def _configure_modified_enter_keys() -> None:
552
+ """Normalize modified Enter; reserve Ctrl+Enter for follow-up override.
553
+
554
+ prompt_toolkit 3.x aliases xterm modified Enter to plain Enter and lacks
555
+ CSI-u mappings. Register both encodings before reading composer input.
556
+ This does not enable an extended keyboard protocol: other keys retain
557
+ their terminal's existing encoding. Legacy terminals that send only CR
558
+ cannot expose these modifiers; Ctrl+J and Esc, Enter remain available.
559
+ """
560
+ for modifier in range(2, 9): # Shift, Alt, Ctrl, and their combinations.
561
+ key = Keys.F24 if modifier == 5 else Keys.ControlJ
562
+ ANSI_SEQUENCES[f"\x1b[13;{modifier}u"] = key
563
+ ANSI_SEQUENCES[f"\x1b[27;{modifier};13~"] = key
564
+ # The parser caches unmatched prefixes too; earlier prompts may have
565
+ # encountered these sequences before the composer was constructed.
566
+ _IS_PREFIX_OF_LONGER_MATCH_CACHE.clear()
567
+
568
+
569
+ def _alt_enter_label() -> str:
570
+ return "Opt+Enter" if sys.platform == "darwin" else "Alt+Enter"
571
+
572
+
573
+ class _ComposerRightMargin(Margin):
574
+ """Reserve a border cell on every visible input row, including soft wraps."""
575
+
576
+ def get_width(self, get_ui_content: Callable[[], Any]) -> int:
577
+ return 1
578
+
579
+ def create_margin(
580
+ self, window_render_info: Any, width: int, height: int
581
+ ) -> list[tuple[str, str]]:
582
+ return [("fg:ansicyan", "\n".join("│" for _ in range(height)))]
583
+
584
+
585
+ def _configure_composer_layout(
586
+ prompt_session: PromptSession[str],
587
+ *,
588
+ status_text: Callable[[], Any] | None = None,
589
+ queued_text: Callable[[], Any] | None = None,
590
+ has_queued_text: Callable[[], bool] | None = None,
591
+ ) -> None:
592
+ """Keep the composer and its status rows together at the terminal bottom."""
593
+ root = prompt_session.layout.container
594
+ if not isinstance(root, HSplit) or not root.children:
595
+ return
596
+ main = root.children[0]
597
+ if not isinstance(main, FloatContainer) or not isinstance(main.content, HSplit):
598
+ return
599
+
600
+ # PromptSession lets its input container fill the area above a bottom
601
+ # toolbar. Bottom-aligning the prompt within that area removes the large
602
+ # gap that otherwise splits the frame in two.
603
+ main.content.align = VerticalAlign.BOTTOM
604
+
605
+ prefix_rows: list[Any] = []
606
+ if status_text is not None:
607
+ prefix_rows.append(
608
+ Window(
609
+ FormattedTextControl(status_text),
610
+ height=Dimension.exact(1),
611
+ dont_extend_height=True,
612
+ )
613
+ )
614
+ if queued_text is not None and has_queued_text is not None:
615
+ prefix_rows.append(
616
+ ConditionalContainer(
617
+ Window(
618
+ FormattedTextControl(queued_text),
619
+ height=Dimension.exact(1),
620
+ dont_extend_height=True,
621
+ ),
622
+ filter=Condition(has_queued_text),
623
+ )
624
+ )
625
+ if prefix_rows:
626
+ main.content.children = [*prefix_rows, *main.content.children]
627
+
628
+ # Fit explicit newlines and soft wraps without stretching into empty rows.
629
+ # When the terminal is full, Window scrolls to keep the cursor visible.
630
+ input_window = prompt_session.layout.current_window
631
+ if isinstance(input_window, Window):
632
+ input_window.height = Dimension(min=1)
633
+ input_window.dont_extend_height = Condition(lambda: True)
634
+ input_window.right_margins = [_ComposerRightMargin()]
635
+
636
+ # The stock bottom-toolbar Window has only a minimum height, so HSplit can
637
+ # stretch it across all remaining rows and draw its text on the final one.
638
+ # The frame edge is intrinsically a single line.
639
+ toolbar = root.children[-1]
640
+ if isinstance(toolbar, ConditionalContainer) and isinstance(
641
+ toolbar.content,
642
+ Window,
643
+ ):
644
+ toolbar.content.height = Dimension.exact(1)
645
+
646
+ # Some embedded terminals clip the glyphs drawn on their physical last
647
+ # row. Keep one reserved row below the frame so its lower border remains
648
+ # fully visible instead of being cut in half by the terminal viewport.
649
+ root.children.append(
650
+ Window(
651
+ height=Dimension.exact(1),
652
+ dont_extend_height=True,
653
+ )
654
+ )
655
+
656
+
657
+ def _render_submitted_input(text: str, *, steering: bool = False) -> None:
658
+ """Put submitted text into scrollback without leaving a stale frame."""
659
+ prefix = " ↳ " if steering else " ❯ "
660
+ line = Text(prefix, style="cyan")
661
+ line.append(text)
662
+ console.print(line)
663
+
664
+
665
+ def _render_repl_error(message: str) -> None:
666
+ # Provider responses may contain brackets/HTML. Treat them as literal
667
+ # text so displaying an API failure cannot itself raise a MarkupError.
668
+ console.print(Text(f"\nError: {safe_utf8_str(message)}", style="bold red"))
669
+
670
+
671
+ @dataclass(frozen=True)
672
+ class _QueuedTurn:
673
+ request_id: str
674
+ text: str
675
+
676
+
677
+ class _PersistentComposer:
678
+ """One long-lived input application shared by idle and working states."""
679
+
680
+ def __init__(
681
+ self,
682
+ session: CoreSession,
683
+ pending_images: list[dict[str, str]],
684
+ ) -> None:
685
+ _configure_modified_enter_keys()
686
+ self._session = session
687
+ self._pending_images = pending_images
688
+ self._events: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
689
+ self._busy = False
690
+ self._phase = ""
691
+ self._activity_running = False
692
+ self._activity_started_at = 0.0
693
+ self._verb_index = 0
694
+ self._notice = ""
695
+ self._queued_messages: list[str] = []
696
+ self._queued_turns: list[_QueuedTurn] = []
697
+ self._submit_opposite = False
698
+ self._task: asyncio.Task[str] | None = None
699
+ self._animation_task: asyncio.Task[None] | None = None
700
+ self._exit_keys = _CtrlCDoubleExit()
701
+
702
+ bindings = KeyBindings()
703
+
704
+ @bindings.add("enter", filter=has_focus(DEFAULT_BUFFER))
705
+ def _submit(event: Any) -> None:
706
+ self._exit_keys.clear()
707
+ event.current_buffer.validate_and_handle()
708
+
709
+ @bindings.add("f24", filter=has_focus(DEFAULT_BUFFER))
710
+ @bindings.add("c-s", filter=has_focus(DEFAULT_BUFFER))
711
+ def _submit_other_mode(event: Any) -> None:
712
+ if not self._busy:
713
+ event.current_buffer.insert_text("\n")
714
+ return
715
+ self._exit_keys.clear()
716
+ if not event.current_buffer.text.strip() and not self._pending_images:
717
+ if self._queued_turns:
718
+ # Capture identity at key receipt, before a queued turn can
719
+ # start. A delayed key must never promote a different item.
720
+ self._events.put_nowait(("steer_queued", self._queued_turns[0].request_id))
721
+ return
722
+ self._submit_opposite = True
723
+ try:
724
+ event.current_buffer.validate_and_handle()
725
+ finally:
726
+ self._submit_opposite = False
727
+
728
+ @bindings.add("c-j", filter=has_focus(DEFAULT_BUFFER))
729
+ @bindings.add("escape", "enter", filter=has_focus(DEFAULT_BUFFER))
730
+ def _newline(event: Any) -> None:
731
+ event.current_buffer.insert_text("\n")
732
+
733
+ @bindings.add("c-c", eager=True)
734
+ def _interrupt(event: Any) -> None:
735
+ # Handle the escape hatch at input receipt, even if the consumer
736
+ # is still awaiting the first interrupt or session cleanup.
737
+ if self._exit_keys.should_exit_now():
738
+ _force_exit()
739
+ return
740
+ self._events.put_nowait(("interrupt", ""))
741
+
742
+ @bindings.add("c-d", eager=True)
743
+ def _eof(event: Any) -> None:
744
+ self._events.put_nowait(("eof", ""))
745
+
746
+ self.prompt_session: PromptSession[str] = PromptSession(
747
+ message=lambda: _composer_prompt(
748
+ self._session,
749
+ pending_images=bool(self._pending_images),
750
+ ),
751
+ bottom_toolbar=lambda: _composer_toolbar(
752
+ busy=self._busy,
753
+ queued_count=len(self._queued_messages) + len(self._queued_turns),
754
+ follow_up_mode=self.follow_up_mode,
755
+ ),
756
+ history=InMemoryHistory(),
757
+ completer=_CrabCodeCompleter(session),
758
+ complete_while_typing=True,
759
+ multiline=True,
760
+ prompt_continuation=lambda width, line_number, wrap_count: [
761
+ ("fg:ansicyan", "│"),
762
+ ("", " " * max(0, width - 1)),
763
+ ],
764
+ key_bindings=bindings,
765
+ style=_COMPOSER_STYLE,
766
+ erase_when_done=True,
767
+ )
768
+ _configure_composer_layout(
769
+ self.prompt_session,
770
+ status_text=self._status_text,
771
+ queued_text=self._queued_text,
772
+ has_queued_text=lambda: bool(self._queued_messages or self._queued_turns),
773
+ )
774
+ self.prompt_session.default_buffer.accept_handler = self._accept
775
+
776
+ def _accept(self, buffer: Buffer) -> bool:
777
+ text = buffer.text.strip()
778
+ if text:
779
+ self._notice = ""
780
+ self._events.put_nowait(("submit_opposite" if self._submit_opposite else "submit", text))
781
+ return False
782
+
783
+ @property
784
+ def follow_up_mode(self) -> str:
785
+ return getattr(getattr(self._session, "settings", None), "follow_up_mode", "queue")
786
+
787
+ def _status_text(self) -> list[tuple[str, str]]:
788
+ # Status labels are literal text. HTML parsing would treat command
789
+ # placeholders such as /model <name> as tags and break every redraw.
790
+ if self._busy:
791
+ elapsed = max(
792
+ 0.0,
793
+ time.monotonic() - self._activity_started_at,
794
+ )
795
+ frame = _SPINNER_FRAMES[
796
+ int(elapsed / 0.08) % len(_SPINNER_FRAMES)
797
+ ]
798
+ if self._activity_running:
799
+ suffix = f" ({elapsed:.0f}s)" if elapsed >= 2 else ""
800
+ return [
801
+ ("class:ansicyan", f" {frame} {self._phase}…"),
802
+ ("class:gray", suffix),
803
+ ]
804
+ return [("class:ansicyan", f" {frame} Working")]
805
+ if self._notice:
806
+ return [("class:gray", f" ● {self._notice}")]
807
+ return [("class:gray", " ● Ready")]
808
+
809
+ def _queued_text(self) -> list[tuple[str, str]]:
810
+ if self._queued_turns:
811
+ preview = " ".join(self._queued_turns[0].text.split())
812
+ label = " #1 "
813
+ hint = " · empty Ctrl+S steers #1 · /queue"
814
+ else:
815
+ preview = " ".join((self._queued_messages or [""])[-1].split())
816
+ label = " ↳ "
817
+ hint = ""
818
+ suffix = f" ({len(self._queued_turns)} queued · {len(self._queued_messages)} steering)"
819
+ available = max(0, _composer_columns() - get_cwidth(label + suffix + hint))
820
+ fitted = ""
821
+ for char in preview:
822
+ if get_cwidth(fitted + char) > available:
823
+ break
824
+ fitted += char
825
+ return [
826
+ ("fg:ansicyan", label),
827
+ ("", fitted),
828
+ ("fg:ansigray", suffix + hint),
829
+ ]
830
+
831
+ def _invalidate(self) -> None:
832
+ if self.prompt_session.app.is_running:
833
+ self.prompt_session.app.invalidate()
834
+
835
+ async def _animate_status(self) -> None:
836
+ try:
837
+ while self._busy:
838
+ self._invalidate()
839
+ await asyncio.sleep(0.08)
840
+ except asyncio.CancelledError:
841
+ pass
842
+
843
+ def _start_animation(self) -> None:
844
+ if self._animation_task is None or self._animation_task.done():
845
+ self._animation_task = asyncio.create_task(self._animate_status())
846
+
847
+ def _stop_animation(self) -> None:
848
+ if self._animation_task is not None and not self._animation_task.done():
849
+ self._animation_task.cancel()
850
+ self._animation_task = None
851
+
852
+ def start(self) -> None:
853
+ self._task = asyncio.create_task(
854
+ self.prompt_session.app.run_async(handle_sigint=False)
855
+ )
856
+
857
+ async def close(self) -> None:
858
+ if self._task is None:
859
+ return
860
+ animation_task = self._animation_task
861
+ self._stop_animation()
862
+ if animation_task is not None:
863
+ with contextlib.suppress(asyncio.CancelledError):
864
+ await animation_task
865
+ if not self._task.done() and self.prompt_session.app.is_running:
866
+ self.prompt_session.app.exit(result="")
867
+ with contextlib.suppress(asyncio.CancelledError):
868
+ await self._task
869
+ self._task = None
870
+
871
+ async def next_event(self) -> tuple[str, str]:
872
+ return await self._events.get()
873
+
874
+ @property
875
+ def has_notice(self) -> bool:
876
+ return bool(self._notice)
877
+
878
+ def set_busy(self, busy: bool) -> None:
879
+ if busy and not self._busy:
880
+ self._notice = ""
881
+ self._verb_index = 0
882
+ self._activity_started_at = time.monotonic()
883
+ self._busy = busy
884
+ if busy:
885
+ self._start_animation()
886
+ else:
887
+ self._activity_running = False
888
+ self._phase = ""
889
+ self._stop_animation()
890
+ self._invalidate()
891
+
892
+ def start_activity(self, message: str | None = None) -> None:
893
+ """Start one old-style spinner phase; keep its label stable."""
894
+ if not self._busy or self._activity_running:
895
+ return
896
+ if message is None:
897
+ message = _VERBS[self._verb_index % len(_VERBS)]
898
+ # Legacy _Spinner advanced this counter for every phase start,
899
+ # including named phases such as Generating and Running.
900
+ self._verb_index += 1
901
+ self._phase = message
902
+ self._activity_started_at = time.monotonic()
903
+ self._activity_running = True
904
+ self._invalidate()
905
+
906
+ def update_activity(self, message: str) -> None:
907
+ """Match the legacy spinner update without starting a stopped phase."""
908
+ if not self._busy or not self._activity_running:
909
+ return
910
+ self._phase = message
911
+ self._activity_started_at = time.monotonic()
912
+ self._invalidate()
913
+
914
+ def stop_activity(self) -> None:
915
+ if not self._activity_running:
916
+ return
917
+ self._activity_running = False
918
+ self._phase = ""
919
+ self._invalidate()
920
+
921
+ def set_notice(self, notice: str) -> None:
922
+ self._busy = False
923
+ self._activity_running = False
924
+ self._phase = ""
925
+ self._notice = notice
926
+ self._stop_animation()
927
+ self._invalidate()
928
+
929
+ def add_guidance(self, text: str) -> None:
930
+ self._queued_messages.append(text)
931
+ self._invalidate()
932
+
933
+ def add_queued_turn(self, text: str, request_id: str) -> None:
934
+ self._queued_turns.append(_QueuedTurn(request_id, text))
935
+ self._invalidate()
936
+
937
+ def mark_queued_turn_started(self, request_id: str | None) -> None:
938
+ self._queued_turns = [item for item in self._queued_turns if item.request_id != request_id]
939
+ self._invalidate()
940
+
941
+ async def promote_queued_turn(self, request_id: str) -> bool:
942
+ item = next((item for item in self._queued_turns if item.request_id == request_id), None)
943
+ if item is None or not await self._session.update_queued_message(request_id, "steer"):
944
+ return False
945
+ self.mark_queued_turn_started(request_id)
946
+ self.add_guidance(item.text)
947
+ return True
948
+
949
+ def cancel_pending_follow_ups(self) -> list[str]:
950
+ pending = [item.text for item in self._queued_turns] + self._queued_messages
951
+ self._queued_turns = []
952
+ self._queued_messages = []
953
+ self._invalidate()
954
+ return pending
955
+
956
+ def mark_guidance_applied(self, count: int) -> list[str]:
957
+ count = min(max(0, count), len(self._queued_messages))
958
+ applied = self._queued_messages[:count]
959
+ del self._queued_messages[:count]
960
+ self._invalidate()
961
+ return applied
962
+
963
+
964
+ async def _handle_queue_command(text: str, composer: _PersistentComposer) -> None:
965
+ parts = text.lower().split()
966
+ if len(parts) == 1:
967
+ if not composer._queued_turns:
968
+ console.print(" No queued messages.", style="dim")
969
+ for index, item in enumerate(composer._queued_turns, 1):
970
+ console.print(Text(f" #{index} {item.text}"))
971
+ console.print(" /queue steer [number] · move a queued message into the active turn", style="dim")
972
+ return
973
+ if parts[1] != "steer" or len(parts) > 3:
974
+ console.print(" Usage: /queue [steer [number]]", style="yellow")
975
+ return
976
+ try:
977
+ index = int(parts[2]) if len(parts) == 3 else 1
978
+ except ValueError:
979
+ index = 0
980
+ if not 1 <= index <= len(composer._queued_turns):
981
+ console.print(" No queued message at that number. Use /queue to see pending messages.", style="yellow")
982
+ return
983
+ await _promote_queued_turn(composer, composer._queued_turns[index - 1].request_id)
984
+
985
+
986
+ async def _promote_queued_turn(composer: _PersistentComposer, request_id: str) -> None:
987
+ try:
988
+ accepted = await composer.promote_queued_turn(request_id)
989
+ except RuntimeError as exc:
990
+ _render_repl_error(str(exc))
991
+ return
992
+ if accepted:
993
+ console.print(" Queued message moved to steering · applies at the next safe boundary", style="dim")
994
+ else:
995
+ console.print(" Message is no longer queued or the active turn has stopped.", style="yellow")
996
+
997
+
998
+ class _CtrlCDoubleExit:
999
+ """First Ctrl+C interrupts; a second within the window exits; otherwise reset."""
1000
+
1001
+ __slots__ = ("_window_s", "_first_at")
1002
+
1003
+ def __init__(self, window_s: float = _CTRL_C_EXIT_WINDOW_S) -> None:
1004
+ self._window_s = window_s
1005
+ self._first_at: float | None = None
1006
+
1007
+ def should_exit_now(self) -> bool:
1008
+ """Record this Ctrl+C; return True if the user should exit (second tap in window)."""
1009
+ now = time.monotonic()
1010
+ if self._first_at is not None and (now - self._first_at) <= self._window_s:
1011
+ return True
1012
+ self._first_at = now
1013
+ return False
1014
+
1015
+ def clear(self) -> None:
1016
+ self._first_at = None
1017
+
1018
+
1019
+ # asyncio.run() installs a SIGINT handler: first Ctrl+C cancels the main task
1020
+ # (CancelledError), the second raises KeyboardInterrupt. REPL must handle both.
1021
+ _REPL_INTERRUPT_EXCS: tuple[type[BaseException], ...] = (
1022
+ KeyboardInterrupt,
1023
+ asyncio.CancelledError,
1024
+ )
1025
+
1026
+
1027
+ def _clear_sigint_cancel() -> None:
1028
+ """After handling first SIGINT under asyncio.run(), drop pending cancel so the REPL continues."""
1029
+ task = asyncio.current_task()
1030
+ if task is None:
1031
+ return
1032
+ uncancel = getattr(task, "uncancel", None)
1033
+ if uncancel is None:
1034
+ return
1035
+ while uncancel() > 0:
1036
+ pass
1037
+
1038
+
1039
+ def _read_log_tail(path: Path, max_lines: int = 80) -> str:
1040
+ try:
1041
+ text = path.read_text(encoding="utf-8", errors="replace")
1042
+ except OSError as exc:
1043
+ return f"(failed to read log: {exc})"
1044
+ lines = text.splitlines()
1045
+ if not lines:
1046
+ return "(log is empty)"
1047
+ if len(lines) > max_lines:
1048
+ lines = ["... (truncated)"] + lines[-max_lines:]
1049
+ return "\n".join(lines)
1050
+
1051
+
1052
+ def _format_timestamp(ts: float | None) -> str:
1053
+ if ts is None:
1054
+ return "unknown"
1055
+ try:
1056
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
1057
+ except Exception:
1058
+ logger.debug("Failed to format timestamp: %r", ts, exc_info=True)
1059
+ return "unknown"
1060
+
1061
+
1062
+ def _parse_logs_args(arg: str) -> tuple[bool, bool, int, str | None, str | None]:
1063
+ follow = False
1064
+ clear = False
1065
+ tail = 80
1066
+ name: str | None = None
1067
+ error: str | None = None
1068
+
1069
+ parts = arg.split() if arg else []
1070
+ i = 0
1071
+ while i < len(parts):
1072
+ part = parts[i]
1073
+ if part in ("-f", "--follow"):
1074
+ follow = True
1075
+ elif part == "--clear":
1076
+ clear = True
1077
+ elif part == "--tail":
1078
+ i += 1
1079
+ if i >= len(parts):
1080
+ error = "--tail requires a number"
1081
+ break
1082
+ try:
1083
+ tail = max(1, int(parts[i]))
1084
+ except ValueError:
1085
+ error = "--tail requires an integer"
1086
+ break
1087
+ elif part.startswith("--tail="):
1088
+ value = part.split("=", 1)[1]
1089
+ try:
1090
+ tail = max(1, int(value))
1091
+ except ValueError:
1092
+ error = "--tail requires an integer"
1093
+ break
1094
+ elif part.startswith("-"):
1095
+ error = f"unknown option: {part}"
1096
+ break
1097
+ elif name is None:
1098
+ name = part
1099
+ else:
1100
+ error = f"unexpected argument: {part}"
1101
+ break
1102
+ i += 1
1103
+
1104
+ return follow, clear, tail, name, error
1105
+
1106
+
1107
+ async def _follow_log(path: Path, name: str) -> None:
1108
+ console.print(
1109
+ f"[dim]Following {name}. Press Ctrl+C to stop.[/]"
1110
+ )
1111
+ try:
1112
+ with open(path, encoding="utf-8", errors="replace") as f:
1113
+ f.seek(0, os.SEEK_END)
1114
+ while True:
1115
+ line = f.readline()
1116
+ if line:
1117
+ sys.stdout.write(line)
1118
+ sys.stdout.flush()
1119
+ else:
1120
+ await asyncio.sleep(0.5)
1121
+ except _REPL_INTERRUPT_EXCS:
1122
+ _clear_sigint_cancel()
1123
+ console.print("\n[dim]Stopped log follow.[/]")
1124
+
1125
+
1126
+ class _Spinner:
1127
+ """Async terminal spinner with phase-aware messaging and elapsed timer."""
1128
+
1129
+ def __init__(self, ansi_enabled: bool = True, visible: bool = True) -> None:
1130
+ self._ansi_enabled = ansi_enabled
1131
+ self._visible = visible
1132
+ self._task: asyncio.Task[None] | None = None
1133
+ self._message = ""
1134
+ self._running = False
1135
+ self._start_time = 0.0
1136
+ self._verb_index = 0
1137
+ self._last_line_len = 0
1138
+
1139
+ def start(self, message: str | None = None) -> None:
1140
+ if self._running:
1141
+ return
1142
+ self._message = message or _VERBS[self._verb_index % len(_VERBS)]
1143
+ self._verb_index += 1
1144
+ self._running = True
1145
+ self._start_time = time.monotonic()
1146
+ if self._visible:
1147
+ self._task = asyncio.create_task(self._animate())
1148
+
1149
+ def update(self, message: str) -> None:
1150
+ self._message = message
1151
+ self._start_time = time.monotonic()
1152
+
1153
+ async def stop(self) -> float:
1154
+ """Stop spinner, wait for animation task, clear line, return elapsed seconds."""
1155
+ elapsed = time.monotonic() - self._start_time if self._running else 0.0
1156
+ self._running = False
1157
+ if self._task:
1158
+ self._task.cancel()
1159
+ with contextlib.suppress(asyncio.CancelledError):
1160
+ await self._task
1161
+ self._task = None
1162
+ if self._visible:
1163
+ self._clear_line()
1164
+ sys.stdout.flush()
1165
+ return elapsed
1166
+
1167
+ @property
1168
+ def is_running(self) -> bool:
1169
+ return self._running
1170
+
1171
+ async def _animate(self) -> None:
1172
+ try:
1173
+ idx = 0
1174
+ while self._running:
1175
+ frame = _SPINNER_FRAMES[idx % len(_SPINNER_FRAMES)]
1176
+ elapsed = time.monotonic() - self._start_time
1177
+ suffix = f" ({elapsed:.0f}s)" if elapsed >= 2 else ""
1178
+ line = f"{frame} {self._message}…{suffix}"
1179
+ if self._ansi_enabled:
1180
+ sys.stdout.write(f"\r\033[K\033[2;36m{line}\033[0m")
1181
+ else:
1182
+ pad = max(0, self._last_line_len - len(line))
1183
+ sys.stdout.write(f"\r{line}{' ' * pad}")
1184
+ self._last_line_len = len(line)
1185
+ sys.stdout.flush()
1186
+ idx += 1
1187
+ await asyncio.sleep(0.08)
1188
+ except asyncio.CancelledError:
1189
+ pass
1190
+
1191
+ def _clear_line(self) -> None:
1192
+ if self._ansi_enabled:
1193
+ sys.stdout.write("\r\033[K")
1194
+ self._last_line_len = 0
1195
+ return
1196
+ if self._last_line_len:
1197
+ sys.stdout.write("\r" + (" " * self._last_line_len) + "\r")
1198
+ self._last_line_len = 0
1199
+
1200
+
1201
+ def _tool_summary(name: str, inp: dict) -> str:
1202
+ """Return a human-readable one-liner for a tool call."""
1203
+ if name == "ToolSearch":
1204
+ return str(inp.get("query") or inp.get("group") or ", ".join(inp.get("names", [])) or "Browse available tools")
1205
+ if name == "Bash":
1206
+ cmd = inp.get("command", "")
1207
+ lines = cmd.split("\n")
1208
+ if len(lines) > 3:
1209
+ return "\n".join(lines[:3]) + "\n…"
1210
+ return cmd
1211
+ if name in ("apply_patch", "ApplyPatch"):
1212
+ paths = inp.get("affected_paths")
1213
+ if not isinstance(paths, list):
1214
+ paths = []
1215
+ for line in str(inp.get("patch", "")).splitlines():
1216
+ for prefix in (
1217
+ "*** Add File: ",
1218
+ "*** Update File: ",
1219
+ "*** Delete File: ",
1220
+ "*** Move to: ",
1221
+ ):
1222
+ if line.startswith(prefix):
1223
+ paths.append(line.removeprefix(prefix).strip())
1224
+ break
1225
+ unique_paths = list(dict.fromkeys(str(path) for path in paths if path))
1226
+ if not unique_paths:
1227
+ return "Apply structured patch"
1228
+ preview = ", ".join(unique_paths[:4])
1229
+ suffix = f" (+{len(unique_paths) - 4} more)" if len(unique_paths) > 4 else ""
1230
+ return f"{len(unique_paths)} file{'s' if len(unique_paths) != 1 else ''}: {preview}{suffix}"
1231
+ if name in ("Write", "Edit", "Read"):
1232
+ return inp.get("file_path", inp.get("path", ""))
1233
+ if name == "Grep":
1234
+ pattern = inp.get("pattern", "")
1235
+ path = inp.get("path", ".")
1236
+ return f'"{pattern}" in {path}'
1237
+ if name == "Glob":
1238
+ return inp.get("pattern", inp.get("glob_pattern", ""))
1239
+ if name == "Agent":
1240
+ prompt = inp.get("prompt", "")
1241
+ return (prompt[:100] + "…") if len(prompt) > 100 else prompt
1242
+ if name == "Browser":
1243
+ action = inp.get("action", "")
1244
+ session_id = inp.get("session_id", "")
1245
+ selector = inp.get("selector", "")
1246
+ url = inp.get("url", "")
1247
+ text = inp.get("text", "")
1248
+ path = inp.get("path", "")
1249
+ lines = [f"action: {action}"]
1250
+ if session_id:
1251
+ lines.append(f"session_id: {session_id}")
1252
+ if url:
1253
+ lines.append(f"url: {url}")
1254
+ if selector:
1255
+ lines.append(f"selector: {selector}")
1256
+ if text:
1257
+ lines.append(f"text: {text[:120]}")
1258
+ if path:
1259
+ lines.append(f"path: {path}")
1260
+ return "\n".join(lines)
1261
+ if name == "AskUser":
1262
+ question = inp.get("question", "")
1263
+ options = inp.get("options", [])
1264
+ lines = [question]
1265
+ for i, opt in enumerate(options, 1):
1266
+ lines.append(f" {i}. {opt}")
1267
+ return "\n".join(lines)
1268
+ if name == "PeerMessage":
1269
+ sender = inp.get("from_name", inp.get("from_session_id", "unknown"))
1270
+ sender_id = str(inp.get("from_session_id", ""))[:8]
1271
+ text = str(inp.get("text", ""))
1272
+ preview = text[:500] + ("…" if len(text) > 500 else "")
1273
+ return f"From {sender} · {sender_id}\n\n{preview}"
1274
+ import json
1275
+ raw = json.dumps(inp, ensure_ascii=False)
1276
+ return (raw[:200] + "…") if len(raw) > 200 else raw
1277
+
1278
+
1279
+ def _debug_tool_payload_enabled() -> bool:
1280
+ return os.getenv("CRABCODE_DEBUG_TOOL_PAYLOAD", "").strip().lower() in {
1281
+ "1",
1282
+ "true",
1283
+ "yes",
1284
+ "on",
1285
+ }
1286
+
1287
+
1288
+ def _render_full_tool_payload(tool_name: str, tool_input: dict) -> None:
1289
+ payload = json.dumps(tool_input, ensure_ascii=False, indent=2)
1290
+ console.print(
1291
+ Panel(
1292
+ Text(payload, style="dim"),
1293
+ title=f"[bold yellow]Debug Payload: {tool_name}[/]",
1294
+ border_style="yellow",
1295
+ expand=False,
1296
+ )
1297
+ )
1298
+
1299
+
1300
+ def _render_saved_partial_reply(text: str) -> None:
1301
+ """Echo assistant text that was persisted after interrupt (visible in scrollback)."""
1302
+ body = text.rstrip()
1303
+ if not body:
1304
+ return
1305
+ preview = body if len(body) <= 12000 else body[:12000] + "\n… (truncated in preview; full text is in context)"
1306
+ console.print(
1307
+ Panel(
1308
+ Text(preview, style="dim"),
1309
+ title="[dim]Assistant · partial (saved to context)[/]",
1310
+ border_style="dim",
1311
+ expand=False,
1312
+ )
1313
+ )
1314
+
1315
+
1316
+ def _persist_partial_assistant_for_interrupt(session: CoreSession, raw: str) -> None:
1317
+ """Write streamed assistant text into the session and show it in the terminal."""
1318
+ console.print()
1319
+ if raw.strip():
1320
+ session.record_partial_assistant_output(raw)
1321
+ _render_saved_partial_reply(raw)
1322
+ else:
1323
+ console.print(
1324
+ "[dim](Interrupted before any assistant reply text; only your user message is in context.)[/]"
1325
+ )
1326
+
1327
+
1328
+ def _render_tool_use(event: ToolUseEvent) -> None:
1329
+ """Render a compact tool use call."""
1330
+ summary = _tool_summary(event.tool_name, event.tool_input)
1331
+ agent_prefix = ""
1332
+ if event.agent_id:
1333
+ agent_prefix = f"[{event.agent_id[:8]}] "
1334
+ console.print(
1335
+ Panel(
1336
+ Text(summary, style="dim"),
1337
+ title=f"[bold cyan]{agent_prefix}{event.tool_name}[/]",
1338
+ border_style="cyan",
1339
+ expand=False,
1340
+ )
1341
+ )
1342
+ if _debug_tool_payload_enabled():
1343
+ _render_full_tool_payload(event.tool_name, event.tool_input)
1344
+
1345
+
1346
+ def _truncate_display(display: str, max_lines: int = 50, max_chars: int = 50_000) -> str:
1347
+ """Truncate display text by line count and character count."""
1348
+ # Line-based truncation first
1349
+ lines = display.split("\n")
1350
+ if len(lines) > max_lines:
1351
+ display = "\n".join(lines[:max_lines]) + f"\n… ({len(lines) - max_lines} more lines truncated)"
1352
+ # Character-based safety cap
1353
+ if len(display) > max_chars:
1354
+ display = display[:max_chars] + "\n... (truncated)"
1355
+ return display
1356
+
1357
+
1358
+ def _render_tool_result(event: ToolResultEvent) -> None:
1359
+ """Render a tool result."""
1360
+ display = event.result_for_display or event.result
1361
+
1362
+ if event.tool_name in ("apply_patch", "ApplyPatch", "Edit", "Write") and not event.is_error and "\n@@" in display:
1363
+ _render_diff_result(event.tool_name, display)
1364
+ return
1365
+
1366
+ # Read display limits from settings if available
1367
+ ds = _display_settings
1368
+ max_lines = ds.get_max_lines(event.tool_name) if ds else 50
1369
+ max_chars = ds.max_chars if ds else 50_000
1370
+ display = _truncate_display(display, max_lines=max_lines, max_chars=max_chars)
1371
+
1372
+ style = "red" if event.is_error else "green"
1373
+ title = f"{'Error' if event.is_error else 'Result'}: {event.tool_name}"
1374
+ if event.agent_id:
1375
+ title = f"[{event.agent_id[:8]}] {title}"
1376
+ console.print(
1377
+ Panel(
1378
+ Text(display, style="dim"),
1379
+ title=f"[bold {style}]{title}[/]",
1380
+ border_style=style,
1381
+ expand=False,
1382
+ )
1383
+ )
1384
+
1385
+
1386
+ def _flush_agent_stream_line(active_agent_id: str | None) -> None:
1387
+ if active_agent_id is None:
1388
+ return
1389
+ sys.stdout.write("\n")
1390
+ sys.stdout.flush()
1391
+
1392
+
1393
+ def _render_agent_text_chunk(
1394
+ event: AgentOutputEvent,
1395
+ active_agent_id: str | None,
1396
+ ) -> str | None:
1397
+ if event.stream != "text":
1398
+ return active_agent_id
1399
+ if active_agent_id != event.agent_id:
1400
+ _flush_agent_stream_line(active_agent_id)
1401
+ sys.stdout.write(f"[agent {event.agent_id[:8]}] ")
1402
+ active_agent_id = event.agent_id
1403
+ sys.stdout.write(safe_utf8_str(event.text))
1404
+ sys.stdout.flush()
1405
+ return active_agent_id
1406
+
1407
+
1408
+ def _render_diff_result(tool_name: str, display: str) -> None:
1409
+ """Render a diff result with colored +/- lines."""
1410
+ lines = display.split("\n")
1411
+ header = lines[0] if lines else ""
1412
+
1413
+ diff_parts: list[Text] = []
1414
+ for line in lines[1:]:
1415
+ if line.startswith("+++") or line.startswith("---"):
1416
+ diff_parts.append(Text(line, style="bold dim"))
1417
+ elif line.startswith("@@"):
1418
+ diff_parts.append(Text(line, style="cyan"))
1419
+ elif line.startswith("+"):
1420
+ diff_parts.append(Text(line, style="green"))
1421
+ elif line.startswith("-"):
1422
+ diff_parts.append(Text(line, style="red"))
1423
+ elif line.startswith("... (diff truncated)"):
1424
+ diff_parts.append(Text(line, style="yellow dim"))
1425
+ else:
1426
+ diff_parts.append(Text(line, style="dim"))
1427
+
1428
+ body = Text("\n").join(diff_parts) if diff_parts else Text("(no diff)", style="dim")
1429
+
1430
+ if len(display) > 5000:
1431
+ body = Text(display[:5000] + "\n... (truncated)", style="dim")
1432
+
1433
+ console.print(
1434
+ Panel(
1435
+ body,
1436
+ title=f"[bold green]{tool_name}: {header}[/]",
1437
+ border_style="green",
1438
+ expand=False,
1439
+ )
1440
+ )
1441
+
1442
+
1443
+ def _render_session_history(messages: list[Message], max_messages: int = 50) -> None:
1444
+ """Render a condensed view of conversation history after resuming a session."""
1445
+ if not messages:
1446
+ return
1447
+
1448
+ displayed = messages[-max_messages:]
1449
+ if len(messages) > max_messages:
1450
+ console.print(
1451
+ f" [dim italic]... {len(messages) - max_messages} earlier messages omitted ...[/]\n"
1452
+ )
1453
+
1454
+ for msg in displayed:
1455
+ if msg.role == MessageRole.USER:
1456
+ if (
1457
+ getattr(msg, "source_tool_assistant_uuid", None)
1458
+ or getattr(msg, "origin", None) == "task-notification"
1459
+ ):
1460
+ continue
1461
+ text = msg.text_content.strip()
1462
+ if not text or text.startswith("<system-reminder>"):
1463
+ continue
1464
+ preview = text[:200] + ("…" if len(text) > 200 else "")
1465
+ console.print(f"[bold cyan]❯[/] {preview}")
1466
+
1467
+ elif msg.role == MessageRole.ASSISTANT:
1468
+ text = msg.text_content.strip()
1469
+ tool_blocks = msg.tool_use_blocks
1470
+
1471
+ if text:
1472
+ preview = text[:300] + ("…" if len(text) > 300 else "")
1473
+ console.print(f"[dim]{preview}[/]")
1474
+
1475
+ if tool_blocks:
1476
+ names = [b.name for b in tool_blocks]
1477
+ console.print(f" [dim cyan]⚡ {', '.join(names)}[/]")
1478
+
1479
+ if not text and not tool_blocks:
1480
+ continue
1481
+
1482
+ console.print()
1483
+
1484
+
1485
+ async def _prompt_permission(
1486
+ event: PermissionRequestEvent,
1487
+ session: CoreSession,
1488
+ batch_state: dict | None = None,
1489
+ ) -> None:
1490
+ """Prompt the user for tool permission and push response to session."""
1491
+ is_peer_message = event.request_kind == "peer_message"
1492
+ # If a previous request in this batch was denied, auto-deny silently
1493
+ if not is_peer_message and batch_state and batch_state.get("denied"):
1494
+ await session.respond_permission(
1495
+ PermissionResponseEvent(
1496
+ tool_use_id=event.tool_use_id, allowed=False, agent_id=event.agent_id
1497
+ )
1498
+ )
1499
+ console.print(
1500
+ f" [dim red]✗ {event.tool_name} auto-denied (batch)[/]"
1501
+ )
1502
+ return
1503
+
1504
+ summary = _tool_summary(event.tool_name, event.tool_input)
1505
+ if event.reason:
1506
+ summary = f"{summary}\n\nReason: {event.reason}"
1507
+ console.print(
1508
+ Panel(
1509
+ Text(summary, style="dim"),
1510
+ title=(
1511
+ "[bold yellow]⚠ Cross-session message[/]"
1512
+ if is_peer_message
1513
+ else f"[bold yellow]⚠ {event.tool_name}{f' [{event.agent_id[:8]}]' if event.agent_id else ''}[/]"
1514
+ ),
1515
+ border_style="yellow",
1516
+ expand=False,
1517
+ )
1518
+ )
1519
+
1520
+ perm_prompt_session: PromptSession[str] = PromptSession()
1521
+ while True:
1522
+ try:
1523
+ choice = await perm_prompt_session.prompt_async(
1524
+ HTML(
1525
+ (
1526
+ " Receive this message? "
1527
+ "(y)es / (n)o / (a)lways receive from this session: "
1528
+ if is_peer_message
1529
+ else f" Allow <b>{event.tool_name}</b>? "
1530
+ "(y)es / (n)o / (a)lways allow / (f)eedback: "
1531
+ )
1532
+ )
1533
+ )
1534
+ choice = choice.strip().lower()
1535
+ except (EOFError, KeyboardInterrupt):
1536
+ if not is_peer_message and batch_state is not None:
1537
+ batch_state["denied"] = True
1538
+ await session.respond_permission(
1539
+ PermissionResponseEvent(
1540
+ tool_use_id=event.tool_use_id, allowed=False, agent_id=event.agent_id
1541
+ )
1542
+ )
1543
+ return
1544
+
1545
+ if choice in ("y", "yes", ""):
1546
+ await session.respond_permission(
1547
+ PermissionResponseEvent(
1548
+ tool_use_id=event.tool_use_id, allowed=True, agent_id=event.agent_id
1549
+ )
1550
+ )
1551
+ return
1552
+ elif choice in ("n", "no"):
1553
+ if not is_peer_message and batch_state is not None:
1554
+ batch_state["denied"] = True
1555
+ await session.respond_permission(
1556
+ PermissionResponseEvent(
1557
+ tool_use_id=event.tool_use_id, allowed=False, agent_id=event.agent_id
1558
+ )
1559
+ )
1560
+ return
1561
+ elif choice in ("a", "always"):
1562
+ await session.respond_permission(
1563
+ PermissionResponseEvent(
1564
+ tool_use_id=event.tool_use_id,
1565
+ allowed=True,
1566
+ always_allow=True,
1567
+ agent_id=event.agent_id,
1568
+ )
1569
+ )
1570
+ return
1571
+ elif choice in ("f", "feedback"):
1572
+ try:
1573
+ feedback_session: PromptSession[str] = PromptSession()
1574
+ feedback = await feedback_session.prompt_async(
1575
+ HTML(" Feedback (what to do instead): ")
1576
+ )
1577
+ feedback = feedback.strip()
1578
+ except (EOFError, KeyboardInterrupt):
1579
+ feedback = ""
1580
+ if not is_peer_message and batch_state is not None:
1581
+ batch_state["denied"] = True
1582
+ await session.respond_permission(
1583
+ PermissionResponseEvent(
1584
+ tool_use_id=event.tool_use_id,
1585
+ allowed=False,
1586
+ agent_id=event.agent_id,
1587
+ feedback=feedback or None,
1588
+ )
1589
+ )
1590
+ return
1591
+ else:
1592
+ console.print(" [dim]Please enter y, n, a, or f[/]")
1593
+
1594
+
1595
+ async def _interactive_select(question: str, options: list[str], multiple: bool = False) -> list[str]:
1596
+ """Interactive single/multi select using keyboard navigation.
1597
+
1598
+ Returns a list of selected option strings.
1599
+ """
1600
+ from prompt_toolkit.application import Application
1601
+ from prompt_toolkit.key_binding import KeyBindings
1602
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
1603
+ from prompt_toolkit.formatted_text import FormattedText
1604
+ from prompt_toolkit.styles import Style
1605
+
1606
+ current = 0
1607
+ selected: set[int] = set() if multiple else set()
1608
+
1609
+ def get_text() -> FormattedText:
1610
+ fragments: list[tuple[str, str]] = []
1611
+ # Question
1612
+ fragments.append(("class:question", f" {safe_utf8_str(question)}\n\n"))
1613
+
1614
+ for i, opt in enumerate(options):
1615
+ if multiple:
1616
+ checked = "◉" if i in selected else "○"
1617
+ else:
1618
+ checked = "●" if i == current and not selected else "○"
1619
+ if i in selected:
1620
+ checked = "◉"
1621
+
1622
+ if i == current:
1623
+ prefix = f" ❯ {checked} "
1624
+ style = "class:selected"
1625
+ else:
1626
+ prefix = f" {checked} "
1627
+ style = "class:option"
1628
+
1629
+ fragments.append((style, f"{prefix}{safe_utf8_str(opt)}\n"))
1630
+
1631
+ fragments.append(("", "\n"))
1632
+ if multiple:
1633
+ hint = " ↑↓ navigate · space select · enter confirm · esc cancel"
1634
+ else:
1635
+ hint = " ↑↓ navigate · enter select · esc cancel"
1636
+ fragments.append(("class:hint", hint))
1637
+ return fragments
1638
+
1639
+ class SelectControl(FormattedTextControl):
1640
+ def __init__(self) -> None:
1641
+ super().__init__(text=get_text, focusable=True)
1642
+
1643
+ def move_cursor_down(self) -> None:
1644
+ nonlocal current
1645
+ current = min(current + 1, len(options) - 1)
1646
+
1647
+ def move_cursor_up(self) -> None:
1648
+ nonlocal current
1649
+ current = max(current - 1, 0)
1650
+
1651
+ control = SelectControl()
1652
+
1653
+ kb = KeyBindings()
1654
+
1655
+ @kb.add("up")
1656
+ def _up(event: Any) -> None:
1657
+ control.move_cursor_up()
1658
+
1659
+ @kb.add("down")
1660
+ def _down(event: Any) -> None:
1661
+ control.move_cursor_down()
1662
+
1663
+ @kb.add("j")
1664
+ def _j(event: Any) -> None:
1665
+ control.move_cursor_up()
1666
+
1667
+ @kb.add("k")
1668
+ def _k(event: Any) -> None:
1669
+ control.move_cursor_down()
1670
+
1671
+ if multiple:
1672
+ @kb.add("space")
1673
+ def _toggle(event: Any) -> None:
1674
+ if current in selected:
1675
+ selected.discard(current)
1676
+ else:
1677
+ selected.add(current)
1678
+
1679
+ @kb.add("enter")
1680
+ def _confirm(event: Any) -> None:
1681
+ if multiple:
1682
+ if not selected:
1683
+ selected.add(current)
1684
+ else:
1685
+ selected.clear()
1686
+ selected.add(current)
1687
+ event.app.exit(result=list(selected))
1688
+
1689
+ @kb.add("escape")
1690
+ @kb.add("c-c")
1691
+ def _cancel(event: Any) -> None:
1692
+ event.app.exit(result=None)
1693
+
1694
+ style_dict = {
1695
+ "question": "bold ansicyan",
1696
+ "selected": "bold",
1697
+ "option": "",
1698
+ # prompt_toolkit has no "dim"; use subdued palette color for hints.
1699
+ "hint": "ansibrightblack",
1700
+ }
1701
+
1702
+ layout = Layout(HSplit([Window(content=control, height=len(options) + 4)]))
1703
+
1704
+ app = Application(
1705
+ layout=layout,
1706
+ key_bindings=kb,
1707
+ full_screen=False,
1708
+ style=Style.from_dict(style_dict),
1709
+ )
1710
+
1711
+ result = await app.run_async()
1712
+
1713
+ if result is None:
1714
+ return []
1715
+
1716
+ return [options[i] for i in sorted(result)]
1717
+
1718
+
1719
+ async def _prompt_choice(
1720
+ event: ChoiceRequestEvent,
1721
+ session: CoreSession,
1722
+ ) -> None:
1723
+ """Present an interactive choice to the user and push response to session."""
1724
+ loop = asyncio.get_event_loop()
1725
+
1726
+ if _ANSI_ENABLED and sys.stdin.isatty():
1727
+ try:
1728
+ selected = await _interactive_select(
1729
+ f"{event.question}{f' [{event.agent_id[:8]}]' if event.agent_id else ''}",
1730
+ event.options,
1731
+ event.multiple,
1732
+ )
1733
+ except (EOFError, KeyboardInterrupt):
1734
+ selected = []
1735
+
1736
+ if not selected:
1737
+ await session.respond_choice(
1738
+ ChoiceResponseEvent(
1739
+ tool_use_id=event.tool_use_id,
1740
+ selected=[],
1741
+ cancelled=True,
1742
+ agent_id=event.agent_id,
1743
+ )
1744
+ )
1745
+ else:
1746
+ await session.respond_choice(
1747
+ ChoiceResponseEvent(
1748
+ tool_use_id=event.tool_use_id,
1749
+ selected=selected,
1750
+ agent_id=event.agent_id,
1751
+ )
1752
+ )
1753
+ else:
1754
+ # Fallback for non-interactive terminals: numbered text selection
1755
+ suffix = f" [agent {event.agent_id[:8]}]" if event.agent_id else ""
1756
+ console.print(f"\n [bold cyan]? {event.question}{suffix}[/]")
1757
+ for i, opt in enumerate(event.options, 1):
1758
+ console.print(f" [dim]{i}.[/] {opt}")
1759
+
1760
+ try:
1761
+ default = "1"
1762
+ raw = await loop.run_in_executor(
1763
+ None,
1764
+ lambda: input(f" Enter choice [{default}]: ").strip() or default,
1765
+ )
1766
+ except (EOFError, KeyboardInterrupt):
1767
+ await session.respond_choice(
1768
+ ChoiceResponseEvent(
1769
+ tool_use_id=event.tool_use_id,
1770
+ selected=[],
1771
+ cancelled=True,
1772
+ agent_id=event.agent_id,
1773
+ )
1774
+ )
1775
+ return
1776
+
1777
+ if event.multiple:
1778
+ indices = [int(x.strip()) - 1 for x in raw.split(",") if x.strip().isdigit()]
1779
+ indices = [i for i in indices if 0 <= i < len(event.options)]
1780
+ selected = [event.options[i] for i in indices] if indices else []
1781
+ else:
1782
+ try:
1783
+ idx = int(raw) - 1
1784
+ selected = [event.options[idx]] if 0 <= idx < len(event.options) else []
1785
+ except (ValueError, IndexError):
1786
+ selected = []
1787
+
1788
+ if not selected:
1789
+ await session.respond_choice(
1790
+ ChoiceResponseEvent(
1791
+ tool_use_id=event.tool_use_id,
1792
+ selected=[],
1793
+ cancelled=True,
1794
+ agent_id=event.agent_id,
1795
+ )
1796
+ )
1797
+ else:
1798
+ await session.respond_choice(
1799
+ ChoiceResponseEvent(
1800
+ tool_use_id=event.tool_use_id,
1801
+ selected=selected,
1802
+ agent_id=event.agent_id,
1803
+ )
1804
+ )
1805
+
1806
+
1807
+ async def _consume_background_events(
1808
+ session: CoreSession,
1809
+ ) -> None:
1810
+ """Render events from automatic continuations while the REPL is idle."""
1811
+ text_parts: list[str] = []
1812
+ announced = False
1813
+ permission_batch: dict[str, bool] = {"denied": False}
1814
+
1815
+ async def render(action: Any) -> None:
1816
+ async with in_terminal():
1817
+ action()
1818
+
1819
+ async def announce() -> None:
1820
+ nonlocal announced
1821
+ if announced:
1822
+ return
1823
+ await render(
1824
+ lambda: console.print(
1825
+ "\n [dim cyan]↻ Background agent continuation[/]"
1826
+ )
1827
+ )
1828
+ announced = True
1829
+
1830
+ async def flush_text() -> None:
1831
+ if not text_parts:
1832
+ return
1833
+ body = safe_utf8_str("".join(text_parts)).rstrip()
1834
+ text_parts.clear()
1835
+ if body:
1836
+ await render(lambda: console.print(Text(body)))
1837
+
1838
+ while True:
1839
+ event = await session.next_background_event()
1840
+
1841
+ if isinstance(event, StreamTextEvent):
1842
+ await announce()
1843
+ text_parts.append(event.text)
1844
+ continue
1845
+ if isinstance(event, StreamModeEvent):
1846
+ if event.mode == "compacting":
1847
+ await announce()
1848
+ await render(
1849
+ lambda: console.print(" [dim italic]Compacting conversation…[/]")
1850
+ )
1851
+ continue
1852
+ if isinstance(event, (ThinkingEvent, AgentOutputEvent)):
1853
+ continue
1854
+
1855
+ if isinstance(event, AgentStateEvent):
1856
+ await announce()
1857
+ style = {
1858
+ "queued": "dim",
1859
+ "running": "cyan",
1860
+ "completed": "green",
1861
+ "failed": "red",
1862
+ "cancelled": "yellow",
1863
+ "stopped": "yellow",
1864
+ }.get(event.status, "dim")
1865
+ await render(
1866
+ lambda: console.print(
1867
+ f" [{style}]agent {event.agent_id[:8]} · "
1868
+ f"{event.status} · {event.title}[/]"
1869
+ )
1870
+ )
1871
+ continue
1872
+
1873
+ await announce()
1874
+ await flush_text()
1875
+
1876
+ if isinstance(event, ToolUseEvent):
1877
+ await render(lambda: _render_tool_use(event))
1878
+ elif isinstance(event, ToolResultEvent):
1879
+ await render(lambda: _render_tool_result(event))
1880
+ elif isinstance(event, PermissionRequestEvent):
1881
+ async with in_terminal():
1882
+ await _prompt_permission(event, session, permission_batch)
1883
+ elif isinstance(event, ChoiceRequestEvent):
1884
+ async with in_terminal():
1885
+ await _prompt_choice(event, session)
1886
+ elif isinstance(event, CompactEvent):
1887
+ await render(
1888
+ lambda: console.print(
1889
+ f"[dim italic]Conversation compacted: {event.summary}[/]"
1890
+ )
1891
+ )
1892
+ elif isinstance(event, StreamRetryEvent):
1893
+ await render(
1894
+ lambda: console.print(
1895
+ f" [dim yellow]↻ {event.message}[/]"
1896
+ )
1897
+ )
1898
+ elif isinstance(event, ErrorEvent):
1899
+ await render(lambda: _render_repl_error(event.message))
1900
+ elif isinstance(event, ModeChangeEvent):
1901
+ session.switch_mode(event.mode)
1902
+ await render(
1903
+ lambda: console.print(
1904
+ f" [dim]Background continuation switched to {event.mode} mode[/]"
1905
+ )
1906
+ )
1907
+ elif isinstance(event, PlanReadyEvent):
1908
+ session.set_plan(event.plan)
1909
+ await render(lambda: console.print(" [bold blue]Background plan ready[/]"))
1910
+ elif isinstance(event, PeerMessageEvent):
1911
+ await render(
1912
+ lambda: console.print(
1913
+ f" [dim cyan][peer:{event.from_name}] {event.text[:200]}[/]"
1914
+ )
1915
+ )
1916
+ elif isinstance(event, TeamMessageEvent):
1917
+ await render(
1918
+ lambda: console.print(
1919
+ f" [dim magenta][team:{event.team_id[:8]}] "
1920
+ f"{event.from_agent[:8]} → {event.to_agent[:8]}: "
1921
+ f"{event.text[:100]}[/]"
1922
+ )
1923
+ )
1924
+ elif isinstance(event, TeamStateEvent):
1925
+ await render(
1926
+ lambda: console.print(
1927
+ f" [dim magenta][team:{event.team_id[:8]}] "
1928
+ f"{event.agent_id[:8]} {event.old_state} → {event.new_state}[/]"
1929
+ )
1930
+ )
1931
+ elif isinstance(event, TaskUpdateEvent):
1932
+ await render(
1933
+ lambda: console.print(
1934
+ f" [dim magenta][team:{event.team_id[:8]}] "
1935
+ f"task {event.task_id[:8]} {event.status}[/]"
1936
+ )
1937
+ )
1938
+ elif isinstance(event, ScheduleRunEvent):
1939
+ status_style = "green" if event.status == "success" else "red"
1940
+ detail = event.error_message or event.result_summary or event.status
1941
+ await render(
1942
+ lambda: console.print(
1943
+ f" [dim cyan][schedule:{event.job_id[:8]}] "
1944
+ f"[{status_style}]{event.status}[/] {safe_utf8_str(detail)}[/]"
1945
+ )
1946
+ )
1947
+ elif isinstance(event, TurnCompleteEvent):
1948
+ await render(lambda: _render_context_usage(event))
1949
+ permission_batch["denied"] = False
1950
+ announced = False
1951
+
1952
+
1953
+ async def _stream_agent_until_done(
1954
+ session: CoreSession,
1955
+ target_agent_id: str,
1956
+ ) -> None:
1957
+ active_stream_agent: str | None = None
1958
+ while True:
1959
+ event = await session._agent_event_queue.get() # type: ignore[attr-defined]
1960
+
1961
+ if isinstance(event, AgentOutputEvent):
1962
+ active_stream_agent = _render_agent_text_chunk(event, active_stream_agent)
1963
+ if event.stream == "tool_use" and event.tool_name:
1964
+ _flush_agent_stream_line(active_stream_agent)
1965
+ active_stream_agent = None
1966
+ console.print(
1967
+ f" [dim cyan]↳ agent {event.agent_id[:8]} using {event.tool_name}[/]"
1968
+ )
1969
+ continue
1970
+
1971
+ if isinstance(event, AgentStateEvent):
1972
+ _flush_agent_stream_line(active_stream_agent)
1973
+ active_stream_agent = None
1974
+ style = {
1975
+ "queued": "dim",
1976
+ "running": "cyan",
1977
+ "completed": "green",
1978
+ "failed": "red",
1979
+ "cancelled": "yellow",
1980
+ "stopped": "yellow",
1981
+ }.get(event.status, "dim")
1982
+ console.print(
1983
+ f" [{style}]agent {event.agent_id[:8]} · {event.status} · {event.title}[/]"
1984
+ )
1985
+ if event.agent_id == target_agent_id and event.status in {
1986
+ "completed",
1987
+ "failed",
1988
+ "stopped",
1989
+ "cancelled",
1990
+ }:
1991
+ break
1992
+ continue
1993
+
1994
+ _flush_agent_stream_line(active_stream_agent)
1995
+ active_stream_agent = None
1996
+
1997
+ if isinstance(event, ToolUseEvent):
1998
+ _render_tool_use(event)
1999
+ elif isinstance(event, ToolResultEvent):
2000
+ if event.tool_name == "AskUser":
2001
+ if event.is_error:
2002
+ console.print(" [dim yellow]↳ Selection cancelled[/]")
2003
+ else:
2004
+ console.print(f" [dim green]↳ {safe_utf8_str(event.result)}[/]")
2005
+ else:
2006
+ _render_tool_result(event)
2007
+ elif isinstance(event, PermissionRequestEvent):
2008
+ await _prompt_permission(event, session)
2009
+ elif isinstance(event, ChoiceRequestEvent):
2010
+ await _prompt_choice(event, session)
2011
+ elif isinstance(event, ErrorEvent):
2012
+ _render_repl_error(event.message)
2013
+
2014
+ _flush_agent_stream_line(active_stream_agent)
2015
+
2016
+
2017
+ async def _run_plan_executor_with_runtime_events(
2018
+ session: CoreSession,
2019
+ plan: object,
2020
+ ) -> None:
2021
+ from crabcode_core.plan.executor import PlanExecutor
2022
+
2023
+ merged_events: asyncio.Queue[object] = asyncio.Queue()
2024
+ done_sentinel = object()
2025
+
2026
+ async def _produce_plan_events() -> None:
2027
+ executor = PlanExecutor(
2028
+ plan=plan,
2029
+ spawn_fn=session.spawn_agent,
2030
+ wait_fn=session.wait_agent,
2031
+ cancel_fn=session.cancel_agent,
2032
+ )
2033
+ try:
2034
+ async for plan_event in executor.execute():
2035
+ await merged_events.put(plan_event)
2036
+ finally:
2037
+ await merged_events.put(done_sentinel)
2038
+
2039
+ async def _forward_agent_events() -> None:
2040
+ while True:
2041
+ event = await session._agent_event_queue.get() # type: ignore[attr-defined]
2042
+ await merged_events.put(event)
2043
+
2044
+ producer = asyncio.create_task(_produce_plan_events())
2045
+ forwarder = asyncio.create_task(_forward_agent_events())
2046
+ active_stream_agent: str | None = None
2047
+ batch_state: dict = {"denied": False}
2048
+
2049
+ try:
2050
+ while True:
2051
+ event = await merged_events.get()
2052
+ if event is done_sentinel:
2053
+ break
2054
+
2055
+ if isinstance(event, StreamTextEvent):
2056
+ _flush_agent_stream_line(active_stream_agent)
2057
+ active_stream_agent = None
2058
+ console.print(f" {safe_utf8_str(event.text)}", end="")
2059
+ continue
2060
+
2061
+ if isinstance(event, AgentOutputEvent):
2062
+ active_stream_agent = _render_agent_text_chunk(event, active_stream_agent)
2063
+ if event.stream == "tool_use" and event.tool_name:
2064
+ _flush_agent_stream_line(active_stream_agent)
2065
+ active_stream_agent = None
2066
+ console.print(
2067
+ f" [dim cyan]↳ agent {event.agent_id[:8]} using {event.tool_name}[/]"
2068
+ )
2069
+ continue
2070
+
2071
+ _flush_agent_stream_line(active_stream_agent)
2072
+ active_stream_agent = None
2073
+
2074
+ if isinstance(event, AgentStateEvent):
2075
+ style = {
2076
+ "queued": "dim",
2077
+ "running": "cyan",
2078
+ "completed": "green",
2079
+ "failed": "red",
2080
+ "cancelled": "yellow",
2081
+ "stopped": "yellow",
2082
+ }.get(event.status, "dim")
2083
+ console.print(
2084
+ f" [{style}]agent {event.agent_id[:8]} · {event.status} · {event.title}[/]"
2085
+ )
2086
+ elif isinstance(event, StreamModeEvent):
2087
+ if event.mode == "tool-input":
2088
+ batch_state["denied"] = False
2089
+ elif isinstance(event, StreamRetryEvent):
2090
+ console.print(f" [dim yellow]↻ {event.message}[/]")
2091
+ elif isinstance(event, TurnCompleteEvent):
2092
+ batch_state["denied"] = False
2093
+ elif isinstance(event, ToolUseEvent):
2094
+ _render_tool_use(event)
2095
+ elif isinstance(event, ToolResultEvent):
2096
+ if event.tool_name == "AskUser":
2097
+ if event.is_error:
2098
+ console.print(" [dim yellow]↳ Selection cancelled[/]")
2099
+ else:
2100
+ console.print(f" [dim green]↳ {safe_utf8_str(event.result)}[/]")
2101
+ else:
2102
+ _render_tool_result(event)
2103
+ elif isinstance(event, PermissionRequestEvent):
2104
+ await _prompt_permission(event, session, batch_state)
2105
+ elif isinstance(event, ChoiceRequestEvent):
2106
+ await _prompt_choice(event, session)
2107
+ elif isinstance(event, ErrorEvent):
2108
+ _render_repl_error(event.message)
2109
+ finally:
2110
+ forwarder.cancel()
2111
+ producer.cancel()
2112
+ with contextlib.suppress(asyncio.CancelledError):
2113
+ await forwarder
2114
+ with contextlib.suppress(asyncio.CancelledError):
2115
+ await producer
2116
+ _flush_agent_stream_line(active_stream_agent)
2117
+
2118
+
2119
+ def _print_active_model(
2120
+ settings: CrabCodeSettings,
2121
+ model_name: str | None,
2122
+ *,
2123
+ indent: str = " ",
2124
+ ) -> None:
2125
+ """Print the model that subsequent requests will actually call."""
2126
+ active = settings.get_api_config(model_name)
2127
+ provider = active.provider or "[yellow]not set[/]"
2128
+ model = active.model or "[yellow]not set[/]"
2129
+ label = f"[bold cyan]{model_name}[/] → " if model_name else ""
2130
+ console.print(f"{indent}provider: {provider} model: {label}{model}", style="dim")
2131
+ if not active.model:
2132
+ console.print(
2133
+ f"{indent}[bold yellow]Warning:[/] no model configured. "
2134
+ f"Set [bold]api.model[/] or [bold]models[/] in {escape(str(get_config_home() / 'settings.json'))} "
2135
+ "or use [bold]-m[/] flag.",
2136
+ style="dim",
2137
+ )
2138
+
2139
+
2140
+ async def run_repl(
2141
+ settings: CrabCodeSettings | None = None,
2142
+ cwd: str = ".",
2143
+ resume_session_id: str | None = None,
2144
+ ) -> None:
2145
+ """Run the interactive REPL."""
2146
+ _capture_exit_terminal()
2147
+ if resume_session_id:
2148
+ # Defensive resolution for callers that invoke run_repl directly
2149
+ # (without going through the Typer entry point). Project settings,
2150
+ # tools, and LSP must be initialized against the resumed session's cwd.
2151
+ from crabcode_core.session.storage import SessionStorage
2152
+
2153
+ resolved_storage = SessionStorage.from_session_id(resume_session_id)
2154
+ if resolved_storage is not None:
2155
+ cwd = resolved_storage.cwd
2156
+
2157
+ print_banner(console)
2158
+ console.print(f" cwd: {cwd}", style="dim")
2159
+ # A resumed session may have switched away from the project default.
2160
+ # Print the model only after resume has installed that configuration.
2161
+ if settings and not resume_session_id:
2162
+ _print_active_model(settings, settings.default_model)
2163
+ elif not resume_session_id:
2164
+ console.print(" provider: [yellow]not set[/] model: [yellow]not set[/]", style="dim")
2165
+ console.print(
2166
+ " [bold yellow]Warning:[/] no model configured. "
2167
+ f"Set [bold]api.model[/] or [bold]models[/] in {escape(str(get_config_home() / 'settings.json'))} "
2168
+ "or use [bold]-m[/] flag.",
2169
+ style="dim",
2170
+ )
2171
+ console.print(
2172
+ " Type /help for commands. "
2173
+ "Use /follow-up queue|steer while working; empty Ctrl+S steers the oldest queued message. "
2174
+ f"Ctrl+C interrupts; press again within {_CTRL_C_EXIT_WINDOW_S:.0f}s to exit. "
2175
+ f"Enter sends; Ctrl+J or {_alt_enter_label()} inserts a newline "
2176
+ "(or press Esc, then Enter). Ctrl+D exits.",
2177
+ style="dim",
2178
+ )
2179
+
2180
+ if settings and (
2181
+ settings.permissions.run_everything
2182
+ or settings.permissions.default_mode in {"run_everything", "bypassPermissions"}
2183
+ ):
2184
+ console.print()
2185
+ console.print(
2186
+ " [bold yellow]⚠ WARNING: run_everything mode is enabled.[/] "
2187
+ "All tool calls will execute automatically without asking for permission.",
2188
+ style="yellow",
2189
+ )
2190
+
2191
+ console.print()
2192
+
2193
+ session = CoreSession(cwd=cwd, settings=settings)
2194
+ background_consumer: asyncio.Task[None] | None = None
2195
+ composer: _PersistentComposer | None = None
2196
+ stdout_patch: Any | None = None
2197
+
2198
+ # Pending image attachments for the next user message
2199
+ pending_images: list[dict[str, str]] = []
2200
+
2201
+ global _display_settings
2202
+ _display_settings = settings.display if settings else None
2203
+
2204
+ _progress_line_len = 0
2205
+
2206
+ def _on_tool_event(tool_name: str, event_type: str, data: dict) -> None:
2207
+ nonlocal _progress_line_len
2208
+ if event_type == "progress":
2209
+ msg = data.get("message", "")
2210
+ pct = data.get("percent")
2211
+ bar = ""
2212
+ if pct is not None:
2213
+ filled = int(pct * 20)
2214
+ bar = f" [{'█' * filled}{'░' * (20 - filled)}] {int(pct * 100)}%"
2215
+ line = f" {tool_name}: {msg}{bar}"
2216
+ # Avoid ANSI clear-line control sequences; pad with spaces instead.
2217
+ pad = max(0, _progress_line_len - len(line))
2218
+ sys.stdout.write(f"\r{line}{' ' * pad}")
2219
+ _progress_line_len = len(line)
2220
+ sys.stdout.flush()
2221
+ elif event_type == "ready":
2222
+ if _progress_line_len:
2223
+ sys.stdout.write("\r" + (" " * _progress_line_len) + "\r")
2224
+ sys.stdout.flush()
2225
+ _progress_line_len = 0
2226
+ console.print(f" [green]✓[/] {tool_name}: {data.get('message', 'ready')}")
2227
+
2228
+ session.on_tool_event = _on_tool_event
2229
+
2230
+ computer_use = None
2231
+ try:
2232
+ from crabcode_cli.computer_use import connect_session
2233
+
2234
+ computer_use = await connect_session(session)
2235
+ if resume_session_id:
2236
+ await session.initialize()
2237
+ ok = await session.resume(resume_session_id)
2238
+ if ok:
2239
+ console.print(
2240
+ f" [dim]Resumed session [bold]{resume_session_id[:8]}…[/bold] "
2241
+ f"({len(session.messages)} messages)[/]"
2242
+ )
2243
+ _print_active_model(
2244
+ session.settings,
2245
+ getattr(session, "_current_model_name", None),
2246
+ )
2247
+ console.print()
2248
+ _render_session_history(session.messages)
2249
+ else:
2250
+ console.print(
2251
+ f" [bold yellow]Warning:[/] session {resume_session_id[:8]}… not found, starting fresh.",
2252
+ style="dim",
2253
+ )
2254
+ if settings:
2255
+ _print_active_model(settings, settings.default_model)
2256
+ console.print()
2257
+ else:
2258
+ pass
2259
+
2260
+ composer = _PersistentComposer(session, pending_images)
2261
+ stdout_patch = patch_stdout(raw=True)
2262
+ stdout_patch.__enter__()
2263
+ composer.start()
2264
+ background_consumer = asyncio.create_task(
2265
+ _consume_background_events(session)
2266
+ )
2267
+
2268
+ ctrl_c_exit = _CtrlCDoubleExit()
2269
+ input_state: dict[str, Any] = {
2270
+ "shutdown": False,
2271
+ "deferred": [],
2272
+ "interrupt_requested": False,
2273
+ }
2274
+
2275
+ while True:
2276
+ deferred_inputs = input_state.get("deferred", [])
2277
+ if deferred_inputs:
2278
+ user_input = deferred_inputs.pop(0)
2279
+ else:
2280
+ while True:
2281
+ event_kind, event_text = await composer.next_event()
2282
+ if event_kind in {"submit", "submit_opposite"}:
2283
+ user_input = event_text
2284
+ break
2285
+ if event_kind == "eof":
2286
+ input_state["shutdown"] = True
2287
+ break
2288
+ if event_kind == "interrupt":
2289
+ if ctrl_c_exit.should_exit_now():
2290
+ _force_exit()
2291
+ try:
2292
+ await session.interrupt()
2293
+ except Exception:
2294
+ logger.debug(
2295
+ "Failed to interrupt session after Ctrl+C",
2296
+ exc_info=True,
2297
+ )
2298
+ composer.set_notice(
2299
+ f"Interrupted · Ctrl+C again within "
2300
+ f"{_CTRL_C_EXIT_WINDOW_S:.0f}s to exit"
2301
+ )
2302
+
2303
+ if input_state.get("shutdown"):
2304
+ console.print("\nGoodbye!", style="dim")
2305
+ break
2306
+
2307
+ user_input = user_input.strip()
2308
+ if not user_input:
2309
+ continue
2310
+
2311
+ _render_submitted_input(user_input)
2312
+
2313
+ ctrl_c_exit.clear()
2314
+
2315
+ if user_input.startswith("/"):
2316
+ if user_input.split(None, 1)[0].lower() == "/queue":
2317
+ await _handle_queue_command(user_input, composer)
2318
+ continue
2319
+ # A modal picker shares the terminal's input parser. Stop the
2320
+ # composer first so its pending Escape flush cannot steal keys
2321
+ # from the picker while in_terminal() has detached its input.
2322
+ command_parts = user_input.split()
2323
+ command_name = command_parts[0].lower()
2324
+ modal_picker = (
2325
+ command_parts in (["/resume"], ["/model"])
2326
+ or command_name in {"/add", "/del"}
2327
+ )
2328
+ if modal_picker:
2329
+ await composer.close()
2330
+ async with in_terminal():
2331
+ try:
2332
+ result = await _handle_command(
2333
+ user_input,
2334
+ session,
2335
+ settings,
2336
+ pending_images,
2337
+ )
2338
+ except Exception as exc:
2339
+ logger.exception("REPL command failed")
2340
+ _render_repl_error(f"{type(exc).__name__}: {exc}")
2341
+ continue
2342
+ finally:
2343
+ if modal_picker:
2344
+ composer.start()
2345
+ if result is False:
2346
+ break
2347
+ if isinstance(result, str):
2348
+ user_input = result
2349
+ else:
2350
+ continue
2351
+
2352
+ if user_input.startswith("! "):
2353
+ cmd = user_input[2:]
2354
+ import subprocess
2355
+ async with in_terminal():
2356
+ subprocess.run(managed_process_command(shell_command(cmd)), cwd=cwd, capture_output=False)
2357
+ continue
2358
+
2359
+ streamed_text = ""
2360
+ streamed_text_for_context = ""
2361
+ # The persistent composer itself shows the working state. Avoid a
2362
+ # rapidly redrawn spinner competing with prompt_toolkit for the
2363
+ # terminal's bottom row.
2364
+ spinner = _Spinner(ansi_enabled=_ANSI_ENABLED, visible=False)
2365
+ thinking_start: float = 0.0
2366
+ is_thinking = False
2367
+
2368
+ def _finish_stream_line() -> None:
2369
+ nonlocal streamed_text
2370
+ if streamed_text:
2371
+ if not streamed_text.endswith("\n"):
2372
+ sys.stdout.write("\n")
2373
+ sys.stdout.flush()
2374
+ streamed_text = ""
2375
+
2376
+ async def _stop_spinner_with_thinking() -> None:
2377
+ nonlocal is_thinking
2378
+ composer.stop_activity()
2379
+ if not spinner.is_running:
2380
+ return
2381
+ await spinner.stop()
2382
+ if is_thinking and thinking_start:
2383
+ duration = time.monotonic() - thinking_start
2384
+ if duration >= 1:
2385
+ console.print(
2386
+ f" [dim italic]∴ Thought for {max(1, round(duration))}s[/]"
2387
+ )
2388
+ is_thinking = False
2389
+
2390
+ plan_pending = False
2391
+ input_state["interrupt_requested"] = False
2392
+ composer.set_busy(True)
2393
+
2394
+ async def _consume_turn_input() -> None:
2395
+ while True:
2396
+ event_kind, event_text = await composer.next_event()
2397
+ if event_kind == "steer_queued":
2398
+ await _promote_queued_turn(composer, event_text)
2399
+ continue
2400
+ if event_kind in {"submit", "submit_opposite"}:
2401
+ text = event_text.strip()
2402
+ if not text:
2403
+ continue
2404
+ if text.split(None, 1)[0].lower() == "/queue":
2405
+ await _handle_queue_command(text, composer)
2406
+ continue
2407
+ if text.split(None, 1)[0].lower() in {"/follow-up", "/image"}:
2408
+ try:
2409
+ await _handle_command(text, session, settings, pending_images)
2410
+ except Exception as exc:
2411
+ _render_repl_error(str(exc))
2412
+ composer._invalidate()
2413
+ continue
2414
+ mode = composer.follow_up_mode
2415
+ if event_kind == "submit_opposite":
2416
+ mode = "steer" if mode == "queue" else "queue"
2417
+ try:
2418
+ images = pending_images.copy() if pending_images else None
2419
+ request_id = str(uuid.uuid4())
2420
+ if mode == "queue":
2421
+ accepted = await session.queue_message(text, images=images, request_id=request_id)
2422
+ else:
2423
+ accepted = await session.steer_message(text, images=images)
2424
+ except RuntimeError as exc:
2425
+ _render_repl_error(f"{exc}: {text}")
2426
+ continue
2427
+ if accepted:
2428
+ pending_images.clear()
2429
+ if mode == "queue":
2430
+ composer.add_queued_turn(text, request_id)
2431
+ else:
2432
+ composer.add_guidance(text)
2433
+ continue
2434
+ input_state.setdefault("deferred", []).append(text)
2435
+ return
2436
+
2437
+ if event_kind == "eof":
2438
+ input_state["shutdown"] = True
2439
+ await session.interrupt()
2440
+ return
2441
+
2442
+ if event_kind == "interrupt":
2443
+ if ctrl_c_exit.should_exit_now():
2444
+ _force_exit()
2445
+ else:
2446
+ input_state["interrupt_requested"] = True
2447
+ composer.stop_activity()
2448
+ composer.start_activity("Interrupting")
2449
+ await session.interrupt()
2450
+
2451
+ steering_task = asyncio.create_task(_consume_turn_input())
2452
+ turn_stream = None
2453
+ try:
2454
+ send_images = pending_images.copy() if pending_images else None
2455
+ pending_images.clear()
2456
+ batch_state: dict = {"denied": False}
2457
+ turn_stream = session.send_message(user_input, images=send_images)
2458
+ async for event in turn_stream:
2459
+ if isinstance(event, StreamModeEvent):
2460
+ if event.mode == "requesting":
2461
+ spinner.start()
2462
+ composer.start_activity()
2463
+ is_thinking = False
2464
+ thinking_start = 0.0
2465
+ elif event.mode == "compacting":
2466
+ spinner.start("Compacting")
2467
+ composer.start_activity("Compacting")
2468
+ is_thinking = False
2469
+ thinking_start = 0.0
2470
+ elif event.mode == "thinking":
2471
+ thinking_start = time.monotonic()
2472
+ is_thinking = True
2473
+ spinner.update("Thinking")
2474
+ composer.update_activity("Thinking")
2475
+ elif event.mode == "responding":
2476
+ await _stop_spinner_with_thinking()
2477
+ elif event.mode == "tool-input":
2478
+ batch_state["denied"] = False
2479
+ await _stop_spinner_with_thinking()
2480
+ _finish_stream_line()
2481
+ spinner.start("Generating")
2482
+ composer.start_activity("Generating")
2483
+ elif event.mode == "tool-running":
2484
+ await _stop_spinner_with_thinking()
2485
+ _finish_stream_line()
2486
+ spinner.start("Running")
2487
+ composer.start_activity("Running")
2488
+
2489
+ elif isinstance(event, ThinkingEvent):
2490
+ pass
2491
+
2492
+ elif isinstance(event, StreamTextEvent):
2493
+ await _stop_spinner_with_thinking()
2494
+ chunk = safe_utf8_str(event.text)
2495
+ sys.stdout.write(chunk)
2496
+ # StdoutProxy keeps a partial line until it receives a
2497
+ # newline. Flushing every token makes a persistent
2498
+ # prompt repaint those fragments at the same cursor
2499
+ # position, which visually drops the beginning of the
2500
+ # response. Complete lines are emitted immediately;
2501
+ # _finish_stream_line commits the final partial line.
2502
+ streamed_text += chunk
2503
+ streamed_text_for_context += event.text
2504
+
2505
+ elif isinstance(event, ToolUseEvent):
2506
+ if event.tool_name == "AskUser":
2507
+ pass
2508
+ else:
2509
+ await _stop_spinner_with_thinking()
2510
+ _finish_stream_line()
2511
+ _render_tool_use(event)
2512
+
2513
+ elif isinstance(event, AgentStateEvent):
2514
+ _finish_stream_line()
2515
+ style = {
2516
+ "queued": "dim",
2517
+ "running": "cyan",
2518
+ "completed": "green",
2519
+ "failed": "red",
2520
+ "cancelled": "yellow",
2521
+ "stopped": "yellow",
2522
+ }.get(event.status, "dim")
2523
+ console.print(
2524
+ f" [{style}]agent {event.agent_id[:8]} · {event.status} · {event.title}[/]"
2525
+ )
2526
+
2527
+ elif isinstance(event, AgentOutputEvent):
2528
+ if event.stream == "tool_use" and event.tool_name:
2529
+ _finish_stream_line()
2530
+ console.print(
2531
+ f" [dim cyan]↳ agent {event.agent_id[:8]} using {event.tool_name}[/]"
2532
+ )
2533
+
2534
+ elif isinstance(event, PermissionRequestEvent):
2535
+ await _stop_spinner_with_thinking()
2536
+ _finish_stream_line()
2537
+ async with in_terminal():
2538
+ await _prompt_permission(event, session, batch_state)
2539
+
2540
+ elif isinstance(event, ChoiceRequestEvent):
2541
+ await _stop_spinner_with_thinking()
2542
+ _finish_stream_line()
2543
+ async with in_terminal():
2544
+ await _prompt_choice(event, session)
2545
+
2546
+ elif isinstance(event, ToolResultEvent):
2547
+ _finish_stream_line()
2548
+ if event.tool_name == "AskUser":
2549
+ await _stop_spinner_with_thinking()
2550
+ if event.is_error:
2551
+ console.print(" [dim yellow]↳ Selection cancelled[/]")
2552
+ else:
2553
+ console.print(f" [dim green]↳ {safe_utf8_str(event.result)}[/]")
2554
+ else:
2555
+ await _stop_spinner_with_thinking()
2556
+ _render_tool_result(event)
2557
+
2558
+ elif isinstance(event, CompactEvent):
2559
+ await _stop_spinner_with_thinking()
2560
+ _finish_stream_line()
2561
+ console.print(
2562
+ f"\n[dim italic]Conversation compacted: {event.summary}[/]"
2563
+ )
2564
+
2565
+ elif isinstance(event, StreamRetryEvent):
2566
+ await _stop_spinner_with_thinking()
2567
+ _finish_stream_line()
2568
+ discarded = min(
2569
+ event.discarded_text_chars,
2570
+ len(streamed_text_for_context),
2571
+ )
2572
+ if discarded:
2573
+ streamed_text_for_context = (
2574
+ streamed_text_for_context[:-discarded]
2575
+ )
2576
+ console.print(f" [dim yellow]↻ {event.message}[/]")
2577
+ spinner.start("Reconnecting")
2578
+ composer.start_activity("Reconnecting")
2579
+
2580
+ elif isinstance(event, ErrorEvent):
2581
+ await _stop_spinner_with_thinking()
2582
+ _finish_stream_line()
2583
+ _render_repl_error(event.message)
2584
+ # Non-recoverable means this request cannot be retried
2585
+ # automatically. Drain the stream so Core can commit
2586
+ # history and release the turn before the next input.
2587
+ composer.set_notice("Request failed · retry or use /model <name>")
2588
+ plan_pending = False
2589
+
2590
+ elif isinstance(event, ModeChangeEvent):
2591
+ await _stop_spinner_with_thinking()
2592
+ _finish_stream_line()
2593
+ session.switch_mode(event.mode)
2594
+ if event.mode == "plan":
2595
+ console.print(
2596
+ "\n [bold blue]Switched to plan mode[/] — read-only, agent will only plan"
2597
+ )
2598
+ else:
2599
+ console.print(
2600
+ "\n [bold green]Switched to agent mode[/] — full tool access"
2601
+ )
2602
+
2603
+ elif isinstance(event, TeamMessageEvent):
2604
+ _finish_stream_line()
2605
+ console.print(
2606
+ f" [dim magenta][team:{event.team_id[:8]}] "
2607
+ f"{event.from_agent[:8]} → {event.to_agent[:8]}: "
2608
+ f"{event.text[:100]}[/]"
2609
+ )
2610
+
2611
+ elif isinstance(event, PeerMessageEvent):
2612
+ _finish_stream_line()
2613
+ console.print(
2614
+ f" [dim cyan][peer:{event.from_name}] {event.text[:200]}[/]"
2615
+ )
2616
+
2617
+ elif isinstance(event, TeamStateEvent):
2618
+ _finish_stream_line()
2619
+ console.print(
2620
+ f" [dim magenta][team:{event.team_id[:8]}] "
2621
+ f"{event.agent_id[:8]} {event.old_state} → {event.new_state}[/]"
2622
+ )
2623
+
2624
+ elif isinstance(event, TaskUpdateEvent):
2625
+ _finish_stream_line()
2626
+ console.print(
2627
+ f" [dim magenta][team:{event.team_id[:8]}] "
2628
+ f"task {event.task_id[:8]} {event.status}"
2629
+ f"{f' by {event.assignee[:8]}' if event.assignee else ''}[/]"
2630
+ )
2631
+
2632
+ elif isinstance(event, ScheduleRunEvent):
2633
+ await _stop_spinner_with_thinking()
2634
+ _finish_stream_line()
2635
+ status_style = "green" if event.status == "success" else "red"
2636
+ detail = event.error_message or event.result_summary or event.status
2637
+ console.print(
2638
+ f"\n [dim cyan][schedule:{event.job_id[:8]}] "
2639
+ f"[{status_style}]{event.status}[/] {safe_utf8_str(detail)}[/]"
2640
+ )
2641
+
2642
+ elif isinstance(event, PlanReadyEvent):
2643
+ await _stop_spinner_with_thinking()
2644
+ _finish_stream_line()
2645
+ session.set_plan(event.plan)
2646
+ from crabcode_core.plan.types import ExecutionPlan
2647
+ plan = ExecutionPlan.from_dict(event.plan)
2648
+ console.print(f"\n [bold]Plan received:[/] {plan.title}")
2649
+ console.print(f" [dim]{plan.summary}[/]")
2650
+ console.print(Panel(
2651
+ plan.render(),
2652
+ title="[bold]Execution Plan[/]",
2653
+ border_style="blue",
2654
+ expand=False,
2655
+ ))
2656
+ # Prompt only for a plan produced by this turn. The
2657
+ # session deliberately remains read-only until the
2658
+ # user confirms execution below.
2659
+ plan_pending = True
2660
+
2661
+ elif isinstance(event, TurnCompleteEvent):
2662
+ await _stop_spinner_with_thinking()
2663
+ _finish_stream_line()
2664
+ _render_context_usage(event)
2665
+
2666
+ elif isinstance(event, SteeringAppliedEvent):
2667
+ applied = composer.mark_guidance_applied(event.count)
2668
+ if applied:
2669
+ _finish_stream_line()
2670
+ for guidance in applied:
2671
+ _render_submitted_input(guidance, steering=True)
2672
+
2673
+ elif isinstance(event, QueuedMessageStartedEvent):
2674
+ composer.mark_queued_turn_started(event.request_id)
2675
+ _finish_stream_line()
2676
+ _render_submitted_input(event.text)
2677
+
2678
+ except _REPL_INTERRUPT_EXCS:
2679
+ await spinner.stop()
2680
+ if ctrl_c_exit.should_exit_now():
2681
+ _force_exit()
2682
+ _clear_sigint_cancel()
2683
+ try:
2684
+ await session.interrupt()
2685
+ except Exception:
2686
+ logger.debug("Failed to interrupt session while streaming", exc_info=True)
2687
+ if streamed_text_for_context.strip():
2688
+ _persist_partial_assistant_for_interrupt(
2689
+ session, streamed_text_for_context
2690
+ )
2691
+ composer.set_notice(
2692
+ f"Interrupted · Ctrl+C again within "
2693
+ f"{_CTRL_C_EXIT_WINDOW_S:.0f}s to exit"
2694
+ )
2695
+ except Exception as exc:
2696
+ # Initialization, request preparation and rendering can fail
2697
+ # outside query_loop's ErrorEvent boundary. Keep the session
2698
+ # and composer alive so the user can select a working model.
2699
+ logger.exception("REPL request failed")
2700
+ _finish_stream_line()
2701
+ _render_repl_error(f"{type(exc).__name__}: {exc}")
2702
+ composer.set_notice("Request failed · retry or use /model <name>")
2703
+ plan_pending = False
2704
+ finally:
2705
+ steering_task.cancel()
2706
+ steering_results = await asyncio.gather(steering_task, return_exceptions=True)
2707
+ if isinstance(steering_results[0], Exception):
2708
+ _render_repl_error(str(steering_results[0]))
2709
+ try:
2710
+ if turn_stream is not None:
2711
+ await turn_stream.aclose()
2712
+ except Exception as exc:
2713
+ logger.exception("Failed to close REPL request stream")
2714
+ _render_repl_error(f"{type(exc).__name__}: {exc}")
2715
+ plan_pending = False
2716
+ await _stop_spinner_with_thinking()
2717
+ composer.set_busy(False)
2718
+ for pending in composer.cancel_pending_follow_ups():
2719
+ console.print(f" [yellow]Not sent (run stopped):[/] {escape(pending)}")
2720
+
2721
+ if input_state.pop("interrupt_requested", False):
2722
+ if streamed_text_for_context.strip():
2723
+ _persist_partial_assistant_for_interrupt(
2724
+ session,
2725
+ streamed_text_for_context,
2726
+ )
2727
+ composer.set_notice(
2728
+ f"Interrupted · Ctrl+C again within "
2729
+ f"{_CTRL_C_EXIT_WINDOW_S:.0f}s to exit"
2730
+ )
2731
+ elif not composer.has_notice:
2732
+ composer.set_busy(False)
2733
+
2734
+ if input_state.get("shutdown"):
2735
+ console.print("\nGoodbye!", style="dim")
2736
+ break
2737
+
2738
+ if streamed_text:
2739
+ sys.stdout.write("\n")
2740
+ sys.stdout.flush()
2741
+
2742
+ # Execute plan after the send_message generator has fully completed,
2743
+ # to avoid deadlock with the event queue inside send_message.
2744
+ if plan_pending:
2745
+ async with in_terminal():
2746
+ await _prompt_plan_action(session, console)
2747
+
2748
+ console.print()
2749
+ finally:
2750
+ with _shutdown_deadline():
2751
+ if background_consumer is not None:
2752
+ background_consumer.cancel()
2753
+ with contextlib.suppress(asyncio.CancelledError):
2754
+ await background_consumer
2755
+ try:
2756
+ await session.close()
2757
+ finally:
2758
+ try:
2759
+ if computer_use is not None:
2760
+ await computer_use.close()
2761
+ finally:
2762
+ if composer is not None:
2763
+ await composer.close()
2764
+ if stdout_patch is not None:
2765
+ stdout_patch.__exit__(None, None, None)
2766
+
2767
+
2768
+ async def _prompt_plan_action(session: CoreSession, console: Console) -> None:
2769
+ """Prompt user to execute, modify, or cancel a pending plan."""
2770
+ from crabcode_core.plan.types import ExecutionPlan
2771
+
2772
+ plan_data = session.current_plan
2773
+ if not plan_data:
2774
+ return
2775
+
2776
+ plan = ExecutionPlan.from_dict(plan_data) if isinstance(plan_data, dict) else plan_data
2777
+
2778
+ console.print()
2779
+ console.print(
2780
+ " [bold]What would you like to do?[/]\n"
2781
+ " [bold green]y[/] / [bold green]yes[/] — execute the plan\n"
2782
+ " [bold blue]m[/] / [bold blue]modify[/] — request changes (stay in plan mode)\n"
2783
+ " [bold red]n[/] / [bold red]no[/] — cancel the plan"
2784
+ )
2785
+
2786
+ try:
2787
+ choice_session: PromptSession[str] = PromptSession()
2788
+ answer = await choice_session.prompt_async(
2789
+ HTML("<b><ansicyan>plan❯ </ansicyan></b>"),
2790
+ )
2791
+ answer = answer.strip().lower()
2792
+ except (EOFError, KeyboardInterrupt):
2793
+ answer = "n"
2794
+
2795
+ if answer in ("y", "yes", "execute", "run"):
2796
+ total_steps, stale_reset = plan.prepare_for_execution()
2797
+ first_wave = len(plan.get_ready_steps())
2798
+ session.set_plan(None)
2799
+ session.switch_mode("agent")
2800
+ stale_note = (
2801
+ f", {stale_reset} stale status(es) reset to pending"
2802
+ if stale_reset
2803
+ else ""
2804
+ )
2805
+ console.print(
2806
+ "\n [bold cyan]Executing plan via DAG scheduler...[/]\n"
2807
+ f" [dim]Plan confirmed (y): scheduling {total_steps} step(s)"
2808
+ f", {first_wave} runnable now{stale_note}[/]\n"
2809
+ )
2810
+ logger.info(
2811
+ "Plan confirmed (y): title=%r steps=%d first_wave=%d stale_reset=%d",
2812
+ plan.title,
2813
+ total_steps,
2814
+ first_wave,
2815
+ stale_reset,
2816
+ )
2817
+ try:
2818
+ await _run_plan_executor_with_runtime_events(session, plan)
2819
+ except _REPL_INTERRUPT_EXCS:
2820
+ _clear_sigint_cancel()
2821
+ console.print("\n [dim yellow]Plan execution interrupted.[/]")
2822
+ console.print(f"\n [dim]{plan.render()}[/]\n")
2823
+
2824
+ elif answer in ("m", "modify", "edit", "change"):
2825
+ session.switch_mode("plan")
2826
+ console.print(
2827
+ "\n [bold blue]Staying in plan mode.[/] "
2828
+ "Describe the changes you want — the plan will be revised.\n"
2829
+ " [dim]The current plan is preserved in context.[/]"
2830
+ )
2831
+
2832
+ else:
2833
+ session.set_plan(None)
2834
+ console.print(" [dim]Plan cancelled.[/]")
2835
+
2836
+
2837
+ async def _handle_command(
2838
+ command: str,
2839
+ session: CoreSession,
2840
+ settings: CrabCodeSettings | None,
2841
+ pending_images: list[dict[str, str]] | None = None,
2842
+ ) -> bool | str:
2843
+ """Handle slash commands.
2844
+
2845
+ Returns:
2846
+ True — command handled, continue the REPL loop.
2847
+ False — exit requested.
2848
+ str — expand the returned string as a user message (skill invocation).
2849
+ """
2850
+ parts = command.strip().split(None, 1)
2851
+ cmd = parts[0].lower()
2852
+ arg = parts[1].strip() if len(parts) > 1 else ""
2853
+
2854
+ # --- Built-in commands take priority over skill names ---
2855
+ skills = getattr(session, "skills", [])
2856
+
2857
+ if cmd == "/follow-up":
2858
+ if arg and arg not in {"queue", "steer"}:
2859
+ console.print("[yellow]Usage: /follow-up [queue|steer][/]")
2860
+ return True
2861
+ if arg:
2862
+ from crabcode_core.config.manager import ConfigManager
2863
+ ConfigManager(cwd=session.cwd).update_settings("userSettings", {"follow_up_mode": arg})
2864
+ session.settings.follow_up_mode = arg
2865
+ console.print(f" Follow-up mode: [bold]{session.settings.follow_up_mode}[/] · Ctrl+S / Ctrl+Enter sends the opposite while working")
2866
+ return True
2867
+
2868
+ if cmd == "/image":
2869
+ if not arg:
2870
+ if pending_images is not None and pending_images:
2871
+ console.print(f"[dim]{len(pending_images)} image(s) attached for next message.[/]")
2872
+ else:
2873
+ console.print("[dim]Usage: /image <path> [path2 ...][/]")
2874
+ return True
2875
+ import base64
2876
+ import mimetypes
2877
+ from pathlib import Path
2878
+
2879
+ paths = arg.split()
2880
+ added = 0
2881
+ for p in paths:
2882
+ img_path = Path(p).expanduser()
2883
+ if not img_path.exists():
2884
+ console.print(f"[bold red]File not found: {p}[/]")
2885
+ continue
2886
+ if img_path.stat().st_size > 20 * 1024 * 1024:
2887
+ console.print(f"[bold red]File too large (>20MB): {p}[/]")
2888
+ continue
2889
+ media_type = mimetypes.guess_type(str(img_path))[0] or "image/png"
2890
+ if not media_type.startswith("image/"):
2891
+ console.print(f"[bold red]Not an image: {p} (detected: {media_type})[/]")
2892
+ continue
2893
+ with open(img_path, "rb") as f:
2894
+ data = base64.b64encode(f.read()).decode("ascii")
2895
+ if pending_images is not None:
2896
+ pending_images.append({"media_type": media_type, "data": data})
2897
+ added += 1
2898
+ size_kb = img_path.stat().st_size / 1024
2899
+ console.print(f" [green]✓[/] Attached: {p} ({media_type}, {size_kb:.0f}KB)")
2900
+ if added and pending_images is not None:
2901
+ console.print(f"[dim]{len(pending_images)} image(s) will be sent with your next message.[/]")
2902
+ return True
2903
+
2904
+ if cmd == "/help":
2905
+ skills_section = ""
2906
+ if skills:
2907
+ skill_lines = "\n".join(
2908
+ f"[bold]/{s.name}[/] — {s.description or s.when_to_use or 'skill'}"
2909
+ for s in skills
2910
+ )
2911
+ skills_section = f"\n\n[bold]Skills[/]\n{skill_lines}"
2912
+ console.print(Panel(
2913
+ "[bold]/help[/] — show this help\n"
2914
+ "[bold]/follow-up [queue|steer][/] — choose how follow-ups are sent (saved)\n"
2915
+ "[bold]/queue [steer [number]][/] — list queued messages or move one into the active turn\n"
2916
+ "[bold]/goal [objective][/] — set or view the persistent task goal\n"
2917
+ "[bold]/goal edit <objective>[/] — edit the current goal\n"
2918
+ "[bold]/goal pause|resume|complete|blocked|clear[/] — manage goal state\n"
2919
+ "[bold]/plan[/] — switch to plan mode (read-only analysis)\n"
2920
+ "[bold]/agent[/] — switch to agent mode (full execution; no args)\n"
2921
+ "[bold]/plan-status[/] — show current plan status\n"
2922
+ "[bold]/agents[/] — list managed agents\n"
2923
+ "[bold]/peers[/] — list messageable CrabCode sessions\n"
2924
+ "[bold]/tasks[/] — list background agents and monitors\n"
2925
+ "[bold]/tasks stop <id>[/] — stop a background task\n"
2926
+ "[bold]/agent <id>[/] — show a managed agent\n"
2927
+ "[bold]/agent-log <id>[/] — show an agent transcript\n"
2928
+ "[bold]/agent-send <id> <prompt>[/] — send more input to an agent\n"
2929
+ "[bold]/wait <id>[/] — wait for a managed agent\n"
2930
+ "[bold]/cancel-agent <id>[/] — cancel a managed agent\n"
2931
+ "[bold]/team list[/] — list active teams\n"
2932
+ "[bold]/team create <name> [max][/] — create a team\n"
2933
+ "[bold]/team status <id>[/] — show team status\n"
2934
+ "[bold]/team messages <id>[/] — show team messages\n"
2935
+ "[bold]/team tasks <id>[/] — show the team task board\n"
2936
+ "[bold]/team spawn <id> [options] <prompt>[/] — add a teammate\n"
2937
+ "[bold]/team message <id> <agent> <text>[/] — message a teammate\n"
2938
+ "[bold]/team broadcast <id> <text>[/] — message all teammates\n"
2939
+ "[bold]/team task-add <id> <description>[/] — add a team task\n"
2940
+ "[bold]/team task-claim <id> <task> [agent][/] — claim a task\n"
2941
+ "[bold]/team task-complete <id> <task> [result][/] — complete a task\n"
2942
+ "[bold]/team shutdown <id>[/] — shut down a team\n"
2943
+ "[bold]/schedule list [filters][/] — list scheduled tasks\n"
2944
+ "[bold]/schedule show <id>[/] — show a scheduled task\n"
2945
+ "[bold]/schedule runs <id> [filters][/] — show execution history\n"
2946
+ "[bold]/schedule create [options] <name> <type> <schedule> <prompt>[/] — create a task\n"
2947
+ "[bold]/schedule pause|resume|run|cancel <id>[/] — manage a task\n"
2948
+ "[bold]/status[/] — show session status (model, effort, context, Computer Use)\n"
2949
+ "[bold]/effort [auto|none|minimal|low|medium|high|xhigh|max][/] — show/set reasoning effort\n"
2950
+ "[bold]/ultra [true|false][/] — toggle or explicitly set ultra mode\n"
2951
+ "[bold]/logs[/] — show background tool logs summary\n"
2952
+ "[bold]/logs <name>[/] — show a background log tail\n"
2953
+ "[bold]/logs --tail 200 <name>[/] — show more log lines\n"
2954
+ "[bold]/logs -f <name>[/] — follow a background log live\n"
2955
+ "[bold]/logs --clear <name>[/] — clear a background log\n"
2956
+ "[bold]/model[/] — browse and switch configured models\n"
2957
+ "[bold]/model <name>[/] — switch to a named model\n"
2958
+ "[bold]/add [model|group][/] — add a model profile or shared group\n"
2959
+ "[bold]/del [model|group][/] — delete a model profile or shared group\n"
2960
+ "[bold]/new[/] — start a new session\n"
2961
+ "[bold]/compact[/] — compact conversation\n"
2962
+ "[bold]/clear[/] — clear conversation history\n"
2963
+ "[bold]/sessions[/] — list recent sessions\n"
2964
+ "[bold]/recent[/] — list recent sessions across all projects\n"
2965
+ "[bold]/search <query>[/] — search sessions by title or message\n"
2966
+ "[bold]/archive <id>[/] — archive a session\n"
2967
+ "[bold]/export [md|json] [path][/] — export current session\n"
2968
+ "[bold]/stats[/] — usage statistics\n"
2969
+ "[bold]/checkpoint [label][/] — create checkpoint (with file snapshot)\n"
2970
+ "[bold]/checkpoints[/] — list checkpoints\n"
2971
+ "[bold]/rollback <id|#>[/] — rollback conversation to a checkpoint\n"
2972
+ "[bold]/revert <id|#>[/] — revert files + conversation to a checkpoint\n"
2973
+ "[bold]/undo[/] — revert last checkpoint (files + conversation)\n"
2974
+ "[bold]/resume [id][/] — browse history or resume a previous session\n"
2975
+ "[bold]/fork [message-uuid][/] — fork at an assistant reply\n"
2976
+ "[bold]/image <path>[/] — attach image(s) to your next message\n"
2977
+ "[bold]/exit[/] — exit CrabCode\n"
2978
+ f"[bold]Ctrl+C[/] — interrupt; press again within {_CTRL_C_EXIT_WINDOW_S:.0f}s to exit\n"
2979
+ "[bold]While working[/] — Enter uses /follow-up mode; Ctrl+S / Ctrl+Enter sends the opposite\n"
2980
+ "[bold]Empty input while working[/] — Ctrl+S / Ctrl+Enter steers the oldest queued message\n"
2981
+ "\n"
2982
+ "[bold]! <cmd>[/] — run a shell command"
2983
+ + skills_section,
2984
+ title="[bold]Commands[/]",
2985
+ border_style="blue",
2986
+ ))
2987
+ return True
2988
+
2989
+ if cmd == "/goal":
2990
+ from crabcode_core.command_line import split_command_arguments
2991
+
2992
+ def render_goal() -> None:
2993
+ goal = session.get_goal()
2994
+ if goal is None:
2995
+ console.print("[dim]No goal is set.[/]")
2996
+ return
2997
+ status_styles = {
2998
+ "active": "green",
2999
+ "paused": "yellow",
3000
+ "complete": "cyan",
3001
+ "blocked": "red",
3002
+ }
3003
+ style = status_styles.get(goal.status, "white")
3004
+ body = Text(goal.objective)
3005
+ body.append("\n\nStatus: ")
3006
+ body.append(goal.status, style=style)
3007
+ if goal.token_budget is not None:
3008
+ remaining = goal.remaining_tokens
3009
+ body.append(
3010
+ f"\nTokens: {goal.tokens_used:,} / {goal.token_budget:,} "
3011
+ f"({remaining:,} remaining)"
3012
+ )
3013
+ console.print(Panel(body, title="Goal", border_style=style, expand=False))
3014
+
3015
+ if not arg or arg.lower() in {"show", "status", "view"}:
3016
+ render_goal()
3017
+ return True
3018
+
3019
+ try:
3020
+ tokens = split_command_arguments(arg)
3021
+ except ValueError as exc:
3022
+ console.print(f"[bold red]Invalid goal command:[/] {exc}")
3023
+ return True
3024
+ if not tokens:
3025
+ render_goal()
3026
+ return True
3027
+
3028
+ action = tokens[0].lower()
3029
+ if action in {"pause", "resume", "complete", "blocked"}:
3030
+ if len(tokens) != 1:
3031
+ console.print(f"[dim]Usage: /goal {action}[/]")
3032
+ return True
3033
+ status = {
3034
+ "pause": "paused",
3035
+ "resume": "active",
3036
+ }.get(action, action)
3037
+ try:
3038
+ session.update_goal(status)
3039
+ except (RuntimeError, ValueError) as exc:
3040
+ console.print(f"[bold red]{exc}[/]")
3041
+ return True
3042
+ render_goal()
3043
+ return True
3044
+
3045
+ if action == "clear":
3046
+ if len(tokens) != 1:
3047
+ console.print("[dim]Usage: /goal clear[/]")
3048
+ return True
3049
+ session.clear_goal()
3050
+ console.print("[dim]Goal cleared.[/]")
3051
+ return True
3052
+
3053
+ edit = action == "edit"
3054
+ if action in {"set", "edit"}:
3055
+ tokens = tokens[1:]
3056
+
3057
+ token_budget: int | None = None
3058
+ budget_was_set = False
3059
+ objective_parts: list[str] = []
3060
+ index = 0
3061
+ while index < len(tokens):
3062
+ token = tokens[index]
3063
+ if token == "--no-budget":
3064
+ token_budget = None
3065
+ budget_was_set = True
3066
+ index += 1
3067
+ continue
3068
+ if token == "--budget":
3069
+ if index + 1 >= len(tokens):
3070
+ console.print("[dim]Usage: /goal [set|edit] [--budget N] <objective>[/]")
3071
+ return True
3072
+ try:
3073
+ token_budget = int(tokens[index + 1])
3074
+ except ValueError:
3075
+ console.print("[bold red]Goal token budget must be a positive integer.[/]")
3076
+ return True
3077
+ budget_was_set = True
3078
+ index += 2
3079
+ continue
3080
+ if token.startswith("--budget="):
3081
+ try:
3082
+ token_budget = int(token.split("=", 1)[1])
3083
+ except ValueError:
3084
+ console.print("[bold red]Goal token budget must be a positive integer.[/]")
3085
+ return True
3086
+ budget_was_set = True
3087
+ index += 1
3088
+ continue
3089
+ objective_parts.append(token)
3090
+ index += 1
3091
+
3092
+ objective = " ".join(objective_parts).strip()
3093
+ if not objective:
3094
+ console.print("[dim]Usage: /goal [set|edit] [--budget N] <objective>[/]")
3095
+ return True
3096
+ try:
3097
+ if edit:
3098
+ if budget_was_set:
3099
+ session.edit_goal(objective, token_budget=token_budget)
3100
+ else:
3101
+ session.edit_goal(objective)
3102
+ else:
3103
+ session.create_goal(objective, token_budget=token_budget)
3104
+ except (RuntimeError, ValueError) as exc:
3105
+ console.print(f"[bold red]{exc}[/]")
3106
+ return True
3107
+ render_goal()
3108
+ return True
3109
+
3110
+ if cmd == "/plan":
3111
+ session.switch_mode("plan")
3112
+ console.print("[bold blue]Switched to plan mode[/] — read-only, agent will only plan")
3113
+ return True
3114
+
3115
+ if cmd == "/agent" and not arg:
3116
+ session.switch_mode("agent")
3117
+ console.print("[bold green]Switched to agent mode[/] — full tool access")
3118
+ return True
3119
+
3120
+ if cmd == "/plan-status":
3121
+ plan_data = session.current_plan
3122
+ if plan_data:
3123
+ from crabcode_core.plan.types import ExecutionPlan
3124
+ plan = ExecutionPlan.from_dict(plan_data) if isinstance(plan_data, dict) else plan_data
3125
+ console.print(Panel(
3126
+ plan.render(),
3127
+ title="[bold]Current Plan[/]",
3128
+ border_style="blue",
3129
+ expand=False,
3130
+ ))
3131
+ else:
3132
+ console.print("[dim]No active plan.[/]")
3133
+ mode = getattr(session, '_agent_mode', 'agent')
3134
+ console.print(f" Mode: [bold]{'plan' if mode == 'plan' else 'agent'}[/]")
3135
+ return True
3136
+
3137
+ if cmd == "/logs":
3138
+ try:
3139
+ from crabcode_search.background import list_background_logs, read_background_status
3140
+
3141
+ logs = list_background_logs(session.cwd)
3142
+ bg_status = read_background_status(session.cwd)
3143
+ except Exception:
3144
+ logger.debug("Failed to load background logs metadata", exc_info=True)
3145
+ logs = {}
3146
+ bg_status = None
3147
+
3148
+ follow, clear, tail, name, parse_error = _parse_logs_args(arg)
3149
+ if parse_error:
3150
+ console.print(f"[dim]Usage error: {parse_error}[/]")
3151
+ return True
3152
+
3153
+ if not name:
3154
+ if not logs:
3155
+ console.print("[dim]No background logs found.[/]")
3156
+ return True
3157
+
3158
+ lines = []
3159
+ for name, path_str in sorted(logs.items()):
3160
+ log_path = Path(path_str)
3161
+ try:
3162
+ mtime = log_path.stat().st_mtime
3163
+ except OSError:
3164
+ mtime = None
3165
+ meta: list[str] = [f"updated={_format_timestamp(mtime)}"]
3166
+ if name == "search" and bg_status:
3167
+ state = bg_status.get("state")
3168
+ if state:
3169
+ meta.append(f"state={state}")
3170
+ lines.append(f"[bold]{name}[/] — {path_str} · {' · '.join(meta)}")
3171
+
3172
+ console.print(Panel(
3173
+ "\n".join(lines),
3174
+ title="[bold]Background Logs[/]",
3175
+ border_style="blue",
3176
+ expand=False,
3177
+ ))
3178
+ return True
3179
+
3180
+ try:
3181
+ path_str = logs[name]
3182
+ except KeyError:
3183
+ available = ", ".join(sorted(logs)) if logs else "(none)"
3184
+ console.print(f"[dim]Unknown log: {name}. Available: {available}[/]")
3185
+ return True
3186
+
3187
+ log_path = Path(path_str)
3188
+ if clear:
3189
+ try:
3190
+ log_path.write_text("", encoding="utf-8")
3191
+ except OSError as exc:
3192
+ console.print(f"[bold red]Failed to clear log {name}: {exc}[/]")
3193
+ return True
3194
+ console.print(f"[dim]Cleared log: {name}[/]")
3195
+ if not follow:
3196
+ return True
3197
+
3198
+ if follow:
3199
+ await _follow_log(log_path, name)
3200
+ return True
3201
+
3202
+ body = _read_log_tail(log_path, max_lines=tail)
3203
+ console.print(Panel(
3204
+ Text(body, style="dim"),
3205
+ title=f"[bold]Log: {name}[/]",
3206
+ border_style="blue",
3207
+ expand=False,
3208
+ ))
3209
+ return True
3210
+
3211
+ if cmd == "/model":
3212
+ if not arg:
3213
+ from crabcode_cli.model_picker import select_model
3214
+
3215
+ arg = await select_model(session)
3216
+ if not arg or arg == getattr(session, "_current_model_name", None):
3217
+ return True
3218
+
3219
+ # /model <name> — switch to named model
3220
+ named = session.list_models()
3221
+ if not named:
3222
+ console.print("[dim]No named models configured in settings. Add a [bold]models[/] section to settings.json.[/]")
3223
+ return True
3224
+
3225
+ if arg not in named:
3226
+ console.print(f"[bold red]Unknown model: {arg}[/] Available: {', '.join(named)}")
3227
+ return True
3228
+
3229
+ ok = session.switch_model(arg)
3230
+ if ok:
3231
+ active_cfg = session.settings.get_api_config(arg)
3232
+ console.print(
3233
+ f"[green]✓[/] Switched to [bold cyan]{arg}[/] "
3234
+ f"({active_cfg.provider or 'anthropic'}/{active_cfg.model or 'default'}; "
3235
+ f"group={active_cfg.group or 'default'})"
3236
+ )
3237
+ else:
3238
+ console.print(f"[bold red]Failed to switch model to: {arg}[/]")
3239
+ return True
3240
+
3241
+ if cmd in {"/add", "/del"}:
3242
+ kind_value = arg.casefold()
3243
+ if kind_value not in {"", "model", "group"}:
3244
+ console.print(f"[dim]Usage: {cmd} [model|group][/]")
3245
+ return True
3246
+
3247
+ from crabcode_cli.model_manager import manage_model_catalog
3248
+ from crabcode_core.config.model_catalog import ModelCatalogError
3249
+
3250
+ try:
3251
+ result = await manage_model_catalog(
3252
+ session,
3253
+ "add" if cmd == "/add" else "delete",
3254
+ kind_value or None,
3255
+ )
3256
+ except ModelCatalogError as exc:
3257
+ error = Text("Model configuration error: ", style="bold red")
3258
+ error.append(str(exc))
3259
+ console.print(error)
3260
+ return True
3261
+ if result is None:
3262
+ console.print("[dim]Model configuration unchanged.[/]")
3263
+ return True
3264
+
3265
+ message = Text()
3266
+ message.append("✓ ", style="green")
3267
+ message.append("Added" if result.action == "add" else "Deleted", style="bold green")
3268
+ message.append(f" {result.kind} ")
3269
+ message.append(result.name, style="bold cyan")
3270
+ message.append(f" · {result.path}", style="dim")
3271
+ if result.cleared_default:
3272
+ message.append(" · cleared this layer's default", style="yellow")
3273
+ console.print(message)
3274
+ if result.action == "add" and result.kind == "model":
3275
+ console.print(Text(f" Switch now with /model {result.name}", style="dim"))
3276
+ return True
3277
+
3278
+ if cmd == "/effort":
3279
+ if not arg:
3280
+ current = session.reasoning_effort or "auto"
3281
+ available = " | ".join(("auto", *REASONING_EFFORT_LEVELS))
3282
+ console.print(
3283
+ f"Reasoning effort: [bold cyan]{current}[/] "
3284
+ f"[dim](set with /effort <{available}>)[/]"
3285
+ )
3286
+ return True
3287
+
3288
+ effort = arg.lower()
3289
+ if not session.set_reasoning_effort(effort):
3290
+ available = " | ".join(("auto", *REASONING_EFFORT_LEVELS))
3291
+ console.print(
3292
+ f"[bold red]Invalid effort: {arg}[/] "
3293
+ f"[dim]Usage: /effort <{available}>[/]"
3294
+ )
3295
+ return True
3296
+ console.print(
3297
+ f"[green]✓[/] Reasoning effort set to [bold cyan]{effort}[/] "
3298
+ "for subsequent requests."
3299
+ )
3300
+ return True
3301
+
3302
+ if cmd == "/ultra":
3303
+ enabled: bool | None = None
3304
+ if arg:
3305
+ value = arg.lower()
3306
+ if value not in {"true", "false"}:
3307
+ console.print(
3308
+ f"[bold red]Invalid ultra mode value: {arg}[/] "
3309
+ "[dim]Usage: /ultra [true|false][/]"
3310
+ )
3311
+ return True
3312
+ enabled = value == "true"
3313
+
3314
+ ultra_enabled = session.set_ultra_mode(enabled)
3315
+ state = "on" if ultra_enabled else "off"
3316
+ style = "bold green" if ultra_enabled else "bold yellow"
3317
+ console.print(
3318
+ f"[green]✓[/] Ultra mode is now [{style}]{state}[/] "
3319
+ "for subsequent requests."
3320
+ )
3321
+ return True
3322
+
3323
+ if cmd == "/status":
3324
+ from crabcode_cli.computer_use import session_status as computer_use_status
3325
+ from crabcode_core.api.model_info import DEFAULT_CONTEXT_WINDOW, lookup_context_window
3326
+ from crabcode_core.compact.compact import estimate_token_count
3327
+
3328
+ initialized = getattr(session, "_initialized", False)
3329
+
3330
+ current_name = getattr(session, "_current_model_name", None)
3331
+ active_cfg = session.settings.get_api_config(current_name)
3332
+ provider = active_cfg.provider or "anthropic"
3333
+ model = active_cfg.model or "unknown"
3334
+ model_display = f"{current_name} → " if current_name else ""
3335
+ model_display += f"{provider}/{model}"
3336
+
3337
+ ctx_used = getattr(session, "last_context_used_tokens", 0) or estimate_token_count(session.messages)
3338
+ ctx_window = (
3339
+ getattr(session, "last_context_window_tokens", 0)
3340
+ or session.settings.max_context_length
3341
+ or active_cfg.context_window
3342
+ )
3343
+ adapter = getattr(session, "_api_adapter", None)
3344
+ if not ctx_window and provider == "codex" and hasattr(
3345
+ adapter, "resolve_context_window"
3346
+ ):
3347
+ try:
3348
+ ctx_window = await adapter.resolve_context_window()
3349
+ except Exception:
3350
+ ctx_window = 0
3351
+ ctx_window = (
3352
+ ctx_window
3353
+ or lookup_context_window(active_cfg.model)
3354
+ or DEFAULT_CONTEXT_WINDOW
3355
+ )
3356
+ ctx_pct = int(ctx_used / ctx_window * 100) if ctx_window else 0
3357
+ ctx_note = " (Local estimate: server-side count not yet available)" if getattr(session, "last_context_token_source", "estimated") == "estimated" else ""
3358
+
3359
+ def _fmt_k(n: int) -> str:
3360
+ return f"{n // 1000}k" if n >= 1000 else str(n)
3361
+
3362
+ thinking = "on" if active_cfg.thinking_enabled else "off"
3363
+ effort = session.reasoning_effort or "auto"
3364
+ ultra = "on" if session.ultra_mode else "off"
3365
+ max_tok = active_cfg.max_tokens
3366
+
3367
+ sid = session.session_id or "(none)"
3368
+ sid_short = sid[:8] + "…" if len(sid) > 8 else sid
3369
+ msg_count = len(session.messages)
3370
+ compact_count = getattr(session, "compact_count", 0)
3371
+ auto_compact = "on" if session.settings.auto_compact_enabled else "off"
3372
+ search_status = None
3373
+
3374
+ if initialized:
3375
+ tool_count = len([t for t in session.tools if t.is_enabled])
3376
+ tool_display = str(tool_count)
3377
+ else:
3378
+ tool_display = "[dim]not loaded[/]"
3379
+
3380
+ if "crabcode_search.CodebaseSearchTool" in session.settings.extra_tools:
3381
+ try:
3382
+ from crabcode_search.background import read_background_status
3383
+
3384
+ bg_status = read_background_status(session.cwd)
3385
+ except Exception:
3386
+ logger.debug("Failed to read CodebaseSearch background status", exc_info=True)
3387
+ bg_status = None
3388
+
3389
+ if bg_status:
3390
+ state = bg_status.get("state", "unknown")
3391
+ if state == "ready":
3392
+ chunks = bg_status.get("chunks")
3393
+ files = bg_status.get("files")
3394
+ details: list[str] = []
3395
+ if chunks is not None:
3396
+ details.append(f"{chunks} chunks")
3397
+ if files is not None:
3398
+ details.append(f"{files} files")
3399
+ suffix = f" ({', '.join(details)})" if details else ""
3400
+ search_status = f"ready{suffix}"
3401
+ elif state == "indexing":
3402
+ done = bg_status.get("done")
3403
+ total = bg_status.get("total")
3404
+ if isinstance(done, int) and isinstance(total, int) and total > 0:
3405
+ pct = int(done / total * 100)
3406
+ search_status = f"indexing {done}/{total} ({pct}%)"
3407
+ else:
3408
+ search_status = "indexing"
3409
+ else:
3410
+ search_status = str(state)
3411
+ else:
3412
+ search_status = "enabled, waiting to start"
3413
+
3414
+ agent_mode = getattr(session, '_agent_mode', 'agent')
3415
+ mode_display = "[bold blue]plan[/]" if agent_mode == "plan" else "[bold green]agent[/]"
3416
+ lines = [
3417
+ f"[bold cyan]🦀 CrabCode[/] v{__import__('crabcode_cli').__version__}",
3418
+ f"[bold]🧠 Model:[/] {model_display} · [bold]Mode:[/] {mode_display}",
3419
+ f"[bold]📚 Context:[/] {_fmt_k(ctx_used)} / {_fmt_k(ctx_window)} ({ctx_pct}%){ctx_note} · [bold]💬 Messages:[/] {msg_count}",
3420
+ f"[bold]🧹 Compactions:[/] {compact_count} · [bold]Auto-compact:[/] {auto_compact}",
3421
+ f"[bold]🧵 Session:[/] {sid_short}",
3422
+ f"[bold]⚙️ Config:[/] effort={effort} · ultra={ultra} · "
3423
+ f"think={thinking} · max_tokens={max_tok} · tools={tool_display}",
3424
+ f"[bold]Computer Use:[/] {escape(computer_use_status(session))}",
3425
+ ]
3426
+ budget = getattr(session, "last_prompt_budget", None)
3427
+ if budget:
3428
+ lines.append(
3429
+ f"[bold]Prompt (estimated):[/] system={budget['system_tokens']} · tools={budget['tool_tokens']} "
3430
+ f"· directory (included)={budget['directory_tokens']} · loaded={budget['loaded_tools']}/{budget['available_tools']} "
3431
+ f"({budget['mode']}, last request)"
3432
+ )
3433
+ agents = session.list_agents()
3434
+ if agents:
3435
+ active_agents = sum(1 for item in agents if item.status in {"queued", "running"})
3436
+ failed_agents = sum(1 for item in agents if item.status == "failed")
3437
+ pending_callbacks = sum(
3438
+ 1
3439
+ for item in agents
3440
+ if item.callback_enabled
3441
+ and item.callback_state in {"pending", "injected"}
3442
+ )
3443
+ lines.append(
3444
+ f"[bold]🤖 Agents:[/] total={len(agents)} · active={active_agents} "
3445
+ f"· failed={failed_agents} · callbacks={pending_callbacks} "
3446
+ f"· max_concurrency={session.settings.agent.max_concurrency}"
3447
+ )
3448
+ monitors = session.list_monitor_tasks()
3449
+ if monitors:
3450
+ active_monitors = sum(1 for item in monitors if item.status == "running")
3451
+ failed_monitors = sum(1 for item in monitors if item.status == "failed")
3452
+ lines.append(
3453
+ f"[bold]📡 Monitors:[/] total={len(monitors)} "
3454
+ f"· active={active_monitors} · failed={failed_monitors}"
3455
+ )
3456
+ if search_status is not None:
3457
+ lines.append(f"[bold]🔎 Search:[/] {search_status}")
3458
+ console.print(Panel(
3459
+ "\n".join(lines),
3460
+ title="[bold]Status[/]",
3461
+ border_style="cyan",
3462
+ expand=False,
3463
+ ))
3464
+ return True
3465
+
3466
+ if cmd == "/tasks":
3467
+ await session.initialize()
3468
+ if arg.startswith("stop "):
3469
+ task_id = arg[5:].strip()
3470
+ if not task_id:
3471
+ console.print("[dim]Usage: /tasks stop <task-id>[/]")
3472
+ return True
3473
+ stopped = await session.stop_background_task(task_id)
3474
+ if stopped:
3475
+ console.print(f"[green]Stopped background task {task_id}.[/]")
3476
+ else:
3477
+ console.print(f"[bold red]No running background task: {task_id}[/]")
3478
+ return True
3479
+
3480
+ agents = session.list_agents()
3481
+ monitors = session.list_monitor_tasks()
3482
+ if not agents and not monitors:
3483
+ console.print("[dim]No background tasks.[/]")
3484
+ return True
3485
+ from rich.table import Table
3486
+
3487
+ table = Table(title="Background Tasks", border_style="blue", expand=False)
3488
+ table.add_column("ID", style="cyan", width=10)
3489
+ table.add_column("Status", style="dim", width=10)
3490
+ table.add_column("Type", style="dim", width=12)
3491
+ table.add_column("Description")
3492
+ for snapshot in monitors[:20]:
3493
+ table.add_row(
3494
+ snapshot.task_id[:8],
3495
+ snapshot.status,
3496
+ "monitor",
3497
+ snapshot.description[:60],
3498
+ )
3499
+ for snapshot in agents[:20]:
3500
+ table.add_row(
3501
+ snapshot.agent_id[:8],
3502
+ snapshot.status,
3503
+ "agent",
3504
+ snapshot.title[:60],
3505
+ )
3506
+ console.print(table)
3507
+ return True
3508
+
3509
+ if cmd == "/agents":
3510
+ await session.initialize()
3511
+ agents = session.list_agents()
3512
+ if not agents:
3513
+ console.print("[dim]No managed agents.[/]")
3514
+ return True
3515
+ from rich.table import Table
3516
+ table = Table(title="Managed Agents", border_style="blue", expand=False)
3517
+ table.add_column("ID", style="cyan", width=10)
3518
+ table.add_column("Status", style="dim", width=10)
3519
+ table.add_column("Type", style="dim", width=14)
3520
+ table.add_column("Depth", style="dim", width=5)
3521
+ table.add_column("Callback", style="dim", width=10)
3522
+ table.add_column("Title")
3523
+ for snapshot in agents[:20]:
3524
+ table.add_row(
3525
+ snapshot.agent_id[:8],
3526
+ snapshot.status,
3527
+ snapshot.subagent_type,
3528
+ str(snapshot.depth),
3529
+ snapshot.callback_state if snapshot.callback_enabled else "—",
3530
+ snapshot.title[:60],
3531
+ )
3532
+ console.print(table)
3533
+ return True
3534
+
3535
+ if cmd == "/peers":
3536
+ try:
3537
+ runtime = await session.ensure_peer_runtime()
3538
+ except Exception as exc:
3539
+ console.print(f"[bold red]Cross-session messaging unavailable: {exc}[/]")
3540
+ return True
3541
+ peers = runtime.list_peers() if runtime is not None else []
3542
+ if not peers:
3543
+ console.print("[dim]No other messageable sessions.[/]")
3544
+ return True
3545
+ from rich.table import Table
3546
+ table = Table(title="CrabCode Session Peers", border_style="blue", expand=False)
3547
+ table.add_column("Name", style="cyan")
3548
+ table.add_column("Session", style="dim", width=10)
3549
+ table.add_column("Permissions", style="dim", width=12)
3550
+ table.add_column("Working Directory")
3551
+ for peer in peers[:50]:
3552
+ table.add_row(
3553
+ peer.name,
3554
+ peer.session_id[:8],
3555
+ peer.permission_class,
3556
+ peer.cwd,
3557
+ )
3558
+ console.print(table)
3559
+ return True
3560
+
3561
+ if cmd == "/agent":
3562
+ await session.initialize()
3563
+ if not arg:
3564
+ console.print("[dim]Usage: /agent <agent-id>[/]")
3565
+ return True
3566
+ snapshot = session.get_agent(arg) or next(
3567
+ (item for item in session.list_agents() if item.agent_id.startswith(arg)),
3568
+ None,
3569
+ )
3570
+ if not snapshot:
3571
+ console.print(f"[bold red]Unknown agent: {arg}[/]")
3572
+ return True
3573
+ from crabcode_core.agent_manager import AgentManager
3574
+ console.print(
3575
+ Panel(
3576
+ Text(
3577
+ AgentManager.format_snapshot(
3578
+ snapshot,
3579
+ max_result_chars=session.settings.agent.max_output_chars,
3580
+ ),
3581
+ style="dim",
3582
+ ),
3583
+ title=f"[bold]Agent {snapshot.agent_id[:8]}[/]",
3584
+ border_style="cyan",
3585
+ expand=False,
3586
+ )
3587
+ )
3588
+ return True
3589
+
3590
+ if cmd == "/agent-log":
3591
+ await session.initialize()
3592
+ if not arg:
3593
+ console.print("[dim]Usage: /agent-log <agent-id>[/]")
3594
+ return True
3595
+ snapshot = session.get_agent(arg) or next(
3596
+ (item for item in session.list_agents() if item.agent_id.startswith(arg)),
3597
+ None,
3598
+ )
3599
+ if not snapshot:
3600
+ console.print(f"[bold red]Unknown agent: {arg}[/]")
3601
+ return True
3602
+ if not snapshot.transcript_path:
3603
+ console.print("[dim]This agent has no transcript path.[/]")
3604
+ return True
3605
+ path = Path(snapshot.transcript_path)
3606
+ if not path.exists():
3607
+ console.print(f"[dim]Transcript not found: {path}[/]")
3608
+ return True
3609
+ body = _read_log_tail(path, max_lines=200)
3610
+ console.print(
3611
+ Panel(
3612
+ Text(body, style="dim"),
3613
+ title=f"[bold]Agent Log {snapshot.agent_id[:8]}[/]",
3614
+ border_style="blue",
3615
+ expand=False,
3616
+ )
3617
+ )
3618
+ return True
3619
+
3620
+ if cmd == "/agent-send":
3621
+ await session.initialize()
3622
+ if not arg or " " not in arg.strip():
3623
+ console.print("[dim]Usage: /agent-send <agent-id> <prompt>[/]")
3624
+ return True
3625
+ agent_ref, prompt = arg.split(None, 1)
3626
+ snapshot = session.get_agent(agent_ref) or next(
3627
+ (item for item in session.list_agents() if item.agent_id.startswith(agent_ref)),
3628
+ None,
3629
+ )
3630
+ if not snapshot:
3631
+ console.print(f"[bold red]Unknown agent: {agent_ref}[/]")
3632
+ return True
3633
+ ok = await session.send_agent_input(snapshot.agent_id, prompt, interrupt=False)
3634
+ if ok:
3635
+ console.print(f"[green]✓[/] Sent input to agent {snapshot.agent_id[:8]}")
3636
+ if session.settings.agent.stream_send_input_output:
3637
+ await _stream_agent_until_done(session, snapshot.agent_id)
3638
+ else:
3639
+ console.print(f"[bold red]Failed to send input to agent {snapshot.agent_id[:8]}[/]")
3640
+ return True
3641
+
3642
+ if cmd == "/wait":
3643
+ await session.initialize()
3644
+ if not arg:
3645
+ console.print("[dim]Usage: /wait <agent-id>[/]")
3646
+ return True
3647
+ agent = session.get_agent(arg) or next(
3648
+ (item for item in session.list_agents() if item.agent_id.startswith(arg)),
3649
+ None,
3650
+ )
3651
+ if not agent:
3652
+ console.print(f"[bold red]Unknown agent: {arg}[/]")
3653
+ return True
3654
+ snapshot = await session.wait_agent(agent.agent_id, timeout_ms=None)
3655
+ if not snapshot:
3656
+ console.print(f"[bold red]Failed to wait for agent: {arg}[/]")
3657
+ return True
3658
+ from crabcode_core.agent_manager import AgentManager
3659
+ console.print(
3660
+ Panel(
3661
+ Text(
3662
+ AgentManager.format_snapshot(
3663
+ snapshot,
3664
+ max_result_chars=session.settings.agent.max_output_chars,
3665
+ ),
3666
+ style="dim",
3667
+ ),
3668
+ title=f"[bold]Agent {snapshot.agent_id[:8]}[/]",
3669
+ border_style="cyan",
3670
+ expand=False,
3671
+ )
3672
+ )
3673
+ return True
3674
+
3675
+ if cmd == "/cancel-agent":
3676
+ await session.initialize()
3677
+ if not arg:
3678
+ console.print("[dim]Usage: /cancel-agent <agent-id>[/]")
3679
+ return True
3680
+ agent = session.get_agent(arg) or next(
3681
+ (item for item in session.list_agents() if item.agent_id.startswith(arg)),
3682
+ None,
3683
+ )
3684
+ if not agent:
3685
+ console.print(f"[bold red]Unknown agent: {arg}[/]")
3686
+ return True
3687
+ ok = await session.cancel_agent(agent.agent_id)
3688
+ if ok:
3689
+ console.print(f"[yellow]Cancelled agent {agent.agent_id[:8]}[/]")
3690
+ else:
3691
+ console.print(f"[dim]Agent {agent.agent_id[:8]} is not running.[/]")
3692
+ return True
3693
+
3694
+ if cmd == "/team":
3695
+ from crabcode_core.command_line import split_command_arguments
3696
+
3697
+ await session.initialize()
3698
+ team_mgr = getattr(session, "_team_manager", None)
3699
+ try:
3700
+ tokens = split_command_arguments(arg)
3701
+ except ValueError as exc:
3702
+ console.print(f"[bold red]Invalid team command:[/] {exc}")
3703
+ return True
3704
+ subcmd = tokens.pop(0).lower() if tokens else "list"
3705
+ usage = (
3706
+ "/team [list|create|status|messages|tasks|spawn|message|broadcast|"
3707
+ "task-add|task-claim|task-complete|shutdown] [args]"
3708
+ )
3709
+
3710
+ if subcmd == "list":
3711
+ teams = team_mgr.list_teams() if team_mgr else []
3712
+ if not teams:
3713
+ console.print("[dim]No active teams.[/]")
3714
+ else:
3715
+ for tid in teams:
3716
+ status = team_mgr.get_team_status(tid) if team_mgr else {}
3717
+ count = status.get("teammate_count", "?")
3718
+ state = status.get("state", "?")
3719
+ console.print(f" [cyan]{tid}[/] · {count} teammates · {state}")
3720
+ return True
3721
+
3722
+ if team_mgr is None:
3723
+ console.print("[dim]Team manager not initialized.[/]")
3724
+ return True
3725
+
3726
+ if subcmd == "create":
3727
+ if not tokens or len(tokens) > 2:
3728
+ console.print("[dim]Usage: /team create <name> [max-teammates][/]")
3729
+ return True
3730
+ max_teammates = None
3731
+ if len(tokens) == 2:
3732
+ try:
3733
+ max_teammates = int(tokens[1])
3734
+ except ValueError:
3735
+ console.print("[bold red]max-teammates must be a positive integer.[/]")
3736
+ return True
3737
+ try:
3738
+ team_id = await team_mgr.create_team(
3739
+ tokens[0],
3740
+ max_teammates=max_teammates,
3741
+ )
3742
+ except (RuntimeError, ValueError) as exc:
3743
+ console.print(f"[bold red]{safe_utf8_str(str(exc))}[/]")
3744
+ return True
3745
+ console.print(f"[green]Created team '{team_id}'.[/]")
3746
+ return True
3747
+
3748
+ if subcmd == "status":
3749
+ if len(tokens) != 1:
3750
+ console.print("[dim]Usage: /team status <team-id>[/]")
3751
+ return True
3752
+ team_id = tokens[0]
3753
+ status = team_mgr.get_team_status(team_id)
3754
+ if not status:
3755
+ console.print(f"[bold red]Team '{team_id}' not found.[/]")
3756
+ return True
3757
+ lines = [
3758
+ f"Team: {status['team_id']} State: {status['state']}",
3759
+ f"Teammates: {status['teammate_count']}/{status['max_teammates']}",
3760
+ ]
3761
+ for t in status["teammates"]:
3762
+ name = t.get("name") or t["agent_id"][:8]
3763
+ lines.append(f" {name} · {t['role']} · {t['state']}")
3764
+ tasks = status["tasks"]
3765
+ lines.append(
3766
+ f"Tasks: {tasks['total']} total "
3767
+ f"({tasks['pending']} pending, {tasks['claimed']} claimed, "
3768
+ f"{tasks['completed']} done, {tasks['failed']} failed)"
3769
+ )
3770
+ console.print(Panel(
3771
+ "\n".join(lines),
3772
+ title=f"[bold]Team: {team_id}[/]",
3773
+ border_style="blue",
3774
+ expand=False,
3775
+ ))
3776
+ return True
3777
+
3778
+ if subcmd == "messages":
3779
+ if len(tokens) != 1:
3780
+ console.print("[dim]Usage: /team messages <team-id>[/]")
3781
+ return True
3782
+ team_id = tokens[0]
3783
+ # Show recent messages for all teammates
3784
+ status = team_mgr.get_team_status(team_id)
3785
+ if not status:
3786
+ console.print(f"[bold red]Team '{team_id}' not found.[/]")
3787
+ return True
3788
+ messages_by_id: dict[str, Any] = {}
3789
+ for t in status["teammates"]:
3790
+ aid = t["agent_id"]
3791
+ for message in team_mgr.get_all_messages(team_id, aid):
3792
+ messages_by_id.setdefault(message.id, message)
3793
+ messages = sorted(
3794
+ messages_by_id.values(),
3795
+ key=lambda item: item.timestamp,
3796
+ )[-50:]
3797
+ all_msgs: list[str] = []
3798
+ for message in messages:
3799
+ direction = f"{message.from_agent[:8]} -> {message.to_agent[:8]}"
3800
+ read_flag = "" if message.read else " (unread)"
3801
+ all_msgs.append(f" {direction}: {message.text[:100]}{read_flag}")
3802
+ if not all_msgs:
3803
+ console.print("[dim]No messages.[/]")
3804
+ else:
3805
+ console.print(Panel(
3806
+ Text("\n".join(all_msgs), style="dim"),
3807
+ title=f"[bold]Messages: {team_id}[/]",
3808
+ border_style="blue",
3809
+ expand=False,
3810
+ ))
3811
+ return True
3812
+
3813
+ if subcmd == "tasks":
3814
+ if len(tokens) != 1:
3815
+ console.print("[dim]Usage: /team tasks <team-id>[/]")
3816
+ return True
3817
+ team_id = tokens[0]
3818
+ if not team_mgr.get_team_status(team_id):
3819
+ console.print(f"[bold red]Team '{team_id}' not found.[/]")
3820
+ return True
3821
+ tasks = team_mgr.list_tasks(team_id)
3822
+ if not tasks:
3823
+ console.print("[dim]No team tasks.[/]")
3824
+ return True
3825
+ from rich.table import Table
3826
+
3827
+ table = Table(title=f"Team Tasks: {team_id}", border_style="blue", expand=False)
3828
+ table.add_column("ID", style="cyan", width=10)
3829
+ table.add_column("Status", style="dim", width=10)
3830
+ table.add_column("Assignee", style="dim", width=10)
3831
+ table.add_column("Description")
3832
+ for task in tasks:
3833
+ status = getattr(task.status, "value", str(task.status))
3834
+ table.add_row(
3835
+ task.id[:8],
3836
+ status,
3837
+ (task.assignee or "")[:8],
3838
+ task.description[:80],
3839
+ )
3840
+ console.print(table)
3841
+ return True
3842
+
3843
+ if subcmd == "spawn":
3844
+ if len(tokens) < 2:
3845
+ console.print(
3846
+ "[dim]Usage: /team spawn <team-id> "
3847
+ "[--role ROLE] [--name NAME] [--model PROFILE] <prompt>[/]"
3848
+ )
3849
+ return True
3850
+ team_id = tokens.pop(0)
3851
+ role_value = "worker"
3852
+ teammate_name = None
3853
+ model_profile = None
3854
+ prompt_parts: list[str] = []
3855
+ index = 0
3856
+ while index < len(tokens):
3857
+ token = tokens[index]
3858
+ option, separator, inline_value = token.partition("=")
3859
+ if option in {"--role", "--name", "--model", "--model-profile"}:
3860
+ if separator:
3861
+ value = inline_value
3862
+ elif index + 1 < len(tokens):
3863
+ index += 1
3864
+ value = tokens[index]
3865
+ else:
3866
+ console.print(f"[bold red]Missing value for {option}.[/]")
3867
+ return True
3868
+ if not value:
3869
+ console.print(f"[bold red]Missing value for {option}.[/]")
3870
+ return True
3871
+ if option == "--role":
3872
+ role_value = value
3873
+ elif option == "--name":
3874
+ teammate_name = value
3875
+ else:
3876
+ model_profile = value
3877
+ elif token.startswith("-"):
3878
+ console.print(f"[bold red]Unknown option: {token}[/]")
3879
+ return True
3880
+ else:
3881
+ prompt_parts.append(token)
3882
+ index += 1
3883
+ if not prompt_parts:
3884
+ console.print("[dim]A teammate prompt is required.[/]")
3885
+ return True
3886
+ from crabcode_core.team.models import TeammateRole
3887
+
3888
+ try:
3889
+ agent_id = await team_mgr.add_teammate(
3890
+ team_id,
3891
+ role=TeammateRole(role_value),
3892
+ prompt=" ".join(prompt_parts),
3893
+ name=teammate_name,
3894
+ model_profile=model_profile,
3895
+ )
3896
+ except (RuntimeError, ValueError) as exc:
3897
+ console.print(f"[bold red]{safe_utf8_str(str(exc))}[/]")
3898
+ return True
3899
+ console.print(f"[green]Added teammate {agent_id[:8]} to '{team_id}'.[/]")
3900
+ return True
3901
+
3902
+ if subcmd == "message":
3903
+ if len(tokens) < 3:
3904
+ console.print("[dim]Usage: /team message <team-id> <agent-id> <text>[/]")
3905
+ return True
3906
+ team_id, agent_id = tokens[:2]
3907
+ message = await team_mgr.send_message(
3908
+ team_id,
3909
+ "",
3910
+ agent_id,
3911
+ " ".join(tokens[2:]),
3912
+ )
3913
+ if message is None:
3914
+ console.print("[bold red]Team message delivery failed.[/]")
3915
+ else:
3916
+ console.print(f"[green]Sent team message {message.id[:8]}.[/]")
3917
+ return True
3918
+
3919
+ if subcmd == "broadcast":
3920
+ if len(tokens) < 2:
3921
+ console.print("[dim]Usage: /team broadcast <team-id> <text>[/]")
3922
+ return True
3923
+ team_id = tokens[0]
3924
+ messages = await team_mgr.broadcast(team_id, "", " ".join(tokens[1:]))
3925
+ console.print(f"[green]Broadcast to {len(messages)} teammate(s).[/]")
3926
+ return True
3927
+
3928
+ if subcmd == "task-add":
3929
+ if len(tokens) < 2:
3930
+ console.print("[dim]Usage: /team task-add <team-id> <description>[/]")
3931
+ return True
3932
+ try:
3933
+ task_id = await team_mgr.add_task(tokens[0], " ".join(tokens[1:]))
3934
+ except (RuntimeError, ValueError) as exc:
3935
+ console.print(f"[bold red]{safe_utf8_str(str(exc))}[/]")
3936
+ return True
3937
+ console.print(f"[green]Added team task {task_id[:8]}.[/]")
3938
+ return True
3939
+
3940
+ if subcmd == "task-claim":
3941
+ if len(tokens) not in {2, 3}:
3942
+ console.print("[dim]Usage: /team task-claim <team-id> <task-id> [agent-id][/]")
3943
+ return True
3944
+ claimed = await team_mgr.claim_task(
3945
+ tokens[0],
3946
+ tokens[1],
3947
+ tokens[2] if len(tokens) == 3 else "",
3948
+ )
3949
+ if claimed:
3950
+ console.print(f"[green]Claimed team task {tokens[1][:8]}.[/]")
3951
+ else:
3952
+ console.print("[bold red]Task not found or already claimed.[/]")
3953
+ return True
3954
+
3955
+ if subcmd == "task-complete":
3956
+ if len(tokens) < 2:
3957
+ console.print(
3958
+ "[dim]Usage: /team task-complete <team-id> <task-id> "
3959
+ "[--agent ID] [result][/]"
3960
+ )
3961
+ return True
3962
+ team_id, task_id = tokens[:2]
3963
+ rest = tokens[2:]
3964
+ agent_id = None
3965
+ result_parts: list[str] = []
3966
+ index = 0
3967
+ while index < len(rest):
3968
+ token = rest[index]
3969
+ option, separator, inline_value = token.partition("=")
3970
+ if option in {"--agent", "--agent-id"}:
3971
+ if separator:
3972
+ agent_id = inline_value
3973
+ elif index + 1 < len(rest):
3974
+ index += 1
3975
+ agent_id = rest[index]
3976
+ else:
3977
+ console.print(f"[bold red]Missing value for {option}.[/]")
3978
+ return True
3979
+ elif token.startswith("-"):
3980
+ console.print(f"[bold red]Unknown option: {token}[/]")
3981
+ return True
3982
+ else:
3983
+ result_parts.append(token)
3984
+ index += 1
3985
+ completed = await team_mgr.complete_task(
3986
+ team_id,
3987
+ task_id,
3988
+ " ".join(result_parts),
3989
+ agent_id,
3990
+ )
3991
+ if completed:
3992
+ console.print(f"[green]Completed team task {task_id[:8]}.[/]")
3993
+ else:
3994
+ console.print("[bold red]Task not found or not in claimed state.[/]")
3995
+ return True
3996
+
3997
+ if subcmd == "shutdown":
3998
+ if len(tokens) != 1:
3999
+ console.print("[dim]Usage: /team shutdown <team-id>[/]")
4000
+ return True
4001
+ team_id = tokens[0]
4002
+ ok = await team_mgr.shutdown_team(team_id)
4003
+ if ok:
4004
+ console.print(f"[green]Team '{team_id}' shut down.[/]")
4005
+ else:
4006
+ console.print(f"[bold red]Team '{team_id}' not found.[/]")
4007
+ return True
4008
+
4009
+ console.print(f"[dim]Usage: {usage}[/]")
4010
+ return True
4011
+
4012
+ if cmd == "/schedule":
4013
+ from crabcode_core.command_line import split_command_arguments
4014
+
4015
+ from crabcode_core.tools.schedule import (
4016
+ _format_job_brief,
4017
+ _format_job_detail,
4018
+ _format_run_brief,
4019
+ _model_dict,
4020
+ )
4021
+
4022
+ await session.initialize()
4023
+ manager = getattr(session, "_schedule_manager", None)
4024
+ if manager is None:
4025
+ console.print("[bold red]Schedule manager is unavailable.[/]")
4026
+ return True
4027
+ try:
4028
+ tokens = split_command_arguments(arg)
4029
+ except ValueError as exc:
4030
+ console.print(f"[bold red]Invalid schedule command:[/] {exc}")
4031
+ return True
4032
+
4033
+ subcmd = tokens.pop(0).lower() if tokens else "list"
4034
+ usage = (
4035
+ "/schedule [list|show|runs|create|pause|resume|run|cancel] [args]"
4036
+ )
4037
+
4038
+ def option_value(
4039
+ values: list[str],
4040
+ index: int,
4041
+ token: str,
4042
+ ) -> tuple[str, int]:
4043
+ _, separator, inline = token.partition("=")
4044
+ if separator:
4045
+ if not inline:
4046
+ raise ValueError(f"Missing value for {token.partition('=')[0]}")
4047
+ return inline, index
4048
+ if index + 1 >= len(values):
4049
+ raise ValueError(f"Missing value for {token}")
4050
+ return values[index + 1], index + 1
4051
+
4052
+ if subcmd == "list":
4053
+ status = None
4054
+ schedule_type = None
4055
+ enabled = None
4056
+ limit = 100
4057
+ index = 0
4058
+ try:
4059
+ while index < len(tokens):
4060
+ token = tokens[index]
4061
+ option = token.partition("=")[0]
4062
+ if option == "--status":
4063
+ status, index = option_value(tokens, index, token)
4064
+ elif option in {"--type", "--schedule-type"}:
4065
+ schedule_type, index = option_value(tokens, index, token)
4066
+ if schedule_type not in {"cron", "interval", "once"}:
4067
+ raise ValueError("schedule type must be cron, interval, or once")
4068
+ elif option == "--enabled":
4069
+ raw_enabled, index = option_value(tokens, index, token)
4070
+ if raw_enabled.lower() not in {"true", "false"}:
4071
+ raise ValueError("enabled must be true or false")
4072
+ enabled = raw_enabled.lower() == "true"
4073
+ elif option == "--limit":
4074
+ raw_limit, index = option_value(tokens, index, token)
4075
+ limit = int(raw_limit)
4076
+ if limit <= 0:
4077
+ raise ValueError("limit must be greater than zero")
4078
+ else:
4079
+ raise ValueError(f"Unknown option: {token}")
4080
+ index += 1
4081
+ except (TypeError, ValueError) as exc:
4082
+ console.print(f"[bold red]{safe_utf8_str(str(exc))}[/]")
4083
+ console.print(
4084
+ "[dim]Usage: /schedule list [--status STATUS] "
4085
+ "[--type cron|interval|once] [--enabled true|false] "
4086
+ "[--limit N][/]"
4087
+ )
4088
+ return True
4089
+
4090
+ try:
4091
+ jobs = manager.list_jobs(
4092
+ status=status,
4093
+ schedule_type=schedule_type,
4094
+ enabled=enabled,
4095
+ limit=limit,
4096
+ )
4097
+ except Exception as exc:
4098
+ console.print(f"[bold red]Failed to list schedules:[/] {safe_utf8_str(str(exc))}")
4099
+ return True
4100
+ if not jobs:
4101
+ console.print("[dim]No scheduled tasks found.[/]")
4102
+ return True
4103
+ lines = [_format_job_brief(_model_dict(job)) for job in jobs]
4104
+ console.print(
4105
+ Panel(
4106
+ Text("\n".join(lines)),
4107
+ title=f"[bold]Scheduled Tasks ({len(jobs)})[/]",
4108
+ border_style="blue",
4109
+ expand=False,
4110
+ )
4111
+ )
4112
+ return True
4113
+
4114
+ if subcmd in {"show", "status"}:
4115
+ if len(tokens) != 1:
4116
+ console.print("[dim]Usage: /schedule show <job-id>[/]")
4117
+ return True
4118
+ try:
4119
+ job = manager.get_job(tokens[0])
4120
+ except Exception as exc:
4121
+ console.print(f"[bold red]Failed to read schedule:[/] {safe_utf8_str(str(exc))}")
4122
+ return True
4123
+ if job is None:
4124
+ console.print("[bold red]Scheduled task not found or ID is ambiguous.[/]")
4125
+ return True
4126
+ data = _model_dict(job)
4127
+ console.print(
4128
+ Panel(
4129
+ Text(_format_job_detail(data)),
4130
+ title=f"[bold]Schedule {str(data.get('id', ''))[:8]}[/]",
4131
+ border_style="cyan",
4132
+ expand=False,
4133
+ )
4134
+ )
4135
+ return True
4136
+
4137
+ if subcmd in {"runs", "history"}:
4138
+ if not tokens:
4139
+ console.print(
4140
+ "[dim]Usage: /schedule runs <job-id> "
4141
+ "[--status STATUS] [--limit N][/]"
4142
+ )
4143
+ return True
4144
+ job_id = tokens.pop(0)
4145
+ status = None
4146
+ limit = 50
4147
+ index = 0
4148
+ try:
4149
+ while index < len(tokens):
4150
+ token = tokens[index]
4151
+ option = token.partition("=")[0]
4152
+ if option == "--status":
4153
+ status, index = option_value(tokens, index, token)
4154
+ elif option == "--limit":
4155
+ raw_limit, index = option_value(tokens, index, token)
4156
+ limit = int(raw_limit)
4157
+ if limit <= 0:
4158
+ raise ValueError("limit must be greater than zero")
4159
+ else:
4160
+ raise ValueError(f"Unknown option: {token}")
4161
+ index += 1
4162
+ except (TypeError, ValueError) as exc:
4163
+ console.print(f"[bold red]{safe_utf8_str(str(exc))}[/]")
4164
+ console.print(
4165
+ "[dim]Usage: /schedule runs <job-id> "
4166
+ "[--status STATUS] [--limit N][/]"
4167
+ )
4168
+ return True
4169
+ try:
4170
+ job = manager.get_job(job_id)
4171
+ runs = (
4172
+ manager.list_runs(job_id, status=status, limit=limit)
4173
+ if job is not None
4174
+ else []
4175
+ )
4176
+ except Exception as exc:
4177
+ console.print(f"[bold red]Failed to read schedule runs:[/] {safe_utf8_str(str(exc))}")
4178
+ return True
4179
+ if job is None:
4180
+ console.print("[bold red]Scheduled task not found or ID is ambiguous.[/]")
4181
+ return True
4182
+ if not runs:
4183
+ console.print("[dim]No execution history found.[/]")
4184
+ return True
4185
+ lines = [_format_run_brief(_model_dict(run)) for run in runs]
4186
+ console.print(
4187
+ Panel(
4188
+ Text("\n".join(lines)),
4189
+ title=f"[bold]Schedule Runs ({len(runs)})[/]",
4190
+ border_style="blue",
4191
+ expand=False,
4192
+ )
4193
+ )
4194
+ return True
4195
+
4196
+ if subcmd == "create":
4197
+ positionals: list[str] = []
4198
+ tags: list[str] = []
4199
+ cwd = None
4200
+ enabled = True
4201
+ max_runs = None
4202
+ next_run = None
4203
+ description = ""
4204
+ timeout = None
4205
+ model_profile = None
4206
+ job_session_id = None
4207
+ extra: dict[str, Any] = {}
4208
+ index = 0
4209
+ try:
4210
+ while index < len(tokens):
4211
+ token = tokens[index]
4212
+ if token == "--":
4213
+ positionals.extend(tokens[index + 1 :])
4214
+ break
4215
+ option = token.partition("=")[0]
4216
+ if option == "--disabled":
4217
+ if "=" in token:
4218
+ raise ValueError("--disabled does not accept a value")
4219
+ enabled = False
4220
+ index += 1
4221
+ continue
4222
+ if option in {
4223
+ "--cwd",
4224
+ "--enabled",
4225
+ "--max-runs",
4226
+ "--next-run",
4227
+ "--description",
4228
+ "--tag",
4229
+ "--timeout",
4230
+ "--model",
4231
+ "--model-profile",
4232
+ "--job-session",
4233
+ "--run-session",
4234
+ "--extra",
4235
+ }:
4236
+ value, index = option_value(tokens, index, token)
4237
+ if option == "--cwd":
4238
+ cwd = value
4239
+ elif option == "--enabled":
4240
+ if value.lower() not in {"true", "false"}:
4241
+ raise ValueError("enabled must be true or false")
4242
+ enabled = value.lower() == "true"
4243
+ elif option == "--max-runs":
4244
+ max_runs = int(value)
4245
+ if max_runs <= 0:
4246
+ raise ValueError("max-runs must be greater than zero")
4247
+ elif option == "--next-run":
4248
+ next_run = value
4249
+ elif option == "--description":
4250
+ description = value
4251
+ elif option == "--tag":
4252
+ tags.append(value)
4253
+ elif option == "--timeout":
4254
+ timeout = int(value)
4255
+ if timeout <= 0:
4256
+ raise ValueError("timeout must be greater than zero")
4257
+ elif option in {"--model", "--model-profile"}:
4258
+ model_profile = value
4259
+ elif option in {"--job-session", "--run-session"}:
4260
+ job_session_id = value
4261
+ else:
4262
+ parsed_extra = json.loads(value)
4263
+ if not isinstance(parsed_extra, dict):
4264
+ raise ValueError("extra must be a JSON object")
4265
+ extra.update(parsed_extra)
4266
+ elif token.startswith("-"):
4267
+ raise ValueError(f"Unknown option: {token}")
4268
+ else:
4269
+ positionals.append(token)
4270
+ index += 1
4271
+ except (TypeError, ValueError) as exc:
4272
+ console.print(f"[bold red]{safe_utf8_str(str(exc))}[/]")
4273
+ console.print(
4274
+ "[dim]Usage: /schedule create [options] <name> "
4275
+ "<cron|interval|once> <schedule> <prompt>[/]"
4276
+ )
4277
+ return True
4278
+
4279
+ if len(positionals) < 4:
4280
+ console.print(
4281
+ "[dim]Usage: /schedule create [options] <name> "
4282
+ "<cron|interval|once> <schedule> <prompt>[/]"
4283
+ )
4284
+ return True
4285
+ name, schedule_type, schedule_value = positionals[:3]
4286
+ if schedule_type not in {"cron", "interval", "once"}:
4287
+ console.print("[bold red]Schedule type must be cron, interval, or once.[/]")
4288
+ return True
4289
+ prompt = " ".join(positionals[3:])
4290
+ try:
4291
+ job = manager.create_job(
4292
+ name=name,
4293
+ prompt=prompt,
4294
+ schedule=schedule_value,
4295
+ schedule_type=schedule_type,
4296
+ cwd=cwd,
4297
+ enabled=enabled,
4298
+ max_runs=max_runs,
4299
+ next_run=next_run,
4300
+ description=description,
4301
+ tags=tags,
4302
+ timeout=timeout,
4303
+ model_profile=model_profile,
4304
+ session_id=job_session_id,
4305
+ extra=extra,
4306
+ )
4307
+ except Exception as exc:
4308
+ console.print(f"[bold red]Failed to create schedule:[/] {safe_utf8_str(str(exc))}")
4309
+ return True
4310
+ data = _model_dict(job)
4311
+ console.print(
4312
+ Panel(
4313
+ Text(_format_job_detail(data)),
4314
+ title="[bold]Scheduled Task Created[/]",
4315
+ border_style="green",
4316
+ expand=False,
4317
+ )
4318
+ )
4319
+ return True
4320
+
4321
+ lifecycle_aliases = {
4322
+ "trigger": "run",
4323
+ "delete": "cancel",
4324
+ }
4325
+ action = lifecycle_aliases.get(subcmd, subcmd)
4326
+ if action in {"pause", "resume", "run", "cancel"}:
4327
+ if len(tokens) != 1:
4328
+ console.print(f"[dim]Usage: /schedule {subcmd} <job-id>[/]")
4329
+ return True
4330
+ job_id = tokens[0]
4331
+ try:
4332
+ if action == "pause":
4333
+ job = manager.pause_job(job_id)
4334
+ if job is None:
4335
+ raise LookupError("Scheduled task not found or ID is ambiguous")
4336
+ data = _model_dict(job)
4337
+ console.print(f"[green]Scheduled task paused:[/] {data['name']} ({data['status']})")
4338
+ elif action == "resume":
4339
+ job = manager.resume_job(job_id)
4340
+ if job is None:
4341
+ raise LookupError("Scheduled task not found or ID is ambiguous")
4342
+ data = _model_dict(job)
4343
+ console.print(f"[green]Scheduled task resumed:[/] {data['name']} ({data['status']})")
4344
+ elif action == "run":
4345
+ started = await manager.trigger_job(job_id)
4346
+ if not started:
4347
+ raise RuntimeError(
4348
+ "Scheduled task is missing, inactive, or already running"
4349
+ )
4350
+ console.print(f"[green]Scheduled task started:[/] {job_id[:8]}")
4351
+ else:
4352
+ job = manager.get_job(job_id)
4353
+ if job is None or not manager.cancel_job(job_id):
4354
+ raise LookupError("Scheduled task not found or ID is ambiguous")
4355
+ data = _model_dict(job)
4356
+ console.print(f"[yellow]Scheduled task deleted:[/] {data['name']}")
4357
+ except Exception as exc:
4358
+ console.print(f"[bold red]{safe_utf8_str(str(exc))}[/]")
4359
+ return True
4360
+
4361
+ console.print(f"[dim]Usage: {usage}[/]")
4362
+ return True
4363
+
4364
+ if cmd == "/new":
4365
+ try:
4366
+ new_id = session.new_session()
4367
+ except RuntimeError as exc:
4368
+ console.print(f"[bold yellow]{safe_utf8_str(str(exc))}[/]")
4369
+ return True
4370
+ console.print(f"[dim]New session started: [bold]{new_id[:8]}…[/bold][/]")
4371
+ return True
4372
+
4373
+ if cmd == "/compact":
4374
+ accepted = await session.compact(arg or None)
4375
+ if accepted:
4376
+ console.print("[dim]Conversation compacted (or queued at the active turn boundary).[/]")
4377
+ else:
4378
+ console.print("[dim]Not enough history to compact, or checkpoint generation failed.[/]")
4379
+ return True
4380
+
4381
+ if cmd == "/clear":
4382
+ cleared = await session.clear_history()
4383
+ console.print(f"[dim]Conversation cleared ({cleared} message(s)).[/]")
4384
+ return True
4385
+
4386
+ if cmd == "/sessions":
4387
+ from crabcode_core.session.storage import SessionStorage
4388
+ sessions = SessionStorage.list_sessions(session.cwd)
4389
+ if not sessions:
4390
+ console.print("[dim]No sessions found.[/]")
4391
+ return True
4392
+ from rich.table import Table
4393
+ table = Table(title="Recent Sessions", border_style="blue", expand=False)
4394
+ table.add_column("#", style="dim", width=3)
4395
+ table.add_column("ID", style="cyan", width=8)
4396
+ table.add_column("Model", style="dim", width=16)
4397
+ table.add_column("Tokens", style="dim", width=8, justify="right")
4398
+ table.add_column("Modified", style="dim", width=16)
4399
+ table.add_column("Preview")
4400
+ for i, s in enumerate(sessions[:20], 1):
4401
+ sid = s["session_id"]
4402
+ is_current = sid == session.session_id
4403
+ marker = " *" if is_current else ""
4404
+ tokens = s.get("tokens_used", 0)
4405
+ tokens_str = f"{tokens // 1000}k" if tokens >= 1000 else str(tokens)
4406
+ table.add_row(
4407
+ str(i),
4408
+ sid[:8] + marker,
4409
+ s.get("model", "")[:16],
4410
+ tokens_str,
4411
+ s.get("modified", "")[:16],
4412
+ s.get("preview", "")[:50],
4413
+ )
4414
+ console.print(table)
4415
+ return True
4416
+
4417
+ if cmd == "/search":
4418
+ if not arg:
4419
+ console.print("[dim]Usage: /search <query>[/]")
4420
+ return True
4421
+ from crabcode_core.session.storage import SessionStorage
4422
+ results = SessionStorage.search_sessions(arg)
4423
+ if not results:
4424
+ console.print(f"[dim]No sessions matching \"{arg}\".[/]")
4425
+ return True
4426
+ from rich.table import Table as SearchTable
4427
+ table = SearchTable(title=f"Search: \"{arg}\"", border_style="blue", expand=False)
4428
+ table.add_column("#", style="dim", width=3)
4429
+ table.add_column("ID", style="cyan", width=8)
4430
+ table.add_column("Project", style="dim", width=20)
4431
+ table.add_column("Model", style="dim", width=16)
4432
+ table.add_column("Tokens", style="dim", width=8, justify="right")
4433
+ table.add_column("Preview")
4434
+ for i, r in enumerate(results[:20], 1):
4435
+ sid = r.get("id", "")
4436
+ cwd_display = r.get("cwd", "")
4437
+ if len(cwd_display) > 20:
4438
+ cwd_display = "…" + cwd_display[-19:]
4439
+ tokens = r.get("tokens_used", 0)
4440
+ tokens_str = f"{tokens // 1000}k" if tokens >= 1000 else str(tokens)
4441
+ preview = r.get("title", "") or r.get("first_user_message", "")
4442
+ table.add_row(
4443
+ str(i),
4444
+ sid[:8],
4445
+ cwd_display,
4446
+ r.get("model", "")[:16],
4447
+ tokens_str,
4448
+ preview[:50],
4449
+ )
4450
+ console.print(table)
4451
+ return True
4452
+
4453
+ if cmd == "/stats":
4454
+ from crabcode_core.session.meta_db import SessionMetaStore as StatsStore
4455
+ store = StatsStore()
4456
+ try:
4457
+ g = store.stats_global()
4458
+ p = store.stats_by_project(os.path.abspath(session.cwd))
4459
+ models = store.stats_by_model(limit=5)
4460
+ finally:
4461
+ store.close()
4462
+
4463
+ def _fmt_tok(n: int) -> str:
4464
+ if n >= 1_000_000:
4465
+ return f"{n / 1_000_000:.1f}M"
4466
+ if n >= 1_000:
4467
+ return f"{n / 1_000:.1f}k"
4468
+ return str(n)
4469
+
4470
+ lines = [
4471
+ f"[bold]Global:[/] {g['total_sessions']} sessions | "
4472
+ f"{_fmt_tok(g['total_tokens'])} tokens | "
4473
+ f"{g['active_projects']} projects",
4474
+ f"[bold]This week:[/] {g['week_sessions']} sessions | "
4475
+ f"{_fmt_tok(g['week_tokens'])} tokens",
4476
+ f"[bold]This project:[/] {p['total_sessions']} sessions | "
4477
+ f"{_fmt_tok(p['total_tokens'])} tokens | "
4478
+ f"{p['total_messages']} messages",
4479
+ ]
4480
+ if models:
4481
+ model_parts = [f"{m['model']} ({_fmt_tok(m['tokens'])})" for m in models]
4482
+ lines.append(f"[bold]Top models:[/] {', '.join(model_parts)}")
4483
+ console.print(Panel(
4484
+ "\n".join(lines),
4485
+ title="[bold]Usage Statistics[/]",
4486
+ border_style="blue",
4487
+ expand=False,
4488
+ ))
4489
+ return True
4490
+
4491
+ if cmd == "/checkpoint":
4492
+ label = arg or ""
4493
+ # File snapshots may scan/copy a large workspace. Keep this blocking
4494
+ # work off the REPL event loop so input handling and background events
4495
+ # remain responsive while the checkpoint is created.
4496
+ cp_id = await asyncio.to_thread(session.checkpoint, label=label)
4497
+ if cp_id:
4498
+ label_display = f" \"{label}\"" if label else ""
4499
+ snap_indicator = (
4500
+ " [dim](file snapshot included)[/]"
4501
+ if session.last_checkpoint_snapshot_included
4502
+ else " [dim](conversation only; file snapshot skipped)[/]"
4503
+ )
4504
+ console.print(
4505
+ f"[green]✓[/] Checkpoint created{label_display}: [bold]{cp_id[:8]}…[/bold] "
4506
+ f"(at message {len(session.messages)}){snap_indicator}"
4507
+ )
4508
+ else:
4509
+ console.print("[dim]No active session or no messages to checkpoint.[/]")
4510
+ return True
4511
+
4512
+ if cmd == "/checkpoints":
4513
+ cps = session.list_checkpoints()
4514
+ if not cps:
4515
+ console.print("[dim]No checkpoints for this session.[/]")
4516
+ return True
4517
+ from rich.table import Table as CpTable
4518
+ table = CpTable(title="Checkpoints", border_style="blue", expand=False)
4519
+ table.add_column("#", style="dim", width=3)
4520
+ table.add_column("ID", style="cyan", width=8)
4521
+ table.add_column("Msg#", style="dim", width=5, justify="right")
4522
+ table.add_column("Files", style="green", width=5, justify="center")
4523
+ table.add_column("Label")
4524
+ table.add_column("Created", style="dim", width=20)
4525
+ for i, cp in enumerate(cps, 1):
4526
+ ts = cp.get("created_at", 0)
4527
+ created = _format_timestamp(ts) if ts else ""
4528
+ has_snap = "✓" if cp.get("snapshot_id") else "✗"
4529
+ table.add_row(
4530
+ str(i),
4531
+ cp["id"][:8],
4532
+ str(cp.get("message_index", "")),
4533
+ has_snap,
4534
+ cp.get("label", ""),
4535
+ created,
4536
+ )
4537
+ console.print(table)
4538
+ return True
4539
+
4540
+ if cmd == "/rollback":
4541
+ if not arg:
4542
+ console.print("[dim]Usage: /rollback <checkpoint-id or #>[/]")
4543
+ return True
4544
+ cps = session.list_checkpoints()
4545
+ target_id = arg
4546
+ # Try numeric index first
4547
+ try:
4548
+ idx = int(arg) - 1
4549
+ if 0 <= idx < len(cps):
4550
+ target_id = cps[idx]["id"]
4551
+ except ValueError:
4552
+ # Try prefix match
4553
+ for cp in cps:
4554
+ if cp["id"].startswith(arg):
4555
+ target_id = cp["id"]
4556
+ break
4557
+ old_count = len(session.messages)
4558
+ ok = session.rollback(target_id)
4559
+ if ok:
4560
+ console.print(
4561
+ f"[green]✓[/] Rolled back to checkpoint [bold]{target_id[:8]}…[/bold] "
4562
+ f"({old_count} → {len(session.messages)} messages)"
4563
+ )
4564
+ else:
4565
+ console.print(f"[bold red]Checkpoint not found: {arg}[/]")
4566
+ return True
4567
+
4568
+ if cmd == "/revert" or cmd == "/undo":
4569
+ cps = session.list_checkpoints()
4570
+ if cmd == "/undo":
4571
+ # /undo = revert the most recent checkpoint
4572
+ if not cps:
4573
+ console.print("[dim]No checkpoints to undo.[/]")
4574
+ return True
4575
+ target_id = cps[0]["id"]
4576
+ else:
4577
+ if not arg:
4578
+ if not cps:
4579
+ console.print("[dim]No checkpoints to revert.[/]")
4580
+ return True
4581
+ target_id = cps[0]["id"]
4582
+ else:
4583
+ target_id = arg
4584
+ try:
4585
+ idx = int(arg) - 1
4586
+ if 0 <= idx < len(cps):
4587
+ target_id = cps[idx]["id"]
4588
+ except ValueError:
4589
+ for cp in cps:
4590
+ if cp["id"].startswith(arg):
4591
+ target_id = cp["id"]
4592
+ break
4593
+
4594
+ result = session.revert(target_id)
4595
+ if result.get("success"):
4596
+ parts = [f"[green]✓[/] Reverted to checkpoint [bold]{target_id[:8]}…[/bold]"]
4597
+ if result.get("files_restored"):
4598
+ parts.append(f" [green]{len(result['files_restored'])} file(s) restored[/]")
4599
+ if result.get("messages_rolled_back"):
4600
+ parts.append(f" [dim]{result['messages_rolled_back']} message(s) removed[/]")
4601
+ if result.get("warning"):
4602
+ parts.append(f" [yellow]⚠ {result['warning']}[/]")
4603
+ console.print("\n".join(parts))
4604
+ else:
4605
+ console.print(f"[bold red]Checkpoint not found or revert failed: {target_id[:8]}[/]")
4606
+ return True
4607
+
4608
+ if cmd == "/export":
4609
+ if not session.session_id:
4610
+ console.print("[dim]No active session to export.[/]")
4611
+ return True
4612
+ parts = arg.split() if arg else []
4613
+ fmt = "md"
4614
+ out_path = ""
4615
+ for p in parts:
4616
+ if p in ("md", "markdown", "json"):
4617
+ fmt = "json" if p == "json" else "md"
4618
+ else:
4619
+ out_path = p
4620
+ from crabcode_core.session.export import export_markdown, export_json
4621
+ if fmt == "json":
4622
+ content = export_json(session.session_id, session.cwd)
4623
+ ext = ".json"
4624
+ else:
4625
+ content = export_markdown(session.session_id, session.cwd)
4626
+ ext = ".md"
4627
+ if not out_path:
4628
+ out_path = os.path.join(session.cwd, f"{session.session_id[:8]}{ext}")
4629
+ try:
4630
+ with open(out_path, "w", encoding="utf-8") as f:
4631
+ f.write(content)
4632
+ console.print(f"[green]✓[/] Exported to [bold]{out_path}[/]")
4633
+ except OSError as exc:
4634
+ console.print(f"[bold red]Export failed: {exc}[/]")
4635
+ return True
4636
+
4637
+ if cmd == "/archive":
4638
+ if not arg:
4639
+ console.print("[dim]Usage: /archive <session-id>[/]")
4640
+ return True
4641
+ from crabcode_core.session.storage import SessionStorage as ArchiveStorage
4642
+ sessions = ArchiveStorage.list_sessions(session.cwd)
4643
+ match = None
4644
+ for s in sessions:
4645
+ if s["session_id"] == arg or s["session_id"].startswith(arg):
4646
+ match = s["session_id"]
4647
+ break
4648
+ if not match:
4649
+ try:
4650
+ idx = int(arg) - 1
4651
+ if 0 <= idx < len(sessions):
4652
+ match = sessions[idx]["session_id"]
4653
+ except ValueError:
4654
+ pass
4655
+ if not match:
4656
+ console.print(f"[bold red]Session not found: {arg}[/]")
4657
+ return True
4658
+ from crabcode_core.session.meta_db import SessionMetaStore as ArchiveStore
4659
+ store = ArchiveStore()
4660
+ try:
4661
+ store.archive(match)
4662
+ finally:
4663
+ store.close()
4664
+ console.print(f"[dim]Archived session [bold]{match[:8]}…[/bold][/]")
4665
+ return True
4666
+
4667
+ if cmd == "/recent":
4668
+ from crabcode_core.session.meta_db import SessionMetaStore
4669
+ store = SessionMetaStore()
4670
+ try:
4671
+ rows = store.list_recent(limit=20)
4672
+ finally:
4673
+ store.close()
4674
+ if not rows:
4675
+ console.print("[dim]No sessions found.[/]")
4676
+ return True
4677
+ from rich.table import Table as RecentTable
4678
+ table = RecentTable(title="Recent Sessions (all projects)", border_style="blue", expand=False)
4679
+ table.add_column("#", style="dim", width=3)
4680
+ table.add_column("ID", style="cyan", width=8)
4681
+ table.add_column("Project", style="dim", width=24)
4682
+ table.add_column("Model", style="dim", width=16)
4683
+ table.add_column("Tokens", style="dim", width=8, justify="right")
4684
+ table.add_column("Preview")
4685
+ for i, r in enumerate(rows, 1):
4686
+ sid = r.get("id", "")
4687
+ cwd_display = r.get("cwd", "")
4688
+ if len(cwd_display) > 24:
4689
+ cwd_display = "…" + cwd_display[-23:]
4690
+ tokens = r.get("tokens_used", 0)
4691
+ tokens_str = f"{tokens // 1000}k" if tokens >= 1000 else str(tokens)
4692
+ preview = r.get("title", "") or r.get("first_user_message", "")
4693
+ table.add_row(
4694
+ str(i),
4695
+ sid[:8],
4696
+ cwd_display,
4697
+ r.get("model", "")[:16],
4698
+ tokens_str,
4699
+ preview[:50],
4700
+ )
4701
+ console.print(table)
4702
+ return True
4703
+
4704
+ if cmd == "/resume":
4705
+ if not arg:
4706
+ from crabcode_cli.session_picker import select_session
4707
+
4708
+ arg = await select_session(session.cwd, session.session_id)
4709
+ if not arg:
4710
+ return True
4711
+
4712
+ from crabcode_core.session.storage import SessionStorage
4713
+ sessions = SessionStorage.list_sessions(session.cwd)
4714
+ session_id = arg
4715
+
4716
+ match = None
4717
+ match_source = "local"
4718
+ # 1) Try current project: exact, prefix, or numeric index
4719
+ for s in sessions:
4720
+ if s["session_id"] == session_id or s["session_id"].startswith(session_id):
4721
+ match = s["session_id"]
4722
+ break
4723
+ if not match:
4724
+ try:
4725
+ idx = int(session_id) - 1
4726
+ if 0 <= idx < len(sessions):
4727
+ match = sessions[idx]["session_id"]
4728
+ except ValueError:
4729
+ pass
4730
+
4731
+ # 2) Fallback: cross-project lookup via SQLite
4732
+ if not match:
4733
+ from crabcode_core.session.meta_db import SessionMetaStore as ResumeStore
4734
+ store = ResumeStore()
4735
+ try:
4736
+ # Try exact match first
4737
+ row = store.get(session_id)
4738
+ if row:
4739
+ match = row["id"]
4740
+ match_source = row.get("cwd", "")
4741
+ else:
4742
+ # Try prefix match across all recent sessions
4743
+ recent = store.list_recent(limit=100)
4744
+ for r in recent:
4745
+ if r["id"].startswith(session_id):
4746
+ match = r["id"]
4747
+ match_source = r.get("cwd", "")
4748
+ break
4749
+ finally:
4750
+ store.close()
4751
+
4752
+ if not match:
4753
+ console.print(f"[bold red]Session not found: {session_id}[/]")
4754
+ return True
4755
+
4756
+ if match_source != "local" and match_source:
4757
+ cwd_display = match_source
4758
+ if len(cwd_display) > 40:
4759
+ cwd_display = "…" + cwd_display[-39:]
4760
+ console.print(f"[dim]Found in project: {cwd_display}[/]")
4761
+
4762
+ ok = await session.resume(match)
4763
+ if ok:
4764
+ console.print(
4765
+ f"[dim]Resumed session [bold]{match[:8]}…[/bold] "
4766
+ f"({len(session.messages)} messages)[/]"
4767
+ )
4768
+ _print_active_model(
4769
+ session.settings,
4770
+ getattr(session, "_current_model_name", None),
4771
+ indent="",
4772
+ )
4773
+ console.print()
4774
+ _render_session_history(session.messages)
4775
+ else:
4776
+ console.print(f"[bold red]Failed to resume session {match[:8]}…[/]")
4777
+ return True
4778
+
4779
+ if cmd == "/fork":
4780
+ from crabcode_core.session.storage import SessionStorage
4781
+
4782
+ source_id = str(session.session_id or "").strip()
4783
+ if not source_id:
4784
+ console.print("[dim]No active session to fork.[/]")
4785
+ return True
4786
+ assistants = [
4787
+ message for message in session.messages
4788
+ if message.role == MessageRole.ASSISTANT and message.uuid
4789
+ ]
4790
+ if not assistants:
4791
+ console.print("[dim]No assistant reply is available to fork.[/]")
4792
+ return True
4793
+
4794
+ selector = arg.strip()
4795
+ if selector:
4796
+ matches = [
4797
+ message for message in assistants
4798
+ if message.uuid == selector or message.uuid.startswith(selector)
4799
+ ]
4800
+ if len(matches) != 1:
4801
+ detail = "not found" if not matches else "ambiguous"
4802
+ console.print(f"[bold red]Assistant message selector {detail}: {selector}[/]")
4803
+ return True
4804
+ target = matches[0]
4805
+ else:
4806
+ target = assistants[-1]
4807
+
4808
+ try:
4809
+ forked = SessionStorage.fork_from(session.cwd, source_id, target.uuid)
4810
+ if not await session.resume(forked.session_id):
4811
+ console.print(f"[bold red]Failed to resume fork {forked.session_id[:8]}…[/]")
4812
+ return True
4813
+ except (OSError, ValueError, RuntimeError) as exc:
4814
+ console.print(f"[bold red]Fork failed: {safe_utf8_str(str(exc))}[/]")
4815
+ return True
4816
+
4817
+ console.print(
4818
+ f"[dim]Forked [bold]{source_id[:8]}…[/bold] at [bold]{target.uuid[:8]}…[/bold] "
4819
+ f"into [bold]{forked.session_id[:8]}…[/bold][/dim]"
4820
+ )
4821
+ console.print()
4822
+ _render_session_history(session.messages)
4823
+ return True
4824
+
4825
+ if cmd in ("/exit", "/quit"):
4826
+ return False
4827
+
4828
+ # --- Skill invocation: /<skill-name> [user input] ---
4829
+ skill_name = cmd.lstrip("/")
4830
+ matched_skill = next((s for s in skills if s.name == skill_name), None)
4831
+ if matched_skill:
4832
+ prompt = matched_skill.content
4833
+ if arg:
4834
+ if "$USER_INPUT" in prompt:
4835
+ prompt = prompt.replace("$USER_INPUT", arg)
4836
+ else:
4837
+ prompt = f"{prompt}\n\nUser input: {arg}"
4838
+ return prompt
4839
+
4840
+ console.print(f"[dim]Unknown command: {command}. Type /help for available commands.[/]")
4841
+ return True