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