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/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