crabcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. crabcode-0.1.0.dist-info/METADATA +23 -0
  2. crabcode-0.1.0.dist-info/RECORD +64 -0
  3. crabcode-0.1.0.dist-info/WHEEL +4 -0
  4. crabcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +3 -0
  6. crabcode_cli/app.py +142 -0
  7. crabcode_cli/banner.py +42 -0
  8. crabcode_cli/pipe.py +58 -0
  9. crabcode_cli/render/__init__.py +0 -0
  10. crabcode_cli/render/messages.py +19 -0
  11. crabcode_cli/repl.py +1263 -0
  12. crabcode_core/__init__.py +3 -0
  13. crabcode_core/api/__init__.py +6 -0
  14. crabcode_core/api/anthropic_adapter.py +250 -0
  15. crabcode_core/api/base.py +66 -0
  16. crabcode_core/api/codex_adapter.py +390 -0
  17. crabcode_core/api/openai_adapter.py +221 -0
  18. crabcode_core/api/registry.py +40 -0
  19. crabcode_core/compact/__init__.py +7 -0
  20. crabcode_core/compact/compact.py +150 -0
  21. crabcode_core/config/__init__.py +3 -0
  22. crabcode_core/config/claudemd.py +53 -0
  23. crabcode_core/config/manager.py +152 -0
  24. crabcode_core/events.py +392 -0
  25. crabcode_core/mcp/__init__.py +4 -0
  26. crabcode_core/mcp/client.py +163 -0
  27. crabcode_core/mcp/config.py +52 -0
  28. crabcode_core/permissions/__init__.py +3 -0
  29. crabcode_core/permissions/manager.py +106 -0
  30. crabcode_core/prompts/__init__.py +0 -0
  31. crabcode_core/prompts/context.py +209 -0
  32. crabcode_core/prompts/profile.py +66 -0
  33. crabcode_core/prompts/system.py +405 -0
  34. crabcode_core/prompts/templates.py +60 -0
  35. crabcode_core/query/__init__.py +0 -0
  36. crabcode_core/query/loop.py +442 -0
  37. crabcode_core/session/__init__.py +3 -0
  38. crabcode_core/session/storage.py +153 -0
  39. crabcode_core/skills/__init__.py +5 -0
  40. crabcode_core/skills/loader.py +147 -0
  41. crabcode_core/tools/__init__.py +29 -0
  42. crabcode_core/tools/agent.py +160 -0
  43. crabcode_core/tools/bash.py +127 -0
  44. crabcode_core/tools/diff_utils.py +120 -0
  45. crabcode_core/tools/file_edit.py +145 -0
  46. crabcode_core/tools/file_read.py +123 -0
  47. crabcode_core/tools/file_write.py +102 -0
  48. crabcode_core/tools/glob.py +109 -0
  49. crabcode_core/tools/grep.py +130 -0
  50. crabcode_core/tools/lint.py +368 -0
  51. crabcode_core/tools/memory.py +236 -0
  52. crabcode_core/tools/skill.py +112 -0
  53. crabcode_core/types/__init__.py +73 -0
  54. crabcode_core/types/config.py +103 -0
  55. crabcode_core/types/event.py +105 -0
  56. crabcode_core/types/message.py +177 -0
  57. crabcode_core/types/tool.py +138 -0
  58. crabcode_search/__init__.py +11 -0
  59. crabcode_search/background.py +371 -0
  60. crabcode_search/chunker.py +517 -0
  61. crabcode_search/embedder.py +447 -0
  62. crabcode_search/indexer.py +212 -0
  63. crabcode_search/store.py +222 -0
  64. crabcode_search/tool.py +232 -0
crabcode_cli/repl.py ADDED
@@ -0,0 +1,1263 @@
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 os
8
+ import sys
9
+ import time
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from prompt_toolkit import PromptSession
14
+ from prompt_toolkit.completion import Completer, Completion
15
+ from prompt_toolkit.formatted_text import HTML
16
+ from prompt_toolkit.history import InMemoryHistory
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.text import Text
20
+
21
+ from crabcode_cli.banner import print_banner
22
+ from crabcode_core.events import CoreSession
23
+ from crabcode_core.types.config import CrabCodeSettings, DisplaySettings
24
+ from crabcode_core.types.message import Message, MessageRole
25
+ from crabcode_core.types.event import (
26
+ CompactEvent,
27
+ ErrorEvent,
28
+ PermissionRequestEvent,
29
+ PermissionResponseEvent,
30
+ StreamModeEvent,
31
+ StreamTextEvent,
32
+ ThinkingEvent,
33
+ ToolResultEvent,
34
+ ToolUseEvent,
35
+ TurnCompleteEvent,
36
+ )
37
+
38
+ # Module-level display settings, set during run_repl()
39
+ _display_settings: DisplaySettings | None = None
40
+
41
+
42
+ def _supports_ansi_output() -> bool:
43
+ """Return True when stdout is an interactive ANSI-capable terminal."""
44
+ if os.getenv("CRABCODE_PLAIN_OUTPUT") or os.getenv("NO_COLOR"):
45
+ return False
46
+ if os.getenv("TERM", "").lower() == "dumb":
47
+ return False
48
+ return bool(getattr(sys.stdin, "isatty", lambda: False)()) and bool(
49
+ getattr(sys.stdout, "isatty", lambda: False)()
50
+ )
51
+
52
+
53
+ _ANSI_ENABLED = _supports_ansi_output()
54
+ console = Console(no_color=not _ANSI_ENABLED, force_terminal=_ANSI_ENABLED)
55
+
56
+
57
+ # Slash commands with their arguments for auto-completion
58
+ _SLASH_COMMANDS: dict[str, list[str]] = {
59
+ "/help": [],
60
+ "/status": [],
61
+ "/logs": ["-f", "--follow", "--clear", "--tail"],
62
+ "/model": [], # Dynamic: model names
63
+ "/new": [],
64
+ "/compact": [],
65
+ "/clear": [],
66
+ "/sessions": [],
67
+ "/resume": [], # Dynamic: session IDs
68
+ "/exit": [],
69
+ "/quit": [],
70
+ }
71
+
72
+
73
+ class _CrabCodeCompleter(Completer):
74
+ """Auto-completer for slash commands and their arguments."""
75
+
76
+ def __init__(self, session: "CoreSession" | None = None) -> None:
77
+ self._session = session
78
+
79
+ def get_completions(self, document, complete_event):
80
+ text = document.text_before_cursor.lstrip()
81
+ word_before_cursor = document.get_word_before_cursor(WORD=False)
82
+
83
+ # Only complete after /
84
+ if not text.startswith("/"):
85
+ return
86
+
87
+ parts = text.split()
88
+ cmd = parts[0].lower() if parts else ""
89
+
90
+ # First token must include leading "/"; get_word_before_cursor(WORD=False)
91
+ # treats "/" as a separator, so "/sta" was only replacing "sta" -> "//status".
92
+ if len(parts) <= 1 and not text.endswith(" "):
93
+ replace_len = len(parts[0])
94
+ for name in _SLASH_COMMANDS:
95
+ if name.startswith(cmd):
96
+ yield Completion(
97
+ name,
98
+ start_position=-replace_len,
99
+ display=name,
100
+ display_meta=self._get_command_description(name),
101
+ )
102
+ # Also complete skill names
103
+ if self._session:
104
+ skills = getattr(self._session, "skills", [])
105
+ for skill in skills:
106
+ skill_cmd = f"/{skill.name}"
107
+ if skill_cmd.startswith(cmd):
108
+ yield Completion(
109
+ skill_cmd,
110
+ start_position=-replace_len,
111
+ display=skill_cmd,
112
+ display_meta=skill.description or skill.when_to_use or "skill",
113
+ )
114
+ return
115
+
116
+ # Complete arguments for specific commands
117
+ if len(parts) >= 2 or text.endswith(" "):
118
+ # /model <name> — complete model names
119
+ if cmd == "/model":
120
+ if self._session:
121
+ models = self._session.list_models()
122
+ for name in models:
123
+ if name.startswith(word_before_cursor):
124
+ yield Completion(
125
+ name,
126
+ start_position=-len(word_before_cursor),
127
+ display=name,
128
+ )
129
+ return
130
+
131
+ # /logs <name> — complete log names
132
+ if cmd == "/logs":
133
+ try:
134
+ from crabcode_search.background import list_background_logs
135
+ logs = list_background_logs(self._session.cwd if self._session else ".")
136
+ except Exception:
137
+ logs = {}
138
+ for name in logs:
139
+ if name.startswith(word_before_cursor):
140
+ yield Completion(
141
+ name,
142
+ start_position=-len(word_before_cursor),
143
+ display=name,
144
+ )
145
+ # Also complete flags
146
+ for flag in _SLASH_COMMANDS.get("/logs", []):
147
+ if flag.startswith(word_before_cursor):
148
+ yield Completion(
149
+ flag,
150
+ start_position=-len(word_before_cursor),
151
+ display=flag,
152
+ )
153
+ return
154
+
155
+ # /resume <id> — complete session IDs
156
+ if cmd == "/resume":
157
+ from crabcode_core.session.storage import SessionStorage
158
+ sessions = SessionStorage.list_sessions(self._session.cwd if self._session else ".")
159
+ for s in sessions[:20]:
160
+ sid = s["session_id"]
161
+ if sid.startswith(word_before_cursor):
162
+ yield Completion(
163
+ sid,
164
+ start_position=-len(word_before_cursor),
165
+ display=sid[:12] + "…",
166
+ display_meta=s.get("preview", "")[:40],
167
+ )
168
+ return
169
+
170
+ def _get_command_description(self, cmd: str) -> str:
171
+ descriptions = {
172
+ "/help": "show help",
173
+ "/status": "show session status",
174
+ "/logs": "show background logs",
175
+ "/model": "show/switch model",
176
+ "/new": "start new session",
177
+ "/compact": "compact conversation",
178
+ "/clear": "clear history",
179
+ "/sessions": "list sessions",
180
+ "/resume": "resume session",
181
+ "/exit": "exit CrabCode",
182
+ "/quit": "exit CrabCode",
183
+ }
184
+ return descriptions.get(cmd, "")
185
+
186
+
187
+ _SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
188
+ _VERBS = ["Thinking", "Reasoning", "Analyzing", "Processing", "Understanding"]
189
+
190
+
191
+ def _force_exit(code: int = 130) -> None:
192
+ """Exit immediately without waiting for executor/native thread cleanup."""
193
+ try:
194
+ sys.stdout.flush()
195
+ sys.stderr.flush()
196
+ finally:
197
+ os._exit(code)
198
+
199
+
200
+ _CTRL_C_EXIT_WINDOW_S = 5.0
201
+
202
+
203
+ class _CtrlCDoubleExit:
204
+ """First Ctrl+C interrupts; a second within the window exits; otherwise reset."""
205
+
206
+ __slots__ = ("_window_s", "_first_at")
207
+
208
+ def __init__(self, window_s: float = _CTRL_C_EXIT_WINDOW_S) -> None:
209
+ self._window_s = window_s
210
+ self._first_at: float | None = None
211
+
212
+ def should_exit_now(self) -> bool:
213
+ """Record this Ctrl+C; return True if the user should exit (second tap in window)."""
214
+ now = time.monotonic()
215
+ if self._first_at is not None and (now - self._first_at) <= self._window_s:
216
+ return True
217
+ self._first_at = now
218
+ return False
219
+
220
+ def clear(self) -> None:
221
+ self._first_at = None
222
+
223
+
224
+ # asyncio.run() installs a SIGINT handler: first Ctrl+C cancels the main task
225
+ # (CancelledError), the second raises KeyboardInterrupt. REPL must handle both.
226
+ _REPL_INTERRUPT_EXCS: tuple[type[BaseException], ...] = (
227
+ KeyboardInterrupt,
228
+ asyncio.CancelledError,
229
+ )
230
+
231
+
232
+ def _clear_sigint_cancel() -> None:
233
+ """After handling first SIGINT under asyncio.run(), drop pending cancel so the REPL continues."""
234
+ task = asyncio.current_task()
235
+ if task is None:
236
+ return
237
+ uncancel = getattr(task, "uncancel", None)
238
+ if uncancel is None:
239
+ return
240
+ while uncancel() > 0:
241
+ pass
242
+
243
+
244
+ def _read_log_tail(path: Path, max_lines: int = 80) -> str:
245
+ try:
246
+ text = path.read_text(encoding="utf-8", errors="replace")
247
+ except OSError as exc:
248
+ return f"(failed to read log: {exc})"
249
+ lines = text.splitlines()
250
+ if not lines:
251
+ return "(log is empty)"
252
+ if len(lines) > max_lines:
253
+ lines = ["... (truncated)"] + lines[-max_lines:]
254
+ return "\n".join(lines)
255
+
256
+
257
+ def _format_timestamp(ts: float | None) -> str:
258
+ if ts is None:
259
+ return "unknown"
260
+ try:
261
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
262
+ except Exception:
263
+ return "unknown"
264
+
265
+
266
+ def _parse_logs_args(arg: str) -> tuple[bool, bool, int, str | None, str | None]:
267
+ follow = False
268
+ clear = False
269
+ tail = 80
270
+ name: str | None = None
271
+ error: str | None = None
272
+
273
+ parts = arg.split() if arg else []
274
+ i = 0
275
+ while i < len(parts):
276
+ part = parts[i]
277
+ if part in ("-f", "--follow"):
278
+ follow = True
279
+ elif part == "--clear":
280
+ clear = True
281
+ elif part == "--tail":
282
+ i += 1
283
+ if i >= len(parts):
284
+ error = "--tail requires a number"
285
+ break
286
+ try:
287
+ tail = max(1, int(parts[i]))
288
+ except ValueError:
289
+ error = "--tail requires an integer"
290
+ break
291
+ elif part.startswith("--tail="):
292
+ value = part.split("=", 1)[1]
293
+ try:
294
+ tail = max(1, int(value))
295
+ except ValueError:
296
+ error = "--tail requires an integer"
297
+ break
298
+ elif part.startswith("-"):
299
+ error = f"unknown option: {part}"
300
+ break
301
+ elif name is None:
302
+ name = part
303
+ else:
304
+ error = f"unexpected argument: {part}"
305
+ break
306
+ i += 1
307
+
308
+ return follow, clear, tail, name, error
309
+
310
+
311
+ async def _follow_log(path: Path, name: str) -> None:
312
+ console.print(
313
+ f"[dim]Following {name}. Press Ctrl+C to stop.[/]"
314
+ )
315
+ try:
316
+ with open(path, encoding="utf-8", errors="replace") as f:
317
+ f.seek(0, os.SEEK_END)
318
+ while True:
319
+ line = f.readline()
320
+ if line:
321
+ sys.stdout.write(line)
322
+ sys.stdout.flush()
323
+ else:
324
+ await asyncio.sleep(0.5)
325
+ except _REPL_INTERRUPT_EXCS:
326
+ _clear_sigint_cancel()
327
+ console.print("\n[dim]Stopped log follow.[/]")
328
+
329
+
330
+ class _Spinner:
331
+ """Async terminal spinner with phase-aware messaging and elapsed timer."""
332
+
333
+ def __init__(self, ansi_enabled: bool = True) -> None:
334
+ self._ansi_enabled = ansi_enabled
335
+ self._task: asyncio.Task[None] | None = None
336
+ self._message = ""
337
+ self._running = False
338
+ self._start_time = 0.0
339
+ self._verb_index = 0
340
+ self._last_line_len = 0
341
+
342
+ def start(self, message: str | None = None) -> None:
343
+ if self._running:
344
+ return
345
+ self._message = message or _VERBS[self._verb_index % len(_VERBS)]
346
+ self._verb_index += 1
347
+ self._running = True
348
+ self._start_time = time.monotonic()
349
+ self._task = asyncio.create_task(self._animate())
350
+
351
+ def update(self, message: str) -> None:
352
+ self._message = message
353
+ self._start_time = time.monotonic()
354
+
355
+ async def stop(self) -> float:
356
+ """Stop spinner, wait for animation task, clear line, return elapsed seconds."""
357
+ elapsed = time.monotonic() - self._start_time if self._running else 0.0
358
+ self._running = False
359
+ if self._task:
360
+ self._task.cancel()
361
+ with contextlib.suppress(asyncio.CancelledError):
362
+ await self._task
363
+ self._task = None
364
+ self._clear_line()
365
+ sys.stdout.flush()
366
+ return elapsed
367
+
368
+ @property
369
+ def is_running(self) -> bool:
370
+ return self._running
371
+
372
+ async def _animate(self) -> None:
373
+ try:
374
+ idx = 0
375
+ while self._running:
376
+ frame = _SPINNER_FRAMES[idx % len(_SPINNER_FRAMES)]
377
+ elapsed = time.monotonic() - self._start_time
378
+ suffix = f" ({elapsed:.0f}s)" if elapsed >= 2 else ""
379
+ line = f"{frame} {self._message}…{suffix}"
380
+ if self._ansi_enabled:
381
+ sys.stdout.write(f"\r\033[K\033[2;36m{line}\033[0m")
382
+ else:
383
+ pad = max(0, self._last_line_len - len(line))
384
+ sys.stdout.write(f"\r{line}{' ' * pad}")
385
+ self._last_line_len = len(line)
386
+ sys.stdout.flush()
387
+ idx += 1
388
+ await asyncio.sleep(0.08)
389
+ except asyncio.CancelledError:
390
+ pass
391
+
392
+ def _clear_line(self) -> None:
393
+ if self._ansi_enabled:
394
+ sys.stdout.write("\r\033[K")
395
+ self._last_line_len = 0
396
+ return
397
+ if self._last_line_len:
398
+ sys.stdout.write("\r" + (" " * self._last_line_len) + "\r")
399
+ self._last_line_len = 0
400
+
401
+
402
+ def _tool_summary(name: str, inp: dict) -> str:
403
+ """Return a human-readable one-liner for a tool call."""
404
+ if name == "Bash":
405
+ cmd = inp.get("command", "")
406
+ lines = cmd.split("\n")
407
+ if len(lines) > 3:
408
+ return "\n".join(lines[:3]) + "\n…"
409
+ return cmd
410
+ if name in ("Write", "Edit", "Read"):
411
+ return inp.get("file_path", inp.get("path", ""))
412
+ if name == "Grep":
413
+ pattern = inp.get("pattern", "")
414
+ path = inp.get("path", ".")
415
+ return f'"{pattern}" in {path}'
416
+ if name == "Glob":
417
+ return inp.get("pattern", inp.get("glob_pattern", ""))
418
+ if name == "Agent":
419
+ prompt = inp.get("prompt", "")
420
+ return (prompt[:100] + "…") if len(prompt) > 100 else prompt
421
+ import json
422
+ raw = json.dumps(inp, ensure_ascii=False)
423
+ return (raw[:200] + "…") if len(raw) > 200 else raw
424
+
425
+
426
+ def _render_saved_partial_reply(text: str) -> None:
427
+ """Echo assistant text that was persisted after interrupt (visible in scrollback)."""
428
+ body = text.rstrip()
429
+ if not body:
430
+ return
431
+ preview = body if len(body) <= 12000 else body[:12000] + "\n… (truncated in preview; full text is in context)"
432
+ console.print(
433
+ Panel(
434
+ Text(preview, style="dim"),
435
+ title="[dim]Assistant · partial (saved to context)[/]",
436
+ border_style="dim",
437
+ expand=False,
438
+ )
439
+ )
440
+
441
+
442
+ def _persist_partial_assistant_for_interrupt(session: CoreSession, raw: str) -> None:
443
+ """Write streamed assistant text into the session and show it in the terminal."""
444
+ console.print()
445
+ if raw.strip():
446
+ session.record_partial_assistant_output(raw)
447
+ _render_saved_partial_reply(raw)
448
+ else:
449
+ console.print(
450
+ "[dim](Interrupted before any assistant reply text; only your user message is in context.)[/]"
451
+ )
452
+
453
+
454
+ def _render_tool_use(event: ToolUseEvent) -> None:
455
+ """Render a compact tool use call."""
456
+ summary = _tool_summary(event.tool_name, event.tool_input)
457
+ console.print(
458
+ Panel(
459
+ Text(summary, style="dim"),
460
+ title=f"[bold cyan]{event.tool_name}[/]",
461
+ border_style="cyan",
462
+ expand=False,
463
+ )
464
+ )
465
+
466
+
467
+ def _truncate_display(display: str, max_lines: int = 50, max_chars: int = 50_000) -> str:
468
+ """Truncate display text by line count and character count."""
469
+ # Line-based truncation first
470
+ lines = display.split("\n")
471
+ if len(lines) > max_lines:
472
+ display = "\n".join(lines[:max_lines]) + f"\n… ({len(lines) - max_lines} more lines truncated)"
473
+ # Character-based safety cap
474
+ if len(display) > max_chars:
475
+ display = display[:max_chars] + "\n... (truncated)"
476
+ return display
477
+
478
+
479
+ def _render_tool_result(event: ToolResultEvent) -> None:
480
+ """Render a tool result."""
481
+ display = event.result_for_display or event.result
482
+
483
+ if event.tool_name in ("Edit", "Write") and not event.is_error and "\n@@" in display:
484
+ _render_diff_result(event.tool_name, display)
485
+ return
486
+
487
+ # Read display limits from settings if available
488
+ ds = _display_settings
489
+ max_lines = ds.get_max_lines(event.tool_name) if ds else 50
490
+ max_chars = ds.max_chars if ds else 50_000
491
+ display = _truncate_display(display, max_lines=max_lines, max_chars=max_chars)
492
+
493
+ style = "red" if event.is_error else "green"
494
+ title = f"{'Error' if event.is_error else 'Result'}: {event.tool_name}"
495
+ console.print(
496
+ Panel(
497
+ Text(display, style="dim"),
498
+ title=f"[bold {style}]{title}[/]",
499
+ border_style=style,
500
+ expand=False,
501
+ )
502
+ )
503
+
504
+
505
+ def _render_diff_result(tool_name: str, display: str) -> None:
506
+ """Render a diff result with colored +/- lines."""
507
+ lines = display.split("\n")
508
+ header = lines[0] if lines else ""
509
+
510
+ diff_parts: list[Text] = []
511
+ for line in lines[1:]:
512
+ if line.startswith("+++") or line.startswith("---"):
513
+ diff_parts.append(Text(line, style="bold dim"))
514
+ elif line.startswith("@@"):
515
+ diff_parts.append(Text(line, style="cyan"))
516
+ elif line.startswith("+"):
517
+ diff_parts.append(Text(line, style="green"))
518
+ elif line.startswith("-"):
519
+ diff_parts.append(Text(line, style="red"))
520
+ elif line.startswith("... (diff truncated)"):
521
+ diff_parts.append(Text(line, style="yellow dim"))
522
+ else:
523
+ diff_parts.append(Text(line, style="dim"))
524
+
525
+ body = Text("\n").join(diff_parts) if diff_parts else Text("(no diff)", style="dim")
526
+
527
+ if len(display) > 5000:
528
+ body = Text(display[:5000] + "\n... (truncated)", style="dim")
529
+
530
+ console.print(
531
+ Panel(
532
+ body,
533
+ title=f"[bold green]{tool_name}: {header}[/]",
534
+ border_style="green",
535
+ expand=False,
536
+ )
537
+ )
538
+
539
+
540
+ def _render_session_history(messages: list[Message], max_messages: int = 50) -> None:
541
+ """Render a condensed view of conversation history after resuming a session."""
542
+ if not messages:
543
+ return
544
+
545
+ displayed = messages[-max_messages:]
546
+ if len(messages) > max_messages:
547
+ console.print(
548
+ f" [dim italic]... {len(messages) - max_messages} earlier messages omitted ...[/]\n"
549
+ )
550
+
551
+ for msg in displayed:
552
+ if msg.role == MessageRole.USER:
553
+ if getattr(msg, "source_tool_assistant_uuid", None):
554
+ continue
555
+ text = msg.text_content.strip()
556
+ if not text or text.startswith("<system-reminder>"):
557
+ continue
558
+ preview = text[:200] + ("…" if len(text) > 200 else "")
559
+ console.print(f"[bold cyan]❯[/] {preview}")
560
+
561
+ elif msg.role == MessageRole.ASSISTANT:
562
+ text = msg.text_content.strip()
563
+ tool_blocks = msg.tool_use_blocks
564
+
565
+ if text:
566
+ preview = text[:300] + ("…" if len(text) > 300 else "")
567
+ console.print(f"[dim]{preview}[/]")
568
+
569
+ if tool_blocks:
570
+ names = [b.name for b in tool_blocks]
571
+ console.print(f" [dim cyan]⚡ {', '.join(names)}[/]")
572
+
573
+ if not text and not tool_blocks:
574
+ continue
575
+
576
+ console.print()
577
+
578
+
579
+ async def _prompt_permission(
580
+ event: PermissionRequestEvent,
581
+ session: CoreSession,
582
+ ) -> None:
583
+ """Prompt the user for tool permission and push response to session."""
584
+ summary = _tool_summary(event.tool_name, event.tool_input)
585
+ console.print(
586
+ Panel(
587
+ Text(summary, style="dim"),
588
+ title=f"[bold yellow]⚠ {event.tool_name}[/]",
589
+ border_style="yellow",
590
+ expand=False,
591
+ )
592
+ )
593
+
594
+ loop = asyncio.get_event_loop()
595
+ while True:
596
+ try:
597
+ choice = await loop.run_in_executor(
598
+ None,
599
+ lambda: input(
600
+ f" Allow {event.tool_name}? "
601
+ "(y)es / (n)o / (a)lways allow: "
602
+ ).strip().lower(),
603
+ )
604
+ except (EOFError, KeyboardInterrupt):
605
+ await session.respond_permission(
606
+ PermissionResponseEvent(
607
+ tool_use_id=event.tool_use_id, allowed=False
608
+ )
609
+ )
610
+ return
611
+
612
+ if choice in ("y", "yes", ""):
613
+ await session.respond_permission(
614
+ PermissionResponseEvent(
615
+ tool_use_id=event.tool_use_id, allowed=True
616
+ )
617
+ )
618
+ return
619
+ elif choice in ("n", "no"):
620
+ await session.respond_permission(
621
+ PermissionResponseEvent(
622
+ tool_use_id=event.tool_use_id, allowed=False
623
+ )
624
+ )
625
+ return
626
+ elif choice in ("a", "always"):
627
+ await session.respond_permission(
628
+ PermissionResponseEvent(
629
+ tool_use_id=event.tool_use_id,
630
+ allowed=True,
631
+ always_allow=True,
632
+ )
633
+ )
634
+ return
635
+ else:
636
+ console.print(" [dim]Please enter y, n, or a[/]")
637
+
638
+
639
+ async def run_repl(
640
+ settings: CrabCodeSettings | None = None,
641
+ cwd: str = ".",
642
+ resume_session_id: str | None = None,
643
+ ) -> None:
644
+ """Run the interactive REPL."""
645
+ print_banner(console)
646
+ console.print(f" cwd: {cwd}", style="dim")
647
+ if settings:
648
+ active_cfg = settings.get_api_config()
649
+ provider = active_cfg.provider
650
+ model = active_cfg.model
651
+ model_label = f"[bold cyan]{settings.default_model}[/] → " if settings.default_model else ""
652
+ else:
653
+ provider = model = None
654
+ model_label = ""
655
+ provider_str = provider or "[yellow]not set[/]"
656
+ model_str = model or "[yellow]not set[/]"
657
+ console.print(f" provider: {provider_str} model: {model_label}{model_str}", style="dim")
658
+ if not model:
659
+ console.print(
660
+ " [bold yellow]Warning:[/] no model configured. "
661
+ "Set [bold]api.model[/] or [bold]models[/] in ~/.crabcode/settings.json or use [bold]-m[/] flag.",
662
+ style="dim",
663
+ )
664
+ console.print(
665
+ " Type /help for commands. "
666
+ f"Ctrl+C interrupts; press again within {_CTRL_C_EXIT_WINDOW_S:.0f}s to exit. "
667
+ "Ctrl+D exits.",
668
+ style="dim",
669
+ )
670
+
671
+ if settings and settings.permissions.run_everything:
672
+ console.print()
673
+ console.print(
674
+ " [bold yellow]⚠ WARNING: run_everything is enabled.[/] "
675
+ "All tool calls will execute automatically without asking for permission.",
676
+ style="yellow",
677
+ )
678
+
679
+ console.print()
680
+
681
+ session = CoreSession(cwd=cwd, settings=settings)
682
+
683
+ global _display_settings
684
+ _display_settings = settings.display if settings else None
685
+
686
+ _progress_line_len = 0
687
+
688
+ def _on_tool_event(tool_name: str, event_type: str, data: dict) -> None:
689
+ nonlocal _progress_line_len
690
+ if event_type == "progress":
691
+ msg = data.get("message", "")
692
+ pct = data.get("percent")
693
+ bar = ""
694
+ if pct is not None:
695
+ filled = int(pct * 20)
696
+ bar = f" [{'█' * filled}{'░' * (20 - filled)}] {int(pct * 100)}%"
697
+ line = f" {tool_name}: {msg}{bar}"
698
+ # Avoid ANSI clear-line control sequences; pad with spaces instead.
699
+ pad = max(0, _progress_line_len - len(line))
700
+ sys.stdout.write(f"\r{line}{' ' * pad}")
701
+ _progress_line_len = len(line)
702
+ sys.stdout.flush()
703
+ elif event_type == "ready":
704
+ if _progress_line_len:
705
+ sys.stdout.write("\r" + (" " * _progress_line_len) + "\r")
706
+ sys.stdout.flush()
707
+ _progress_line_len = 0
708
+ console.print(f" [green]✓[/] {tool_name}: {data.get('message', 'ready')}")
709
+
710
+ session.on_tool_event = _on_tool_event
711
+
712
+ if resume_session_id:
713
+ await session.initialize()
714
+ ok = await session.resume(resume_session_id)
715
+ if ok:
716
+ console.print(
717
+ f" [dim]Resumed session [bold]{resume_session_id[:8]}…[/bold] "
718
+ f"({len(session.messages)} messages)[/]"
719
+ )
720
+ console.print()
721
+ _render_session_history(session.messages)
722
+ else:
723
+ console.print(
724
+ f" [bold yellow]Warning:[/] session {resume_session_id[:8]}… not found, starting fresh.",
725
+ style="dim",
726
+ )
727
+ console.print()
728
+ else:
729
+ # Lazy initialization: session.initialize() is called by send_message().
730
+ # Avoid background startup races that can garble terminal output and
731
+ # block shutdown when Ctrl+C happens during native model loading.
732
+ pass
733
+
734
+ prompt_session: PromptSession[str] = PromptSession(
735
+ history=InMemoryHistory(),
736
+ completer=_CrabCodeCompleter(session),
737
+ complete_while_typing=True,
738
+ )
739
+
740
+ ctrl_c_exit = _CtrlCDoubleExit()
741
+
742
+ while True:
743
+ try:
744
+ user_input = await prompt_session.prompt_async(
745
+ HTML("<b><ansicyan>❯ </ansicyan></b>"),
746
+ )
747
+ except EOFError:
748
+ console.print("\nGoodbye!", style="dim")
749
+ try:
750
+ await session.interrupt()
751
+ except Exception:
752
+ pass
753
+ _force_exit()
754
+ except _REPL_INTERRUPT_EXCS:
755
+ if ctrl_c_exit.should_exit_now():
756
+ console.print("\nGoodbye!", style="dim")
757
+ try:
758
+ await session.interrupt()
759
+ except Exception:
760
+ pass
761
+ _force_exit()
762
+ _clear_sigint_cancel()
763
+ try:
764
+ await session.interrupt()
765
+ except Exception:
766
+ pass
767
+ console.print(
768
+ f"\n[dim]Interrupted. Press Ctrl+C again within {_CTRL_C_EXIT_WINDOW_S:.0f}s to exit.[/]"
769
+ )
770
+ continue
771
+
772
+ user_input = user_input.strip()
773
+ if not user_input:
774
+ continue
775
+
776
+ ctrl_c_exit.clear()
777
+
778
+ if user_input.startswith("/"):
779
+ result = await _handle_command(user_input, session, settings)
780
+ if result is False:
781
+ break
782
+ if isinstance(result, str):
783
+ user_input = result
784
+ else:
785
+ continue
786
+
787
+ if user_input.startswith("! "):
788
+ cmd = user_input[2:]
789
+ import subprocess
790
+ result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=False)
791
+ continue
792
+
793
+ streamed_text = ""
794
+ streamed_text_for_context = ""
795
+ spinner = _Spinner(ansi_enabled=_ANSI_ENABLED)
796
+ thinking_start: float = 0.0
797
+ is_thinking = False
798
+
799
+ async def _stop_spinner_with_thinking() -> None:
800
+ nonlocal is_thinking
801
+ if not spinner.is_running:
802
+ return
803
+ await spinner.stop()
804
+ if is_thinking and thinking_start:
805
+ duration = time.monotonic() - thinking_start
806
+ if duration >= 1:
807
+ console.print(
808
+ f" [dim italic]∴ Thought for {max(1, round(duration))}s[/]"
809
+ )
810
+ is_thinking = False
811
+
812
+ try:
813
+ async for event in session.send_message(user_input):
814
+ if isinstance(event, StreamModeEvent):
815
+ if event.mode == "requesting":
816
+ spinner.start()
817
+ is_thinking = False
818
+ thinking_start = 0.0
819
+ elif event.mode == "thinking":
820
+ thinking_start = time.monotonic()
821
+ is_thinking = True
822
+ spinner.update("Thinking")
823
+ elif event.mode == "responding":
824
+ await _stop_spinner_with_thinking()
825
+ elif event.mode == "tool-input":
826
+ await _stop_spinner_with_thinking()
827
+ if streamed_text:
828
+ sys.stdout.write("\n")
829
+ sys.stdout.flush()
830
+ streamed_text = ""
831
+ spinner.start("Generating")
832
+ elif event.mode == "tool-running":
833
+ await _stop_spinner_with_thinking()
834
+ if streamed_text:
835
+ sys.stdout.write("\n")
836
+ sys.stdout.flush()
837
+ streamed_text = ""
838
+ spinner.start("Running")
839
+
840
+ elif isinstance(event, ThinkingEvent):
841
+ pass
842
+
843
+ elif isinstance(event, StreamTextEvent):
844
+ await _stop_spinner_with_thinking()
845
+ sys.stdout.write(event.text)
846
+ sys.stdout.flush()
847
+ streamed_text += event.text
848
+ streamed_text_for_context += event.text
849
+
850
+ elif isinstance(event, ToolUseEvent):
851
+ await _stop_spinner_with_thinking()
852
+ if streamed_text:
853
+ sys.stdout.write("\n")
854
+ sys.stdout.flush()
855
+ streamed_text = ""
856
+ _render_tool_use(event)
857
+
858
+ elif isinstance(event, PermissionRequestEvent):
859
+ await _stop_spinner_with_thinking()
860
+ if streamed_text:
861
+ sys.stdout.write("\n")
862
+ sys.stdout.flush()
863
+ streamed_text = ""
864
+ await _prompt_permission(event, session)
865
+
866
+ elif isinstance(event, ToolResultEvent):
867
+ await _stop_spinner_with_thinking()
868
+ _render_tool_result(event)
869
+
870
+ elif isinstance(event, CompactEvent):
871
+ console.print(
872
+ f"\n[dim italic]Conversation compacted: {event.summary}[/]"
873
+ )
874
+
875
+ elif isinstance(event, ErrorEvent):
876
+ await _stop_spinner_with_thinking()
877
+ console.print(f"\n[bold red]Error: {event.message}[/]")
878
+ if not event.recoverable:
879
+ break
880
+
881
+ elif isinstance(event, TurnCompleteEvent):
882
+ await _stop_spinner_with_thinking()
883
+
884
+ except _REPL_INTERRUPT_EXCS:
885
+ await spinner.stop()
886
+ if ctrl_c_exit.should_exit_now():
887
+ console.print("\nGoodbye!", style="dim")
888
+ try:
889
+ await session.interrupt()
890
+ except Exception:
891
+ pass
892
+ _persist_partial_assistant_for_interrupt(
893
+ session, streamed_text_for_context
894
+ )
895
+ _force_exit()
896
+ _clear_sigint_cancel()
897
+ try:
898
+ await session.interrupt()
899
+ except Exception:
900
+ pass
901
+ _persist_partial_assistant_for_interrupt(
902
+ session, streamed_text_for_context
903
+ )
904
+ console.print(
905
+ f"[dim]Interrupted. Press Ctrl+C again within {_CTRL_C_EXIT_WINDOW_S:.0f}s to exit.[/]"
906
+ )
907
+
908
+ if streamed_text:
909
+ sys.stdout.write("\n")
910
+ sys.stdout.flush()
911
+
912
+ console.print()
913
+
914
+
915
+ async def _handle_command(
916
+ command: str,
917
+ session: CoreSession,
918
+ settings: CrabCodeSettings | None,
919
+ ) -> bool | str:
920
+ """Handle slash commands.
921
+
922
+ Returns:
923
+ True — command handled, continue the REPL loop.
924
+ False — exit requested.
925
+ str — expand the returned string as a user message (skill invocation).
926
+ """
927
+ parts = command.strip().split(None, 1)
928
+ cmd = parts[0].lower()
929
+ arg = parts[1].strip() if len(parts) > 1 else ""
930
+
931
+ # --- Skill invocation: /<skill-name> [user input] ---
932
+ skill_name = cmd.lstrip("/")
933
+ skills = getattr(session, "skills", [])
934
+ matched_skill = next((s for s in skills if s.name == skill_name), None)
935
+ if matched_skill:
936
+ prompt = matched_skill.content
937
+ if arg:
938
+ if "$USER_INPUT" in prompt:
939
+ prompt = prompt.replace("$USER_INPUT", arg)
940
+ else:
941
+ prompt = f"{prompt}\n\nUser input: {arg}"
942
+ return prompt
943
+
944
+ if cmd == "/help":
945
+ skills_section = ""
946
+ if skills:
947
+ skill_lines = "\n".join(
948
+ f"[bold]/{s.name}[/] — {s.description or s.when_to_use or 'skill'}"
949
+ for s in skills
950
+ )
951
+ skills_section = f"\n\n[bold]Skills[/]\n{skill_lines}"
952
+ console.print(Panel(
953
+ "[bold]/help[/] — show this help\n"
954
+ "[bold]/status[/] — show session status (model, context, compactions)\n"
955
+ "[bold]/logs[/] — show background tool logs summary\n"
956
+ "[bold]/logs <name>[/] — show a background log tail\n"
957
+ "[bold]/logs --tail 200 <name>[/] — show more log lines\n"
958
+ "[bold]/logs -f <name>[/] — follow a background log live\n"
959
+ "[bold]/logs --clear <name>[/] — clear a background log\n"
960
+ "[bold]/model[/] — show current model and list configured models\n"
961
+ "[bold]/model <name>[/] — switch to a named model\n"
962
+ "[bold]/new[/] — start a new session\n"
963
+ "[bold]/compact[/] — compact conversation\n"
964
+ "[bold]/clear[/] — clear conversation history\n"
965
+ "[bold]/sessions[/] — list recent sessions\n"
966
+ "[bold]/resume <id>[/] — resume a previous session\n"
967
+ "[bold]/exit[/] — exit CrabCode\n"
968
+ f"[bold]Ctrl+C[/] — interrupt; press again within {_CTRL_C_EXIT_WINDOW_S:.0f}s to exit\n"
969
+ "\n"
970
+ "[bold]! <cmd>[/] — run a shell command"
971
+ + skills_section,
972
+ title="[bold]Commands[/]",
973
+ border_style="blue",
974
+ ))
975
+ return True
976
+
977
+ if cmd == "/logs":
978
+ try:
979
+ from crabcode_search.background import list_background_logs, read_background_status
980
+
981
+ logs = list_background_logs(session.cwd)
982
+ bg_status = read_background_status(session.cwd)
983
+ except Exception:
984
+ logs = {}
985
+ bg_status = None
986
+
987
+ follow, clear, tail, name, parse_error = _parse_logs_args(arg)
988
+ if parse_error:
989
+ console.print(f"[dim]Usage error: {parse_error}[/]")
990
+ return True
991
+
992
+ if not name:
993
+ if not logs:
994
+ console.print("[dim]No background logs found.[/]")
995
+ return True
996
+
997
+ lines = []
998
+ for name, path_str in sorted(logs.items()):
999
+ log_path = Path(path_str)
1000
+ try:
1001
+ mtime = log_path.stat().st_mtime
1002
+ except OSError:
1003
+ mtime = None
1004
+ meta: list[str] = [f"updated={_format_timestamp(mtime)}"]
1005
+ if name == "search" and bg_status:
1006
+ state = bg_status.get("state")
1007
+ if state:
1008
+ meta.append(f"state={state}")
1009
+ lines.append(f"[bold]{name}[/] — {path_str} · {' · '.join(meta)}")
1010
+
1011
+ console.print(Panel(
1012
+ "\n".join(lines),
1013
+ title="[bold]Background Logs[/]",
1014
+ border_style="blue",
1015
+ expand=False,
1016
+ ))
1017
+ return True
1018
+
1019
+ try:
1020
+ path_str = logs[name]
1021
+ except KeyError:
1022
+ available = ", ".join(sorted(logs)) if logs else "(none)"
1023
+ console.print(f"[dim]Unknown log: {name}. Available: {available}[/]")
1024
+ return True
1025
+
1026
+ log_path = Path(path_str)
1027
+ if clear:
1028
+ try:
1029
+ log_path.write_text("", encoding="utf-8")
1030
+ except OSError as exc:
1031
+ console.print(f"[bold red]Failed to clear log {name}: {exc}[/]")
1032
+ return True
1033
+ console.print(f"[dim]Cleared log: {name}[/]")
1034
+ if not follow:
1035
+ return True
1036
+
1037
+ if follow:
1038
+ await _follow_log(log_path, name)
1039
+ return True
1040
+
1041
+ body = _read_log_tail(log_path, max_lines=tail)
1042
+ console.print(Panel(
1043
+ Text(body, style="dim"),
1044
+ title=f"[bold]Log: {name}[/]",
1045
+ border_style="blue",
1046
+ expand=False,
1047
+ ))
1048
+ return True
1049
+
1050
+ if cmd == "/model":
1051
+ if not arg:
1052
+ # Show current model
1053
+ current_name = getattr(session, "_current_model_name", None)
1054
+ active_cfg = session.settings.get_api_config(current_name)
1055
+ provider = active_cfg.provider
1056
+ model = active_cfg.model
1057
+ label = f"[bold cyan]{current_name}[/] " if current_name else ""
1058
+ console.print(
1059
+ f"Current: {label}"
1060
+ f"provider=[bold]{provider or '[yellow]not set[/]'}[/] "
1061
+ f"model=[bold]{model or '[yellow]not set[/]'}[/]"
1062
+ )
1063
+ named = session.list_models()
1064
+ if named:
1065
+ console.print("\nConfigured models (use [bold]/model <name>[/] to switch):")
1066
+ for name, desc in named.items():
1067
+ marker = " [bold green]← active[/]" if name == current_name else ""
1068
+ console.print(f" [cyan]{name}[/] {desc}{marker}")
1069
+ return True
1070
+
1071
+ # /model <name> — switch to named model
1072
+ named = session.list_models()
1073
+ if not named:
1074
+ console.print("[dim]No named models configured in settings. Add a [bold]models[/] section to settings.json.[/]")
1075
+ return True
1076
+
1077
+ if arg not in named:
1078
+ console.print(f"[bold red]Unknown model: {arg}[/] Available: {', '.join(named)}")
1079
+ return True
1080
+
1081
+ ok = session.switch_model(arg)
1082
+ if ok:
1083
+ active_cfg = session.settings.models[arg]
1084
+ console.print(
1085
+ f"[green]✓[/] Switched to [bold cyan]{arg}[/] "
1086
+ f"({active_cfg.provider or 'anthropic'}/{active_cfg.model or 'default'})"
1087
+ )
1088
+ else:
1089
+ console.print(f"[bold red]Failed to switch model to: {arg}[/]")
1090
+ return True
1091
+
1092
+ if cmd == "/status":
1093
+ from crabcode_core.compact.compact import (
1094
+ DEFAULT_COMPACT_THRESHOLD,
1095
+ estimate_token_count,
1096
+ )
1097
+
1098
+ initialized = getattr(session, "_initialized", False)
1099
+
1100
+ current_name = getattr(session, "_current_model_name", None)
1101
+ active_cfg = session.settings.get_api_config(current_name)
1102
+ provider = active_cfg.provider or "anthropic"
1103
+ model = active_cfg.model or "unknown"
1104
+ model_display = f"{current_name} → " if current_name else ""
1105
+ model_display += f"{provider}/{model}"
1106
+
1107
+ ctx_threshold = session.settings.max_context_length or DEFAULT_COMPACT_THRESHOLD
1108
+ ctx_used = estimate_token_count(session.messages)
1109
+ ctx_pct = int(ctx_used / ctx_threshold * 100) if ctx_threshold else 0
1110
+
1111
+ def _fmt_k(n: int) -> str:
1112
+ return f"{n // 1000}k" if n >= 1000 else str(n)
1113
+
1114
+ thinking = "on" if active_cfg.thinking_enabled else "off"
1115
+ max_tok = active_cfg.max_tokens
1116
+
1117
+ sid = session.session_id or "(none)"
1118
+ sid_short = sid[:8] + "…" if len(sid) > 8 else sid
1119
+ msg_count = len(session.messages)
1120
+ compact_count = getattr(session, "compact_count", 0)
1121
+ auto_compact = "on" if session.settings.auto_compact_enabled else "off"
1122
+ search_status = None
1123
+
1124
+ if initialized:
1125
+ tool_count = len([t for t in session.tools if t.is_enabled])
1126
+ tool_display = str(tool_count)
1127
+ else:
1128
+ tool_display = "[dim]not loaded[/]"
1129
+
1130
+ if "crabcode_search.CodebaseSearchTool" in session.settings.extra_tools:
1131
+ try:
1132
+ from crabcode_search.background import read_background_status
1133
+
1134
+ bg_status = read_background_status(session.cwd)
1135
+ except Exception:
1136
+ bg_status = None
1137
+
1138
+ if bg_status:
1139
+ state = bg_status.get("state", "unknown")
1140
+ if state == "ready":
1141
+ chunks = bg_status.get("chunks")
1142
+ files = bg_status.get("files")
1143
+ details: list[str] = []
1144
+ if chunks is not None:
1145
+ details.append(f"{chunks} chunks")
1146
+ if files is not None:
1147
+ details.append(f"{files} files")
1148
+ suffix = f" ({', '.join(details)})" if details else ""
1149
+ search_status = f"ready{suffix}"
1150
+ elif state == "indexing":
1151
+ done = bg_status.get("done")
1152
+ total = bg_status.get("total")
1153
+ if isinstance(done, int) and isinstance(total, int) and total > 0:
1154
+ pct = int(done / total * 100)
1155
+ search_status = f"indexing {done}/{total} ({pct}%)"
1156
+ else:
1157
+ search_status = "indexing"
1158
+ else:
1159
+ search_status = str(state)
1160
+ else:
1161
+ search_status = "enabled, waiting to start"
1162
+
1163
+ lines = [
1164
+ f"[bold cyan]🦀 CrabCode[/] v{__import__('crabcode_cli').__version__}",
1165
+ f"[bold]🧠 Model:[/] {model_display}",
1166
+ f"[bold]📚 Context:[/] {_fmt_k(ctx_used)} / {_fmt_k(ctx_threshold)} ({ctx_pct}%) · [bold]💬 Messages:[/] {msg_count}",
1167
+ f"[bold]🧹 Compactions:[/] {compact_count} · [bold]Auto-compact:[/] {auto_compact}",
1168
+ f"[bold]🧵 Session:[/] {sid_short}",
1169
+ f"[bold]⚙️ Config:[/] think={thinking} · max_tokens={max_tok} · tools={tool_display}",
1170
+ ]
1171
+ if search_status is not None:
1172
+ lines.append(f"[bold]🔎 Search:[/] {search_status}")
1173
+ console.print(Panel(
1174
+ "\n".join(lines),
1175
+ title="[bold]Status[/]",
1176
+ border_style="cyan",
1177
+ expand=False,
1178
+ ))
1179
+ return True
1180
+
1181
+ if cmd == "/new":
1182
+ new_id = session.new_session()
1183
+ console.print(f"[dim]New session started: [bold]{new_id[:8]}…[/bold][/]")
1184
+ return True
1185
+
1186
+ if cmd == "/compact":
1187
+ await session.compact()
1188
+ console.print("[dim]Conversation compacted.[/]")
1189
+ return True
1190
+
1191
+ if cmd == "/clear":
1192
+ session.messages.clear()
1193
+ console.print("[dim]Conversation cleared.[/]")
1194
+ return True
1195
+
1196
+ if cmd == "/sessions":
1197
+ from crabcode_core.session.storage import SessionStorage
1198
+ sessions = SessionStorage.list_sessions(session.cwd)
1199
+ if not sessions:
1200
+ console.print("[dim]No sessions found.[/]")
1201
+ return True
1202
+ from rich.table import Table
1203
+ table = Table(title="Recent Sessions", border_style="blue", expand=False)
1204
+ table.add_column("#", style="dim", width=3)
1205
+ table.add_column("Session ID", style="cyan", width=10)
1206
+ table.add_column("Modified", style="dim")
1207
+ table.add_column("Preview")
1208
+ for i, s in enumerate(sessions[:20], 1):
1209
+ sid = s["session_id"]
1210
+ is_current = sid == session.session_id
1211
+ marker = " *" if is_current else ""
1212
+ table.add_row(
1213
+ str(i),
1214
+ sid[:8] + "…" + marker,
1215
+ s.get("modified", "")[:19],
1216
+ s.get("preview", "")[:60],
1217
+ )
1218
+ console.print(table)
1219
+ return True
1220
+
1221
+ if cmd == "/resume":
1222
+ if not arg:
1223
+ console.print("[dim]Usage: /resume <session-id>[/]")
1224
+ return True
1225
+
1226
+ from crabcode_core.session.storage import SessionStorage
1227
+ sessions = SessionStorage.list_sessions(session.cwd)
1228
+ session_id = arg
1229
+
1230
+ match = None
1231
+ for s in sessions:
1232
+ if s["session_id"] == session_id or s["session_id"].startswith(session_id):
1233
+ match = s["session_id"]
1234
+ break
1235
+ if not match:
1236
+ try:
1237
+ idx = int(session_id) - 1
1238
+ if 0 <= idx < len(sessions):
1239
+ match = sessions[idx]["session_id"]
1240
+ except ValueError:
1241
+ pass
1242
+
1243
+ if not match:
1244
+ console.print(f"[bold red]Session not found: {session_id}[/]")
1245
+ return True
1246
+
1247
+ ok = await session.resume(match)
1248
+ if ok:
1249
+ console.print(
1250
+ f"[dim]Resumed session [bold]{match[:8]}…[/bold] "
1251
+ f"({len(session.messages)} messages)[/]"
1252
+ )
1253
+ console.print()
1254
+ _render_session_history(session.messages)
1255
+ else:
1256
+ console.print(f"[bold red]Failed to resume session {match[:8]}…[/]")
1257
+ return True
1258
+
1259
+ if cmd in ("/exit", "/quit"):
1260
+ return False
1261
+
1262
+ console.print(f"[dim]Unknown command: {command}. Type /help for available commands.[/]")
1263
+ return True