hubble-cli 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hubble/repl.py ADDED
@@ -0,0 +1,976 @@
1
+ """Interactive REPL: prompt_toolkit input, slash commands, @file mentions, # memory notes."""
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Callable, Dict, List, Optional, Tuple
11
+
12
+ from prompt_toolkit import PromptSession
13
+ from prompt_toolkit.completion import Completer, Completion
14
+ from prompt_toolkit.filters import completion_is_selected
15
+ from prompt_toolkit.formatted_text import HTML
16
+ from prompt_toolkit.history import FileHistory
17
+ from prompt_toolkit.key_binding import KeyBindings
18
+ from rich.markup import escape
19
+ from rich.table import Table
20
+
21
+ from hubble import __version__
22
+ from hubble.agent import Agent
23
+ from hubble.models import resolve_model
24
+ from hubble.provider import OpenAICompatProvider
25
+ from hubble.providers import (DEFAULT_PROVIDER, ProviderConfig, normalize_provider_name, provider_models,
26
+ remove_provider, resolve_fallback, save_listing, save_provider, scan_file)
27
+ from hubble.permissions import MODE_HELP, MODES
28
+ from hubble.prompts import INIT_PROMPT, PERSONAS
29
+ from hubble.scanner import ModelScanner, scan_age_hours
30
+ from hubble.session import SessionStore
31
+ from hubble.settings import HOME_DIR, save_user_setting
32
+ from hubble.skills import Skill, TEMPLATE, discover_skills
33
+ from hubble.tools import is_secret_path, truncate
34
+ from hubble.ui import console, pick, render_todos
35
+
36
+ MENTION_RX = re.compile(r"(?<![\w@])@([\w./\\:-]+)")
37
+ MAX_MENTION_CHARS = 60000
38
+
39
+
40
+ class Command:
41
+ def __init__(self, name: str, fn: Callable, help: str, aliases: Tuple[str, ...] = (), args: str = ""):
42
+ self.name, self.fn, self.help, self.aliases, self.args = name, fn, help, aliases, args
43
+
44
+
45
+ class ReplCompleter(Completer):
46
+ def __init__(self, repl: "Repl"):
47
+ self.repl = repl
48
+
49
+ def get_completions(self, document, complete_event):
50
+ text = document.text_before_cursor
51
+ m = re.match(r"^/model\s+(\S*)$", text)
52
+ if m:
53
+ typed = m.group(1).lower()
54
+ current = (self.repl.agent.provider_name, self.repl.agent.model)
55
+ for value, label, meta in self.repl.model_items():
56
+ if typed in label.lower():
57
+ mark = " (current)" if value == current else ""
58
+ yield Completion(label, start_position=-len(m.group(1)), display_meta=meta.strip() + mark)
59
+ return
60
+ if text.startswith("/") and " " not in text:
61
+ typed = text[1:].lower()
62
+ for cmd in self.repl.commands.values():
63
+ names = [cmd.name, *cmd.aliases]
64
+ hit = next((n for n in names if n.startswith(typed)), None)
65
+ if hit is None:
66
+ continue
67
+ shown = f"/{cmd.name} {cmd.args}".rstrip()
68
+ yield Completion("/" + (cmd.name if hit == cmd.name or not typed else hit),
69
+ start_position=-len(text), display=shown, display_meta=cmd.help)
70
+ return
71
+ m = re.search(r"@([\w./\\-]*)$", text)
72
+ if m:
73
+ partial = m.group(1).replace("\\", "/")
74
+ base_dir = self.repl.agent.ctx.root / os.path.dirname(partial)
75
+ prefix = os.path.basename(partial)
76
+ try:
77
+ entries = sorted(base_dir.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
78
+ except OSError:
79
+ return
80
+ for e in entries[:200]:
81
+ if e.name.startswith(prefix) and e.name not in (".git", "__pycache__", "node_modules"):
82
+ rel = (Path(os.path.dirname(partial)) / e.name).as_posix().removeprefix("./")
83
+ yield Completion(rel + ("/" if e.is_dir() else ""), start_position=-len(partial))
84
+
85
+
86
+ class Repl:
87
+ def __init__(self, agent: Agent, store: SessionStore, providers: Optional[Dict[str, ProviderConfig]] = None):
88
+ self.providers: Dict[str, ProviderConfig] = dict(providers or {})
89
+ self.clients: Dict[str, OpenAICompatProvider] = {agent.provider_name: agent.provider}
90
+ self.scanners: Dict[str, ModelScanner] = {}
91
+ self._unannounced: set = set()
92
+ self.turn_stats = None
93
+ self.agent = agent
94
+ agent.fallback_resolver = self.fallback_for
95
+ self.store = store
96
+ self.last_model_list: List[str] = []
97
+ self.commands: Dict[str, Command] = {}
98
+ self._register_builtin()
99
+ self._load_custom_commands()
100
+
101
+ # ----- setup ---------------------------------------------------------
102
+
103
+ def _register(self, name, fn, help, aliases=(), args=""):
104
+ self.commands[name] = Command(name, fn, help, aliases, args)
105
+
106
+ def command_names(self) -> Dict[str, Command]:
107
+ names = {}
108
+ for cmd in self.commands.values():
109
+ names[cmd.name] = cmd
110
+ for a in cmd.aliases:
111
+ names[a] = cmd
112
+ return names
113
+
114
+ def _register_builtin(self):
115
+ r = self._register
116
+ r("help", self.cmd_help, "Show commands and shortcuts", ("h", "?"))
117
+ r("home", lambda arg: self.banner(), "Show the animated home screen again", ("intro", "banner"))
118
+ r("exit", self.cmd_exit, "Quit", ("quit", "q"))
119
+ r("clear", self.cmd_clear, "Start a new conversation (new session)", ("new", "reset"))
120
+ r("compact", self.cmd_compact, "Summarize history to free context", args="[focus]")
121
+ r("model", self.cmd_model, "Show or switch model (name, fragment or # from /models)", args="[name|#]")
122
+ r("models", self.cmd_models, "List models; '/models refresh' re-checks which are available",
123
+ args="[filter|refresh]")
124
+ r("provider", self.cmd_provider, "Switch provider, or add/remove one (base URL + API key)",
125
+ ("providers",), "[add|remove <name>|list]")
126
+ r("mode", self.cmd_mode, "Permission mode: default, accept-edits, plan, yolo (Shift+Tab cycles)",
127
+ ("permissions",), "[mode]")
128
+ r("persona", self.cmd_persona, f"System persona: {', '.join(PERSONAS)}", args="[name]")
129
+ r("resume", self.cmd_resume, "Resume a previous session of this project", ("sessions",), "[id|#]")
130
+ r("cost", self.cmd_cost, "Token usage and context size", ("stats", "tokens", "status"))
131
+ r("context", self.cmd_context, "Show what is in the context window")
132
+ r("undo", self.cmd_undo, "Revert file changes from the last turn that edited files", ("rewind",))
133
+ r("diff", self.cmd_diff, "Show git diff of the workspace")
134
+ r("init", self.cmd_init, "Generate HUBBLE.md project instructions")
135
+ r("memory", self.cmd_memory, "Show loaded memory files (HUBBLE.md / AGENTS.md)")
136
+ r("add", self.cmd_add, "Pin a file into the system prompt", ("pin",), "<file>")
137
+ r("drop", self.cmd_drop, "Unpin a file", ("unpin",), "<file>")
138
+ r("files", self.cmd_files, "List pinned files")
139
+ r("todos", self.cmd_todos, "Show the current task list")
140
+ r("test", self.cmd_test, "Check a model responds and measure latency", args="[model]")
141
+ r("temp", self.cmd_temp, "Show or set temperature", args="[0-2]")
142
+ r("fallback", self.cmd_fallback, "Choose the model used when the current one is rate limited",
143
+ args="[model|auto|off]")
144
+ r("thinking", self.cmd_thinking, "Show or hide the model's reasoning text", args="[on|off]")
145
+ r("sandbox", self.cmd_sandbox, "Run shell commands in an isolated Docker container instead of "
146
+ "directly on this machine", args="[on|off]")
147
+ r("config", self.cmd_config, "Show effective settings and allow/deny rules")
148
+ r("export", self.cmd_export, "Export conversation to markdown", ("copy",), "[file]")
149
+ r("skills", self.cmd_skills, "List skills (the model can also call these on its own)")
150
+ r("skill", self.cmd_skill_new, "Create a new skill template to fill in", args="new <name> [user]")
151
+
152
+ def _load_custom_commands(self):
153
+ """Skills become /<name>; a skill's own description is shown in /help and /<tab>."""
154
+ for skill in self.agent.skills:
155
+ self._register(skill.name, self._make_skill_runner(skill),
156
+ f"(skill, {skill.scope}) {skill.description}", args=skill.args_hint or "[args]")
157
+ # Back-compat: a plain command with no frontmatter, e.g. from an older .hubble/commands/ setup.
158
+ for folder in (HOME_DIR / "commands", self.agent.ctx.root / ".hubble" / "commands"):
159
+ if not folder.is_dir():
160
+ continue
161
+ for f in sorted(folder.glob("*.md")):
162
+ name = f.stem.lower()
163
+ if name in self.commands:
164
+ continue
165
+ try:
166
+ body = f.read_text(encoding="utf-8")
167
+ except OSError:
168
+ continue
169
+ first = next((l.strip() for l in body.splitlines() if l.strip()), "")[:60]
170
+
171
+ def run(arg, body=body):
172
+ prompt = body.replace("$ARGUMENTS", arg) if "$ARGUMENTS" in body else \
173
+ (body + (f"\n\n{arg}" if arg else ""))
174
+ self.send(prompt)
175
+ self._register(name, run, f"(custom) {first}", args="[args]")
176
+
177
+ def _make_skill_runner(self, skill: Skill):
178
+ def run(arg):
179
+ body = skill.body()
180
+ prompt = body.replace("$ARGUMENTS", arg) if "$ARGUMENTS" in body else \
181
+ (body + (f"\n\n{arg}" if arg else ""))
182
+ self.send(prompt)
183
+ return run
184
+
185
+ # ----- main loop -----------------------------------------------------
186
+
187
+ def _prompt_session(self) -> PromptSession:
188
+ kb = KeyBindings()
189
+
190
+ @kb.add("escape", "enter")
191
+ def _(event):
192
+ event.current_buffer.insert_text("\n")
193
+
194
+ @kb.add("c-j")
195
+ def _(event):
196
+ event.current_buffer.insert_text("\n")
197
+
198
+ @kb.add("s-tab")
199
+ def _(event):
200
+ self.agent.permissions.cycle_mode()
201
+ event.app.invalidate()
202
+
203
+ @kb.add("enter", filter=completion_is_selected)
204
+ def _(event):
205
+ # A highlighted item in the dropdown (e.g. /model <space>, @file): one Enter both
206
+ # picks it and submits, instead of submitting the still-unfinished typed text.
207
+ buf = event.current_buffer
208
+ buf.apply_completion(buf.complete_state.current_completion)
209
+ buf.validate_and_handle()
210
+
211
+ HOME_DIR.mkdir(parents=True, exist_ok=True)
212
+ return PromptSession(history=FileHistory(str(HOME_DIR / "history")), completer=ReplCompleter(self),
213
+ complete_while_typing=True, key_bindings=kb, bottom_toolbar=self._toolbar,
214
+ multiline=False, enable_history_search=False, refresh_interval=1.0,
215
+ reserve_space_for_menu=14)
216
+
217
+ def _toolbar(self):
218
+ a = self.agent
219
+ mode = a.permissions.mode
220
+ color = {"default": "ansigray", "accept-edits": "ansigreen", "plan": "ansiblue", "yolo": "ansired"}[mode]
221
+ ctx_pct = f"{a.context_ratio():.0%}"
222
+ pinned = f" | {len(a.pinned)} pinned" if a.pinned else ""
223
+ prov = f"{escape_html(a.provider_name)}: " if len(self.providers) > 1 else ""
224
+ line1 = (f" {prov}<b>{escape_html(a.model)}</b> | mode: <style fg='{color}'><b>{mode}</b></style>"
225
+ f" (shift+tab) | persona: {a.persona} | context {ctx_pct}{pinned}"
226
+ + "".join(f" | {escape_html(n)}: {s.summary()}" for n, s in self.scanners.items() if s.running)
227
+ + " ")
228
+ return HTML(line1 + "\n" + self._stats_line())
229
+
230
+ def _stats_line(self) -> str:
231
+ a = self.agent
232
+ s = self.turn_stats
233
+ session_total = a.total_prompt_tokens + a.total_completion_tokens
234
+ if s is None or not s.model_calls:
235
+ return f" Tokens: no turns yet • session total {session_total:,} "
236
+ total = s.prompt_tokens + s.completion_tokens
237
+ speed = s.completion_tokens / s.duration if s.duration else 0.0
238
+ tools = f" | 🔧 {s.tool_calls} tool call{'s' if s.tool_calls != 1 else ''}" if s.tool_calls else ""
239
+ return (f" Tokens: <b>📥 In: {s.prompt_tokens:,}</b> | <b>📤 Out: {s.completion_tokens:,}</b>"
240
+ f" | <b>📊 Total: {total:,}</b> • Time: {s.duration:.2f}s | 🚀 {speed:.1f} tok/s{tools}"
241
+ f" • session {session_total:,} ")
242
+
243
+ def _home_info(self):
244
+ a = self.agent
245
+ known = [m for m in provider_models(a.provider_name) if m.get("available") is not False]
246
+ return dict(version=__version__, provider=a.provider_name, model=a.model, mode=a.permissions.mode,
247
+ root=str(a.ctx.root), session_id=a.session.id if a.session else None,
248
+ memory_files=[p.name for p, _ in a.memory], model_count=len(known) or None,
249
+ provider_count=len(self.providers), resumable=max(0, len(self.store.list()) - 1),
250
+ show_provider=len(self.providers) > 1)
251
+
252
+ def _live_home_ok(self) -> bool:
253
+ return (bool(self.agent.settings.get("home_animation", True)) and sys.stdin.isatty()
254
+ and sys.stdout.isatty() and console.is_terminal and not console.legacy_windows)
255
+
256
+ def banner(self):
257
+ """Show the home screen: animated inside the next prompt when possible, else printed once."""
258
+ if self._live_home_ok():
259
+ from hubble.banner import home_info_lines
260
+ self._home = home_info_lines(**self._home_info())
261
+ self._home_t0 = time.time()
262
+ else:
263
+ from hubble.banner import render_home
264
+ render_home(console, **self._home_info())
265
+
266
+ def _home_message(self):
267
+ """Prompt message for the first prompt: the whole home screen, re-rendered every frame."""
268
+ from prompt_toolkit.application import get_app
269
+ from prompt_toolkit.formatted_text import FormattedText
270
+ from hubble.banner import compose_home_fragments
271
+ try:
272
+ app = get_app()
273
+ size = app.output.get_size()
274
+ typed = app.current_buffer.text
275
+ except Exception:
276
+ return HTML("<ansicyan><b>❯</b></ansicyan> ")
277
+ # Leave room for the prompt line and the 2-line toolbar, plus the completion menu while typing / or @.
278
+ budget = size.rows - 4 - (10 if typed[:1] in ("/", "@") or " @" in typed else 0)
279
+ # prompt_toolkit asks for the message several times per redraw (measure, then draw);
280
+ # build each frame once per 1/30 s tick and reuse it.
281
+ elapsed = time.time() - self._home_t0
282
+ key = (int(elapsed * 30), size.columns, budget)
283
+ cached = getattr(self, "_home_frame", None)
284
+ if cached and cached[0] == key:
285
+ return cached[1]
286
+ status, tips = self._home
287
+ frags = compose_home_fragments(size.columns, max(budget, 3), elapsed, status, tips, __version__)
288
+ frame = FormattedText(frags + [("bold fg:ansicyan", "❯"), ("", " ")])
289
+ self._home_frame = (key, frame)
290
+ return frame
291
+
292
+ def fallback_for(self, provider: str, model: str):
293
+ choice = resolve_fallback(self.agent.settings, self.providers, provider, model)
294
+ if not choice or choice[0] not in self.providers and choice[0] != self.agent.provider_name:
295
+ return None
296
+ return self.client(choice[0]), choice[1]
297
+
298
+ def cmd_fallback(self, arg):
299
+ prov = self.agent.provider_name
300
+ per = dict(self.agent.settings.get("fallback_models") or {})
301
+ current = per.get(prov) or "auto"
302
+ if arg:
303
+ choice = arg.strip()
304
+ elif sys.stdin.isatty():
305
+ auto = resolve_fallback({**self.agent.settings, "fallback_models": {}}, self.providers, prov, self.agent.model)
306
+ auto_meta = f"now: {auto[0]}: {auto[1]}" if auto else "nothing suitable found"
307
+ items = [("auto", "Automatic", auto_meta), ("off", "Off", "never switch models on errors")]
308
+ items += [(v[1], label, meta) for v, label, meta in self.model_items(only=prov) if v[1] != self.agent.model]
309
+ choice = pick(f"Fallback model for {prov}", items, current=current)
310
+ if not choice:
311
+ return
312
+ else:
313
+ console.print(f"Fallback for {escape(prov)}: {escape(current)}")
314
+ return
315
+ if choice == "auto":
316
+ per.pop(prov, None)
317
+ else:
318
+ per[prov] = choice
319
+ self.agent.settings["fallback_models"] = per
320
+ save_user_setting("fallback_models", per)
321
+ console.print(f"[green]Fallback for {escape(prov)}: {escape(choice)}[/green]")
322
+
323
+ def client(self, name: str) -> OpenAICompatProvider:
324
+ if name not in self.clients:
325
+ cfg = self.providers[name]
326
+ self.clients[name] = OpenAICompatProvider(cfg.base_url, cfg.api_key)
327
+ return self.clients[name]
328
+
329
+ def start_scan(self, name: str, reason: str) -> bool:
330
+ cfg = self.providers.get(name)
331
+ if not cfg:
332
+ return False
333
+ scanner = self.scanners.get(name)
334
+ if scanner is None:
335
+ scanner = self.scanners[name] = ModelScanner(cfg.base_url, cfg.api_key, output=scan_file(name))
336
+ if not scanner.start():
337
+ return False
338
+ self._unannounced.add(name)
339
+ console.print(f"[dim] {reason} Checking which {escape(name)} models are available in the background "
340
+ "(progress in the bottom bar)...[/dim]")
341
+ return True
342
+
343
+ def _announce_scans(self):
344
+ for name in list(self._unannounced):
345
+ scanner = self.scanners.get(name)
346
+ if not scanner or scanner.running:
347
+ continue
348
+ self._unannounced.discard(name)
349
+ if scanner.status == "done":
350
+ console.print(f"[green]{escape(name)}: {scanner.working} of {scanner.total} models are "
351
+ "available.[/green] [dim]/model to see them[/dim]")
352
+ elif scanner.status == "failed":
353
+ console.print(f"[yellow]{escape(name)}: model check failed ({escape(scanner.error)}); "
354
+ "keeping the previous list.[/yellow]")
355
+
356
+ def _refresh_stale_scans(self):
357
+ raw = self.agent.settings.get("model_refresh_hours", 0)
358
+ if raw is None: # explicitly disabled ("model_refresh_hours": null)
359
+ return
360
+ hours = float(raw) # 0 (the default): check every startup; >0: only once that stale
361
+ for name, cfg in self.providers.items():
362
+ if not cfg.check_models:
363
+ continue
364
+ age = scan_age_hours(scan_file(name))
365
+ if age is None or hours <= 0 or age >= hours:
366
+ reason = "Model list is missing." if age is None else (
367
+ "Checking on startup." if hours <= 0 else f"Model list is {age:.0f}h old.")
368
+ self.start_scan(name, reason)
369
+
370
+ def run(self, initial_prompt: Optional[str] = None):
371
+ self._home = None
372
+ self.banner()
373
+ self._refresh_stale_scans()
374
+ session = self._prompt_session()
375
+ if initial_prompt:
376
+ self._home = None
377
+ self.handle(initial_prompt)
378
+ while True:
379
+ try:
380
+ self._announce_scans()
381
+ if self._home is not None:
382
+ # Animated home screen lives in the prompt until the first message is sent;
383
+ # the last frame stays in the scrollback.
384
+ text = session.prompt(self._home_message, refresh_interval=1 / 30, reserve_space_for_menu=0)
385
+ self._home = None
386
+ else:
387
+ text = session.prompt(HTML("<ansicyan><b>❯</b></ansicyan> "))
388
+ except KeyboardInterrupt:
389
+ continue
390
+ except EOFError:
391
+ break
392
+ try:
393
+ if self.handle(text) is False:
394
+ break
395
+ except KeyboardInterrupt:
396
+ console.print("[yellow]Interrupted.[/yellow]")
397
+ console.print("[dim]Bye.[/dim]")
398
+
399
+ def handle(self, text: str):
400
+ text = text.strip()
401
+ if not text:
402
+ return None
403
+ if text.startswith("!"):
404
+ self.run_shell(text[1:].strip())
405
+ return None
406
+ if text.startswith("#") and not text.startswith("##"):
407
+ self.remember(text[1:].strip())
408
+ return None
409
+ if text.startswith("/") and not text.startswith("//"):
410
+ name, _, arg = text[1:].partition(" ")
411
+ cmd = self.command_names().get(name.lower())
412
+ if not cmd:
413
+ console.print(f"[yellow]Unknown command /{escape(name)}. Try /help.[/yellow]")
414
+ return None
415
+ return cmd.fn(arg.strip())
416
+ self.send(text)
417
+ return None
418
+
419
+ def send(self, text: str):
420
+ prompt = self.expand_mentions(text)
421
+ self.agent.run(prompt)
422
+ if self.agent.last_stats.model_calls:
423
+ self.turn_stats = self.agent.last_stats # shown in the bar under the input box
424
+
425
+ def expand_mentions(self, text: str) -> str:
426
+ attachments = []
427
+ for m in MENTION_RX.finditer(text):
428
+ raw = m.group(1).rstrip(".,:;")
429
+ try:
430
+ p = self.agent.ctx.resolve(raw)
431
+ except Exception:
432
+ continue
433
+ if is_secret_path(p) and not self.agent.ctx.allow_secrets:
434
+ console.print(f"[yellow]Skipped @{escape(raw)}: looks like a secrets file.[/yellow]")
435
+ continue
436
+ if p.is_file():
437
+ try:
438
+ content = p.read_text(encoding="utf-8", errors="replace")
439
+ except OSError:
440
+ continue
441
+ self.agent.ctx.read_mtimes[str(p)] = p.stat().st_mtime
442
+ attachments.append(f'<file path="{self.agent.ctx.rel(p)}">\n{truncate(content, MAX_MENTION_CHARS)}\n</file>')
443
+ console.print(f"[dim] attached {escape(self.agent.ctx.rel(p))} ({len(content.splitlines())} lines)[/dim]")
444
+ elif p.is_dir():
445
+ listing = "\n".join(sorted(e.name + ("/" if e.is_dir() else "") for e in p.iterdir())[:200])
446
+ attachments.append(f'<directory path="{self.agent.ctx.rel(p)}">\n{listing}\n</directory>')
447
+ if not attachments:
448
+ return text
449
+ return text + "\n\n" + "\n\n".join(attachments)
450
+
451
+ def run_shell(self, cmd: str):
452
+ if not cmd:
453
+ return
454
+ argv = list(self.agent.ctx.shell_argv) + [cmd]
455
+ try:
456
+ subprocess.run(argv, cwd=self.agent.ctx.root)
457
+ except (OSError, KeyboardInterrupt) as e:
458
+ console.print(f"[red]{escape(str(e))}[/red]")
459
+
460
+ def remember(self, note: str):
461
+ if not note:
462
+ return
463
+ path = self.agent.ctx.root / "HUBBLE.md"
464
+ existing = path.read_text(encoding="utf-8") if path.exists() else "# Project notes\n"
465
+ with open(path, "w", encoding="utf-8", newline="") as f:
466
+ f.write(existing.rstrip("\n") + f"\n- {note}\n")
467
+ self.agent.reload_memory()
468
+ console.print(f"[green]Saved to {escape(str(path))}[/green]")
469
+
470
+ # ----- commands ------------------------------------------------------
471
+
472
+ def cmd_help(self, arg):
473
+ table = Table(show_header=False, box=None, padding=(0, 2))
474
+ for cmd in self.commands.values():
475
+ alias = f" [dim](/{', /'.join(cmd.aliases)})[/dim]" if cmd.aliases else ""
476
+ table.add_row(f"[bold]/{cmd.name}[/bold] [dim]{escape(cmd.args)}[/dim]", escape(cmd.help) + alias)
477
+ console.print(table)
478
+ console.print("\n[bold]Input[/bold]\n"
479
+ " @path attach a file or directory listing to your message\n"
480
+ " !command run a shell command yourself (not sent to the model)\n"
481
+ " #note append a note to ./HUBBLE.md (project memory)\n"
482
+ " Esc+Enter new line (also Ctrl+J)\n"
483
+ " Shift+Tab cycle permission mode\n"
484
+ " Ctrl+C cancel the running turn · Ctrl+D quit\n"
485
+ "\n[bold]Skills[/bold]: .hubble/skills/<name>/SKILL.md (or ~/.hubble/skills for every "
486
+ "project) becomes /<name>, and the model can call it itself when its description "
487
+ "matches the task. /skill new <name> creates one; /skills lists them. "
488
+ "$ARGUMENTS is replaced with what you type after the command.")
489
+
490
+ def cmd_exit(self, arg):
491
+ return False
492
+
493
+ def cmd_clear(self, arg):
494
+ self.agent.clear()
495
+ if self.agent.session:
496
+ self.agent.session = self.store.new(self.agent.model)
497
+ console.print(f"[green]New conversation.[/green] "
498
+ f"[dim]session {self.agent.session.id if self.agent.session else '(not saved)'}[/dim]")
499
+
500
+ def cmd_compact(self, arg):
501
+ with console.status("[dim]Compacting...[/dim]"):
502
+ try:
503
+ ok = self.agent.compact(focus=arg)
504
+ except Exception as e:
505
+ console.print(f"[red]Compaction failed: {escape(str(e))}[/red]")
506
+ return
507
+ if not ok:
508
+ console.print("[dim]Nothing to compact.[/dim]")
509
+
510
+ def cmd_model(self, arg):
511
+ if not arg:
512
+ if not sys.stdin.isatty():
513
+ self.cmd_models("")
514
+ return
515
+ self.choose_model()
516
+ return
517
+ if arg.isdigit() and self.last_model_list:
518
+ idx = int(arg) - 1
519
+ if 0 <= idx < len(self.last_model_list):
520
+ return self._set_model(self.last_model_list[idx])
521
+ console.print(f"[red]Pick 1-{len(self.last_model_list)}[/red]")
522
+ return
523
+ pairs = [v for v, _, _ in self.model_items()]
524
+ names = list(dict.fromkeys(m for _, m in pairs))
525
+ matches = resolve_model(arg, names)
526
+ if len(matches) == 1:
527
+ owners = [p for p, m in pairs if m == matches[0]]
528
+ prov = self.agent.provider_name if self.agent.provider_name in owners else owners[0]
529
+ return self._set_model(matches[0], prov)
530
+ if len(matches) > 1:
531
+ self.last_model_list = matches
532
+ for i, m in enumerate(matches, 1):
533
+ console.print(f" {i:>2}. {escape(m)}")
534
+ console.print("[dim]Several match; use /model <#>.[/dim]")
535
+ return
536
+ self._set_model(arg)
537
+ console.print("[dim](not in the registry; using it as given)[/dim]")
538
+
539
+ def model_items(self, only: Optional[str] = None):
540
+ """Picker rows across providers: value (provider, model), label, meta."""
541
+ names = [only] if only else [self.agent.provider_name] + [n for n in self.providers
542
+ if n != self.agent.provider_name]
543
+ multi = len(self.providers) > 1
544
+ items = []
545
+ for pname in names:
546
+ for m in provider_models(pname):
547
+ if m.get("available") is False:
548
+ meta = f"unavailable · {m['category']}"
549
+ elif m.get("available") is None and pname != DEFAULT_PROVIDER:
550
+ meta = "not checked"
551
+ else:
552
+ lat = f"{m['latency_ms']} ms" if m.get("latency_ms") else "-"
553
+ meta = f"{lat:>8} · {m['category']}"
554
+ items.append(((pname, m["model"]), m["model"], (f"{pname} · " if multi else "") + meta))
555
+ return items
556
+
557
+ def choose_model(self, only: Optional[str] = None) -> bool:
558
+ running = [f"{n}: {s.summary()}" for n, s in self.scanners.items() if s.running and (not only or n == only)]
559
+ if running:
560
+ console.print(f"[dim]{'; '.join(running)}. Showing what is known so far.[/dim]")
561
+ items = self.model_items(only)
562
+ if not items:
563
+ console.print("[yellow]No models known for this provider yet.[/yellow]")
564
+ return False
565
+ title = f"Select a model ({only})" if only else "Select a model"
566
+ chosen = pick(title, items, current=(self.agent.provider_name, self.agent.model))
567
+ if not chosen or chosen == (self.agent.provider_name, self.agent.model):
568
+ console.print(f"[dim]Model unchanged: {escape(self.agent.model)}[/dim]")
569
+ return False
570
+ self._set_model(chosen[1], chosen[0])
571
+ return True
572
+
573
+ def _set_model(self, name: str, provider: Optional[str] = None):
574
+ provider = provider or self.agent.provider_name
575
+ if provider != self.agent.provider_name:
576
+ self.agent.provider = self.client(provider)
577
+ self.agent.provider_name = provider
578
+ self.agent.model = name
579
+ self.agent.settings["model"] = name
580
+ if self.agent.session:
581
+ self.agent.session.meta(model=name, provider=provider)
582
+ try:
583
+ save_user_setting("model", name)
584
+ save_user_setting("provider", provider)
585
+ saved = " (saved as default)"
586
+ except OSError:
587
+ saved = ""
588
+ prov = f"{escape(provider)}: " if len(self.providers) > 1 else ""
589
+ console.print(f"[green]Model: {prov}{escape(name)}{saved}[/green]")
590
+
591
+ def cmd_models(self, arg):
592
+ name = self.agent.provider_name
593
+ scanner = self.scanners.get(name)
594
+ if arg.lower() in ("refresh", "scan", "update"):
595
+ if scanner and scanner.running:
596
+ console.print(f"[dim]Already running: {scanner.summary()}[/dim]")
597
+ elif not self.start_scan(name, "Refreshing."):
598
+ console.print("[dim]Cannot check this provider.[/dim]")
599
+ return
600
+ age = scan_age_hours(scan_file(name))
601
+ if scanner and scanner.running:
602
+ console.print(f"[dim]{scanner.summary()}; the list below is from the previous check.[/dim]")
603
+ elif age is not None:
604
+ console.print(f"[dim]{escape(name)}: availability checked {age:.1f}h ago. "
605
+ "'/models refresh' re-checks now.[/dim]")
606
+ models = [m for m in provider_models(name) if not arg or arg.lower() in m["model"].lower()]
607
+ self.last_model_list = [m["model"] for m in models]
608
+ table = Table(box=None, padding=(0, 2))
609
+ table.add_column("#", justify="right", style="dim")
610
+ table.add_column("model", overflow="fold")
611
+ table.add_column("category", style="dim")
612
+ table.add_column("latency", justify="right", style="dim")
613
+ for i, m in enumerate(models, 1):
614
+ label = f"[bold green]{escape(m['model'])}[/bold green]" if m["model"] == self.agent.model else escape(m["model"])
615
+ if m.get("available") is False:
616
+ lat = "[red]unavailable[/red]"
617
+ label = f"[dim]{label}[/dim]"
618
+ else:
619
+ lat = f"{m['latency_ms']} ms" if m.get("latency_ms") else "-"
620
+ table.add_row(str(i), label, m["category"], lat)
621
+ console.print(table)
622
+ console.print("[dim]Switch with /model <#>. Re-check availability: /models refresh[/dim]")
623
+
624
+ def cmd_provider(self, arg):
625
+ sub, _, rest = arg.partition(" ")
626
+ sub = sub.lower()
627
+ if sub == "add":
628
+ return self.add_provider()
629
+ if sub in ("remove", "rm", "delete"):
630
+ return self.remove_provider(rest.strip())
631
+ if sub == "list" or not sys.stdin.isatty():
632
+ for n, cfg in self.providers.items():
633
+ mark = "[bold green]●[/bold green]" if n == self.agent.provider_name else " "
634
+ console.print(f" {mark} [bold]{escape(n):<14}[/bold] [dim]{escape(cfg.base_url)}[/dim]")
635
+ console.print("[dim]/provider add · /provider remove <name> · /provider to switch[/dim]")
636
+ return
637
+ if sub and sub in self.providers:
638
+ return self.choose_model(only=sub)
639
+ items = [(n, n, cfg.base_url + (" (active)" if n == self.agent.provider_name else ""))
640
+ for n, cfg in self.providers.items()]
641
+ items.append(("__add__", "+ Add a provider", "base URL + API key"))
642
+ chosen = pick("Provider", items, current=self.agent.provider_name)
643
+ if chosen == "__add__":
644
+ self.add_provider()
645
+ elif chosen:
646
+ self.choose_model(only=chosen)
647
+
648
+ def add_provider(self):
649
+ from hubble.onboarding import add_provider_wizard, _confirm
650
+ result = add_provider_wizard(self.providers.keys())
651
+ if not result:
652
+ console.print("[dim]Cancelled.[/dim]")
653
+ return
654
+ cfg, ids = result
655
+ register_provider(cfg, ids)
656
+ self.providers[cfg.name] = cfg
657
+ console.print(f"[green]Saved provider {escape(cfg.name)}[/green] [dim](~/.hubble/providers.json)[/dim]")
658
+ if cfg.check_models:
659
+ self.start_scan(cfg.name, "New provider.")
660
+ if _confirm(f"Switch to {cfg.name} now?"):
661
+ self.choose_model(only=cfg.name)
662
+
663
+ def remove_provider(self, name: str):
664
+ if not name:
665
+ console.print("Usage: /provider remove <name>")
666
+ elif name == DEFAULT_PROVIDER:
667
+ console.print("[yellow]The built-in hubble provider comes from HUBBLE_API_KEY / .env; remove it there.[/yellow]")
668
+ elif name == self.agent.provider_name:
669
+ console.print("[yellow]Switch to another provider before removing the active one.[/yellow]")
670
+ elif remove_provider(name):
671
+ self.providers.pop(name, None)
672
+ self.clients.pop(name, None)
673
+ self.scanners.pop(name, None)
674
+ console.print(f"[green]Removed provider {escape(name)}[/green]")
675
+ else:
676
+ console.print(f"[red]No provider named {escape(name)}[/red]")
677
+
678
+ def cmd_mode(self, arg):
679
+ perms = self.agent.permissions
680
+ if not arg:
681
+ if not sys.stdin.isatty():
682
+ for m in MODES:
683
+ mark = "[bold green]●[/bold green]" if m == perms.mode else " "
684
+ console.print(f" {mark} [bold]{m:<13}[/bold] [dim]{MODE_HELP[m]}[/dim]")
685
+ return
686
+ arg = pick("Permission mode", [(m, m, MODE_HELP[m]) for m in MODES], current=perms.mode)
687
+ if not arg:
688
+ return
689
+ if arg not in MODES:
690
+ console.print(f"[red]Unknown mode. Choose: {', '.join(MODES)}[/red]")
691
+ return
692
+ perms.mode = arg
693
+ console.print(f"[green]Permission mode: {arg}[/green] [dim]({MODE_HELP[arg]})[/dim]")
694
+
695
+ def cmd_persona(self, arg):
696
+ if not arg and sys.stdin.isatty():
697
+ arg = pick("Persona", [(p, p, PERSONAS[p][:70]) for p in PERSONAS], current=self.agent.persona) or ""
698
+ if not arg:
699
+ return
700
+ if not arg:
701
+ console.print(f"Persona: {self.agent.persona}. Available: {', '.join(PERSONAS)}")
702
+ elif arg in PERSONAS:
703
+ self.agent.persona = arg
704
+ console.print(f"[green]Persona: {arg}[/green]")
705
+ else:
706
+ console.print(f"[red]Unknown persona. Available: {', '.join(PERSONAS)}[/red]")
707
+
708
+ def cmd_resume(self, arg):
709
+ items = self.store.list()
710
+ if not items:
711
+ console.print("[dim]No saved sessions for this project.[/dim]")
712
+ return
713
+ if not arg and sys.stdin.isatty():
714
+ arg = pick("Resume a session", session_items(items)) or ""
715
+ if not arg:
716
+ return
717
+ if not arg:
718
+ for i, it in enumerate(items, 1):
719
+ when = time.strftime("%m-%d %H:%M", time.localtime(it["updated"]))
720
+ console.print(f" {i:>2}. [dim]{when}[/dim] {escape(it['title'] or '(no prompt)')} "
721
+ f"[dim]{it['messages']} msgs · {escape(str(it['model']))} · {it['id']}[/dim]")
722
+ console.print("[dim]Resume with /resume <#|id>.[/dim]")
723
+ return
724
+ sid = items[int(arg) - 1]["id"] if arg.isdigit() and 0 < int(arg) <= len(items) else arg
725
+ try:
726
+ session, messages, meta = self.store.load(sid)
727
+ except FileNotFoundError as e:
728
+ console.print(f"[red]{escape(str(e))}[/red]")
729
+ return
730
+ self.agent.session = session
731
+ self.agent.load_history(messages)
732
+ prov = normalize_provider_name(meta.get("provider"))
733
+ if meta.get("model") and prov in self.providers:
734
+ if prov != self.agent.provider_name:
735
+ self.agent.provider = self.client(prov)
736
+ self.agent.provider_name = prov
737
+ self.agent.model = meta["model"]
738
+ console.print(f"[green]Resumed {session.id}[/green] [dim]({len(messages)} messages, model {escape(self.agent.model)})[/dim]")
739
+ print_history_tail(messages)
740
+
741
+ def cmd_cost(self, arg):
742
+ a = self.agent
743
+ window = int(a.settings.get("context_window", 128000))
744
+ console.print(f" model {escape(a.model)}")
745
+ console.print(f" input tokens {a.total_prompt_tokens:,}")
746
+ console.print(f" output tokens {a.total_completion_tokens:,}")
747
+ console.print(f" context ~{a.context_tokens:,} / {window:,} ({a.context_ratio():.0%})")
748
+ console.print(f" messages {len(a.messages)}")
749
+
750
+ def cmd_context(self, arg):
751
+ a = self.agent
752
+ from hubble.agent import estimate_messages, estimate_tokens
753
+ sys_tokens = estimate_tokens(a.system_prompt())
754
+ tool_tokens = estimate_tokens(str([t.schema() for t in a.tools]))
755
+ console.print(f" system prompt ~{sys_tokens:,} tokens "
756
+ f"(memory: {len(a.memory)} file(s), pinned: {len(a.pinned)})")
757
+ console.print(f" tool schemas ~{tool_tokens:,} tokens ({len(a.tools)} tools)")
758
+ console.print(f" messages ~{estimate_messages(a.messages):,} tokens ({len(a.messages)} messages)")
759
+
760
+ def cmd_undo(self, arg):
761
+ restored = self.agent.ctx.undo()
762
+ if not restored:
763
+ console.print("[dim]No file changes to undo.[/dim]")
764
+ return
765
+ for r in restored:
766
+ console.print(f" [yellow]reverted[/yellow] {escape(r)}")
767
+ note = "The user reverted your last file changes (/undo): " + ", ".join(restored)
768
+ self.agent.add_user_message(f"[{note}]")
769
+ self.agent._append({"role": "assistant", "content": "Noted; those changes were reverted."})
770
+
771
+ def cmd_diff(self, arg):
772
+ try:
773
+ res = subprocess.run(["git", "diff", "--stat"] + ([arg] if arg else []), cwd=self.agent.ctx.root,
774
+ capture_output=True, text=True, encoding="utf-8", errors="replace")
775
+ full = subprocess.run(["git", "diff"] + ([arg] if arg else []), cwd=self.agent.ctx.root,
776
+ capture_output=True, text=True, encoding="utf-8", errors="replace")
777
+ except OSError as e:
778
+ console.print(f"[red]{escape(str(e))}[/red]")
779
+ return
780
+ if res.returncode != 0:
781
+ console.print(f"[red]{escape(res.stderr.strip() or 'git diff failed')}[/red]")
782
+ return
783
+ if not full.stdout.strip():
784
+ console.print("[dim]No unstaged changes.[/dim]")
785
+ return
786
+ from hubble.ui import render_diff
787
+ console.print(render_diff(full.stdout, max_lines=400))
788
+ console.print(f"[dim]{escape(res.stdout.strip())}[/dim]")
789
+
790
+ def cmd_init(self, arg):
791
+ self.send(INIT_PROMPT + (f"\n\nExtra instructions: {arg}" if arg else ""))
792
+ self.agent.reload_memory()
793
+
794
+ def cmd_memory(self, arg):
795
+ if not self.agent.memory:
796
+ console.print("[dim]No memory files. Create one with /init or add notes with #note.[/dim]")
797
+ for p, text in self.agent.memory:
798
+ console.print(f" [bold]{escape(str(p))}[/bold] [dim]({len(text.splitlines())} lines)[/dim]")
799
+
800
+ def cmd_skills(self, arg):
801
+ if not self.agent.skills:
802
+ console.print("[dim]No skills yet. Ask the model to save one, or /skill new <name>.[/dim]")
803
+ return
804
+ for s in self.agent.skills:
805
+ console.print(f" [bold]/{s.name}[/bold] [dim]({s.scope})[/dim] {escape(s.description)}")
806
+ console.print("[dim]Run one with /<name>, or the model calls them on its own when relevant.[/dim]")
807
+
808
+ def cmd_skill_new(self, arg):
809
+ parts = arg.split()
810
+ if not parts or parts[0] != "new" or len(parts) < 2:
811
+ console.print("Usage: /skill new <name> [user] (default scope: this project)")
812
+ return
813
+ name, scope = parts[1].lower(), ("user" if len(parts) > 2 and parts[2] == "user" else "project")
814
+ if not re.match(r"^[a-z0-9][a-z0-9_-]{0,40}$", name):
815
+ console.print("[red]Name must be lowercase letters, digits, - or _ (max 41 chars).[/red]")
816
+ return
817
+ base = HOME_DIR / "skills" if scope == "user" else self.agent.ctx.root / ".hubble" / "skills"
818
+ path = base / name / "SKILL.md"
819
+ if path.exists():
820
+ console.print(f"[yellow]Already exists: {escape(str(path))}[/yellow]")
821
+ return
822
+ path.parent.mkdir(parents=True, exist_ok=True)
823
+ path.write_text(TEMPLATE.format(name=name, title=name.replace("-", " ").title()), encoding="utf-8")
824
+ self.agent.reload_skills()
825
+ self._load_custom_commands()
826
+ console.print(f"[green]Created {escape(str(path))}[/green] [dim]— fill in its description and "
827
+ f"instructions, then use it as /{name}.[/dim]")
828
+
829
+ def cmd_add(self, arg):
830
+ if not arg:
831
+ console.print("Usage: /add <file>")
832
+ return
833
+ try:
834
+ p = self.agent.ctx.resolve(arg)
835
+ except Exception as e:
836
+ console.print(f"[red]{escape(str(e))}[/red]")
837
+ return
838
+ if not p.is_file():
839
+ console.print(f"[red]Not a file: {escape(arg)}[/red]")
840
+ return
841
+ if is_secret_path(p) and not self.agent.ctx.allow_secrets:
842
+ console.print("[red]Refusing to pin a secrets file.[/red]")
843
+ return
844
+ rel = self.agent.ctx.rel(p)
845
+ self.agent.pinned[rel] = p.read_text(encoding="utf-8", errors="replace")
846
+ console.print(f"[green]Pinned {escape(rel)}[/green] [dim]({len(self.agent.pinned[rel]):,} chars)[/dim]")
847
+
848
+ def cmd_drop(self, arg):
849
+ keys = [k for k in self.agent.pinned if arg and arg.replace("\\", "/") in k]
850
+ if not keys:
851
+ console.print("[red]Not pinned.[/red]")
852
+ return
853
+ for k in keys:
854
+ del self.agent.pinned[k]
855
+ console.print(f"[green]Unpinned {escape(k)}[/green]")
856
+
857
+ def cmd_files(self, arg):
858
+ if not self.agent.pinned:
859
+ console.print("[dim]No pinned files. Use /add <file> or mention @file in a message.[/dim]")
860
+ for k, v in self.agent.pinned.items():
861
+ console.print(f" {escape(k)} [dim]({len(v.splitlines())} lines)[/dim]")
862
+
863
+ def cmd_todos(self, arg):
864
+ if not self.agent.ctx.todos:
865
+ console.print("[dim]No todos.[/dim]")
866
+ render_todos(self.agent.ctx.todos)
867
+
868
+ def cmd_test(self, arg):
869
+ model = arg or self.agent.model
870
+ with console.status(f"[dim]Testing {escape(model)}...[/dim]"):
871
+ res = self.agent.provider.ping(model)
872
+ if res["ok"]:
873
+ console.print(f"[green]✔ {escape(model)} OK[/green] [dim]{res['latency_ms']} ms · {escape(res['msg'])}[/dim]")
874
+ else:
875
+ console.print(f"[red]✘ {escape(model)} failed[/red] [dim]{res['latency_ms']} ms · {escape(res['msg'])}[/dim]")
876
+
877
+ def cmd_thinking(self, arg):
878
+ ev = self.agent.events
879
+ if arg.lower() in ("on", "off"):
880
+ ev.show_reasoning = arg.lower() == "on"
881
+ else:
882
+ ev.show_reasoning = not getattr(ev, "show_reasoning", False)
883
+ state = "shown" if ev.show_reasoning else "hidden (the spinner counts thinking tokens)"
884
+ save_user_setting("show_reasoning", ev.show_reasoning)
885
+ console.print(f"[green]Reasoning text {state}.[/green]")
886
+
887
+ def cmd_sandbox(self, arg):
888
+ ctx = self.agent.ctx
889
+ arg = arg.lower().strip()
890
+ if arg in ("on", "docker"):
891
+ if not shutil.which("docker"):
892
+ console.print("[red]docker was not found on PATH. Install Docker Desktop first.[/red]")
893
+ return
894
+ ctx.sandbox = "docker"
895
+ elif arg == "off":
896
+ ctx.sandbox = "off"
897
+ elif arg:
898
+ console.print("Usage: /sandbox [on|off]")
899
+ return
900
+ if arg:
901
+ self.agent.settings["shell_sandbox"] = ctx.sandbox
902
+ save_user_setting("shell_sandbox", ctx.sandbox)
903
+ if ctx.sandbox == "docker":
904
+ net = "with network access" if ctx.sandbox_network else "network disabled"
905
+ console.print(f"[green]Sandbox: docker[/green] [dim](image {ctx.sandbox_image}, "
906
+ f"{ctx.sandbox_memory} mem, {ctx.sandbox_cpus} cpu, {net})[/dim]")
907
+ else:
908
+ console.print("[yellow]Sandbox: off[/yellow] [dim](shell commands run directly on this machine)[/dim]")
909
+
910
+ def cmd_temp(self, arg):
911
+ if not arg:
912
+ console.print(f"Temperature: {self.agent.temperature}")
913
+ return
914
+ try:
915
+ val = float(arg)
916
+ if not 0 <= val <= 2:
917
+ raise ValueError
918
+ except ValueError:
919
+ console.print("[red]Temperature must be between 0 and 2[/red]")
920
+ return
921
+ self.agent.temperature = val
922
+ console.print(f"[green]Temperature: {val}[/green]")
923
+
924
+ def cmd_config(self, arg):
925
+ s = self.agent.settings
926
+ for key in ("model", "base_url", "max_tokens", "context_window", "auto_compact_ratio", "max_turns",
927
+ "shell", "shell_timeout", "additional_dirs", "allow_secret_files"):
928
+ console.print(f" {key:<20} {escape(str(s.get(key)))}")
929
+ perms = self.agent.permissions
930
+ console.print(f" {'allow rules':<20} {escape(', '.join(perms.allow + perms.session_allow) or '-')}")
931
+ console.print(f" {'deny rules':<20} {escape(', '.join(perms.deny) or '-')}")
932
+ console.print(f"[dim] files: {HOME_DIR / 'settings.json'}, .hubble/settings.json, .hubble/settings.local.json[/dim]")
933
+
934
+ def cmd_export(self, arg):
935
+ path = Path(arg or f"hubble_chat_{time.strftime('%Y%m%d_%H%M%S')}.md")
936
+ if not path.is_absolute():
937
+ path = self.agent.ctx.root / path
938
+ lines = [f"# Hubble session {self.agent.session.id if self.agent.session else ''}", "",
939
+ f"Model: {self.agent.model}", ""]
940
+ for m in self.agent.messages:
941
+ role = m.get("role")
942
+ if role == "tool":
943
+ lines += [f"### Tool result ({m.get('name', '')})", "```", truncate(m.get("content") or "", 3000), "```", ""]
944
+ continue
945
+ lines += [f"### {role.capitalize()}", m.get("content") or "", ""]
946
+ for tc in m.get("tool_calls") or []:
947
+ lines += [f"- tool call `{tc['function']['name']}` `{truncate(tc['function']['arguments'], 500)}`"]
948
+ path.write_text("\n".join(lines), encoding="utf-8")
949
+ console.print(f"[green]Exported to {escape(str(path))}[/green]")
950
+
951
+
952
+ def register_provider(cfg: ProviderConfig, ids):
953
+ save_provider(cfg)
954
+ save_listing(cfg.name, cfg.base_url, ids)
955
+
956
+
957
+ def session_items(items):
958
+ out = []
959
+ for it in items:
960
+ when = time.strftime("%m-%d %H:%M", time.localtime(it["updated"]))
961
+ title = (it["title"] or "(no prompt)")[:55]
962
+ out.append((it["id"], f"{when} {title}", f"{it['messages']} msgs · {it['model']}"))
963
+ return out
964
+
965
+
966
+ def escape_html(text: str) -> str:
967
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
968
+
969
+
970
+ def print_history_tail(messages, n: int = 4):
971
+ shown = [m for m in messages if m.get("role") in ("user", "assistant") and m.get("content")][-n:]
972
+ for m in shown:
973
+ who = "[bold cyan]you[/bold cyan]" if m["role"] == "user" else "[bold magenta]hubble[/bold magenta]"
974
+ text = m["content"].strip().splitlines()
975
+ preview = " ".join(text)[:200] if text else ""
976
+ console.print(f" {who} [dim]{escape(preview)}[/dim]")