dragon-cli 0.2.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.
dragon_cli/menu.py ADDED
@@ -0,0 +1,1140 @@
1
+ """Interactive, fancy (rich-based) main menu for Dragon CLI.
2
+
3
+ This module is a *presentation layer*: a modern, keyboard-navigable menu that
4
+ surfaces the local-LLM toolkit — chat, model listing, hardware-based
5
+ recommendations, the model hub, conversation history, and the HTTP server.
6
+
7
+ It deliberately stays a thin shell over the existing, fully-working modules
8
+ (``hardware``, ``recommendation``, ``runtime``, ``model_hub``, ``models``,
9
+ ``chat``, ``storage``, ``app``). Everything the menu does can still be driven
10
+ from plain argparse flags (``dragon-cli --serve``, etc.), so the menu never
11
+ becomes a second, divergent code path.
12
+
13
+ Navigation:
14
+ * Arrow keys (Up/Down) ........ move the highlight
15
+ * Enter ........................ select the highlighted option
16
+ * 1..7 ........................ jump straight to an option
17
+ * / ........................... open the quick-command line
18
+ * r ........................... refresh the hardware profile
19
+ * q / Esc ..................... quit
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import copy
26
+ import os
27
+ import re
28
+ import sys
29
+ from urllib.parse import unquote, urlparse
30
+ from dataclasses import dataclass
31
+ from typing import Any, Callable, Dict, List, Optional, Tuple
32
+
33
+ from rich.align import Align
34
+ from rich.console import Console, Group
35
+ from rich.live import Live
36
+ from rich.panel import Panel
37
+ from rich.prompt import Confirm, IntPrompt, Prompt
38
+ from rich.table import Table
39
+ from rich.text import Text
40
+
41
+ from . import __version__, app
42
+ from .config import DB_FILE, MODELS_DIR
43
+ from .hardware import detect_hardware
44
+ from .models import MODEL_CATALOG, add_model_to_catalog
45
+ from .recommendation import ModelRecommender
46
+ from .runtime import RuntimeConfig
47
+
48
+ # Palette — kept in one place so the look stays consistent. These are rich
49
+ # style/color fragments used inside markup, e.g. f"[{ACCENT}]text[/]".
50
+ ACCENT = "bright_cyan"
51
+ DIM = "grey58"
52
+ GOOD = "bright_green"
53
+ WARN = "bright_yellow"
54
+ BAD = "bright_red"
55
+
56
+
57
+ # --------------------------------------------------------------------------- #
58
+ # Keyboard input (POSIX + Windows) #
59
+ # --------------------------------------------------------------------------- #
60
+ def _read_key() -> str:
61
+ """Read a single keypress, decoding arrow-key escape sequences.
62
+
63
+ Returns canonical tokens: ``"up"``, ``"down"``, ``"left"``, ``"right"``,
64
+ ``"enter"``, ``"esc"``, or the literal character that was pressed.
65
+ """
66
+ # Windows: use the console API.
67
+ if os.name == "nt":
68
+ try:
69
+ import msvcrt
70
+
71
+ ch = msvcrt.getwch()
72
+ if ch in ("\x00", "\xe0"): # special-key prefix
73
+ code = msvcrt.getwch()
74
+ return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(code, ch)
75
+ if ch in ("\r", "\n"):
76
+ return "enter"
77
+ return ch
78
+ except Exception:
79
+ return "enter"
80
+
81
+ # POSIX: switch the tty to cbreak for a raw read, then restore.
82
+ import select
83
+ import termios
84
+ import tty
85
+
86
+ fd = sys.stdin.fileno()
87
+ if not sys.stdin.isatty():
88
+ # Non-interactive stdin (piped/redirected): read a whole line and
89
+ # collapse it to a single canonical key so the menu stays usable.
90
+ line = sys.stdin.readline()
91
+ if not line: # EOF (e.g. Ctrl-D, or a closed pipe)
92
+ return "esc"
93
+ line = line.strip().lower()
94
+ if not line:
95
+ return "enter"
96
+ return line[0]
97
+
98
+ try:
99
+ old = termios.tcgetattr(fd)
100
+ except termios.error:
101
+ return "esc" # stdin stopped being a tty mid-session: bail out
102
+ try:
103
+ tty.setcbreak(fd)
104
+ ch = sys.stdin.read(1)
105
+ if not ch: # EOF on the tty
106
+ return "esc"
107
+ if ch == "\x1b":
108
+ # Either a bare Esc or an escape sequence like ``\x1b[A`` (up).
109
+ # Probe for more bytes without blocking forever.
110
+ more: List[str] = []
111
+ for _ in range(2):
112
+ r, _, _ = select.select([fd], [], [], 0.05)
113
+ if not r:
114
+ break
115
+ more.append(sys.stdin.read(1))
116
+ if more and more[0] == "[":
117
+ return {"A": "up", "B": "down", "C": "right", "D": "left"}.get(
118
+ more[1] if len(more) > 1 else "", "esc"
119
+ )
120
+ return "esc"
121
+ if ch in ("\r", "\n"):
122
+ return "enter"
123
+ return ch
124
+ finally:
125
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
126
+
127
+
128
+ # --------------------------------------------------------------------------- #
129
+ # Menu model #
130
+ # --------------------------------------------------------------------------- #
131
+ @dataclass
132
+ class MenuOption:
133
+ key: str # numeric shortcut, e.g. "1"
134
+ label: str # menu title
135
+ description: str # one-line help text
136
+ handler: Callable[[], bool] # False => quit the menu, True => stay
137
+
138
+
139
+ class InteractiveMenu:
140
+ """Owns the menu loop, cached hardware profile, and all action handlers."""
141
+
142
+ # (key, label, description) — handler is bound by attribute name below.
143
+ MENU_ITEMS: List[Tuple[str, str, str]] = [
144
+ ("1", "Chat with model", "Talk to a local model with streaming"),
145
+ ("2", "List models", "Browse local and remote models in the catalog"),
146
+ ("3", "Auto recommendation", "Pick the best model for your hardware"),
147
+ ("4", "Connect to hub", "Search and download models from Hugging Face"),
148
+ ("5", "Manage history", "List, resume, export saved conversations"),
149
+ ("6", "Serve HTTP", "Expose the model via an OpenAI-compatible API"),
150
+ ("7", "Manage MCP servers", "Add, remove, and test MCP connectors"),
151
+ ("8", "Quit", "Exit Dragon CLI"),
152
+ ]
153
+
154
+ # Slash shortcuts -> option key (or a special target).
155
+ QUICK_COMMANDS: Dict[str, str] = {
156
+ "/chat": "1", "/models": "2", "/rec": "3", "/recommend": "3",
157
+ "/hub": "4", "/history": "5", "/serve": "6", "/mcp": "7",
158
+ "/quit": "8", "/exit": "8", "/profile": "p", "/refresh": "r",
159
+ }
160
+
161
+ def __init__(self, args: Any, console: Optional[Console] = None) -> None:
162
+ self.args = args
163
+ self.console = console or Console()
164
+ self.hardware: Dict[str, Any] = detect_hardware()
165
+ self.recommender = ModelRecommender(self.hardware)
166
+ self.runtime = RuntimeConfig(hardware_profile=self.hardware)
167
+ self.selected = 0
168
+
169
+ # -- small helpers ------------------------------------------------------ #
170
+ def _pause(self, msg: str = "[Enter to return to menu]") -> None:
171
+ """Block until Enter, used between an action and returning to the menu."""
172
+ self.console.input(f"[{DIM}]{msg}[/]")
173
+
174
+ def _splash(self) -> None:
175
+ """Show the mascot icon + title once, before the live menu starts."""
176
+ from .banner import ICON
177
+
178
+ lines = ICON.splitlines()
179
+ art_width = max((len(ln) for ln in lines), default=0)
180
+ # Block-center: pad every line by the same amount so the picture keeps
181
+ # its shape (rich's Align.center would mangle multi-line ASCII art).
182
+ pad = max(0, (self.console.width - art_width) // 2)
183
+ art = "\n".join(" " * pad + ln for ln in lines)
184
+ self.console.print(Text(art, style=ACCENT))
185
+ self.console.print(Align.center(Text.assemble(
186
+ (" DRAGON CLI ", f"bold white on {ACCENT}"),
187
+ (f" v{__version__} ", DIM),
188
+ (" 100% local LLM + MCP ", f"bold {GOOD}"),
189
+ )))
190
+ self.console.print()
191
+
192
+ # -- public entry point ------------------------------------------------- #
193
+ def run(self) -> None:
194
+ """Main loop: show the menu, dispatch actions until the user quits."""
195
+ self._splash()
196
+ self.console.show_cursor(False)
197
+ try:
198
+ while True:
199
+ choice = self._main_menu()
200
+ if choice is None: # user pressed q / Esc
201
+ break
202
+ if choice.handler() is False:
203
+ break
204
+ except (KeyboardInterrupt, EOFError):
205
+ pass
206
+ finally:
207
+ self.console.show_cursor(True)
208
+ self.console.print(f"\n[{ACCENT}]Dragon asleep.[/] See you next time!\n")
209
+
210
+ # ------------------------------------------------------------------ #
211
+ # Rendering #
212
+ # ------------------------------------------------------------------ #
213
+ def _header(self) -> Panel:
214
+ title = Text()
215
+ title.append(" DRAGON CLI ", style=f"bold white on {ACCENT}")
216
+ title.append(f" v{__version__} ", style=DIM)
217
+ title.append(" 100% local ", style=f"bold {GOOD}")
218
+ return Panel(Align.center(title), border_style=ACCENT, padding=(0, 1))
219
+
220
+ def _status_grid(self) -> Table:
221
+ hw = self.hardware
222
+ grid = Table.grid(padding=(0, 2))
223
+ grid.add_column(style=f"bold {DIM}", no_wrap=True)
224
+ grid.add_column()
225
+
226
+ backend = self.runtime.decide_execution_mode().upper()
227
+ backend_style = GOOD if backend == "GPU" else (WARN if backend == "HYBRID" else ACCENT)
228
+
229
+ grid.add_row("System", str(hw.get("os", "?")))
230
+ grid.add_row("CPU", f"{hw.get('cpu_model', '?')} ({hw.get('cpu_count', '?')} cores)")
231
+
232
+ ram_total = hw.get("ram_total_gb", 0) or 0
233
+ ram_avail = hw.get("ram_available_gb", 0) or 0
234
+ grid.add_row("RAM", f"{ram_avail:.1f} GB free / {ram_total:.1f} GB total")
235
+
236
+ if hw.get("gpu_available"):
237
+ grid.add_row(
238
+ "GPU",
239
+ Text(f"{hw.get('gpu_name', '?')} ({hw.get('gpu_vram_gb', 0):.1f} GB VRAM)", style=GOOD),
240
+ )
241
+ else:
242
+ grid.add_row("GPU", Text("not detected (CPU mode)", style=DIM))
243
+
244
+ grid.add_row("Backend", Text(backend, style=backend_style))
245
+
246
+ best = self.recommender.recommend_best()
247
+ if best:
248
+ score = best.get("compatibility_score", 0)
249
+ grid.add_row("Recommended", f"{best['name']} [{DIM}]({score:.0%})[/]")
250
+ else:
251
+ grid.add_row("Recommended", f"[{DIM}]none compatible[/]")
252
+ return grid
253
+
254
+ def _status_panel(self) -> Panel:
255
+ return Panel(
256
+ self._status_grid(),
257
+ title=f"[bold {ACCENT}]System status[/]",
258
+ border_style=DIM,
259
+ padding=(1, 2),
260
+ )
261
+
262
+ def _menu_panel(self) -> Panel:
263
+ table = Table.grid(padding=(0, 2))
264
+ table.add_column(style="bold", width=3)
265
+ table.add_column(no_wrap=True)
266
+ table.add_column()
267
+
268
+ for i, (key, label, desc) in enumerate(self.MENU_ITEMS):
269
+ if i == self.selected:
270
+ marker = f"[bold {ACCENT}]›[/]"
271
+ label_txt = f"[bold white on {ACCENT}] {label} [/]"
272
+ desc_txt = f"[{DIM}]{desc}[/]"
273
+ else:
274
+ marker = " "
275
+ label_txt = f"[bold]{label}[/]"
276
+ desc_txt = f"[{DIM}]{desc}[/]"
277
+ table.add_row(f"[{DIM}]{key}[/]", f"{marker} {label_txt}", desc_txt)
278
+ return Panel(
279
+ Align.center(table),
280
+ title=f"[bold {ACCENT}]Main menu[/]",
281
+ border_style=ACCENT,
282
+ padding=(1, 2),
283
+ )
284
+
285
+ def _footer(self) -> Text:
286
+ return Align.center(Text.assemble(
287
+ ("Arrows", f"bold {ACCENT}"), (" move ", DIM),
288
+ ("Enter", f"bold {ACCENT}"), (" select ", DIM),
289
+ ("1-8", f"bold {ACCENT}"), (" shortcut ", DIM),
290
+ ("/", f"bold {ACCENT}"), (" commands ", DIM),
291
+ ("r", f"bold {ACCENT}"), (" refresh ", DIM),
292
+ ("q", f"bold {ACCENT}"), (" quit", DIM),
293
+ ))
294
+
295
+ def _frame(self) -> Group:
296
+ return Group(
297
+ self._header(),
298
+ Text(), # spacer
299
+ self._status_panel(),
300
+ Text(),
301
+ self._menu_panel(),
302
+ Text(),
303
+ self._footer(),
304
+ )
305
+
306
+ def _main_menu(self) -> Optional[MenuOption]:
307
+ """Render the live menu and return the option the user picked."""
308
+ options = [
309
+ MenuOption(k, l, d, getattr(self, f"_action_{k}"))
310
+ for k, l, d in self.MENU_ITEMS
311
+ ]
312
+ self.selected = max(0, min(self.selected, len(options) - 1))
313
+ with Live(self._frame(), console=self.console, refresh_per_second=12, screen=False) as live:
314
+ while True:
315
+ key = _read_key()
316
+ if key in ("up", "k"):
317
+ self.selected = (self.selected - 1) % len(options)
318
+ elif key in ("down", "j"):
319
+ self.selected = (self.selected + 1) % len(options)
320
+ elif key == "enter":
321
+ return options[self.selected]
322
+ elif key in ("q", "Q", "esc"):
323
+ return None
324
+ elif key == "r":
325
+ self._refresh()
326
+ elif key == "/":
327
+ target = self._quick_command()
328
+ if target in (None, "p", "r"):
329
+ if target == "p":
330
+ self._show_profile()
331
+ elif target == "r":
332
+ self._refresh()
333
+ else:
334
+ idx = int(target) - 1
335
+ if 0 <= idx < len(options):
336
+ self.selected = idx
337
+ return options[idx]
338
+ elif key.isdigit() and 1 <= int(key) <= len(options):
339
+ self.selected = int(key) - 1
340
+ return options[self.selected]
341
+ live.update(self._frame())
342
+
343
+ # ------------------------------------------------------------------ #
344
+ # Action handlers #
345
+ # ------------------------------------------------------------------ #
346
+ def _refresh(self) -> None:
347
+ """Re-detect hardware and rebuild the recommender/runtime."""
348
+ self.hardware = detect_hardware()
349
+ self.recommender = ModelRecommender(self.hardware)
350
+ self.runtime = RuntimeConfig(hardware_profile=self.hardware)
351
+
352
+ def _quick_command(self) -> Optional[str]:
353
+ """Pop a quick-command line and resolve it to a target option key."""
354
+ self.console.show_cursor(True)
355
+ cmd = Prompt.ask(
356
+ f"\n[{ACCENT}]Quick command[/] [{DIM}](/help to list)[/]",
357
+ console=self.console, default="",
358
+ ).strip().lower()
359
+ self.console.show_cursor(False)
360
+ if not cmd:
361
+ return None
362
+ if cmd in ("/help", "/?"):
363
+ cmds = " ".join(sorted(self.QUICK_COMMANDS))
364
+ self.console.print(Panel(
365
+ cmds, title=f"[{ACCENT}]Quick commands[/]", border_style=DIM))
366
+ self._pause("[Enter to continue]")
367
+ return None
368
+ target = self.QUICK_COMMANDS.get(cmd)
369
+ if target is None:
370
+ self.console.print(f"[{BAD}]Unknown command:[/] {cmd}")
371
+ self._pause("[Enter to continue]")
372
+ return target
373
+
374
+ def _require_llm(self) -> bool:
375
+ """Check that llama-cpp-python is importable, else show how to install."""
376
+ try:
377
+ from .models.loader import _preload_cuda_runtime
378
+ _preload_cuda_runtime()
379
+ import llama_cpp # noqa: F401
380
+ return True
381
+ except ImportError:
382
+ self.console.print(Panel(
383
+ "[bold red]llama-cpp-python is not installed.[/]\n\n"
384
+ "Install the inference backend with:\n"
385
+ f" [{ACCENT}]dragon-cli-install-llm[/]\n\n"
386
+ "(detects your OS/GPU and uses the most suitable prebuilt wheel)",
387
+ title=f"[{BAD}]Missing dependency[/]", border_style=BAD))
388
+ self._pause()
389
+ return False
390
+
391
+ def _run_app(self, **overrides: Any) -> None:
392
+ """Run the async ``app.run`` orchestration with a copy of the args."""
393
+ a = copy.copy(self.args)
394
+ for k, v in overrides.items():
395
+ setattr(a, k, v)
396
+ try:
397
+ asyncio.run(app.run(a))
398
+ except (KeyboardInterrupt, EOFError):
399
+ pass
400
+
401
+ def _ask_gpu(self) -> Dict[str, Any]:
402
+ """Ask how to run the model on the backend; return ``_run_app`` overrides.
403
+
404
+ Mirrors the ``--gpu``/``--cpu``/``--n-gpu-layers`` CLI flags. When no
405
+ GPU is detected the choice is skipped and CPU is forced.
406
+ """
407
+ if not self.hardware.get("gpu_available"):
408
+ return {"cpu": True, "gpu": False, "n_gpu_layers": -1}
409
+ choice = Prompt.ask(
410
+ f"[{ACCENT}]Backend[/] [{DIM}](how to run the model)[/]",
411
+ choices=["auto", "gpu", "cpu"], default="auto", console=self.console,
412
+ )
413
+ return {
414
+ "cpu": choice == "cpu",
415
+ "gpu": choice == "gpu",
416
+ "n_gpu_layers": -1,
417
+ }
418
+
419
+ # 1 - Chat with model ---------------------------------------------- #
420
+ def _action_1(self) -> bool:
421
+ if not self._require_llm():
422
+ return True
423
+ pick = self._pick_model(title="Choose a model to chat with")
424
+ if pick is None:
425
+ return True
426
+ idx, _model = pick
427
+ self._run_app(serve=False, model=idx, **self._ask_gpu())
428
+ self._pause()
429
+ return True
430
+
431
+ # 2 - List models -------------------------------------------------- #
432
+ def _action_2(self) -> bool:
433
+ self._show_models_table()
434
+ self._pause()
435
+ return True
436
+
437
+ # 3 - Auto recommendation ------------------------------------------ #
438
+ def _action_3(self) -> bool:
439
+ self._show_recommendations()
440
+ pick = IntPrompt.ask(
441
+ f"\n[{ACCENT}]Use which model number?[/] [{DIM}](0 = back)[/]",
442
+ console=self.console, default=0,
443
+ )
444
+ if pick == 0:
445
+ return True
446
+ if not (1 <= pick <= len(MODEL_CATALOG)):
447
+ self.console.print(f"[{BAD}]Number out of catalog range.[/]")
448
+ self._pause()
449
+ return True
450
+ if not self._require_llm():
451
+ return True
452
+ self._run_app(serve=False, model=pick)
453
+ self._pause()
454
+ return True
455
+
456
+ # 4 - Connect to hub ----------------------------------------------- #
457
+ def _action_4(self) -> bool:
458
+ self._show_models_table(title="Connect to hub — catalog")
459
+ choice_raw = Prompt.ask(
460
+ f"\n[{ACCENT}]Hub action[/] [{DIM}](catalog # download, 's' search/add, 'hf://' add direct, Enter = back)[/]",
461
+ console=self.console, default="",
462
+ ).strip()
463
+ choice = choice_raw.lower()
464
+ if choice in ("", "0"):
465
+ return True
466
+ if choice in ("s", "search"):
467
+ model = self._hub_search()
468
+ if model:
469
+ idx, created = self._add_model_to_catalog(model)
470
+ action = "Added" if created else "Already in catalog"
471
+ self.console.print(f"[{GOOD}]{action}[/] as catalog #{idx}: {model['name']}")
472
+ if Confirm.ask("Download this model now?", console=self.console, default=True):
473
+ self._download_model_entry(model)
474
+ elif choice.isdigit() and 1 <= int(choice) <= len(MODEL_CATALOG):
475
+ self._download_model(int(choice))
476
+ else:
477
+ model = self._build_model_from_hf_reference(choice_raw)
478
+ if model:
479
+ idx, created = self._add_model_to_catalog(model)
480
+ action = "Added" if created else "Already in catalog"
481
+ self.console.print(f"[{GOOD}]{action}[/] as catalog #{idx}: {model['name']}")
482
+ if Confirm.ask("Download this model now?", console=self.console, default=True):
483
+ self._download_model_entry(model)
484
+ else:
485
+ self.console.print(f"[{BAD}]Invalid option.[/]")
486
+ self._pause()
487
+ return True
488
+
489
+ # 5 - Manage history ----------------------------------------------- #
490
+ def _action_5(self) -> bool:
491
+ self._manage_history()
492
+ return True
493
+
494
+ # 6 - Serve HTTP --------------------------------------------------- #
495
+ def _action_6(self) -> bool:
496
+ if not self._require_llm():
497
+ return True
498
+ host = Prompt.ask("Host", console=self.console, default=getattr(self.args, "host", "127.0.0.1"))
499
+ port = IntPrompt.ask("Port", console=self.console, default=getattr(self.args, "port", 8000))
500
+ gpu = self._ask_gpu()
501
+ pick = self._pick_model(title=f"Model to serve at http://{host}:{port}/v1")
502
+ if pick is None:
503
+ return True
504
+ idx, _model = pick
505
+ self._run_app(serve=True, model=idx, host=host, port=port, **gpu)
506
+ self._pause()
507
+ return True
508
+
509
+ # 7 - Manage MCP servers ------------------------------------------- #
510
+ def _action_7(self) -> bool:
511
+ self._manage_mcp()
512
+ return True
513
+
514
+ # 8 - Quit --------------------------------------------------------- #
515
+ def _action_8(self) -> bool:
516
+ return False
517
+
518
+ # ------------------------------------------------------------------ #
519
+ # MCP management #
520
+ # ------------------------------------------------------------------ #
521
+ def _manage_mcp(self) -> None:
522
+ from .mcp import MCP_PRESETS, load_mcp_config, save_mcp_config
523
+
524
+ config = load_mcp_config()
525
+ servers = dict(config.get("mcpServers", {}))
526
+
527
+ while True:
528
+ self.console.print(Panel(
529
+ f"Config file: [{ACCENT}]{MCP_CONFIG_FILE}[/]",
530
+ title=f"[bold {ACCENT}]MCP servers[/]", border_style=ACCENT))
531
+
532
+ if not servers:
533
+ self.console.print(f"[{DIM}]No MCP servers configured.[/]")
534
+ else:
535
+ table = Table(border_style=DIM, expand=True)
536
+ table.add_column("Name", style=f"bold {ACCENT}")
537
+ table.add_column("Command", overflow="fold")
538
+ table.add_column("Args", overflow="fold")
539
+ for name, cfg in servers.items():
540
+ table.add_row(name, cfg.get("command", "?"),
541
+ " ".join(str(a) for a in cfg.get("args", [])))
542
+ self.console.print(table)
543
+
544
+ action = Prompt.ask(
545
+ f"\n[{ACCENT}]Action[/] [{DIM}](add, remove, test, back)[/]",
546
+ console=self.console, default="",
547
+ ).strip().lower()
548
+ if not action or action in ("back", "b", "q"):
549
+ return
550
+ if action in ("add", "a"):
551
+ self._mcp_add(servers, MCP_PRESETS)
552
+ save_mcp_config(servers)
553
+ elif action in ("remove", "r", "delete", "d"):
554
+ self._mcp_remove(servers)
555
+ save_mcp_config(servers)
556
+ elif action in ("test", "t"):
557
+ self._mcp_test(servers)
558
+ else:
559
+ self.console.print(f"[{BAD}]Unknown action.[/]")
560
+ self._pause("[Enter to continue]")
561
+ self.console.print()
562
+
563
+ def _mcp_add(self, servers: dict, presets: dict) -> None:
564
+ preset_names = sorted(presets)
565
+ self.console.print(f"[{DIM}]Presets:[/] {', '.join(preset_names)} "
566
+ f"(or 'custom' for your own command)")
567
+ choice = Prompt.ask(
568
+ f"[{ACCENT}]Server to add[/]", console=self.console, default="",
569
+ ).strip().lower()
570
+ if not choice:
571
+ return
572
+ if choice in presets:
573
+ name = choice
574
+ if name in servers and not Confirm.ask(
575
+ f"'{name}' already exists. Overwrite with preset?",
576
+ console=self.console, default=False):
577
+ return
578
+ servers[name] = {
579
+ "command": presets[choice]["command"],
580
+ "args": list(presets[choice]["args"]),
581
+ }
582
+ self.console.print(f"[{GOOD}]Added preset '{name}':[/] {presets[choice]['description']}")
583
+ return
584
+ if choice in ("custom", "c"):
585
+ name = Prompt.ask(f"[{ACCENT}]Name for this server[/]", console=self.console).strip()
586
+ if not name:
587
+ return
588
+ if name in servers and not Confirm.ask(
589
+ f"'{name}' exists. Overwrite?", console=self.console, default=False):
590
+ return
591
+ command = Prompt.ask(f"[{ACCENT}]Command[/] [{DIM}](e.g. npx, uvx, python)[/]",
592
+ console=self.console, default="uvx")
593
+ args_raw = Prompt.ask(
594
+ f"[{ACCENT}]Arguments[/] [{DIM}](space-separated)[/]",
595
+ console=self.console, default="",
596
+ )
597
+ servers[name] = {"command": command, "args": args_raw.split()}
598
+ self.console.print(f"[{GOOD}]Added '{name}'.[/]")
599
+ return
600
+ self.console.print(f"[{BAD}]Unknown choice '{choice}'.[/]")
601
+
602
+ def _mcp_remove(self, servers: dict) -> None:
603
+ if not servers:
604
+ self.console.print(f"[{DIM}]Nothing to remove.[/]")
605
+ return
606
+ name = Prompt.ask(
607
+ f"[{ACCENT}]Server name to remove[/] [{DIM}](one of: {', '.join(servers)})[/]",
608
+ console=self.console, default="",
609
+ ).strip()
610
+ if name in servers:
611
+ del servers[name]
612
+ self.console.print(f"[{GOOD}]Removed '{name}'.[/]")
613
+ else:
614
+ self.console.print(f"[{BAD}]No server named '{name}'.[/]")
615
+
616
+ def _mcp_test(self, servers: dict) -> None:
617
+ import asyncio
618
+
619
+ if not servers:
620
+ self.console.print(f"[{DIM}]No servers to test.[/]")
621
+ return
622
+ name = Prompt.ask(
623
+ f"[{ACCENT}]Server to test[/] [{DIM}](Enter = all)[/]",
624
+ console=self.console, default="",
625
+ ).strip()
626
+ targets = {name: servers[name]} if name in servers else servers
627
+ asyncio.run(self._mcp_test_targets(targets))
628
+
629
+ async def _mcp_test_targets(self, targets: dict) -> None:
630
+ from contextlib import AsyncExitStack
631
+
632
+ try:
633
+ from mcp import ClientSession, StdioServerParameters
634
+ from mcp.client.stdio import stdio_client
635
+ except ImportError:
636
+ self.console.print(f"[{BAD}]Package 'mcp' is not installed. "
637
+ f"Run: pip install mcp[/]")
638
+ return
639
+
640
+ stack = AsyncExitStack()
641
+ await stack.__aenter__()
642
+ try:
643
+ for name, cfg in targets.items():
644
+ try:
645
+ params = StdioServerParameters(
646
+ command=cfg["command"], args=cfg.get("args", []), env=cfg.get("env"),
647
+ )
648
+ read, write = await stack.enter_async_context(stdio_client(params))
649
+ session = await stack.enter_async_context(ClientSession(read, write))
650
+ await session.initialize()
651
+ result = await session.list_tools()
652
+ self.console.print(
653
+ f"[{GOOD}]\u2713 {name}[/] connected ({len(result.tools)} tools)")
654
+ except Exception as exc: # noqa: BLE001
655
+ self.console.print(f"[{BAD}]\u2717 {name}[/] failed: {exc}")
656
+ finally:
657
+ await stack.aclose()
658
+
659
+ # ------------------------------------------------------------------ #
660
+ # Rich helper views #
661
+ # ------------------------------------------------------------------ #
662
+ def _pick_model(self, title: str) -> Optional[Tuple[int, dict]]:
663
+ """Render the catalog and ask for a number. Returns (1-based idx, model)."""
664
+ self._show_models_table(title=title)
665
+ choice = IntPrompt.ask(
666
+ f"\n[{ACCENT}]Model number[/] [{DIM}](0 = back)[/]",
667
+ console=self.console, default=0,
668
+ )
669
+ if choice == 0 or not (1 <= choice <= len(MODEL_CATALOG)):
670
+ if choice != 0:
671
+ self.console.print(f"[{BAD}]Number out of catalog range.[/]")
672
+ self._pause("[Enter]")
673
+ return None
674
+ return choice, MODEL_CATALOG[choice - 1]
675
+
676
+ def _show_models_table(self, title: str = "Models — local and remote") -> None:
677
+ from .models.catalog import available_ram_gb
678
+
679
+ ram = available_ram_gb()
680
+ vram = self.hardware.get("gpu_vram_gb", 0) or 0
681
+ has_gpu = self.hardware.get("gpu_available", False)
682
+ table = Table(title=f"[bold {ACCENT}]{title}[/]", border_style=ACCENT, expand=True)
683
+ table.add_column("#", style=DIM, width=3)
684
+ table.add_column("Model", overflow="fold")
685
+ table.add_column("Min RAM", justify="right")
686
+ table.add_column("Context", justify="right")
687
+ table.add_column("GPU", justify="center")
688
+ table.add_column("Source")
689
+
690
+ for i, m in enumerate(MODEL_CATALOG, 1):
691
+ downloaded = (MODELS_DIR / m["filename"]).exists()
692
+ source = f"[{GOOD}]local[/]" if downloaded else f"[{DIM}]remote[/]"
693
+ ram_warn = f" [{BAD}]![/]" if 0 < ram < m["ram_min_gb"] else ""
694
+ need = m.get("vram_min_gb")
695
+ if not has_gpu or not need:
696
+ gpu_cell = f"[{DIM}]-[/]"
697
+ elif vram >= need:
698
+ gpu_cell = f"[{GOOD}]\u2713 {need:.0f}G[/]"
699
+ elif vram >= need * 0.5:
700
+ gpu_cell = f"[{WARN}]~ {need:.0f}G[/]"
701
+ else:
702
+ gpu_cell = f"[{BAD}]\u2717 {need:.0f}G[/]"
703
+ table.add_row(
704
+ str(i), m["name"],
705
+ f"{m['ram_min_gb']} GB{ram_warn}", f"{m['ctx']}", gpu_cell, source,
706
+ )
707
+ self.console.print(table)
708
+ gpu_line = (f" | GPU VRAM: {vram:.1f} GB free"
709
+ if has_gpu else " | GPU: none")
710
+ self.console.print(f"[{DIM}]Free RAM detected: {ram:.1f} GB{gpu_line} "
711
+ f"(\u2713 fits GPU fully, ~ partial, \u2717 too large)[/]\n")
712
+
713
+ def _show_recommendations(self) -> None:
714
+ compatible = self.recommender.get_compatible_models()
715
+ if not compatible:
716
+ self.console.print(Panel(
717
+ f"[{BAD}]No models compatible with your hardware.[/]",
718
+ border_style=BAD))
719
+ return
720
+
721
+ table = Table(
722
+ title=f"[bold {ACCENT}]Auto recommendation[/]",
723
+ border_style=ACCENT, expand=True,
724
+ )
725
+ table.add_column("#", style=DIM, width=3)
726
+ table.add_column("Model", overflow="fold")
727
+ table.add_column("Fit")
728
+ table.add_column("Min RAM", justify="right")
729
+ table.add_column("Backend", justify="center")
730
+
731
+ for i, m in enumerate(compatible, 1):
732
+ score = m.get("compatibility_score", 0)
733
+ bar_len = 10
734
+ filled = int(round(score * bar_len))
735
+ color = GOOD if score >= 0.7 else (WARN if score >= 0.4 else BAD)
736
+ bar = f"[{color}]{'█' * filled}[/][{DIM}]{'░' * (bar_len - filled)}[/] {score:.0%}"
737
+ backend = f"[{GOOD}]GPU[/]" if m.get("can_use_gpu") else f"[{ACCENT}]CPU[/]"
738
+ tag = f" [{GOOD}][recommended][/]" if m.get("recommended") else ""
739
+ table.add_row(str(i), m["name"] + tag, bar, f"{m.get('ram_min_gb', '?')} GB", backend)
740
+
741
+ self.console.print(table)
742
+ best = compatible[0]
743
+ self.console.print(Panel(
744
+ self.recommender.get_recommendation_reason(best),
745
+ title=f"[{ACCENT}]Why {best['name']}?[/]", border_style=DIM))
746
+ self.console.print(f"[{DIM}]Use the number from the table above (1 = best option).[/]\n")
747
+
748
+ def _download_model(self, idx: int) -> None:
749
+ self._download_model_entry(MODEL_CATALOG[idx - 1])
750
+
751
+ def _download_model_entry(self, model: dict) -> None:
752
+ from .models.loader import download_model
753
+
754
+ dest = MODELS_DIR / model["filename"]
755
+ if dest.exists():
756
+ self.console.print(f"[{GOOD}]Model already saved:[/] {dest}")
757
+ return
758
+ self.console.print(f"\n[{ACCENT}]Downloading[/] {model['name']} ...")
759
+ with self.console.status(f"[{ACCENT}]Downloading {model['filename']}[/]", spinner="dots"):
760
+ try:
761
+ path = download_model(model)
762
+ except Exception as exc:
763
+ self.console.print(f"[{BAD}]Download failed:[/] {exc}")
764
+ return
765
+ self.console.print(f"[{GOOD}]Saved at[/] {path}")
766
+
767
+ def _hub_search(self) -> Optional[dict]:
768
+ from .model_hub import ModelHub
769
+
770
+ query = Prompt.ask(
771
+ f"[{ACCENT}]Search Hugging Face[/] [{DIM}](query, owner/repo, hf://, or HF URL)[/]",
772
+ console=self.console, default="GGUF",
773
+ ).strip()
774
+ direct_model = self._build_model_from_hf_reference(query)
775
+ if direct_model is not None:
776
+ return direct_model
777
+
778
+ hub = ModelHub()
779
+ with self.console.status(f"[{ACCENT}]Searching '{query}'...[/]", spinner="dots"):
780
+ try:
781
+ results = hub.fetch_huggingface_models(search_query=query, limit=15)
782
+ except Exception as exc:
783
+ self.console.print(f"[{BAD}]Search error:[/] {exc}")
784
+ return None
785
+ if not results:
786
+ self.console.print(
787
+ f"[{DIM}]No results.[/] Hugging Face search matches repositories, not always exact file names."
788
+ )
789
+ if query.lower().endswith(".gguf"):
790
+ self.console.print(
791
+ f"[{DIM}]Tip:[/] use direct reference, e.g. "
792
+ f"hf://owner/repo/{query} or paste the full HF resolve URL."
793
+ )
794
+ return None
795
+ table = Table(title=f"[bold {ACCENT}]Hugging Face: '{query}'[/]", border_style=ACCENT)
796
+ table.add_column("#", style=DIM, width=3)
797
+ table.add_column("Repository", overflow="fold")
798
+ table.add_column("Downloads", justify="right")
799
+ table.add_column("Likes", justify="right")
800
+ for i, m in enumerate(results, 1):
801
+ table.add_row(
802
+ str(i),
803
+ m.get("name", m.get("repo_id", "?")),
804
+ f"{m.get('downloads', 0):,}",
805
+ f"{m.get('likes', 0):,}",
806
+ )
807
+ self.console.print(table)
808
+ repo_choice = IntPrompt.ask(
809
+ f"\n[{ACCENT}]Repository number[/] [{DIM}](0 = back)[/]",
810
+ console=self.console,
811
+ default=0,
812
+ )
813
+ if repo_choice == 0 or not (1 <= repo_choice <= len(results)):
814
+ return None
815
+
816
+ selected = results[repo_choice - 1]
817
+ repo_id = selected.get("repo_id") or selected.get("name")
818
+ if not repo_id:
819
+ self.console.print(f"[{BAD}]Selected result has no repo id.[/]")
820
+ return None
821
+
822
+ return self._build_model_from_repo(repo_id)
823
+
824
+ def _build_model_from_repo(self, repo_id: str) -> Optional[dict]:
825
+ from .model_hub import ModelHub
826
+
827
+ hub = ModelHub()
828
+ with self.console.status(f"[{ACCENT}]Loading GGUF files from {repo_id}...[/]", spinner="dots"):
829
+ files = hub.get_model_files(repo_id)
830
+ if not files:
831
+ self.console.print(f"[{BAD}]No .gguf files found in[/] {repo_id}")
832
+ return None
833
+
834
+ file_table = Table(title=f"[bold {ACCENT}]GGUF files — {repo_id}[/]", border_style=ACCENT)
835
+ file_table.add_column("#", style=DIM, width=3)
836
+ file_table.add_column("Filename", overflow="fold")
837
+ file_table.add_column("Quant", justify="center")
838
+ for i, fn in enumerate(files, 1):
839
+ file_table.add_row(str(i), fn, self._extract_quantization(fn))
840
+ self.console.print(file_table)
841
+
842
+ recommended_idx = self._pick_recommended_file_index(files)
843
+ self.console.print(
844
+ f"[{DIM}]Tip:[/] recommended file #{recommended_idx} for general local use."
845
+ )
846
+
847
+ file_choice = IntPrompt.ask(
848
+ f"\n[{ACCENT}]File number[/] [{DIM}](0 = back)[/]",
849
+ console=self.console,
850
+ default=recommended_idx,
851
+ )
852
+ if file_choice == 0 or not (1 <= file_choice <= len(files)):
853
+ return None
854
+
855
+ filename = files[file_choice - 1]
856
+ return self._build_model_entry(repo_id, filename)
857
+
858
+ def _build_model_entry(self, repo_id: str, filename: str) -> dict:
859
+ default_name = f"{repo_id.split('/')[-1]} ({self._extract_quantization(filename)}) — hub"
860
+ model_name = Prompt.ask(
861
+ f"[{ACCENT}]Catalog display name[/]",
862
+ console=self.console,
863
+ default=default_name,
864
+ ).strip() or default_name
865
+
866
+ params_b = self._estimate_params_billions(f"{repo_id} {filename}")
867
+ ram_default = self._estimate_ram_default_gb(params_b, filename)
868
+ vram_default = max(1.0, round(params_b * 0.7, 1))
869
+ layers_default = self._estimate_layer_count(params_b)
870
+
871
+ ram_min = IntPrompt.ask("Min RAM (GB)", console=self.console, default=ram_default)
872
+ ctx = IntPrompt.ask("Context window", console=self.console, default=8192)
873
+
874
+ return {
875
+ "name": model_name,
876
+ "repo_id": repo_id,
877
+ "filename": filename,
878
+ "ram_min_gb": max(1, ram_min),
879
+ "vram_min_gb": vram_default,
880
+ "n_layers": layers_default,
881
+ "ctx": max(512, ctx),
882
+ }
883
+
884
+ def _add_model_to_catalog(self, model: dict) -> Tuple[int, bool]:
885
+ return add_model_to_catalog(model, persist=True)
886
+
887
+ @staticmethod
888
+ def _estimate_params_billions(text: str) -> float:
889
+ match = re.search(r"(\d+(?:\.\d+)?)\s*[bB]", text)
890
+ if not match:
891
+ return 7.0
892
+ try:
893
+ return float(match.group(1))
894
+ except ValueError:
895
+ return 7.0
896
+
897
+ @staticmethod
898
+ def _estimate_layer_count(params_b: float) -> int:
899
+ if params_b <= 2:
900
+ return 24
901
+ if params_b <= 4:
902
+ return 28
903
+ if params_b <= 8:
904
+ return 32
905
+ if params_b <= 14:
906
+ return 40
907
+ return 48
908
+
909
+ @staticmethod
910
+ def _extract_quantization(filename: str) -> str:
911
+ upper = filename.upper()
912
+ quant_match = re.search(r"Q\d(?:_[A-Z0-9]+)*", upper)
913
+ return quant_match.group(0) if quant_match else "unknown"
914
+
915
+ def _build_model_from_hf_uri(self, uri: str) -> Optional[dict]:
916
+ """Build a catalog entry from ``hf://repo_id/filename.gguf``."""
917
+ raw = uri.strip()
918
+ if not raw.lower().startswith("hf://"):
919
+ self.console.print(f"[{BAD}]Invalid Hugging Face URI.[/]")
920
+ return None
921
+ payload = raw[5:]
922
+ if "/" not in payload:
923
+ self.console.print(f"[{BAD}]Expected format:[/] hf://owner/repo/file.gguf")
924
+ return None
925
+
926
+ parts = [p for p in payload.split("/") if p]
927
+ if len(parts) < 3:
928
+ self.console.print(f"[{BAD}]Expected format:[/] hf://owner/repo/file.gguf")
929
+ return None
930
+
931
+ owner = parts[0]
932
+ repo = parts[1]
933
+ filename = "/".join(parts[2:])
934
+ if not filename.lower().endswith(".gguf"):
935
+ self.console.print(f"[{BAD}]Only .gguf files are supported here.[/]")
936
+ return None
937
+
938
+ repo_id = f"{owner}/{repo}"
939
+ return self._build_model_entry(repo_id, filename)
940
+
941
+ def _build_model_from_hf_reference(self, raw: str) -> Optional[dict]:
942
+ """Accept hf:// URIs or Hugging Face resolve/blob URLs and build a model.
943
+
944
+ Returns None when ``raw`` doesn't look like a direct model reference.
945
+ """
946
+ text = raw.strip()
947
+ if not text:
948
+ return None
949
+
950
+ if text.lower().startswith("hf://"):
951
+ return self._build_model_from_hf_uri(text)
952
+
953
+ if text.lower().startswith("http://") or text.lower().startswith("https://"):
954
+ parsed = urlparse(text)
955
+ if "huggingface.co" not in (parsed.netloc or "").lower():
956
+ return None
957
+ parts = [unquote(p) for p in parsed.path.split("/") if p]
958
+ if len(parts) >= 2:
959
+ owner, repo = parts[0], parts[1]
960
+ if len(parts) >= 5:
961
+ marker = parts[2].lower()
962
+ if marker in ("resolve", "blob"):
963
+ filename = "/".join(parts[4:])
964
+ if filename.lower().endswith(".gguf"):
965
+ return self._build_model_from_hf_uri(f"hf://{owner}/{repo}/{filename}")
966
+ return self._build_model_from_repo(f"{owner}/{repo}")
967
+
968
+ snippet_repo = self._extract_repo_id_from_text(text)
969
+ if snippet_repo:
970
+ return self._build_model_from_repo(snippet_repo)
971
+
972
+ if re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", text):
973
+ return self._build_model_from_repo(text)
974
+
975
+ return None
976
+
977
+ @staticmethod
978
+ def _extract_repo_id_from_text(text: str) -> Optional[str]:
979
+ quoted = re.findall(r"['\"]([^'\"]+)['\"]", text)
980
+ for token in quoted:
981
+ if re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", token):
982
+ return token
983
+ inline = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", text)
984
+ if inline:
985
+ return inline.group(1)
986
+ return None
987
+
988
+ @staticmethod
989
+ def _estimate_ram_default_gb(params_b: float, filename: str) -> int:
990
+ quant = filename.upper()
991
+ # Conservative but closer to practical defaults for GGUF quantizations.
992
+ if "Q2" in quant or "IQ2" in quant:
993
+ factor = 0.45
994
+ elif "Q3" in quant or "IQ3" in quant:
995
+ factor = 0.6
996
+ elif "Q4" in quant or "IQ4" in quant:
997
+ factor = 0.8
998
+ elif "Q5" in quant:
999
+ factor = 1.0
1000
+ elif "Q6" in quant:
1001
+ factor = 1.15
1002
+ elif "Q8" in quant:
1003
+ factor = 1.45
1004
+ else:
1005
+ factor = 0.9
1006
+ return max(2, int(round(max(1.5, params_b * factor))))
1007
+
1008
+ def _pick_recommended_file_index(self, files: List[str]) -> int:
1009
+ ranked = sorted(
1010
+ enumerate(files, start=1),
1011
+ key=lambda item: self._file_rank(item[1]),
1012
+ )
1013
+ return ranked[0][0] if ranked else 1
1014
+
1015
+ @staticmethod
1016
+ def _file_rank(filename: str) -> Tuple[int, int, str]:
1017
+ upper = filename.upper()
1018
+ # Prefer common local-friendly quantizations first.
1019
+ quant_priority = [
1020
+ "Q4_K_M", "Q4_K_S", "Q4_0", "Q5_K_M", "Q5_0", "Q3_K_M", "Q8_0",
1021
+ ]
1022
+ for i, q in enumerate(quant_priority):
1023
+ if q in upper:
1024
+ return (i, len(filename), upper)
1025
+ return (len(quant_priority), len(filename), upper)
1026
+
1027
+ def _show_profile(self) -> None:
1028
+ hw = self.hardware
1029
+ grid = Table.grid(padding=(0, 2))
1030
+ grid.add_column(style=f"bold {DIM}")
1031
+ grid.add_column()
1032
+ for label, value in (
1033
+ ("System", hw.get("os", "?")),
1034
+ ("CPU", hw.get("cpu_model", "?")),
1035
+ ("Cores (logical)", hw.get("cpu_count", "?")),
1036
+ ("Total RAM", f"{hw.get('ram_total_gb', 0):.1f} GB"),
1037
+ ("Free RAM", f"{hw.get('ram_available_gb', 0):.1f} GB"),
1038
+ ("Free disk", f"{hw.get('disk_free_gb', 0):.1f} GB"),
1039
+ ("GPU", hw.get("gpu_name", "not detected")),
1040
+ ("VRAM", f"{hw.get('gpu_vram_gb', 0):.1f} GB"),
1041
+ ):
1042
+ grid.add_row(label, str(value))
1043
+ self.console.print(Panel(
1044
+ grid, title=f"[bold {ACCENT}]Hardware profile[/]", border_style=ACCENT))
1045
+ self._pause("[Enter to continue]")
1046
+
1047
+ def _manage_history(self) -> None:
1048
+ from pathlib import Path
1049
+
1050
+ from .storage import ConversationStore
1051
+
1052
+ store = ConversationStore(Path(getattr(self.args, "db", DB_FILE)))
1053
+ try:
1054
+ convs = store.list()
1055
+ if not convs:
1056
+ self.console.print(Panel(
1057
+ f"[{DIM}]No saved conversations yet.[/]", border_style=DIM))
1058
+ self._pause()
1059
+ return
1060
+
1061
+ table = Table(title=f"[bold {ACCENT}]Saved conversations[/]", border_style=ACCENT, expand=True)
1062
+ table.add_column("ID", style=DIM, width=5)
1063
+ table.add_column("Title", overflow="fold")
1064
+ table.add_column("Model", overflow="fold")
1065
+ table.add_column("Msgs", justify="right")
1066
+ table.add_column("Updated")
1067
+ for c in convs:
1068
+ table.add_row(
1069
+ f"#{c.id}", c.title, c.model_name or "-",
1070
+ str(c.message_count), c.updated_at,
1071
+ )
1072
+ self.console.print(table)
1073
+
1074
+ choice = Prompt.ask(
1075
+ f"\n[{ACCENT}]Action[/] [{DIM}](resume <id>, delete <id>, export <id>, Enter = back)[/]",
1076
+ console=self.console, default="",
1077
+ ).strip()
1078
+ if not choice:
1079
+ return
1080
+ parts = choice.split()
1081
+ cmd = parts[0].lower()
1082
+ if cmd in ("resume", "r") and len(parts) == 2 and parts[1].isdigit():
1083
+ self._resume_conversation(store, int(parts[1]))
1084
+ elif cmd in ("delete", "d") and len(parts) == 2 and parts[1].isdigit():
1085
+ self._delete_conversation(store, int(parts[1]))
1086
+ elif cmd in ("export", "e") and len(parts) >= 2 and parts[1].isdigit():
1087
+ fmt = parts[2] if len(parts) > 2 and parts[2] in ("md", "json") else "md"
1088
+ self._export_conversation(store, int(parts[1]), fmt)
1089
+ else:
1090
+ self.console.print(
1091
+ f"[{BAD}]Usage:[/] resume <id> | delete <id> | export <id> [md|json]")
1092
+ finally:
1093
+ store.close()
1094
+ self._pause()
1095
+
1096
+ def _resume_conversation(self, store: Any, conv_id: int) -> None:
1097
+ if store.get(conv_id) is None:
1098
+ self.console.print(f"[{BAD}]Conversation #{conv_id} not found.[/]")
1099
+ return
1100
+ if not self._require_llm():
1101
+ return
1102
+ pick = self._pick_model(title="Model to resume the conversation")
1103
+ if pick is None:
1104
+ return
1105
+ idx, _model = pick
1106
+ self._run_app(serve=False, model=idx, resume=conv_id)
1107
+
1108
+ def _delete_conversation(self, store: Any, conv_id: int) -> None:
1109
+ if not Confirm.ask(f"Delete conversation #{conv_id}?", console=self.console, default=False):
1110
+ self.console.print(f"[{DIM}]Cancelled.[/]")
1111
+ return
1112
+ if store.delete(conv_id):
1113
+ self.console.print(f"[{GOOD}]Conversation #{conv_id} deleted.[/]")
1114
+ else:
1115
+ self.console.print(f"[{BAD}]Conversation #{conv_id} not found.[/]")
1116
+
1117
+ def _export_conversation(self, store: Any, conv_id: int, fmt: str) -> None:
1118
+ from pathlib import Path
1119
+
1120
+ path = Path(f"conversation_{conv_id}.{fmt}")
1121
+ result = store.export(conv_id, path, fmt)
1122
+ if result is None:
1123
+ self.console.print(f"[{BAD}]Conversation #{conv_id} not found.[/]")
1124
+ else:
1125
+ self.console.print(f"[{GOOD}]Exported to[/] {result.resolve()}")
1126
+
1127
+
1128
+ def run(args: Any) -> None:
1129
+ """Entry point called from ``cli.py`` when no action flag is given."""
1130
+ InteractiveMenu(args).run()
1131
+
1132
+
1133
+ if __name__ == "__main__":
1134
+ import argparse
1135
+
1136
+ run(argparse.Namespace(
1137
+ db=str(DB_FILE), host="127.0.0.1", port=8000,
1138
+ no_mcp=False, serve=False, model=None, resume=None,
1139
+ gpu=False, cpu=False, n_gpu_layers=-1,
1140
+ ))