codepilot-cli-app 0.9.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +1 -0
- cli/__main__.py +21 -0
- cli/agent.yaml +60 -0
- cli/app.py +1991 -0
- cli/modals.py +6 -0
- cli/sessions.py +65 -0
- cli/theme.py +121 -0
- codepilot_cli_app-0.9.6.dist-info/METADATA +33 -0
- codepilot_cli_app-0.9.6.dist-info/RECORD +12 -0
- codepilot_cli_app-0.9.6.dist-info/WHEEL +5 -0
- codepilot_cli_app-0.9.6.dist-info/entry_points.txt +2 -0
- codepilot_cli_app-0.9.6.dist-info/top_level.txt +1 -0
cli/app.py
ADDED
|
@@ -0,0 +1,1991 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli/app.py – CodePilot CLI (Rich + prompt_toolkit, fully synchronous)
|
|
3
|
+
|
|
4
|
+
Architecture:
|
|
5
|
+
Main thread → prompt_toolkit input + Rich rendering + queue consumer
|
|
6
|
+
Worker thread → Runtime.run(task) — blocks until agent finishes
|
|
7
|
+
Spinner thread → braille animation while agent is thinking
|
|
8
|
+
All streaming output delivered via thread-safe queue.Queue
|
|
9
|
+
|
|
10
|
+
No asyncio event loop is used anywhere in this module.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import json
|
|
17
|
+
import difflib
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import subprocess
|
|
27
|
+
from importlib import resources
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Callable, Optional
|
|
30
|
+
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
from rich.text import Text
|
|
33
|
+
from rich.rule import Rule
|
|
34
|
+
from rich.theme import Theme
|
|
35
|
+
from rich.live import Live
|
|
36
|
+
from rich.markdown import Markdown
|
|
37
|
+
|
|
38
|
+
from prompt_toolkit.application import Application
|
|
39
|
+
from prompt_toolkit import PromptSession
|
|
40
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
41
|
+
from prompt_toolkit.formatted_text import HTML
|
|
42
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
43
|
+
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
44
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
45
|
+
from prompt_toolkit.styles import Style as PTStyle
|
|
46
|
+
|
|
47
|
+
from .theme import (
|
|
48
|
+
APP_NAME, APP_VERSION, GRADIENT, PROVIDERS, MODEL_TO_PROVIDER,
|
|
49
|
+
ALL_MODELS, DEFAULT_MODEL, DEFAULT_PROVIDER, PROVIDER_YAML_NAME,
|
|
50
|
+
SLASH_COMMANDS, gradient_text,
|
|
51
|
+
MODEL_CONTEXT_WINDOWS, DEFAULT_CONTEXT_WINDOW,
|
|
52
|
+
)
|
|
53
|
+
from .sessions import SESSION_DIR, list_sessions, next_session_id
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from codepilot import (
|
|
57
|
+
Runtime,
|
|
58
|
+
on_ask_user,
|
|
59
|
+
on_finish,
|
|
60
|
+
on_permission_request,
|
|
61
|
+
on_runtime_error,
|
|
62
|
+
on_stream,
|
|
63
|
+
on_thinking_stream,
|
|
64
|
+
on_tool_call,
|
|
65
|
+
on_tool_result,
|
|
66
|
+
on_user_message_injected,
|
|
67
|
+
on_user_message_queued,
|
|
68
|
+
on_context_drop,
|
|
69
|
+
on_subagent_spawn,
|
|
70
|
+
on_subagent_message,
|
|
71
|
+
on_subagent_finish,
|
|
72
|
+
on_llm_response,
|
|
73
|
+
)
|
|
74
|
+
HAS_RUNTIME = True
|
|
75
|
+
except ImportError:
|
|
76
|
+
HAS_RUNTIME = False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Console ────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
RICH_THEME = Theme({
|
|
82
|
+
"brand": "bold #FF8533",
|
|
83
|
+
"brand.dim": "dim #FF8533",
|
|
84
|
+
"tool": "bold #FF8533",
|
|
85
|
+
"tool.result": "dim #3a3a3a",
|
|
86
|
+
"tool.path": "#FFAE70",
|
|
87
|
+
"terminal": "dim #9aa0a6",
|
|
88
|
+
"diff.add": "#66BB6A",
|
|
89
|
+
"diff.del": "#EF5350",
|
|
90
|
+
"diff.meta": "dim #777777",
|
|
91
|
+
"diff.ctx": "#bdbdbd",
|
|
92
|
+
"diff.no": "dim #666666",
|
|
93
|
+
"finish": "#66BB6A",
|
|
94
|
+
"finish.icon": "bold #66BB6A",
|
|
95
|
+
"perm": "bold #FFA726",
|
|
96
|
+
"question": "bold #4FC3F7",
|
|
97
|
+
"answer": "#4FC3F7",
|
|
98
|
+
"muted": "dim #555555",
|
|
99
|
+
"error": "bold #EF5350",
|
|
100
|
+
"stream": "#d4d4d4",
|
|
101
|
+
"divider": "#1e1e1e",
|
|
102
|
+
"status.key": "dim #444444",
|
|
103
|
+
"status.val": "#FF8533",
|
|
104
|
+
"ready": "dim #3a3a3a",
|
|
105
|
+
"heading": "bold #FF8533",
|
|
106
|
+
"success": "bold #66BB6A",
|
|
107
|
+
"warn": "bold #FFA726",
|
|
108
|
+
"pill": "bold #66BB6A",
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
console = Console(theme=RICH_THEME, highlight=False)
|
|
112
|
+
|
|
113
|
+
PT_STYLE = PTStyle.from_dict({
|
|
114
|
+
"prompt": "#FF8533 bold",
|
|
115
|
+
"select.title": "#FF8533 bold",
|
|
116
|
+
"select.help": "#555555",
|
|
117
|
+
"select.cursor": "#FF8533 bold",
|
|
118
|
+
"select.item": "#d0d0d0",
|
|
119
|
+
"select.item-selected": "#FFAE70 bold",
|
|
120
|
+
"select.meta": "#666666",
|
|
121
|
+
"select.rule": "#333333",
|
|
122
|
+
# Autocomplete dropdown styles
|
|
123
|
+
"completion-menu": "bg:#1a1a1a #888888",
|
|
124
|
+
"completion-menu.completion": "bg:#1a1a1a #888888",
|
|
125
|
+
"completion-menu.completion.current": "bg:#2a2a2a #FFAE70 bold",
|
|
126
|
+
"completion-menu.meta.completion": "bg:#141414 #555555",
|
|
127
|
+
"completion-menu.meta.completion.current": "bg:#1e1e1e #777777",
|
|
128
|
+
"completion-menu.multi-column-meta": "bg:#141414 #555555",
|
|
129
|
+
"scrollbar.background": "bg:#111111",
|
|
130
|
+
"scrollbar.button": "bg:#333333",
|
|
131
|
+
"": "",
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── Slash command autocompleter ────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
class SlashCompleter(Completer):
|
|
138
|
+
"""Fires completion suggestions for any input that starts with '/'."""
|
|
139
|
+
|
|
140
|
+
def get_completions(self, document, complete_event):
|
|
141
|
+
text = document.text_before_cursor
|
|
142
|
+
if not text.startswith("/"):
|
|
143
|
+
return
|
|
144
|
+
partial = text.lower()
|
|
145
|
+
for cmd, desc in SLASH_COMMANDS.items():
|
|
146
|
+
if cmd.startswith(partial):
|
|
147
|
+
# display = command, meta = description
|
|
148
|
+
yield Completion(
|
|
149
|
+
cmd,
|
|
150
|
+
start_position=-len(text),
|
|
151
|
+
display=cmd,
|
|
152
|
+
display_meta=desc,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# ── Banner ─────────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
_BANNER_ART = (
|
|
158
|
+
" ______ __ ____ _ __ __ \n"
|
|
159
|
+
" / ____/___ ____/ /__ / __ \\(_) /___ / /_\n"
|
|
160
|
+
" / / / __ \\/ __ / _ \\/ /_/ / / / __ \\/ __/\n"
|
|
161
|
+
"/ /___/ /_/ / /_/ / __/ ____/ / / /_/ / /_ \n"
|
|
162
|
+
"\\____/\\____/\\__,_/\\___/_/ /_/_/\\____/\\__/ "
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _make_banner() -> Text:
|
|
167
|
+
lines = _BANNER_ART.splitlines()
|
|
168
|
+
all_chars = [(li, ch) for li, line in enumerate(lines) for ch in line]
|
|
169
|
+
printable = [(li, ch) for li, ch in all_chars if ch.strip()]
|
|
170
|
+
total = max(len(printable) - 1, 1)
|
|
171
|
+
n = len(GRADIENT)
|
|
172
|
+
line_texts: list[Text] = [Text() for _ in lines]
|
|
173
|
+
p_idx = 0
|
|
174
|
+
for li, ch in all_chars:
|
|
175
|
+
if not ch.strip():
|
|
176
|
+
line_texts[li].append(ch)
|
|
177
|
+
else:
|
|
178
|
+
stop = GRADIENT[int(p_idx / total * (n - 1))]
|
|
179
|
+
line_texts[li].append(ch, style=f"bold {stop}")
|
|
180
|
+
p_idx += 1
|
|
181
|
+
result = Text()
|
|
182
|
+
for i, t in enumerate(line_texts):
|
|
183
|
+
result.append(" ")
|
|
184
|
+
result.append_text(t)
|
|
185
|
+
if i < len(line_texts) - 1:
|
|
186
|
+
result.append("\n")
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def print_banner(work_dir: Path, session_id: str, model: str) -> None:
|
|
191
|
+
console.clear()
|
|
192
|
+
console.print()
|
|
193
|
+
console.print(_make_banner())
|
|
194
|
+
console.print()
|
|
195
|
+
console.print(
|
|
196
|
+
f" [status.key]version[/status.key] [brand.dim]{APP_VERSION}[/brand.dim]"
|
|
197
|
+
f" [status.key]workspace[/status.key] [status.val]{work_dir}[/status.val]"
|
|
198
|
+
)
|
|
199
|
+
console.print()
|
|
200
|
+
console.print(Rule(style="dim #1e1e1e"))
|
|
201
|
+
console.print()
|
|
202
|
+
console.print(
|
|
203
|
+
f" [status.key]session[/status.key] [status.val]{session_id}[/status.val]"
|
|
204
|
+
f" [status.key]model[/status.key] [status.val]{model}[/status.val]"
|
|
205
|
+
)
|
|
206
|
+
console.print()
|
|
207
|
+
console.print(" [muted]Type a task, or /help for commands.[/muted]")
|
|
208
|
+
console.print()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── Session picker ─────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _term_height(default: int = 24) -> int:
|
|
215
|
+
try:
|
|
216
|
+
return shutil.get_terminal_size().lines
|
|
217
|
+
except Exception:
|
|
218
|
+
return default
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _select(
|
|
222
|
+
title: str,
|
|
223
|
+
entries: list[Any],
|
|
224
|
+
render: Callable[[Any, bool], Text],
|
|
225
|
+
*,
|
|
226
|
+
selected: int = 0,
|
|
227
|
+
subtitle: str = "",
|
|
228
|
+
empty: str = "Nothing to select.",
|
|
229
|
+
cancelable: bool = True,
|
|
230
|
+
) -> Any | None:
|
|
231
|
+
if not entries:
|
|
232
|
+
console.print(f" [muted]{empty}[/muted]")
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
selected = max(0, min(selected, len(entries) - 1))
|
|
236
|
+
visible = max(6, min(14, _term_height() - 10))
|
|
237
|
+
state = {"selected": selected}
|
|
238
|
+
|
|
239
|
+
def fragments():
|
|
240
|
+
selected_idx = state["selected"]
|
|
241
|
+
top = max(0, min(selected_idx - visible // 2, max(0, len(entries) - visible)))
|
|
242
|
+
bottom = min(len(entries), top + visible)
|
|
243
|
+
if subtitle:
|
|
244
|
+
header = f" {title}\n {subtitle}\n"
|
|
245
|
+
else:
|
|
246
|
+
header = f" {title}\n"
|
|
247
|
+
controls = "↑/↓ move Enter select Esc cancel" if cancelable else "↑/↓ move Enter select"
|
|
248
|
+
parts: list[tuple[str, str]] = [
|
|
249
|
+
("class:select.title", header),
|
|
250
|
+
("class:select.help", f" {controls}\n\n"),
|
|
251
|
+
("class:select.rule", " " + "─" * 56 + "\n\n"),
|
|
252
|
+
]
|
|
253
|
+
if top:
|
|
254
|
+
parts.append(("class:select.meta", f" ... {top} above\n"))
|
|
255
|
+
|
|
256
|
+
for idx in range(top, bottom):
|
|
257
|
+
is_selected = idx == selected_idx
|
|
258
|
+
item = render(entries[idx], is_selected).plain
|
|
259
|
+
prefix = "> " if is_selected else " "
|
|
260
|
+
parts.append(("class:select.cursor" if is_selected else "class:select.meta", f" {prefix}"))
|
|
261
|
+
parts.append(("class:select.item-selected" if is_selected else "class:select.item", item))
|
|
262
|
+
parts.append(("", "\n"))
|
|
263
|
+
|
|
264
|
+
if bottom < len(entries):
|
|
265
|
+
parts.append(("class:select.meta", f" ... {len(entries) - bottom} below\n"))
|
|
266
|
+
return parts
|
|
267
|
+
|
|
268
|
+
kb = KeyBindings()
|
|
269
|
+
|
|
270
|
+
@kb.add("up")
|
|
271
|
+
def _(event):
|
|
272
|
+
state["selected"] = (state["selected"] - 1) % len(entries)
|
|
273
|
+
event.app.invalidate()
|
|
274
|
+
|
|
275
|
+
@kb.add("down")
|
|
276
|
+
def _(event):
|
|
277
|
+
state["selected"] = (state["selected"] + 1) % len(entries)
|
|
278
|
+
event.app.invalidate()
|
|
279
|
+
|
|
280
|
+
@kb.add("pageup")
|
|
281
|
+
def _(event):
|
|
282
|
+
state["selected"] = max(0, state["selected"] - visible)
|
|
283
|
+
event.app.invalidate()
|
|
284
|
+
|
|
285
|
+
@kb.add("pagedown")
|
|
286
|
+
def _(event):
|
|
287
|
+
state["selected"] = min(len(entries) - 1, state["selected"] + visible)
|
|
288
|
+
event.app.invalidate()
|
|
289
|
+
|
|
290
|
+
@kb.add("enter")
|
|
291
|
+
def _(event):
|
|
292
|
+
event.app.exit(result=entries[state["selected"]])
|
|
293
|
+
|
|
294
|
+
@kb.add("escape")
|
|
295
|
+
@kb.add("c-c")
|
|
296
|
+
def _(event):
|
|
297
|
+
if cancelable:
|
|
298
|
+
event.app.exit(result=None)
|
|
299
|
+
|
|
300
|
+
app = Application(
|
|
301
|
+
layout=Layout(HSplit([Window(FormattedTextControl(fragments), wrap_lines=False)])),
|
|
302
|
+
key_bindings=kb,
|
|
303
|
+
style=PT_STYLE,
|
|
304
|
+
full_screen=True,
|
|
305
|
+
mouse_support=False,
|
|
306
|
+
)
|
|
307
|
+
return app.run()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def pick_session_interactive() -> str:
|
|
311
|
+
sessions = list_sessions()
|
|
312
|
+
|
|
313
|
+
if not sessions:
|
|
314
|
+
console.clear()
|
|
315
|
+
console.print()
|
|
316
|
+
console.print(" [muted]No saved sessions yet.[/muted]")
|
|
317
|
+
console.print()
|
|
318
|
+
console.print(" [brand.dim]> Starting new session...[/brand.dim]")
|
|
319
|
+
console.print()
|
|
320
|
+
return next_session_id()
|
|
321
|
+
|
|
322
|
+
entries = [None] + sessions
|
|
323
|
+
|
|
324
|
+
def render_session(item: Any, selected: bool) -> Text:
|
|
325
|
+
if item is None:
|
|
326
|
+
return Text("New session", style="bold #e0e0e0" if selected else "#d0d0d0")
|
|
327
|
+
text = Text(item.session_id, style="#FFAE70" if selected else "#d0d0d0")
|
|
328
|
+
text.append(f" {item.updated_at} {item.message_count} msgs", style="dim #666666")
|
|
329
|
+
return text
|
|
330
|
+
|
|
331
|
+
choice = _select("CodePilot Sessions", entries, render_session, cancelable=False)
|
|
332
|
+
console.clear()
|
|
333
|
+
if choice is None:
|
|
334
|
+
return next_session_id()
|
|
335
|
+
return choice.session_id
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ── Config patching ────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
def _find_base_config() -> Path:
|
|
341
|
+
packaged_config = resources.files("cli").joinpath("agent.yaml")
|
|
342
|
+
with resources.as_file(packaged_config) as config_path:
|
|
343
|
+
if config_path.exists():
|
|
344
|
+
return config_path
|
|
345
|
+
|
|
346
|
+
local_config = Path.cwd() / "agent.yaml"
|
|
347
|
+
if local_config.exists():
|
|
348
|
+
return local_config
|
|
349
|
+
|
|
350
|
+
return Path(__file__).resolve().parent / "agent.yaml"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def build_patched_config(work_dir: Path, model: str, provider_ui: str) -> Path:
|
|
354
|
+
base = _find_base_config()
|
|
355
|
+
if not base.exists():
|
|
356
|
+
raise FileNotFoundError(f"agent.yaml not found (tried {base})")
|
|
357
|
+
content = base.read_text()
|
|
358
|
+
content = content.replace("${WORK_DIR}", str(work_dir))
|
|
359
|
+
yaml_provider = PROVIDER_YAML_NAME.get(provider_ui, provider_ui.lower())
|
|
360
|
+
content = re.sub(
|
|
361
|
+
r'^([ \t]{4}provider:\s*")[^"]*(")',
|
|
362
|
+
rf'\g<1>{yaml_provider}\g<2>',
|
|
363
|
+
content,
|
|
364
|
+
flags=re.MULTILINE,
|
|
365
|
+
)
|
|
366
|
+
content = re.sub(
|
|
367
|
+
r'^([ \t]{4}provider:\s*)(\S+)',
|
|
368
|
+
lambda m: m.group(1) + yaml_provider,
|
|
369
|
+
content,
|
|
370
|
+
flags=re.MULTILINE,
|
|
371
|
+
)
|
|
372
|
+
content = re.sub(
|
|
373
|
+
r'^([ \t]{4}name:\s*)(\S+)',
|
|
374
|
+
lambda m: m.group(1) + model,
|
|
375
|
+
content,
|
|
376
|
+
flags=re.MULTILINE,
|
|
377
|
+
)
|
|
378
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
379
|
+
mode="w", suffix=".yaml", delete=False, prefix="codepilot_"
|
|
380
|
+
)
|
|
381
|
+
tmp.write(content)
|
|
382
|
+
tmp.close()
|
|
383
|
+
return Path(tmp.name)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ── Thread-safe Spinner ───────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class Spinner:
|
|
392
|
+
"""Thread-based spinner — no asyncio, no event loop, no race conditions."""
|
|
393
|
+
|
|
394
|
+
def __init__(self):
|
|
395
|
+
self._lock = threading.Lock()
|
|
396
|
+
self._running = False
|
|
397
|
+
self._thread: Optional[threading.Thread] = None
|
|
398
|
+
self._label = "thinking"
|
|
399
|
+
self._started_at = 0.0
|
|
400
|
+
self._timeout: int | None = None
|
|
401
|
+
|
|
402
|
+
def start(self, label: str = "thinking", timeout: int | None = None) -> None:
|
|
403
|
+
with self._lock:
|
|
404
|
+
self._label = label
|
|
405
|
+
self._timeout = timeout
|
|
406
|
+
self._started_at = time.monotonic()
|
|
407
|
+
if self._running:
|
|
408
|
+
return
|
|
409
|
+
self._running = True
|
|
410
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
411
|
+
self._thread.start()
|
|
412
|
+
|
|
413
|
+
def stop(self) -> None:
|
|
414
|
+
with self._lock:
|
|
415
|
+
if not self._running:
|
|
416
|
+
return
|
|
417
|
+
self._running = False
|
|
418
|
+
|
|
419
|
+
if self._thread is not None:
|
|
420
|
+
self._thread.join(timeout=1.0)
|
|
421
|
+
self._thread = None
|
|
422
|
+
|
|
423
|
+
# Clear the spinner line
|
|
424
|
+
sys.stdout.write("\r\033[K")
|
|
425
|
+
sys.stdout.flush()
|
|
426
|
+
|
|
427
|
+
def _spin(self) -> None:
|
|
428
|
+
i = 0
|
|
429
|
+
while True:
|
|
430
|
+
with self._lock:
|
|
431
|
+
if not self._running:
|
|
432
|
+
break
|
|
433
|
+
label = self._label
|
|
434
|
+
started_at = self._started_at
|
|
435
|
+
timeout = self._timeout
|
|
436
|
+
frame = _FRAMES[i % len(_FRAMES)]
|
|
437
|
+
elapsed = max(0, int(time.monotonic() - started_at))
|
|
438
|
+
suffix = f" / {timeout}s" if timeout else "s"
|
|
439
|
+
sys.stdout.write(f"\r\033[2m{frame} {label}... {elapsed}{suffix}\033[0m")
|
|
440
|
+
sys.stdout.flush()
|
|
441
|
+
time.sleep(0.08)
|
|
442
|
+
i += 1
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
spinner = Spinner()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
def _truncate(text: str, limit: int = 120) -> str:
|
|
451
|
+
text = text.replace("\n", " ")
|
|
452
|
+
return text[:limit] + "…" if len(text) > limit else text
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _middle_truncate(text: str, limit: int = 96) -> str:
|
|
456
|
+
text = text.replace("\n", "\\n")
|
|
457
|
+
if len(text) <= limit:
|
|
458
|
+
return text
|
|
459
|
+
head = max(12, limit // 2 - 2)
|
|
460
|
+
tail = max(12, limit - head - 3)
|
|
461
|
+
return text[:head] + "..." + text[-tail:]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _resolve_work_path(runtime: Any, path: str | None) -> Path | None:
|
|
465
|
+
if not path:
|
|
466
|
+
return None
|
|
467
|
+
try:
|
|
468
|
+
return Path(runtime.config.runtime.work_dir) / path
|
|
469
|
+
except Exception:
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _read_text_if_exists(path: Path | None) -> str:
|
|
474
|
+
if path is None or not path.is_file():
|
|
475
|
+
return ""
|
|
476
|
+
try:
|
|
477
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
478
|
+
except Exception:
|
|
479
|
+
return ""
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _print_diff_lines(lines: list[str], *, max_lines: int = 80) -> None:
|
|
483
|
+
omitted = max(0, len(lines) - max_lines)
|
|
484
|
+
for line in lines[:max_lines]:
|
|
485
|
+
if line.startswith("+++ ") or line.startswith("--- ") or line.startswith("@@"):
|
|
486
|
+
console.print(f" [diff.meta]{line}[/diff.meta]")
|
|
487
|
+
elif line.startswith("+"):
|
|
488
|
+
console.print(f" [diff.add]{line}[/diff.add]")
|
|
489
|
+
elif line.startswith("-"):
|
|
490
|
+
console.print(f" [diff.del]{line}[/diff.del]")
|
|
491
|
+
else:
|
|
492
|
+
console.print(f" [diff.ctx]{line}[/diff.ctx]")
|
|
493
|
+
if omitted:
|
|
494
|
+
console.print(f" [muted]... {omitted} diff lines omitted[/muted]")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _render_file_snapshot(result: str, *, max_lines: int = 70) -> None:
|
|
498
|
+
lines = result.splitlines()
|
|
499
|
+
if not lines:
|
|
500
|
+
return
|
|
501
|
+
console.print(f" [tool.result]{lines[0]}[/tool.result]")
|
|
502
|
+
body = lines[1:]
|
|
503
|
+
omitted = max(0, len(body) - max_lines)
|
|
504
|
+
for line in body[:max_lines]:
|
|
505
|
+
if line.startswith("[END") or line.startswith("[TRUNCATED"):
|
|
506
|
+
console.print(f" [diff.meta]{line}[/diff.meta]")
|
|
507
|
+
elif " | " in line[:10]:
|
|
508
|
+
number, content = line.split(" | ", 1)
|
|
509
|
+
console.print(f" [diff.no]{number} |[/diff.no] [diff.add]{content}[/diff.add]")
|
|
510
|
+
else:
|
|
511
|
+
console.print(f" [diff.ctx]{line}[/diff.ctx]")
|
|
512
|
+
if omitted:
|
|
513
|
+
console.print(f" [muted]... {omitted} lines omitted[/muted]")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _split_terminal_result(result: str) -> tuple[str, str, list[str], str]:
|
|
517
|
+
lines = result.splitlines()
|
|
518
|
+
if not lines:
|
|
519
|
+
return "", "", [], ""
|
|
520
|
+
|
|
521
|
+
header = lines[0]
|
|
522
|
+
footer = ""
|
|
523
|
+
body = lines[1:]
|
|
524
|
+
if body and body[-1].startswith("[status:"):
|
|
525
|
+
footer = body[-1]
|
|
526
|
+
body = body[:-1]
|
|
527
|
+
|
|
528
|
+
label = ""
|
|
529
|
+
if header.startswith("[terminal:"):
|
|
530
|
+
close = header.find("]")
|
|
531
|
+
if close != -1:
|
|
532
|
+
label = header[close + 1:].strip()
|
|
533
|
+
header = header[:close + 1]
|
|
534
|
+
|
|
535
|
+
return header, label, body, footer
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _render_terminal_result(result: str, *, max_lines: int = 28) -> None:
|
|
539
|
+
lines = result.splitlines()
|
|
540
|
+
if not lines:
|
|
541
|
+
console.print(" [tool.result][no output][/tool.result]")
|
|
542
|
+
return
|
|
543
|
+
header, label, body, footer = _split_terminal_result(result)
|
|
544
|
+
status = ""
|
|
545
|
+
if footer:
|
|
546
|
+
status_match = re.search(r"\[status:\s*([^|\]]+)", footer)
|
|
547
|
+
if status_match:
|
|
548
|
+
status = status_match.group(1).strip()
|
|
549
|
+
if status == "completed":
|
|
550
|
+
status_str = "✔ Completed"
|
|
551
|
+
status_style = "success"
|
|
552
|
+
elif status == "running":
|
|
553
|
+
status_str = "⟳ Running"
|
|
554
|
+
status_style = "warn"
|
|
555
|
+
else:
|
|
556
|
+
status_str = status
|
|
557
|
+
status_style = "success"
|
|
558
|
+
status_text = f" [{status_style}]{status_str}[/{status_style}]" if status else ""
|
|
559
|
+
console.print(f" [tool.result]{header}[/tool.result]{status_text}")
|
|
560
|
+
|
|
561
|
+
if label and label not in ("(continued output)", "(complete output)"):
|
|
562
|
+
console.print(f" [diff.meta]$ {_middle_truncate(label[2:] if label.startswith('$ ') else label, 110)}[/diff.meta]")
|
|
563
|
+
elif label:
|
|
564
|
+
console.print(f" [diff.meta]{label}[/diff.meta]")
|
|
565
|
+
|
|
566
|
+
omitted = max(0, len(body) - max_lines)
|
|
567
|
+
for line in body[:max_lines]:
|
|
568
|
+
if "status=running" in line or "running" in line.lower():
|
|
569
|
+
console.print(f" [warn]{line}[/warn]")
|
|
570
|
+
elif line == label:
|
|
571
|
+
continue
|
|
572
|
+
elif "Permission denied" in line or "Error:" in line:
|
|
573
|
+
console.print(f" [error]{line}[/error]")
|
|
574
|
+
else:
|
|
575
|
+
console.print(f" [terminal]{line}[/terminal]")
|
|
576
|
+
if omitted:
|
|
577
|
+
console.print(f" [muted]... {omitted} output lines omitted[/muted]")
|
|
578
|
+
if footer:
|
|
579
|
+
console.print(f" [diff.meta]{footer}[/diff.meta]")
|
|
580
|
+
if "status=running" in result or "[running]" in result:
|
|
581
|
+
console.print(" [muted]process is still running; agent can call read_output() to wait for more[/muted]")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _tool_wait_label(tool: str, args: dict, display: str) -> tuple[str, int | None]:
|
|
585
|
+
timeout = args.get("timeout") if isinstance(args, dict) else None
|
|
586
|
+
timeout = timeout if isinstance(timeout, int) and timeout > 0 else None
|
|
587
|
+
if tool == "execute":
|
|
588
|
+
command = args.get("command", "") if isinstance(args, dict) else ""
|
|
589
|
+
return f"running {_middle_truncate(command, 52) or 'command'}", timeout
|
|
590
|
+
if tool == "read_output":
|
|
591
|
+
session_id = args.get("session_id", "terminal") if isinstance(args, dict) else "terminal"
|
|
592
|
+
return f"waiting for terminal output [{session_id}]", timeout
|
|
593
|
+
if tool == "send_input":
|
|
594
|
+
session_id = args.get("session_id", "terminal") if isinstance(args, dict) else "terminal"
|
|
595
|
+
return f"sending input [{session_id}]", timeout
|
|
596
|
+
if tool == "write_file":
|
|
597
|
+
path = args.get("path", "file") if isinstance(args, dict) else "file"
|
|
598
|
+
return f"writing {path}", None
|
|
599
|
+
if tool == "read_file":
|
|
600
|
+
path = args.get("path", "file") if isinstance(args, dict) else "file"
|
|
601
|
+
return f"reading {path}", None
|
|
602
|
+
if tool == "file_editor":
|
|
603
|
+
path = args.get("path", "file") if isinstance(args, dict) else "file"
|
|
604
|
+
mode = args.get("mode", "view") if isinstance(args, dict) else "view"
|
|
605
|
+
verb = "reading" if mode == "view" else "writing" if mode == "create" else "editing"
|
|
606
|
+
return f"{verb} {path}", None
|
|
607
|
+
return f"working {display or tool}", timeout
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _tool_call_summary(tool: str, args: dict, display: str) -> str:
|
|
611
|
+
if tool == "execute":
|
|
612
|
+
timeout = args.get("timeout") if isinstance(args, dict) else None
|
|
613
|
+
session = args.get("session_id", "main") if isinstance(args, dict) else "main"
|
|
614
|
+
suffix = f" timeout {timeout}s" if timeout else ""
|
|
615
|
+
return f"session {session}{suffix}"
|
|
616
|
+
if tool == "read_output":
|
|
617
|
+
timeout = args.get("timeout") if isinstance(args, dict) else None
|
|
618
|
+
session = args.get("session_id", "main") if isinstance(args, dict) else "main"
|
|
619
|
+
suffix = f" timeout {timeout}s" if timeout else ""
|
|
620
|
+
return f"session {session}{suffix}"
|
|
621
|
+
if tool == "write_file":
|
|
622
|
+
path = args.get("path", "") if isinstance(args, dict) else ""
|
|
623
|
+
mode = args.get("mode", "") if isinstance(args, dict) else ""
|
|
624
|
+
return f"{path} {mode}".strip()
|
|
625
|
+
if tool == "read_file":
|
|
626
|
+
path = args.get("path", "") if isinstance(args, dict) else ""
|
|
627
|
+
start = args.get("start_line") if isinstance(args, dict) else None
|
|
628
|
+
end = args.get("end_line") if isinstance(args, dict) else None
|
|
629
|
+
if start and end:
|
|
630
|
+
return f"{path} L{start}-{end}"
|
|
631
|
+
return path
|
|
632
|
+
if tool == "file_editor":
|
|
633
|
+
path = args.get("path", "") if isinstance(args, dict) else ""
|
|
634
|
+
mode = args.get("mode", "") if isinstance(args, dict) else ""
|
|
635
|
+
if mode == "view":
|
|
636
|
+
start = args.get("start_line") if isinstance(args, dict) else None
|
|
637
|
+
end = args.get("end_line") if isinstance(args, dict) else None
|
|
638
|
+
if start and end:
|
|
639
|
+
return f"{path} L{start}-{end} {mode}"
|
|
640
|
+
return f"{path} {mode}"
|
|
641
|
+
return f"{path} {mode}".strip()
|
|
642
|
+
return _middle_truncate(display, 110)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ── /models picker ─────────────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
def show_models_picker(current_model: str) -> str | None:
|
|
648
|
+
entries: list[tuple[str, str]] = []
|
|
649
|
+
for provider, models in PROVIDERS.items():
|
|
650
|
+
for m in models:
|
|
651
|
+
entries.append((provider, m))
|
|
652
|
+
|
|
653
|
+
def render_model(item: tuple[str, str], selected: bool) -> Text:
|
|
654
|
+
provider, model = item
|
|
655
|
+
marker = "● " if model == current_model else " "
|
|
656
|
+
text = Text(marker + model, style="#FFAE70" if selected else "#d0d0d0")
|
|
657
|
+
text.append(f" {provider}", style="dim #666666")
|
|
658
|
+
return text
|
|
659
|
+
|
|
660
|
+
current_idx = next((i for i, (_, m) in enumerate(entries) if m == current_model), 0)
|
|
661
|
+
choice = _select("Models", entries, render_model, selected=current_idx)
|
|
662
|
+
console.clear()
|
|
663
|
+
return choice[1] if choice else None
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ── /sessions picker (inline) ──────────────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
def show_sessions_picker() -> str | None:
|
|
669
|
+
sessions = list_sessions()
|
|
670
|
+
if not sessions:
|
|
671
|
+
console.print(" [muted]No saved sessions.[/muted]")
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
def render_session(item: Any, selected: bool) -> Text:
|
|
675
|
+
text = Text(item.session_id, style="#FFAE70" if selected else "#d0d0d0")
|
|
676
|
+
text.append(f" {item.updated_at} {item.message_count} msgs", style="dim #666666")
|
|
677
|
+
return text
|
|
678
|
+
|
|
679
|
+
choice = _select("Resume Session", sessions, render_session)
|
|
680
|
+
console.clear()
|
|
681
|
+
return choice.session_id if choice else None
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ── Permission prompt ──────────────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
def ask_permission(runtime: Any, tool: str, description: str) -> bool:
|
|
687
|
+
# ── Header: permission label + tool name
|
|
688
|
+
console.print()
|
|
689
|
+
console.print(f" [perm]⚠ Permission[/perm] [tool.path]{tool}[/tool.path]")
|
|
690
|
+
console.print()
|
|
691
|
+
|
|
692
|
+
# ── Command/description text — visible, not muted
|
|
693
|
+
desc_text = _middle_truncate(description, 140)
|
|
694
|
+
console.print(f" [stream]{desc_text}[/stream]")
|
|
695
|
+
console.print()
|
|
696
|
+
|
|
697
|
+
# ── Dim separator
|
|
698
|
+
console.print(Rule(style="dim #2a2a2a"))
|
|
699
|
+
console.print()
|
|
700
|
+
|
|
701
|
+
# ── Key hints: key colored, action word dim
|
|
702
|
+
console.print(
|
|
703
|
+
" [success]Enter[/success] [muted]allow[/muted] "
|
|
704
|
+
"[error]Esc[/error] [muted]reject[/muted] "
|
|
705
|
+
"[question]Ctrl+I[/question] [muted]instruct[/muted]"
|
|
706
|
+
)
|
|
707
|
+
console.print()
|
|
708
|
+
|
|
709
|
+
try:
|
|
710
|
+
result: dict[str, str] = {"action": "allow"}
|
|
711
|
+
kb = KeyBindings()
|
|
712
|
+
|
|
713
|
+
@kb.add("enter")
|
|
714
|
+
def _(event):
|
|
715
|
+
result["action"] = "allow"
|
|
716
|
+
event.app.exit()
|
|
717
|
+
|
|
718
|
+
@kb.add("escape")
|
|
719
|
+
@kb.add("c-c")
|
|
720
|
+
def _(event):
|
|
721
|
+
result["action"] = "reject"
|
|
722
|
+
event.app.exit()
|
|
723
|
+
|
|
724
|
+
@kb.add("c-i")
|
|
725
|
+
def _(event):
|
|
726
|
+
result["action"] = "instruct"
|
|
727
|
+
event.app.exit()
|
|
728
|
+
|
|
729
|
+
prompt = PromptSession(key_bindings=kb, style=PT_STYLE)
|
|
730
|
+
prompt.prompt(HTML('<b><style fg="#FFA726">permission ›</style></b> '))
|
|
731
|
+
key = result["action"]
|
|
732
|
+
except (KeyboardInterrupt, EOFError):
|
|
733
|
+
key = "reject"
|
|
734
|
+
|
|
735
|
+
if key == "allow":
|
|
736
|
+
console.print(" [success]✔ Approved[/success]")
|
|
737
|
+
console.print()
|
|
738
|
+
return True
|
|
739
|
+
|
|
740
|
+
if key == "instruct":
|
|
741
|
+
try:
|
|
742
|
+
prompt = PromptSession(style=PT_STYLE)
|
|
743
|
+
instruction = prompt.prompt(
|
|
744
|
+
HTML('<b><style fg="#4FC3F7">instruct ›</style></b> ')
|
|
745
|
+
).strip()
|
|
746
|
+
except (KeyboardInterrupt, EOFError):
|
|
747
|
+
instruction = ""
|
|
748
|
+
if instruction:
|
|
749
|
+
runtime.send_message(
|
|
750
|
+
f"Permission guidance for {tool}: the requested action was not approved. "
|
|
751
|
+
f"Instead, follow this instruction: {instruction}"
|
|
752
|
+
)
|
|
753
|
+
console.print(" [question]instruction queued for agent[/question]")
|
|
754
|
+
else:
|
|
755
|
+
console.print(" [muted]no instruction entered; rejected[/muted]")
|
|
756
|
+
console.print()
|
|
757
|
+
return False
|
|
758
|
+
|
|
759
|
+
console.print(" [error]✖ Rejected[/error]")
|
|
760
|
+
console.print()
|
|
761
|
+
return False
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
# ── Streaming output lock ──────────────────────────────────────────────────────
|
|
765
|
+
|
|
766
|
+
_output_lock = threading.Lock()
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _safe_write(text: str) -> None:
|
|
770
|
+
"""Thread-safe stdout write — prevents interleaved output."""
|
|
771
|
+
with _output_lock:
|
|
772
|
+
sys.stdout.write(text)
|
|
773
|
+
sys.stdout.flush()
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
# ── Runtime hooks ──────────────────────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
# Mutable state shared between install_hooks and run_cli
|
|
779
|
+
_cli_state: dict[str, Any] = {"model": ""}
|
|
780
|
+
|
|
781
|
+
# Running token / call stats accumulated during the session
|
|
782
|
+
_session_stats: dict[str, Any] = {
|
|
783
|
+
"calls": 0,
|
|
784
|
+
"est_input_tokens": 0,
|
|
785
|
+
"est_output_tokens": 0,
|
|
786
|
+
# snapshot of message count after last call (to compute deltas)
|
|
787
|
+
"_last_msg_count": 0,
|
|
788
|
+
"_last_msg_chars": 0,
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
# Raw LLM generations — exact response_text captured for observability.
|
|
792
|
+
# Populated by the on_llm_response hook; exported via /export for observability.
|
|
793
|
+
_raw_generations: list[dict[str, Any]] = []
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def install_hooks(runtime: Any) -> None:
|
|
797
|
+
_last_args = {}
|
|
798
|
+
_file_before: dict[str, str] = {}
|
|
799
|
+
|
|
800
|
+
@on_llm_response(runtime)
|
|
801
|
+
def _on_llm_response(step: int, response: str, **_):
|
|
802
|
+
"""Capture the exact raw LLM generation for the trace JSON.
|
|
803
|
+
This fires before history persistence, so the payload is always
|
|
804
|
+
the full, unmodified string the model produced.
|
|
805
|
+
"""
|
|
806
|
+
_raw_generations.append({
|
|
807
|
+
"step": step,
|
|
808
|
+
"response": response,
|
|
809
|
+
})
|
|
810
|
+
# Rolling window state for thinking display
|
|
811
|
+
# Tracks how many dim lines are currently rendered so we can erase them
|
|
812
|
+
_thinking_rendered: list = [0] # list so inner functions can mutate
|
|
813
|
+
|
|
814
|
+
def _erase_thinking_lines():
|
|
815
|
+
"""Erase all currently rendered thinking lines from the terminal."""
|
|
816
|
+
n = _thinking_rendered[0]
|
|
817
|
+
if n > 0:
|
|
818
|
+
for _ in range(n):
|
|
819
|
+
sys.stdout.write("\x1b[A\x1b[2K")
|
|
820
|
+
sys.stdout.flush()
|
|
821
|
+
_thinking_rendered[0] = 0
|
|
822
|
+
|
|
823
|
+
_markdown_buf: list[str] = [""]
|
|
824
|
+
_live_display: list[Any] = [None]
|
|
825
|
+
|
|
826
|
+
def _stop_live_display():
|
|
827
|
+
with _output_lock:
|
|
828
|
+
if _live_display[0] is not None:
|
|
829
|
+
_live_display[0].stop()
|
|
830
|
+
_live_display[0] = None
|
|
831
|
+
_markdown_buf[0] = ""
|
|
832
|
+
|
|
833
|
+
@on_stream(runtime)
|
|
834
|
+
def _on_stream(text: str, **_):
|
|
835
|
+
# Thinking is now intercepted at the runtime level and routed to
|
|
836
|
+
# THINKING_STREAM — this handler only ever receives clean response text.
|
|
837
|
+
spinner.stop()
|
|
838
|
+
_erase_thinking_lines()
|
|
839
|
+
with _output_lock:
|
|
840
|
+
_markdown_buf[0] += text
|
|
841
|
+
if _live_display[0] is None:
|
|
842
|
+
_live_display[0] = Live(Markdown(_markdown_buf[0]), console=console, refresh_per_second=15, auto_refresh=False)
|
|
843
|
+
_live_display[0].start()
|
|
844
|
+
else:
|
|
845
|
+
_live_display[0].update(Markdown(_markdown_buf[0]), refresh=True)
|
|
846
|
+
|
|
847
|
+
_thinking_buf: list = [""] # accumulates partial line across chunks
|
|
848
|
+
_thinking_lines_acc: list = [[]] # completed lines accumulator
|
|
849
|
+
|
|
850
|
+
@on_thinking_stream(runtime)
|
|
851
|
+
def _on_thinking_stream(thinking: str, **_):
|
|
852
|
+
spinner.stop()
|
|
853
|
+
_thinking_buf[0] += thinking
|
|
854
|
+
lines = _thinking_buf[0].split("\n")
|
|
855
|
+
# Complete lines are everything except the last element
|
|
856
|
+
complete = lines[:-1]
|
|
857
|
+
_thinking_lines_acc[0].extend(complete)
|
|
858
|
+
_thinking_buf[0] = lines[-1] # keep partial line in buffer
|
|
859
|
+
|
|
860
|
+
# Collect 3-line display window from last N completed lines + current partial
|
|
861
|
+
all_lines = _thinking_lines_acc[0] + ([_thinking_buf[0]] if _thinking_buf[0] else [])
|
|
862
|
+
display_lines = all_lines[-3:]
|
|
863
|
+
|
|
864
|
+
if not display_lines:
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
# Erase previously rendered thinking lines before redrawing
|
|
868
|
+
_erase_thinking_lines()
|
|
869
|
+
|
|
870
|
+
import shutil
|
|
871
|
+
term_width = shutil.get_terminal_size().columns
|
|
872
|
+
drawn = 0
|
|
873
|
+
for line in display_lines:
|
|
874
|
+
# Strip any stray ANSI codes from thinking text itself
|
|
875
|
+
clean = line.replace("\x1b", "")
|
|
876
|
+
if len(clean) > term_width - 4:
|
|
877
|
+
clean = clean[:term_width - 7] + "..."
|
|
878
|
+
sys.stdout.write(f"\x1b[2m{clean}\x1b[0m\n")
|
|
879
|
+
drawn += 1
|
|
880
|
+
sys.stdout.flush()
|
|
881
|
+
_thinking_rendered[0] = drawn
|
|
882
|
+
|
|
883
|
+
def _reset_thinking_state():
|
|
884
|
+
"""Called when a new task/step starts to reset accumulator state."""
|
|
885
|
+
_erase_thinking_lines()
|
|
886
|
+
_thinking_buf[0] = ""
|
|
887
|
+
_thinking_lines_acc[0] = []
|
|
888
|
+
|
|
889
|
+
@on_tool_call(runtime)
|
|
890
|
+
def _on_tool_call(tool: str, args: dict, label: str = "", **_):
|
|
891
|
+
_stop_live_display()
|
|
892
|
+
_erase_thinking_lines()
|
|
893
|
+
_reset_thinking_state()
|
|
894
|
+
_last_args[tool] = args
|
|
895
|
+
spinner.stop()
|
|
896
|
+
display = label or (_truncate(json.dumps(args), 80) if args else "")
|
|
897
|
+
if tool in ("write_file", "file_editor"):
|
|
898
|
+
mode = args.get("mode", "w" if tool == "write_file" else "view") if isinstance(args, dict) else ""
|
|
899
|
+
if mode != "view":
|
|
900
|
+
path = args.get("path") if isinstance(args, dict) else None
|
|
901
|
+
work_path = _resolve_work_path(runtime, path)
|
|
902
|
+
if path:
|
|
903
|
+
_file_before[path] = _read_text_if_exists(work_path)
|
|
904
|
+
_safe_write("\n")
|
|
905
|
+
with _output_lock:
|
|
906
|
+
icon = ">" if tool == "execute" else "•"
|
|
907
|
+
summary = _tool_call_summary(tool, args if isinstance(args, dict) else {}, display)
|
|
908
|
+
console.print(f" [tool]{icon} {tool}[/tool] [muted]{summary}[/muted]")
|
|
909
|
+
label, timeout = _tool_wait_label(tool, args if isinstance(args, dict) else {}, display)
|
|
910
|
+
spinner.start(label, timeout)
|
|
911
|
+
|
|
912
|
+
@on_tool_result(runtime)
|
|
913
|
+
def _on_tool_result(tool: str, result: str, **_):
|
|
914
|
+
spinner.stop()
|
|
915
|
+
with _output_lock:
|
|
916
|
+
if tool == "read_file":
|
|
917
|
+
_render_file_snapshot(result)
|
|
918
|
+
elif tool == "file_editor" and _last_args.get(tool, {}).get("mode", "view") == "view":
|
|
919
|
+
_render_file_snapshot(result)
|
|
920
|
+
elif tool in ("write_file", "file_editor"):
|
|
921
|
+
lines = result.splitlines()
|
|
922
|
+
if lines:
|
|
923
|
+
console.print(f" [tool.result]{lines[0]}[/tool.result]")
|
|
924
|
+
args = _last_args.get(tool, {})
|
|
925
|
+
path = args.get("path") if isinstance(args, dict) else None
|
|
926
|
+
before = _file_before.pop(path, "") if path else ""
|
|
927
|
+
after = _read_text_if_exists(_resolve_work_path(runtime, path))
|
|
928
|
+
if path and ("ERROR" not in result) and (before or after):
|
|
929
|
+
diff = list(difflib.unified_diff(
|
|
930
|
+
before.splitlines(),
|
|
931
|
+
after.splitlines(),
|
|
932
|
+
fromfile=f"a/{path}",
|
|
933
|
+
tofile=f"b/{path}",
|
|
934
|
+
lineterm="",
|
|
935
|
+
n=3,
|
|
936
|
+
))
|
|
937
|
+
if diff:
|
|
938
|
+
_print_diff_lines(diff)
|
|
939
|
+
elif len(lines) > 1:
|
|
940
|
+
_print_diff_lines(lines[1:])
|
|
941
|
+
elif tool in ("execute", "read_output", "send_input"):
|
|
942
|
+
_render_terminal_result(result)
|
|
943
|
+
else:
|
|
944
|
+
if result.strip():
|
|
945
|
+
preview = _truncate(result.strip(), 120)
|
|
946
|
+
console.print(f" [tool.result]{preview}[/tool.result]")
|
|
947
|
+
console.print()
|
|
948
|
+
spinner.start("thinking")
|
|
949
|
+
|
|
950
|
+
@on_ask_user(runtime)
|
|
951
|
+
def _on_ask_user(question: str, **_):
|
|
952
|
+
_stop_live_display()
|
|
953
|
+
_erase_thinking_lines()
|
|
954
|
+
spinner.stop()
|
|
955
|
+
_safe_write("\n")
|
|
956
|
+
with _output_lock:
|
|
957
|
+
console.print(f" [question]? {question}[/question]")
|
|
958
|
+
try:
|
|
959
|
+
sys.stdout.write(" \033[38;2;79;195;247m›\033[0m ")
|
|
960
|
+
sys.stdout.flush()
|
|
961
|
+
return input().strip()
|
|
962
|
+
except (KeyboardInterrupt, EOFError):
|
|
963
|
+
return ""
|
|
964
|
+
|
|
965
|
+
@on_finish(runtime)
|
|
966
|
+
def _on_finish(summary: str, **_):
|
|
967
|
+
_stop_live_display()
|
|
968
|
+
_erase_thinking_lines()
|
|
969
|
+
_reset_thinking_state()
|
|
970
|
+
spinner.stop()
|
|
971
|
+
_safe_write("\n")
|
|
972
|
+
with _output_lock:
|
|
973
|
+
console.print(Rule(style="dim #2a2a2a"))
|
|
974
|
+
model_name = _cli_state.get("model", "")
|
|
975
|
+
model_part = f"[dim #555555]◉[/dim #555555] [muted]{model_name}[/muted]" if model_name else ""
|
|
976
|
+
await_part = "[bold #3d7a3d]●[/bold #3d7a3d] [dim #4a7c4a]awaiting task...[/dim #4a7c4a]"
|
|
977
|
+
spacer = " " if model_name else ""
|
|
978
|
+
console.print(f" {model_part}{spacer}{await_part}")
|
|
979
|
+
|
|
980
|
+
# ── Accumulate session stats ──────────────────────────────────────────
|
|
981
|
+
try:
|
|
982
|
+
msgs = list(runtime.messages) # snapshot (thread-safe copy)
|
|
983
|
+
total_chars = sum(len(m.get("content") or "") for m in msgs)
|
|
984
|
+
prev_chars = _session_stats["_last_msg_chars"]
|
|
985
|
+
prev_count = _session_stats["_last_msg_count"]
|
|
986
|
+
|
|
987
|
+
# New chars since last call
|
|
988
|
+
delta_chars = max(0, total_chars - prev_chars)
|
|
989
|
+
new_msgs = msgs[prev_count:] # new messages since last call
|
|
990
|
+
|
|
991
|
+
# Rough split: user/tool messages → input, assistant → output
|
|
992
|
+
in_chars = sum(
|
|
993
|
+
len(m.get("content") or "")
|
|
994
|
+
for m in new_msgs
|
|
995
|
+
if m.get("role") != "assistant"
|
|
996
|
+
)
|
|
997
|
+
out_chars = sum(
|
|
998
|
+
len(m.get("content") or "")
|
|
999
|
+
for m in new_msgs
|
|
1000
|
+
if m.get("role") == "assistant"
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
_session_stats["calls"] += 1
|
|
1004
|
+
_session_stats["est_input_tokens"] += max(0, in_chars // 4)
|
|
1005
|
+
_session_stats["est_output_tokens"] += max(0, out_chars // 4)
|
|
1006
|
+
_session_stats["_last_msg_count"] = len(msgs)
|
|
1007
|
+
_session_stats["_last_msg_chars"] = total_chars
|
|
1008
|
+
except Exception:
|
|
1009
|
+
pass
|
|
1010
|
+
|
|
1011
|
+
@on_permission_request(runtime)
|
|
1012
|
+
def _on_permission(tool: str, description: str, **_):
|
|
1013
|
+
spinner.stop()
|
|
1014
|
+
approved = ask_permission(runtime, tool, description)
|
|
1015
|
+
if approved:
|
|
1016
|
+
spinner.start(f"running {tool}")
|
|
1017
|
+
return approved
|
|
1018
|
+
|
|
1019
|
+
@on_user_message_queued(runtime)
|
|
1020
|
+
def _on_queued(message: str, **_):
|
|
1021
|
+
with _output_lock:
|
|
1022
|
+
console.print(f" [brand.dim]↑ {message}[/brand.dim]")
|
|
1023
|
+
|
|
1024
|
+
@on_user_message_injected(runtime)
|
|
1025
|
+
def _on_injected(message: str, **_):
|
|
1026
|
+
with _output_lock:
|
|
1027
|
+
console.print(f" [brand.dim]↓ {message}[/brand.dim]")
|
|
1028
|
+
|
|
1029
|
+
@on_runtime_error(runtime)
|
|
1030
|
+
def _on_runtime_error(error: str, **_):
|
|
1031
|
+
spinner.stop()
|
|
1032
|
+
_safe_write("\n")
|
|
1033
|
+
with _output_lock:
|
|
1034
|
+
# Show parser errors cleanly — truncate to first 3 lines for readability
|
|
1035
|
+
lines = error.strip().splitlines()
|
|
1036
|
+
header = lines[0] if lines else error
|
|
1037
|
+
console.print(f" [error]✗ {header}[/error]")
|
|
1038
|
+
for line in lines[1:4]:
|
|
1039
|
+
console.print(f" [muted]{line}[/muted]")
|
|
1040
|
+
if len(lines) > 4:
|
|
1041
|
+
console.print(f" [muted]… ({len(lines) - 4} more lines)[/muted]")
|
|
1042
|
+
console.print()
|
|
1043
|
+
spinner.start()
|
|
1044
|
+
|
|
1045
|
+
@on_context_drop(runtime)
|
|
1046
|
+
def _on_context_drop(
|
|
1047
|
+
before_pct: int, after_pct: int, tokens_saved: int,
|
|
1048
|
+
tasks_archived: list, **_
|
|
1049
|
+
):
|
|
1050
|
+
with _output_lock:
|
|
1051
|
+
tasks_str = ", ".join(str(t) for t in tasks_archived)
|
|
1052
|
+
console.print(
|
|
1053
|
+
f" [dim #888888]◈ Context dropped [/dim #888888]"
|
|
1054
|
+
f"[dim #aaaaaa]{before_pct}%[/dim #aaaaaa]"
|
|
1055
|
+
f"[dim #666666] → [/dim #666666]"
|
|
1056
|
+
f"[dim #66BB6A]{after_pct}%[/dim #66BB6A]"
|
|
1057
|
+
f"[dim #888888] (saved ~{tokens_saved:,} tokens · tasks {tasks_str})[/dim #888888]"
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
@on_subagent_spawn(runtime)
|
|
1061
|
+
def _on_subagent_spawn(agent_id: int, task_summary: str, **_):
|
|
1062
|
+
with _output_lock:
|
|
1063
|
+
short = task_summary[:70] + ("…" if len(task_summary) > 70 else "")
|
|
1064
|
+
console.print(
|
|
1065
|
+
f" [dim #4FC3F7]⟳ Sub-Agent #{agent_id} spawned[/dim #4FC3F7]"
|
|
1066
|
+
f"[dim #555555] {short}[/dim #555555]"
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
@on_subagent_message(runtime)
|
|
1070
|
+
def _on_subagent_message(agent_id: int, message: str, **_):
|
|
1071
|
+
with _output_lock:
|
|
1072
|
+
console.print(
|
|
1073
|
+
f" [bold #FFA726]◎ Sub-Agent #{agent_id} →[/bold #FFA726]"
|
|
1074
|
+
f" [dim #ddaa66]{message}[/dim #ddaa66]"
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
@on_subagent_finish(runtime)
|
|
1078
|
+
def _on_subagent_finish(
|
|
1079
|
+
agent_id: int, summary: str, files_written: list,
|
|
1080
|
+
elapsed_seconds: float, error: str | None, **_
|
|
1081
|
+
):
|
|
1082
|
+
with _output_lock:
|
|
1083
|
+
if error:
|
|
1084
|
+
console.print(
|
|
1085
|
+
f" [error]✗ Sub-Agent #{agent_id} failed[/error]"
|
|
1086
|
+
f" [muted]({int(elapsed_seconds)}s)[/muted]"
|
|
1087
|
+
f" [error]{error[:80]}[/error]"
|
|
1088
|
+
)
|
|
1089
|
+
else:
|
|
1090
|
+
files_str = ", ".join(files_written) if files_written else "no files"
|
|
1091
|
+
console.print(
|
|
1092
|
+
f" [bold #66BB6A]✓ Sub-Agent #{agent_id} done[/bold #66BB6A]"
|
|
1093
|
+
f" [muted]({int(elapsed_seconds)}s · {files_str})[/muted]"
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
# ── Agent worker thread ───────────────────────────────────────────────────────
|
|
1098
|
+
|
|
1099
|
+
class AgentWorker:
|
|
1100
|
+
"""Runs Runtime.run(task) on a background thread so main thread stays responsive."""
|
|
1101
|
+
|
|
1102
|
+
def __init__(self, runtime: Any):
|
|
1103
|
+
self.runtime = runtime
|
|
1104
|
+
self._thread: Optional[threading.Thread] = None
|
|
1105
|
+
self._error: Optional[Exception] = None
|
|
1106
|
+
self._done = threading.Event()
|
|
1107
|
+
|
|
1108
|
+
def run_task(self, task: str) -> Optional[Exception]:
|
|
1109
|
+
"""Start agent on background thread, block main thread until done.
|
|
1110
|
+
Returns the exception if one occurred, or None on success.
|
|
1111
|
+
Handles KeyboardInterrupt for clean abort.
|
|
1112
|
+
"""
|
|
1113
|
+
self._error = None
|
|
1114
|
+
self._done.clear()
|
|
1115
|
+
self._thread = threading.Thread(
|
|
1116
|
+
target=self._worker, args=(task,), daemon=True
|
|
1117
|
+
)
|
|
1118
|
+
self._thread.start()
|
|
1119
|
+
|
|
1120
|
+
# Wait with interrupt support
|
|
1121
|
+
try:
|
|
1122
|
+
while not self._done.wait(timeout=0.1):
|
|
1123
|
+
pass
|
|
1124
|
+
except KeyboardInterrupt:
|
|
1125
|
+
self.runtime.abort()
|
|
1126
|
+
spinner.stop()
|
|
1127
|
+
console.print()
|
|
1128
|
+
console.print(" [muted]✗ aborted[/muted]")
|
|
1129
|
+
# Wait for worker to finish after abort
|
|
1130
|
+
self._done.wait(timeout=5.0)
|
|
1131
|
+
return None
|
|
1132
|
+
|
|
1133
|
+
return self._error
|
|
1134
|
+
|
|
1135
|
+
def _worker(self, task: str) -> None:
|
|
1136
|
+
try:
|
|
1137
|
+
self.runtime.run(task)
|
|
1138
|
+
except Exception as exc:
|
|
1139
|
+
self._error = exc
|
|
1140
|
+
finally:
|
|
1141
|
+
self._done.set()
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
# ── Main loop ──────────────────────────────────────────────────────────────────
|
|
1145
|
+
|
|
1146
|
+
# ── Config editor ─────────────────────────────────────────────────────────────
|
|
1147
|
+
|
|
1148
|
+
import yaml # for config editor
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _load_yaml_config(path: Path) -> dict:
|
|
1152
|
+
try:
|
|
1153
|
+
with open(path) as f:
|
|
1154
|
+
return yaml.safe_load(f) or {}
|
|
1155
|
+
except Exception:
|
|
1156
|
+
return {}
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _save_yaml_config(path: Path, data: dict) -> bool:
|
|
1160
|
+
try:
|
|
1161
|
+
with open(path, "w") as f:
|
|
1162
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
1163
|
+
return True
|
|
1164
|
+
except Exception:
|
|
1165
|
+
return False
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def show_config_editor(base_config_path: Path) -> None:
|
|
1169
|
+
"""Interactive config editor backed by agent.yaml."""
|
|
1170
|
+
data = _load_yaml_config(base_config_path)
|
|
1171
|
+
agent = data.get("agent", {})
|
|
1172
|
+
model_cfg = agent.get("model", {})
|
|
1173
|
+
runtime_cfg = agent.get("runtime", {})
|
|
1174
|
+
thinking_cfg = model_cfg.get("thinking", {})
|
|
1175
|
+
|
|
1176
|
+
def _get(d: dict, *keys, default=""):
|
|
1177
|
+
v = d
|
|
1178
|
+
for k in keys:
|
|
1179
|
+
if not isinstance(v, dict):
|
|
1180
|
+
return default
|
|
1181
|
+
v = v.get(k, default)
|
|
1182
|
+
return v if v is not None else default
|
|
1183
|
+
|
|
1184
|
+
def _prompt_text(label: str, current: str, hint: str = "") -> str:
|
|
1185
|
+
hint_str = f" [{hint}]" if hint else ""
|
|
1186
|
+
try:
|
|
1187
|
+
p = PromptSession(style=PT_STYLE)
|
|
1188
|
+
val = p.prompt(
|
|
1189
|
+
HTML(f'<b><style fg="#4FC3F7">{label} ›</style></b> '),
|
|
1190
|
+
default=str(current),
|
|
1191
|
+
).strip()
|
|
1192
|
+
return val if val else str(current)
|
|
1193
|
+
except (KeyboardInterrupt, EOFError):
|
|
1194
|
+
return str(current)
|
|
1195
|
+
|
|
1196
|
+
def _prompt_bool(label: str, current: bool) -> bool:
|
|
1197
|
+
display = "true" if current else "false"
|
|
1198
|
+
console.print(f" [question]{label}[/question] current: [muted]{display}[/muted]")
|
|
1199
|
+
console.print(" [success]Enter[/success] [muted]keep[/muted] [brand]t[/brand] [muted]true[/muted] [error]f[/error] [muted]false[/muted]")
|
|
1200
|
+
console.print()
|
|
1201
|
+
kb = KeyBindings()
|
|
1202
|
+
result = {"val": current}
|
|
1203
|
+
|
|
1204
|
+
@kb.add("t")
|
|
1205
|
+
@kb.add("T")
|
|
1206
|
+
def _(event):
|
|
1207
|
+
result["val"] = True
|
|
1208
|
+
event.app.exit()
|
|
1209
|
+
|
|
1210
|
+
@kb.add("f")
|
|
1211
|
+
@kb.add("F")
|
|
1212
|
+
def _(event):
|
|
1213
|
+
result["val"] = False
|
|
1214
|
+
event.app.exit()
|
|
1215
|
+
|
|
1216
|
+
@kb.add("enter")
|
|
1217
|
+
def _(event):
|
|
1218
|
+
event.app.exit()
|
|
1219
|
+
|
|
1220
|
+
@kb.add("escape")
|
|
1221
|
+
@kb.add("c-c")
|
|
1222
|
+
def _(event):
|
|
1223
|
+
event.app.exit()
|
|
1224
|
+
|
|
1225
|
+
try:
|
|
1226
|
+
p = PromptSession(key_bindings=kb, style=PT_STYLE)
|
|
1227
|
+
p.prompt(HTML('<b><style fg="#FFA726">toggle ›</style></b> '))
|
|
1228
|
+
except (KeyboardInterrupt, EOFError):
|
|
1229
|
+
pass
|
|
1230
|
+
return result["val"]
|
|
1231
|
+
|
|
1232
|
+
def _prompt_choice(label: str, current: str, choices: list[str]) -> str:
|
|
1233
|
+
entries = choices
|
|
1234
|
+
selected = choices.index(current) if current in choices else 0
|
|
1235
|
+
choice = _select(
|
|
1236
|
+
f"{label} (current: {current})",
|
|
1237
|
+
entries,
|
|
1238
|
+
lambda item, sel: Text(item, style="#FFAE70" if sel else "#d0d0d0"),
|
|
1239
|
+
selected=selected,
|
|
1240
|
+
)
|
|
1241
|
+
console.clear()
|
|
1242
|
+
return choice if choice else current
|
|
1243
|
+
|
|
1244
|
+
CONFIG_FIELDS = [
|
|
1245
|
+
("model.name", "Model"),
|
|
1246
|
+
("model.provider", "Provider"),
|
|
1247
|
+
("model.thinking.enabled", "Thinking"),
|
|
1248
|
+
("model.thinking.reasoning_effort","Reasoning Effort"),
|
|
1249
|
+
("runtime.max_steps", "Max Steps"),
|
|
1250
|
+
("runtime.unsafe_mode", "Unsafe Mode"),
|
|
1251
|
+
("agent.system_prompt", "System Prompt"),
|
|
1252
|
+
]
|
|
1253
|
+
|
|
1254
|
+
def _current_value(field: str) -> str:
|
|
1255
|
+
if field == "model.name":
|
|
1256
|
+
return str(_get(model_cfg, "name"))
|
|
1257
|
+
if field == "model.provider":
|
|
1258
|
+
return str(_get(model_cfg, "provider"))
|
|
1259
|
+
if field == "model.thinking.enabled":
|
|
1260
|
+
return "enabled" if _get(thinking_cfg, "enabled", default=False) else "disabled"
|
|
1261
|
+
if field == "model.thinking.reasoning_effort":
|
|
1262
|
+
return str(_get(thinking_cfg, "reasoning_effort", default="high"))
|
|
1263
|
+
if field == "runtime.max_steps":
|
|
1264
|
+
return str(_get(runtime_cfg, "max_steps", default=25))
|
|
1265
|
+
if field == "runtime.unsafe_mode":
|
|
1266
|
+
return "true" if _get(runtime_cfg, "unsafe_mode", default=False) else "false"
|
|
1267
|
+
if field == "agent.system_prompt":
|
|
1268
|
+
sp = str(_get(agent, "system_prompt", default=""))
|
|
1269
|
+
return sp[:60] + "…" if len(sp) > 60 else sp
|
|
1270
|
+
return ""
|
|
1271
|
+
|
|
1272
|
+
def _edit_field(field: str) -> None:
|
|
1273
|
+
nonlocal data, agent, model_cfg, runtime_cfg, thinking_cfg
|
|
1274
|
+
if field == "model.name":
|
|
1275
|
+
# Pick from known models or type manually
|
|
1276
|
+
console.print()
|
|
1277
|
+
choice = _prompt_choice("Model", _get(model_cfg, "name"), ALL_MODELS)
|
|
1278
|
+
if choice:
|
|
1279
|
+
model_cfg["name"] = choice
|
|
1280
|
+
# auto-update provider
|
|
1281
|
+
prov = MODEL_TO_PROVIDER.get(choice, model_cfg.get("provider", ""))
|
|
1282
|
+
model_cfg["provider"] = PROVIDER_YAML_NAME.get(prov, prov.lower())
|
|
1283
|
+
model_cfg["api_key_env"] = f"{prov.upper()}_API_KEY"
|
|
1284
|
+
elif field == "model.provider":
|
|
1285
|
+
providers = list(PROVIDER_YAML_NAME.values())
|
|
1286
|
+
choice = _prompt_choice("Provider", _get(model_cfg, "provider"), providers)
|
|
1287
|
+
if choice:
|
|
1288
|
+
model_cfg["provider"] = choice
|
|
1289
|
+
elif field == "model.thinking.enabled":
|
|
1290
|
+
console.print()
|
|
1291
|
+
val = _prompt_bool("Thinking", bool(_get(thinking_cfg, "enabled", default=False)))
|
|
1292
|
+
thinking_cfg["enabled"] = val
|
|
1293
|
+
elif field == "model.thinking.reasoning_effort":
|
|
1294
|
+
choice = _prompt_choice("Reasoning Effort",
|
|
1295
|
+
_get(thinking_cfg, "reasoning_effort", default="high"),
|
|
1296
|
+
["low", "medium", "high"])
|
|
1297
|
+
thinking_cfg["reasoning_effort"] = choice
|
|
1298
|
+
elif field == "runtime.max_steps":
|
|
1299
|
+
val = _prompt_text("Max Steps", str(_get(runtime_cfg, "max_steps", default=25)))
|
|
1300
|
+
try:
|
|
1301
|
+
runtime_cfg["max_steps"] = int(val)
|
|
1302
|
+
except ValueError:
|
|
1303
|
+
console.print(" [error]Invalid number — keeping previous value[/error]")
|
|
1304
|
+
elif field == "runtime.unsafe_mode":
|
|
1305
|
+
console.print()
|
|
1306
|
+
val = _prompt_bool("Unsafe Mode", bool(_get(runtime_cfg, "unsafe_mode", default=False)))
|
|
1307
|
+
runtime_cfg["unsafe_mode"] = val
|
|
1308
|
+
elif field == "agent.system_prompt":
|
|
1309
|
+
full = str(_get(agent, "system_prompt", default=""))
|
|
1310
|
+
val = _prompt_text("System Prompt", full, hint="edit full text")
|
|
1311
|
+
agent["system_prompt"] = val
|
|
1312
|
+
|
|
1313
|
+
# Propagate nested back into data
|
|
1314
|
+
if "model" not in agent:
|
|
1315
|
+
agent["model"] = {}
|
|
1316
|
+
agent["model"] = model_cfg
|
|
1317
|
+
if "thinking" not in agent["model"]:
|
|
1318
|
+
agent["model"]["thinking"] = {}
|
|
1319
|
+
agent["model"]["thinking"] = thinking_cfg
|
|
1320
|
+
agent["runtime"] = runtime_cfg
|
|
1321
|
+
data["agent"] = agent
|
|
1322
|
+
|
|
1323
|
+
while True:
|
|
1324
|
+
def render_config(item: tuple[str, str], selected: bool) -> Text:
|
|
1325
|
+
field, label = item
|
|
1326
|
+
val = _current_value(field)
|
|
1327
|
+
text = Text(f"{label:<22}", style="#FFAE70" if selected else "#d0d0d0")
|
|
1328
|
+
text.append(val, style="dim #888888")
|
|
1329
|
+
return text
|
|
1330
|
+
|
|
1331
|
+
choice = _select(
|
|
1332
|
+
"Configuration",
|
|
1333
|
+
CONFIG_FIELDS,
|
|
1334
|
+
render_config,
|
|
1335
|
+
subtitle="Enter to edit Esc to save & exit",
|
|
1336
|
+
cancelable=True,
|
|
1337
|
+
)
|
|
1338
|
+
if choice is None:
|
|
1339
|
+
break
|
|
1340
|
+
_edit_field(choice[0])
|
|
1341
|
+
|
|
1342
|
+
# Save back
|
|
1343
|
+
ok = _save_yaml_config(base_config_path, data)
|
|
1344
|
+
if ok:
|
|
1345
|
+
console.print(" [success]✓ configuration saved[/success]")
|
|
1346
|
+
else:
|
|
1347
|
+
console.print(" [error]✗ failed to save configuration[/error]")
|
|
1348
|
+
console.print()
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
# ── Main loop ──────────────────────────────────────────────────────────────────
|
|
1352
|
+
|
|
1353
|
+
# ── /status command ────────────────────────────────────────────────────────────
|
|
1354
|
+
|
|
1355
|
+
def show_status(
|
|
1356
|
+
runtime: Any,
|
|
1357
|
+
session_id: str,
|
|
1358
|
+
current_model: str,
|
|
1359
|
+
current_provider: str,
|
|
1360
|
+
work_dir: Path,
|
|
1361
|
+
) -> None:
|
|
1362
|
+
"""Display a styled system status panel."""
|
|
1363
|
+
import subprocess
|
|
1364
|
+
|
|
1365
|
+
# Collect runtime config values
|
|
1366
|
+
try:
|
|
1367
|
+
safe_mode = runtime.config.runtime.unsafe_mode
|
|
1368
|
+
max_steps = runtime.config.runtime.max_steps
|
|
1369
|
+
sa_enabled = getattr(
|
|
1370
|
+
getattr(runtime.config, "sub_agents", None), "enabled", False
|
|
1371
|
+
)
|
|
1372
|
+
except Exception:
|
|
1373
|
+
safe_mode = False
|
|
1374
|
+
max_steps = "?"
|
|
1375
|
+
sa_enabled = False
|
|
1376
|
+
|
|
1377
|
+
# OS / platform info
|
|
1378
|
+
try:
|
|
1379
|
+
uname = platform.uname()
|
|
1380
|
+
os_str = f"{uname.system} {uname.machine} ({uname.release})"
|
|
1381
|
+
except Exception:
|
|
1382
|
+
os_str = platform.platform()
|
|
1383
|
+
|
|
1384
|
+
# Python runtime
|
|
1385
|
+
py_ver = f"Python {sys.version.split()[0]}"
|
|
1386
|
+
|
|
1387
|
+
# Session message count
|
|
1388
|
+
try:
|
|
1389
|
+
msg_count = len(runtime.messages)
|
|
1390
|
+
except Exception:
|
|
1391
|
+
msg_count = 0
|
|
1392
|
+
|
|
1393
|
+
# Memory / process stats
|
|
1394
|
+
try:
|
|
1395
|
+
import resource
|
|
1396
|
+
mem_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
|
1397
|
+
mem_str = f"{mem_mb:.1f} MB"
|
|
1398
|
+
except Exception:
|
|
1399
|
+
mem_str = "n/a"
|
|
1400
|
+
|
|
1401
|
+
# API key hint (masked)
|
|
1402
|
+
key_env = ""
|
|
1403
|
+
try:
|
|
1404
|
+
key_env = runtime.config.model.api_key_env or ""
|
|
1405
|
+
except Exception:
|
|
1406
|
+
pass
|
|
1407
|
+
key_val = os.environ.get(key_env, "")
|
|
1408
|
+
key_display = f"{key_env} ✓" if key_val else f"{key_env} ✗ (not set)"
|
|
1409
|
+
|
|
1410
|
+
console.print()
|
|
1411
|
+
|
|
1412
|
+
# ── Header bar
|
|
1413
|
+
w = shutil.get_terminal_size().columns
|
|
1414
|
+
bar_inner = " Status "
|
|
1415
|
+
pad = max(0, w - 4 - len(bar_inner))
|
|
1416
|
+
left = pad // 2
|
|
1417
|
+
right = pad - left
|
|
1418
|
+
console.print(f" [brand]{'─' * left}{bar_inner}{'─' * right}[/brand]")
|
|
1419
|
+
console.print()
|
|
1420
|
+
|
|
1421
|
+
rows = [
|
|
1422
|
+
("CodePilot", f"{APP_NAME} {APP_VERSION}"),
|
|
1423
|
+
("Runtime", py_ver),
|
|
1424
|
+
("OS", os_str),
|
|
1425
|
+
("Model", current_model),
|
|
1426
|
+
("Provider", current_provider),
|
|
1427
|
+
("API Key", key_display),
|
|
1428
|
+
("Session ID", session_id),
|
|
1429
|
+
("Messages", str(msg_count)),
|
|
1430
|
+
("Work Dir", str(work_dir)),
|
|
1431
|
+
("Max Steps", str(max_steps)),
|
|
1432
|
+
("Safe Mode", "off (unsafe)" if safe_mode else "on"),
|
|
1433
|
+
("Sub-Agents", "enabled" if sa_enabled else "disabled"),
|
|
1434
|
+
("Memory RSS", mem_str),
|
|
1435
|
+
]
|
|
1436
|
+
|
|
1437
|
+
for key, val in rows:
|
|
1438
|
+
val_style = "status.val" if key in ("Model", "Provider", "CodePilot") else "muted"
|
|
1439
|
+
console.print(
|
|
1440
|
+
f" [question]{key:<14}[/question] [{val_style}]{val}[/{val_style}]"
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
console.print()
|
|
1444
|
+
console.print(f" [brand]{'─' * (w - 4)}[/brand]")
|
|
1445
|
+
console.print()
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
# ── /context command ───────────────────────────────────────────────────────────
|
|
1449
|
+
|
|
1450
|
+
def _est_tokens(val: str | int) -> int:
|
|
1451
|
+
"""Fast character-based token estimate (~4 chars/token)."""
|
|
1452
|
+
if isinstance(val, int):
|
|
1453
|
+
return max(0, val // 4)
|
|
1454
|
+
if isinstance(val, str):
|
|
1455
|
+
return max(0, len(val) // 4)
|
|
1456
|
+
return 0
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _fill_bar(used: int, total: int, width: int = 36) -> str:
|
|
1460
|
+
"""
|
|
1461
|
+
Returns a Rich-markup fill bar string.
|
|
1462
|
+
filled = amber, empty = dim.
|
|
1463
|
+
"""
|
|
1464
|
+
if total <= 0:
|
|
1465
|
+
ratio = 0.0
|
|
1466
|
+
else:
|
|
1467
|
+
ratio = min(1.0, used / total)
|
|
1468
|
+
filled = int(ratio * width)
|
|
1469
|
+
empty = width - filled
|
|
1470
|
+
|
|
1471
|
+
bar = f"[bold #FF8533]{'█' * filled}[/bold #FF8533][dim #333333]{'░' * empty}[/dim #333333]"
|
|
1472
|
+
pct = int(ratio * 100)
|
|
1473
|
+
|
|
1474
|
+
# Colour the percentage: green <50%, amber 50-80%, red >80%
|
|
1475
|
+
if pct < 50:
|
|
1476
|
+
pct_style = "#66BB6A"
|
|
1477
|
+
elif pct < 80:
|
|
1478
|
+
pct_style = "#FFA726"
|
|
1479
|
+
else:
|
|
1480
|
+
pct_style = "#EF5350"
|
|
1481
|
+
|
|
1482
|
+
return f"{bar} [{pct_style}]{pct:3d}%[/{pct_style}]"
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def show_context(runtime: Any, current_model: str) -> None:
|
|
1486
|
+
"""Visual context window usage breakdown with fill bars."""
|
|
1487
|
+
context_window = MODEL_CONTEXT_WINDOWS.get(current_model, DEFAULT_CONTEXT_WINDOW)
|
|
1488
|
+
|
|
1489
|
+
try:
|
|
1490
|
+
msgs = list(runtime.messages)
|
|
1491
|
+
except Exception:
|
|
1492
|
+
msgs = []
|
|
1493
|
+
|
|
1494
|
+
# ── Estimate each category ──────────────────────────────────────────────
|
|
1495
|
+
try:
|
|
1496
|
+
from codepilot.core.memory import count_tokens
|
|
1497
|
+
actual_rt = getattr(runtime, "_async", runtime)
|
|
1498
|
+
|
|
1499
|
+
# 1. Exact System Prompt & Tool Schemas calculation
|
|
1500
|
+
sys_parts = actual_rt._build_system_prompt()
|
|
1501
|
+
sys_str = getattr(sys_parts, "static", "") + "\n" + getattr(sys_parts, "dynamic", "")
|
|
1502
|
+
total_sys = count_tokens(sys_str)
|
|
1503
|
+
|
|
1504
|
+
reg = getattr(actual_rt, "registry", None)
|
|
1505
|
+
defs = ""
|
|
1506
|
+
if reg:
|
|
1507
|
+
try:
|
|
1508
|
+
defs = reg.get_definitions() or ""
|
|
1509
|
+
except Exception:
|
|
1510
|
+
pass
|
|
1511
|
+
|
|
1512
|
+
tools_tokens = count_tokens(defs) if defs else 0
|
|
1513
|
+
# Tool schemas are injected into the static prompt, so subtract them to isolate prompt text
|
|
1514
|
+
sys_tokens = max(0, total_sys - tools_tokens)
|
|
1515
|
+
|
|
1516
|
+
# 2. Exact History Messages calculation
|
|
1517
|
+
user_tokens = sum(count_tokens(m.get("content") or "") for m in msgs if m.get("role") == "user")
|
|
1518
|
+
asst_tokens = sum(count_tokens(m.get("content") or "") for m in msgs if m.get("role") == "assistant")
|
|
1519
|
+
tool_tokens = sum(count_tokens(m.get("content") or "") for m in msgs if m.get("role") not in ("user", "assistant"))
|
|
1520
|
+
|
|
1521
|
+
except Exception:
|
|
1522
|
+
# Fallback if something goes wrong or runtime isn't fully initialized
|
|
1523
|
+
sys_tokens = 3_000
|
|
1524
|
+
tools_tokens = 2_500
|
|
1525
|
+
user_chars = sum(len(m.get("content") or "") for m in msgs if m.get("role") == "user")
|
|
1526
|
+
asst_chars = sum(len(m.get("content") or "") for m in msgs if m.get("role") == "assistant")
|
|
1527
|
+
tool_chars = sum(len(m.get("content") or "") for m in msgs if m.get("role") not in ("user", "assistant"))
|
|
1528
|
+
user_tokens = _est_tokens(user_chars)
|
|
1529
|
+
asst_tokens = _est_tokens(asst_chars)
|
|
1530
|
+
tool_tokens = _est_tokens(tool_chars)
|
|
1531
|
+
|
|
1532
|
+
hist_tokens = user_tokens + asst_tokens + tool_tokens
|
|
1533
|
+
used_tokens = sys_tokens + tools_tokens + hist_tokens
|
|
1534
|
+
free_tokens = max(0, context_window - used_tokens)
|
|
1535
|
+
|
|
1536
|
+
console.print()
|
|
1537
|
+
|
|
1538
|
+
# Header
|
|
1539
|
+
w = shutil.get_terminal_size().columns
|
|
1540
|
+
console.print(f" [brand]Context Window[/brand] [muted]{current_model}[/muted] [dim #555555]{context_window // 1_000}k tokens[/dim #555555]")
|
|
1541
|
+
console.print()
|
|
1542
|
+
|
|
1543
|
+
# ── Overall fill bar
|
|
1544
|
+
overall_bar = _fill_bar(used_tokens, context_window, width=42)
|
|
1545
|
+
used_k = used_tokens / 1_000
|
|
1546
|
+
total_k = context_window / 1_000
|
|
1547
|
+
console.print(f" {overall_bar} [dim #666666]{used_k:.1f}k / {total_k:.0f}k used[/dim #666666]")
|
|
1548
|
+
console.print()
|
|
1549
|
+
|
|
1550
|
+
# ── Section breakdown ───────────────────────────────────────────────────
|
|
1551
|
+
sections = [
|
|
1552
|
+
("System prompt", sys_tokens, "#9C89FF"),
|
|
1553
|
+
("Tool schemas", tools_tokens, "#4FC3F7"),
|
|
1554
|
+
("User messages", user_tokens, "#66BB6A"),
|
|
1555
|
+
("Agent responses", asst_tokens, "#FF8533"),
|
|
1556
|
+
("Tool results", tool_tokens, "#FFA726"),
|
|
1557
|
+
("Free capacity", free_tokens, "#3a6644"),
|
|
1558
|
+
]
|
|
1559
|
+
|
|
1560
|
+
console.print(f" [dim #555555]{'Section':<18} {'Tokens':>8} Fill (relative to window)[/dim #555555]")
|
|
1561
|
+
console.print(f" [dim #2a2a2a]{'─' * 64}[/dim #2a2a2a]")
|
|
1562
|
+
|
|
1563
|
+
for label, toks, color in sections:
|
|
1564
|
+
bar = _fill_bar(toks, context_window, width=28)
|
|
1565
|
+
toks_k = toks / 1_000
|
|
1566
|
+
prefix = " └ " if label not in ("System prompt", "Free capacity") else " ● "
|
|
1567
|
+
console.print(
|
|
1568
|
+
f" [{color}]{prefix}{label:<16}[/{color}] "
|
|
1569
|
+
f"[dim #888888]{toks_k:>5.1f}k[/dim #888888] {bar}"
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
console.print()
|
|
1573
|
+
console.print(f" [dim #444444]✓ token counts calculated exactly via cl100k_base (tiktoken)[/dim #444444]")
|
|
1574
|
+
console.print()
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
# ── /export command ────────────────────────────────────────────────────────────
|
|
1578
|
+
|
|
1579
|
+
def show_export(
|
|
1580
|
+
runtime: Any,
|
|
1581
|
+
session_id: str,
|
|
1582
|
+
current_model: str,
|
|
1583
|
+
current_provider: str,
|
|
1584
|
+
work_dir: Path,
|
|
1585
|
+
) -> None:
|
|
1586
|
+
"""Export full session conversation to a JSON file."""
|
|
1587
|
+
try:
|
|
1588
|
+
msgs = list(runtime.messages)
|
|
1589
|
+
except Exception:
|
|
1590
|
+
msgs = []
|
|
1591
|
+
try:
|
|
1592
|
+
raw_generations = list(runtime.raw_llm_generations())
|
|
1593
|
+
except Exception:
|
|
1594
|
+
raw_generations = list(_raw_generations)
|
|
1595
|
+
if not raw_generations and _raw_generations:
|
|
1596
|
+
raw_generations = list(_raw_generations)
|
|
1597
|
+
|
|
1598
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
1599
|
+
ts_file = now.strftime("%Y%m%d_%H%M%S")
|
|
1600
|
+
ts_iso = now.isoformat()
|
|
1601
|
+
|
|
1602
|
+
# Build conversation list
|
|
1603
|
+
conversation = []
|
|
1604
|
+
for idx, m in enumerate(msgs):
|
|
1605
|
+
role = m.get("role", "unknown")
|
|
1606
|
+
content = m.get("content") or ""
|
|
1607
|
+
conversation.append({"index": idx, "role": role, "content": content})
|
|
1608
|
+
|
|
1609
|
+
# Stats
|
|
1610
|
+
user_count = sum(1 for m in msgs if m.get("role") == "user")
|
|
1611
|
+
asst_count = sum(1 for m in msgs if m.get("role") == "assistant")
|
|
1612
|
+
est_tokens = _est_tokens("".join(m.get("content") or "" for m in msgs))
|
|
1613
|
+
|
|
1614
|
+
payload = {
|
|
1615
|
+
"export_metadata": {
|
|
1616
|
+
"exported_at": ts_iso,
|
|
1617
|
+
"codepilot_version": APP_VERSION,
|
|
1618
|
+
"session_id": session_id,
|
|
1619
|
+
"model": current_model,
|
|
1620
|
+
"provider": current_provider,
|
|
1621
|
+
"work_dir": str(work_dir),
|
|
1622
|
+
},
|
|
1623
|
+
# Model-visible history — exactly what the LLM sees as context.
|
|
1624
|
+
# Agentic turns are stored verbatim; context archiving handles pressure.
|
|
1625
|
+
"conversation": conversation,
|
|
1626
|
+
# Raw LLM generations — the exact, unmodified response_text the model
|
|
1627
|
+
# produced for every agentic step, independent of model-visible history.
|
|
1628
|
+
# Use this for debugging, hallucination analysis, and prompt auditing.
|
|
1629
|
+
"raw_llm_generations": raw_generations,
|
|
1630
|
+
"stats": {
|
|
1631
|
+
"total_messages": len(msgs),
|
|
1632
|
+
"user_messages": user_count,
|
|
1633
|
+
"assistant_messages": asst_count,
|
|
1634
|
+
"other_messages": len(msgs) - user_count - asst_count,
|
|
1635
|
+
"estimated_tokens": est_tokens,
|
|
1636
|
+
"raw_generations_captured": len(raw_generations),
|
|
1637
|
+
},
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
out_path = work_dir / f"codepilot_export_{session_id}_{ts_file}.json"
|
|
1641
|
+
try:
|
|
1642
|
+
out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1643
|
+
except Exception as exc:
|
|
1644
|
+
console.print(f" [error]✗ Export failed: {exc}[/error]")
|
|
1645
|
+
console.print()
|
|
1646
|
+
return
|
|
1647
|
+
|
|
1648
|
+
console.print()
|
|
1649
|
+
console.print(f" [success]✓ Session exported[/success]")
|
|
1650
|
+
console.print()
|
|
1651
|
+
console.print(f" [status.key]{'File':<14}[/status.key] [status.val]{out_path}[/status.val]")
|
|
1652
|
+
console.print(f" [status.key]{'Messages':<14}[/status.key] [muted]{len(msgs)} total ({user_count} user / {asst_count} agent)[/muted]")
|
|
1653
|
+
console.print(f" [status.key]{'Raw traces':<14}[/status.key] [muted]{len(raw_generations)} generation(s) captured[/muted]")
|
|
1654
|
+
console.print(f" [status.key]{'Est. tokens':<14}[/status.key] [muted]~{est_tokens:,}[/muted]")
|
|
1655
|
+
console.print(f" [status.key]{'Timestamp':<14}[/status.key] [muted]{ts_iso}[/muted]")
|
|
1656
|
+
console.print()
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
# ── /stat command ──────────────────────────────────────────────────────────────
|
|
1660
|
+
|
|
1661
|
+
# Approximate cost per 1M tokens (USD) — rough public estimates, June 2026
|
|
1662
|
+
_COST_PER_M: dict[str, tuple[float, float]] = {
|
|
1663
|
+
"deepseek-v4-flash": (0.14, 0.28),
|
|
1664
|
+
"deepseek-v4-pro": (0.55, 2.19),
|
|
1665
|
+
"claude-haiku-4-5": (0.80, 4.00),
|
|
1666
|
+
"claude-sonnet-4-6": (3.00, 15.00),
|
|
1667
|
+
"claude-opus-4-8": (15.00, 75.00),
|
|
1668
|
+
"gpt-5.4-mini": (0.15, 0.60),
|
|
1669
|
+
"gpt-5-mini": (0.15, 0.60),
|
|
1670
|
+
"gpt-5.4": (5.00, 20.00),
|
|
1671
|
+
"gpt-5.5": (5.00, 20.00),
|
|
1672
|
+
"qwen3-coder-plus": (0.70, 2.10),
|
|
1673
|
+
"qwen3-coder-next": (0.70, 2.10),
|
|
1674
|
+
"qwen3-coder-flash": (0.14, 0.42),
|
|
1675
|
+
"qwen3.6-plus": (0.70, 2.10),
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def show_stat(current_model: str) -> None:
|
|
1680
|
+
"""Display session-level token usage stats."""
|
|
1681
|
+
calls = _session_stats["calls"]
|
|
1682
|
+
est_in = _session_stats["est_input_tokens"]
|
|
1683
|
+
est_out = _session_stats["est_output_tokens"]
|
|
1684
|
+
est_total = est_in + est_out
|
|
1685
|
+
|
|
1686
|
+
# Cost estimate
|
|
1687
|
+
in_cost = out_cost = 0.0
|
|
1688
|
+
if current_model in _COST_PER_M:
|
|
1689
|
+
in_rate, out_rate = _COST_PER_M[current_model]
|
|
1690
|
+
in_cost = est_in / 1_000_000 * in_rate
|
|
1691
|
+
out_cost = est_out / 1_000_000 * out_rate
|
|
1692
|
+
total_cost = in_cost + out_cost
|
|
1693
|
+
|
|
1694
|
+
console.print()
|
|
1695
|
+
|
|
1696
|
+
# ── Header
|
|
1697
|
+
w = shutil.get_terminal_size().columns
|
|
1698
|
+
console.print(f" [brand]Token Usage[/brand] [muted]{current_model}[/muted]")
|
|
1699
|
+
console.print()
|
|
1700
|
+
|
|
1701
|
+
if calls == 0:
|
|
1702
|
+
console.print(" [muted]No API calls made yet this session.[/muted]")
|
|
1703
|
+
console.print()
|
|
1704
|
+
return
|
|
1705
|
+
|
|
1706
|
+
# ── Numbers
|
|
1707
|
+
rows = [
|
|
1708
|
+
("API calls", str(calls), "#FFAE70"),
|
|
1709
|
+
("Input tokens", f"~{est_in:,}", "#4FC3F7"),
|
|
1710
|
+
("Output tokens", f"~{est_out:,}", "#FF8533"),
|
|
1711
|
+
("Total tokens", f"~{est_total:,}", "#66BB6A"),
|
|
1712
|
+
]
|
|
1713
|
+
for key, val, color in rows:
|
|
1714
|
+
console.print(f" [dim #888888]{key:<18}[/dim #888888] [{color}]{val}[/{color}]")
|
|
1715
|
+
|
|
1716
|
+
console.print()
|
|
1717
|
+
|
|
1718
|
+
# ── Cost estimate mini-bar
|
|
1719
|
+
if total_cost > 0:
|
|
1720
|
+
console.print(f" [dim #555555]{'Cost estimate':<18}[/dim #555555] [dim #888888]in ${in_cost:.5f} out ${out_cost:.5f} total [bold #FFAE70]${total_cost:.4f}[/bold #FFAE70][/dim #888888]")
|
|
1721
|
+
else:
|
|
1722
|
+
console.print(f" [dim #555555]{'Cost estimate':<18}[/dim #555555] [dim #666666]unavailable for this model[/dim #666666]")
|
|
1723
|
+
|
|
1724
|
+
# ── Throughput fill bar (output vs input ratio)
|
|
1725
|
+
console.print()
|
|
1726
|
+
if est_in > 0:
|
|
1727
|
+
out_ratio = min(1.0, est_out / est_in)
|
|
1728
|
+
bar_w = 32
|
|
1729
|
+
filled = int(out_ratio * bar_w)
|
|
1730
|
+
console.print(
|
|
1731
|
+
f" [dim #555555]Output/Input ratio [/dim #555555]"
|
|
1732
|
+
f"[bold #FF8533]{'█' * filled}[/bold #FF8533]"
|
|
1733
|
+
f"[dim #333333]{'░' * (bar_w - filled)}[/dim #333333]"
|
|
1734
|
+
f" [dim #888888]{out_ratio:.0%}[/dim #888888]"
|
|
1735
|
+
)
|
|
1736
|
+
console.print()
|
|
1737
|
+
|
|
1738
|
+
console.print(f" [dim #444444]⚠ estimates based on ~4 chars/token heuristic; reset with /reset[/dim #444444]")
|
|
1739
|
+
console.print()
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def run_cli() -> None:
|
|
1744
|
+
work_dir = Path.cwd()
|
|
1745
|
+
session_id = pick_session_interactive()
|
|
1746
|
+
|
|
1747
|
+
# ── Read model/provider from agent.yaml (fallback to compiled defaults) ──
|
|
1748
|
+
_base_cfg_path = _find_base_config()
|
|
1749
|
+
_base_cfg_data = _load_yaml_config(_base_cfg_path) if _base_cfg_path.exists() else {}
|
|
1750
|
+
_yaml_model = (_base_cfg_data.get("agent", {}) or {}).get("model", {}) or {}
|
|
1751
|
+
_yaml_model_name = _yaml_model.get("name", "").strip()
|
|
1752
|
+
_yaml_provider = _yaml_model.get("provider", "").strip()
|
|
1753
|
+
|
|
1754
|
+
# Resolve to a known model name; fall back to compiled default
|
|
1755
|
+
if _yaml_model_name and _yaml_model_name in ALL_MODELS:
|
|
1756
|
+
current_model = _yaml_model_name
|
|
1757
|
+
else:
|
|
1758
|
+
current_model = DEFAULT_MODEL
|
|
1759
|
+
|
|
1760
|
+
# Resolve provider: prefer yaml, then MODEL_TO_PROVIDER lookup, then default
|
|
1761
|
+
if _yaml_provider:
|
|
1762
|
+
# yaml stores lowercase provider name; find the display name key
|
|
1763
|
+
_prov_display = next(
|
|
1764
|
+
(k for k, v in PROVIDER_YAML_NAME.items() if v == _yaml_provider),
|
|
1765
|
+
None
|
|
1766
|
+
)
|
|
1767
|
+
current_provider = _prov_display or MODEL_TO_PROVIDER.get(current_model, DEFAULT_PROVIDER)
|
|
1768
|
+
else:
|
|
1769
|
+
current_provider = MODEL_TO_PROVIDER.get(current_model, DEFAULT_PROVIDER)
|
|
1770
|
+
|
|
1771
|
+
# Expose model name to hooks via shared state
|
|
1772
|
+
_cli_state["model"] = current_model
|
|
1773
|
+
|
|
1774
|
+
print_banner(work_dir, session_id, current_model)
|
|
1775
|
+
|
|
1776
|
+
if not HAS_RUNTIME:
|
|
1777
|
+
console.print(" [error]✗ codepilot package not found — install it first.[/error]")
|
|
1778
|
+
console.print()
|
|
1779
|
+
return
|
|
1780
|
+
|
|
1781
|
+
config_path = build_patched_config(work_dir, current_model, current_provider)
|
|
1782
|
+
runtime: Any = None
|
|
1783
|
+
|
|
1784
|
+
def _make_runtime(cfg: Path) -> Any:
|
|
1785
|
+
rt = Runtime(
|
|
1786
|
+
str(cfg),
|
|
1787
|
+
session="file",
|
|
1788
|
+
session_id=session_id,
|
|
1789
|
+
session_dir=SESSION_DIR,
|
|
1790
|
+
stream=True,
|
|
1791
|
+
)
|
|
1792
|
+
install_hooks(rt)
|
|
1793
|
+
return rt
|
|
1794
|
+
|
|
1795
|
+
try:
|
|
1796
|
+
runtime = _make_runtime(config_path)
|
|
1797
|
+
worker = AgentWorker(runtime)
|
|
1798
|
+
pt_session: PromptSession = PromptSession(
|
|
1799
|
+
style=PT_STYLE,
|
|
1800
|
+
completer=SlashCompleter(),
|
|
1801
|
+
complete_while_typing=True,
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
while True:
|
|
1805
|
+
# ── Prompt ────────────────────────────────────────────────────
|
|
1806
|
+
try:
|
|
1807
|
+
task = pt_session.prompt(
|
|
1808
|
+
HTML('<b><style fg="#4FC3F7">›</style></b> '),
|
|
1809
|
+
).strip()
|
|
1810
|
+
except (KeyboardInterrupt, EOFError):
|
|
1811
|
+
console.print("\n [muted]Goodbye.[/muted]")
|
|
1812
|
+
return
|
|
1813
|
+
except Exception:
|
|
1814
|
+
task = ""
|
|
1815
|
+
|
|
1816
|
+
if not task:
|
|
1817
|
+
continue
|
|
1818
|
+
|
|
1819
|
+
# ── Run shell command prefix ──────────────────────────────────
|
|
1820
|
+
if task.startswith("!"):
|
|
1821
|
+
cmd = task[1:].strip()
|
|
1822
|
+
if not cmd:
|
|
1823
|
+
console.print(" [error]No command provided after ![/error]")
|
|
1824
|
+
continue
|
|
1825
|
+
console.print(f" [brand]⚡ Running local command:[/brand] [muted]{cmd}[/muted]")
|
|
1826
|
+
console.print()
|
|
1827
|
+
try:
|
|
1828
|
+
subprocess.run(cmd, shell=True)
|
|
1829
|
+
except Exception as exc:
|
|
1830
|
+
console.print(f" [error]Failed to run command: {exc}[/error]")
|
|
1831
|
+
console.print()
|
|
1832
|
+
continue
|
|
1833
|
+
|
|
1834
|
+
# ── Built-ins ─────────────────────────────────────────────────
|
|
1835
|
+
if task.lower() in {"quit", "exit"}:
|
|
1836
|
+
console.print(" [muted]Goodbye.[/muted]")
|
|
1837
|
+
return
|
|
1838
|
+
|
|
1839
|
+
# ── Slash commands ────────────────────────────────────────────
|
|
1840
|
+
if task.startswith("/"):
|
|
1841
|
+
cmd = task.split()[0].lower()
|
|
1842
|
+
|
|
1843
|
+
if cmd == "/help":
|
|
1844
|
+
console.print()
|
|
1845
|
+
for c, desc in SLASH_COMMANDS.items():
|
|
1846
|
+
console.print(
|
|
1847
|
+
f" [brand]{c:<12}[/brand] [muted]{desc}[/muted]"
|
|
1848
|
+
)
|
|
1849
|
+
console.print()
|
|
1850
|
+
|
|
1851
|
+
elif cmd == "/models":
|
|
1852
|
+
chosen = show_models_picker(current_model)
|
|
1853
|
+
if chosen and chosen != current_model:
|
|
1854
|
+
current_model = chosen
|
|
1855
|
+
current_provider = MODEL_TO_PROVIDER.get(chosen, DEFAULT_PROVIDER)
|
|
1856
|
+
_cli_state["model"] = current_model
|
|
1857
|
+
try:
|
|
1858
|
+
config_path.unlink(missing_ok=True)
|
|
1859
|
+
except Exception:
|
|
1860
|
+
pass
|
|
1861
|
+
config_path = build_patched_config(
|
|
1862
|
+
work_dir, current_model, current_provider
|
|
1863
|
+
)
|
|
1864
|
+
runtime = _make_runtime(config_path)
|
|
1865
|
+
worker = AgentWorker(runtime)
|
|
1866
|
+
console.print(
|
|
1867
|
+
f" [finish]✓ model → {current_model}[/finish]"
|
|
1868
|
+
f" [muted]({current_provider})[/muted]"
|
|
1869
|
+
)
|
|
1870
|
+
console.print()
|
|
1871
|
+
|
|
1872
|
+
elif cmd == "/config":
|
|
1873
|
+
base_cfg = _find_base_config()
|
|
1874
|
+
show_config_editor(base_cfg)
|
|
1875
|
+
# Rebuild runtime with updated config
|
|
1876
|
+
try:
|
|
1877
|
+
config_path.unlink(missing_ok=True)
|
|
1878
|
+
except Exception:
|
|
1879
|
+
pass
|
|
1880
|
+
config_path = build_patched_config(work_dir, current_model, current_provider)
|
|
1881
|
+
runtime = _make_runtime(config_path)
|
|
1882
|
+
worker = AgentWorker(runtime)
|
|
1883
|
+
|
|
1884
|
+
elif cmd == "/sessions":
|
|
1885
|
+
chosen = show_sessions_picker()
|
|
1886
|
+
if chosen and chosen != session_id:
|
|
1887
|
+
session_id = chosen
|
|
1888
|
+
runtime = _make_runtime(config_path)
|
|
1889
|
+
worker = AgentWorker(runtime)
|
|
1890
|
+
console.print(
|
|
1891
|
+
f" [finish]✓ resumed session {session_id}[/finish]"
|
|
1892
|
+
)
|
|
1893
|
+
console.print()
|
|
1894
|
+
|
|
1895
|
+
elif cmd == "/session":
|
|
1896
|
+
console.print()
|
|
1897
|
+
try:
|
|
1898
|
+
meta = runtime.metadata()
|
|
1899
|
+
if isinstance(meta, dict):
|
|
1900
|
+
for k, v in meta.items():
|
|
1901
|
+
console.print(
|
|
1902
|
+
f" [status.key]{k:<18}[/status.key] [muted]{v}[/muted]"
|
|
1903
|
+
)
|
|
1904
|
+
except Exception:
|
|
1905
|
+
pass
|
|
1906
|
+
console.print(f" [status.key]{'session_id':<18}[/status.key] [status.val]{session_id}[/status.val]")
|
|
1907
|
+
console.print(f" [status.key]{'model':<18}[/status.key] [status.val]{current_model}[/status.val]")
|
|
1908
|
+
console.print(f" [status.key]{'provider':<18}[/status.key] [status.val]{current_provider}[/status.val]")
|
|
1909
|
+
console.print(f" [status.key]{'work_dir':<18}[/status.key] [muted]{work_dir}[/muted]")
|
|
1910
|
+
console.print()
|
|
1911
|
+
|
|
1912
|
+
elif cmd == "/status":
|
|
1913
|
+
show_status(
|
|
1914
|
+
runtime, session_id, current_model,
|
|
1915
|
+
current_provider, work_dir,
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
elif cmd == "/context":
|
|
1919
|
+
show_context(runtime, current_model)
|
|
1920
|
+
|
|
1921
|
+
elif cmd == "/export":
|
|
1922
|
+
show_export(
|
|
1923
|
+
runtime, session_id, current_model,
|
|
1924
|
+
current_provider, work_dir,
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
elif cmd == "/stat":
|
|
1928
|
+
show_stat(current_model)
|
|
1929
|
+
|
|
1930
|
+
elif cmd == "/reset":
|
|
1931
|
+
runtime.reset()
|
|
1932
|
+
# Also wipe accumulated session stats
|
|
1933
|
+
_session_stats["calls"] = 0
|
|
1934
|
+
_session_stats["est_input_tokens"] = 0
|
|
1935
|
+
_session_stats["est_output_tokens"] = 0
|
|
1936
|
+
_session_stats["_last_msg_count"] = 0
|
|
1937
|
+
_session_stats["_last_msg_chars"] = 0
|
|
1938
|
+
console.print(" [muted]✓ session cleared[/muted]")
|
|
1939
|
+
console.print()
|
|
1940
|
+
|
|
1941
|
+
elif cmd == "/exit":
|
|
1942
|
+
console.print(" [muted]Goodbye.[/muted]")
|
|
1943
|
+
return
|
|
1944
|
+
|
|
1945
|
+
elif cmd in {"/bash", "/shell"}:
|
|
1946
|
+
console.print()
|
|
1947
|
+
console.print(" [brand]⚡ Entering interactive bash shell. Type 'exit' to return to CodePilot.[/brand]")
|
|
1948
|
+
console.print()
|
|
1949
|
+
try:
|
|
1950
|
+
subprocess.run(["/bin/bash"])
|
|
1951
|
+
except Exception as exc:
|
|
1952
|
+
console.print(f" [error]Failed to start shell: {exc}[/error]")
|
|
1953
|
+
console.print()
|
|
1954
|
+
console.print(" [brand]✓ Returned to CodePilot[/brand]")
|
|
1955
|
+
console.print()
|
|
1956
|
+
|
|
1957
|
+
else:
|
|
1958
|
+
console.print(
|
|
1959
|
+
f" [error]Unknown command: {cmd}[/error] [muted]/help for list[/muted]"
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
continue
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
# ── Run task ──────────────────────────────────────────────────
|
|
1966
|
+
console.print()
|
|
1967
|
+
console.print(Rule(style="dim #2a2a2a")) # separator: prompt → agent
|
|
1968
|
+
console.print()
|
|
1969
|
+
spinner.start()
|
|
1970
|
+
|
|
1971
|
+
error = worker.run_task(task)
|
|
1972
|
+
spinner.stop()
|
|
1973
|
+
|
|
1974
|
+
if error is not None:
|
|
1975
|
+
console.print(f" [error]✗ {error}[/error]")
|
|
1976
|
+
console.print()
|
|
1977
|
+
console.print(Rule(style="dim #2a2a2a"))
|
|
1978
|
+
model_name = _cli_state.get("model", "")
|
|
1979
|
+
model_part = f"[dim #555555]◉[/dim #555555] [muted]{model_name}[/muted]" if model_name else ""
|
|
1980
|
+
await_part = "[bold #3d7a3d]●[/bold #3d7a3d] [dim #4a7c4a]awaiting task...[/dim #4a7c4a]"
|
|
1981
|
+
spacer = " " if model_name else ""
|
|
1982
|
+
console.print(f" {model_part}{spacer}{await_part}")
|
|
1983
|
+
console.print()
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
finally:
|
|
1987
|
+
spinner.stop()
|
|
1988
|
+
try:
|
|
1989
|
+
config_path.unlink(missing_ok=True)
|
|
1990
|
+
except Exception:
|
|
1991
|
+
pass
|