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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- 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
|