deepwrap 0.1.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.
@@ -0,0 +1,966 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shlex
7
+ import subprocess
8
+ import time
9
+
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from deepwrap.utils.config_store import AppConfig as CLIConfig
14
+ from deepwrap.utils.config_store import ConfigStore
15
+
16
+ try:
17
+ from rich.console import Console, Group
18
+ from rich.live import Live
19
+ from rich.markdown import Markdown
20
+ from rich.rule import Rule
21
+ from rich.spinner import Spinner
22
+ from rich.text import Text
23
+ except ImportError as exc: # pragma: no cover
24
+ raise RuntimeError(
25
+ "Missing dependency: rich. Install it with: pip install rich"
26
+ ) from exc
27
+
28
+ try:
29
+ from prompt_toolkit import PromptSession
30
+ from prompt_toolkit.application import Application
31
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
32
+ from prompt_toolkit.completion import Completer, Completion, CompleteEvent
33
+ from prompt_toolkit.document import Document
34
+ from prompt_toolkit.history import FileHistory
35
+ from prompt_toolkit.key_binding import KeyBindings
36
+ from prompt_toolkit.layout import Layout
37
+ from prompt_toolkit.layout.containers import Window
38
+ from prompt_toolkit.layout.controls import FormattedTextControl
39
+ from prompt_toolkit.styles import Style
40
+ except ImportError as exc: # pragma: no cover
41
+ raise RuntimeError(
42
+ "Missing dependency: prompt_toolkit. Install it with: pip install prompt_toolkit"
43
+ ) from exc
44
+
45
+ from deepwrap import Client
46
+ from deepwrap.core import Auth
47
+
48
+ APP_NAME = "deepwrap"
49
+ TOKEN_ENV_NAME = "DEEPWRAP_API_KEY"
50
+ SUPPORTED_MODELS = ("expert", "default", "vision")
51
+ COMMANDS = (
52
+ "/help",
53
+ "/exit",
54
+ "/quit",
55
+ "/clear",
56
+ "/new",
57
+ "/model",
58
+ "/token",
59
+ "/thinking",
60
+ "/search",
61
+ "/god",
62
+ "/save",
63
+ "/status",
64
+ )
65
+
66
+ HELP_ITEMS = [
67
+ ("/help", "Show this help"),
68
+ ("/exit", "Exit the CLI"),
69
+ ("/clear", "Clear the terminal"),
70
+ ("/new", "Start a fresh chat session"),
71
+ ("/model <name>", "Switch model (expert, default, vision)"),
72
+ ("/token", "Set token interactively"),
73
+ ('/token "<token>"', "Set token inline"),
74
+ ("/thinking on|off", "Show or hide <think> blocks in UI"),
75
+ ("/search on|off", "Enable or disable search"),
76
+ ("/god on|off", "Enable or disable God Mode"),
77
+ ("/save", "Save current settings to config"),
78
+ ("/status", "Show current session status"),
79
+ ]
80
+
81
+
82
+
83
+ class SlashCommandCompleter(Completer):
84
+ """
85
+ Autocomplete slash commands only when the input starts with a slash.
86
+ """
87
+
88
+ def get_completions(self, document: Document, complete_event: CompleteEvent):
89
+ text = document.text_before_cursor
90
+
91
+ if not text.startswith("/"):
92
+ return
93
+
94
+ for command in COMMANDS:
95
+ if command.startswith(text):
96
+ yield Completion(
97
+ command,
98
+ start_position = -len(text),
99
+ display = command,
100
+ )
101
+
102
+
103
+ class DeepWrapCLI:
104
+ """
105
+ Interactive terminal UI for DeepWrap.
106
+ """
107
+
108
+ def __init__(self) -> None:
109
+ self.console = Console(highlight = False)
110
+ self.store = ConfigStore()
111
+ self.config = self.store.load()
112
+
113
+ env_token = os.getenv(TOKEN_ENV_NAME) or os.getenv("DEEPSEEK_API_KEY")
114
+ if env_token:
115
+ self.config.token = env_token
116
+
117
+ history_path = self.store.path.parent / "history.txt"
118
+
119
+ self.prompt = PromptSession(
120
+ history = FileHistory(str(history_path)),
121
+ auto_suggest = AutoSuggestFromHistory(),
122
+ completer = SlashCommandCompleter(),
123
+ complete_while_typing = True,
124
+ )
125
+
126
+ self.client: Optional[Client] = None
127
+ self.chat = None
128
+ self._last_ctrl_c_at = 0.0
129
+
130
+ if self.config.token:
131
+ self._boot_client()
132
+
133
+ def _erase_last_input_line(self) -> None:
134
+ """
135
+ Remove the last prompt line from the terminal.
136
+
137
+ This is used to avoid showing the raw prompt-toolkit input line and the
138
+ styled user bubble at the same time.
139
+
140
+ The sequence is intentionally conservative because moving the cursor
141
+ around near the terminal bottom edge is fragile on some terminals.
142
+ """
143
+
144
+ try:
145
+ stream = self.console.file
146
+
147
+ if hasattr(stream, "isatty") and not stream.isatty():
148
+ return
149
+
150
+ stream.write("\r")
151
+ stream.write("\x1b[2K")
152
+ stream.write("\x1b[1A")
153
+ stream.write("\x1b[2K")
154
+ stream.write("\r")
155
+ stream.flush()
156
+ except Exception:
157
+ pass
158
+
159
+ def run(self) -> None:
160
+ """
161
+ Run the interactive CLI loop.
162
+ """
163
+
164
+ self._clear_screen()
165
+ self._render_header()
166
+ self._render_intro()
167
+
168
+ if not self.config.token:
169
+ self._print_system(
170
+ "No token configured.",
171
+ style = "yellow",
172
+ )
173
+ self._offer_token_setup()
174
+
175
+ while True:
176
+ try:
177
+ user_input = self.prompt.prompt(
178
+ self._prompt_text(),
179
+ )
180
+
181
+ self._erase_last_input_line()
182
+ self._last_ctrl_c_at = 0.0
183
+
184
+ except KeyboardInterrupt:
185
+ now = time.monotonic()
186
+
187
+ if now - self._last_ctrl_c_at <= 1.2:
188
+ self.console.print()
189
+ self._print_system("Bye.", style = "dim")
190
+ return
191
+
192
+ self._last_ctrl_c_at = now
193
+ self.console.print()
194
+ self._print_system(
195
+ "Input cancelled. Press Ctrl+C again to exit.",
196
+ style = "yellow",
197
+ )
198
+ continue
199
+
200
+ except EOFError:
201
+ self.console.print()
202
+ self._print_system("Bye.", style = "dim")
203
+ return
204
+
205
+ message = user_input.strip()
206
+ if not message:
207
+ continue
208
+
209
+ if message.startswith("/"):
210
+ if not self._handle_command(message):
211
+ return
212
+ continue
213
+
214
+ if not self._ensure_chat():
215
+ continue
216
+
217
+ self._render_user(message)
218
+ self._stream_assistant(message)
219
+
220
+ def _handle_command(self, raw: str) -> bool:
221
+ """
222
+ Handle slash commands.
223
+
224
+ Returns:
225
+ False if the CLI should exit, otherwise True.
226
+ """
227
+
228
+ try:
229
+ parts = shlex.split(raw)
230
+ except ValueError as exc:
231
+ self._print_system(f"Command parse error: {exc}", style = "red")
232
+ return True
233
+
234
+ command = parts[0].lower()
235
+ args = parts[1:]
236
+
237
+ if command in {"/exit", "/quit"}:
238
+ return False
239
+
240
+ if command == "/help":
241
+ self._render_help()
242
+ return True
243
+
244
+ if command == "/clear":
245
+ self._clear_screen()
246
+ self._render_header()
247
+ self._render_intro()
248
+ return True
249
+
250
+ if command == "/new":
251
+ self._create_chat()
252
+ self._print_system("Started a new chat session.", style = "green")
253
+ return True
254
+
255
+ if command == "/status":
256
+ self._render_status()
257
+ return True
258
+
259
+ if command == "/save":
260
+ self.store.save(self.config)
261
+ self._print_system(f"Saved config to {self.store.path}", style = "green")
262
+ return True
263
+
264
+ if command == "/model":
265
+ if not args:
266
+ self._print_system(
267
+ f"Current model: {self.config.model}",
268
+ style = "cyan",
269
+ )
270
+ return True
271
+
272
+ model = args[0].strip().lower()
273
+
274
+ if model not in SUPPORTED_MODELS:
275
+ self._print_system(
276
+ f"Unsupported model: {model}. Supported: {', '.join(SUPPORTED_MODELS)}",
277
+ style = "red",
278
+ )
279
+ return True
280
+
281
+ self.config.model = model
282
+ self._create_chat()
283
+ self._print_system(f"Model switched to {model}.", style = "green")
284
+ return True
285
+
286
+ if command == "/thinking":
287
+ if not args:
288
+ state = "on" if self.config.show_thinking else "off"
289
+ self._print_system(f"Thinking display is {state}.", style = "cyan")
290
+ return True
291
+
292
+ value = args[0].strip().lower()
293
+
294
+ if value not in {"on", "off"}:
295
+ self._print_system("Usage: /thinking on|off", style = "red")
296
+ return True
297
+
298
+ self.config.show_thinking = value == "on"
299
+ state = "enabled" if self.config.show_thinking else "hidden"
300
+ self._print_system(f"Thinking blocks are now {state}.", style = "green")
301
+ return True
302
+
303
+ if command == "/search":
304
+ if not args:
305
+ state = "on" if self.config.search_enabled else "off"
306
+ self._print_system(f"Search is {state}.", style = "cyan")
307
+ return True
308
+
309
+ value = args[0].strip().lower()
310
+
311
+ if value not in {"on", "off"}:
312
+ self._print_system("Usage: /search on|off", style = "red")
313
+ return True
314
+
315
+ self.config.search_enabled = value == "on"
316
+ state = "enabled" if self.config.search_enabled else "disabled"
317
+ self._print_system(f"Search is now {state}.", style = "green")
318
+ return True
319
+
320
+ if command == "/god":
321
+ if not args:
322
+ state = "on" if self.config.god_mode else "off"
323
+ self._print_system(f"God Mode is {state}.", style = "cyan")
324
+ return True
325
+
326
+ value = args[0].strip().lower()
327
+
328
+ if value not in {"on", "off"}:
329
+ self._print_system("Usage: /god on|off", style = "red")
330
+ return True
331
+
332
+ self.config.god_mode = value == "on"
333
+ self._create_chat()
334
+ state = "enabled" if self.config.god_mode else "disabled"
335
+ self._print_system(f"God Mode is now {state}.", style = "green")
336
+ return True
337
+
338
+ if command == "/token":
339
+ inline_token = args[0] if args else None
340
+ self._set_token_interactive(inline_token)
341
+ return True
342
+
343
+ self._print_system(f"Unknown command: {command}", style = "red")
344
+ return True
345
+
346
+ def _offer_token_setup(self) -> None:
347
+ """
348
+ Offer a modern auth method picker when no token is configured.
349
+ """
350
+
351
+ choice = self._select_auth_method()
352
+
353
+ if choice == "browser":
354
+ self._run_browser_auth()
355
+ return
356
+
357
+ if choice == "manual":
358
+ self._set_token_interactive()
359
+ return
360
+
361
+ self._print_system(
362
+ "Token setup skipped. You can run /token later.",
363
+ style = "yellow",
364
+ )
365
+
366
+ def _select_auth_method(self) -> Optional[str]:
367
+ """
368
+ Show a modern arrow-key auth selector.
369
+
370
+ Returns:
371
+ "browser", "manual", or None.
372
+ """
373
+
374
+ options = [
375
+ (
376
+ "browser",
377
+ "Login via browser (Auto)",
378
+ "Open the browser and let Client.auth.browser() handle auth.",
379
+ ),
380
+ (
381
+ "manual",
382
+ "Use Bearer (Manual)",
383
+ "Paste a bearer token with a cleaner prompt.",
384
+ ),
385
+ ]
386
+
387
+ selected = {"index": 0}
388
+
389
+ def get_fragments():
390
+ fragments = [
391
+ ("fg:#60a5fa bold", " DeepWrap setup\n"),
392
+ ("fg:#94a3b8", " Choose an auth method\n\n"),
393
+ ]
394
+
395
+ for index, (_, label, description) in enumerate(options):
396
+ active = index == selected["index"]
397
+
398
+ if active:
399
+ fragments.extend(
400
+ [
401
+ ("bg:#172554 fg:#ffffff bold", " ● "),
402
+ ("bg:#172554 fg:#bfdbfe bold", f"{label}"),
403
+ ("", "\n"),
404
+ ("fg:#93c5fd", f" {description}\n\n"),
405
+ ]
406
+ )
407
+ else:
408
+ fragments.extend(
409
+ [
410
+ ("fg:#475569", " ○ "),
411
+ ("fg:#94a3b8", f"{label}"),
412
+ ("", "\n"),
413
+ ("fg:#64748b", f" {description}\n\n"),
414
+ ]
415
+ )
416
+
417
+ fragments.append(
418
+ ("fg:#64748b", " ↑/↓ or Tab to move • Enter to confirm • Esc to cancel ")
419
+ )
420
+
421
+ return fragments
422
+
423
+ kb = KeyBindings()
424
+
425
+ @kb.add("up")
426
+ def _(event):
427
+ selected["index"] = (selected["index"] - 1) % len(options)
428
+
429
+ @kb.add("down")
430
+ @kb.add("tab")
431
+ def _(event):
432
+ selected["index"] = (selected["index"] + 1) % len(options)
433
+
434
+ @kb.add("s-tab")
435
+ def _(event):
436
+ selected["index"] = (selected["index"] - 1) % len(options)
437
+
438
+ @kb.add("enter")
439
+ def _(event):
440
+ event.app.exit(result = options[selected["index"]][0])
441
+
442
+ @kb.add("escape")
443
+ @kb.add("c-c")
444
+ def _(event):
445
+ event.app.exit(result = None)
446
+
447
+ style = Style.from_dict(
448
+ {
449
+ "dialog": "bg:#020617",
450
+ }
451
+ )
452
+
453
+ body = Window(
454
+ content = FormattedTextControl(get_fragments),
455
+ always_hide_cursor = True,
456
+ )
457
+
458
+ app = Application(
459
+ layout = Layout(body),
460
+ key_bindings = kb,
461
+ full_screen = False,
462
+ mouse_support = False,
463
+ style = style,
464
+ )
465
+
466
+ self.console.print()
467
+ result = app.run()
468
+ self.console.print()
469
+
470
+ return result
471
+
472
+ def _run_browser_auth(self) -> None:
473
+ """
474
+ Start browser-based auth via the client's auth API.
475
+
476
+ This assumes your Client supports being instantiated without a token
477
+ and exposes `client.auth.browser()`.
478
+ """
479
+
480
+ self._clear_screen()
481
+ self._render_header()
482
+ self._render_intro()
483
+
484
+ self._print_system(
485
+ "Starting browser auth...",
486
+ style = "cyan",
487
+ )
488
+
489
+ try:
490
+ result = Auth().browser()
491
+
492
+ if result is None:
493
+ self._print_system("Browser auth was cancelled or failed.", style = "red")
494
+ return
495
+
496
+ except KeyboardInterrupt:
497
+ self._print_system("Browser auth cancelled.", style = "yellow")
498
+ return
499
+
500
+ except Exception as exc:
501
+ self._print_system(f"Browser auth failed: {exc}", style = "red")
502
+ self._offer_token_setup()
503
+ return
504
+
505
+ token = self._extract_token_from_auth_result(result)
506
+
507
+ if not token or token is None or len(token.strip()) == 0:
508
+ self._print_system(
509
+ "Browser auth did not return a token.",
510
+ style = "red",
511
+ )
512
+
513
+ self._offer_token_setup()
514
+ return
515
+
516
+ self.config.token = token
517
+ self._boot_client(reset_chat = True)
518
+ self._print_system("Browser auth completed.", style = "green")
519
+
520
+ save_local = self._ask_yes_no(
521
+ "Save token to DeepWrap config file?",
522
+ default = True,
523
+ )
524
+
525
+ if save_local:
526
+ self.store.save(self.config)
527
+ self._print_system(f"Saved token to {self.store.path}", style = "green")
528
+
529
+ @staticmethod
530
+ def _extract_token_from_auth_result(result) -> Optional[str]:
531
+ """
532
+ Normalize the return value of `client.auth.browser()` into a token.
533
+ """
534
+
535
+ if isinstance(result, str):
536
+ return result.strip() or None
537
+
538
+ if isinstance(result, dict):
539
+ for key in ("token", "api_key", "bearer_token", "access_token"):
540
+ value = result.get(key)
541
+ if isinstance(value, str) and value.strip():
542
+ return value.strip()
543
+
544
+ for attr in ("token", "api_key", "bearer_token", "access_token"):
545
+ value = getattr(result, attr, None)
546
+ if isinstance(value, str) and value.strip():
547
+ return value.strip()
548
+
549
+ return None
550
+
551
+ def _set_token_interactive(self, token: Optional[str] = None) -> None:
552
+ """
553
+ Set the API token and optionally persist it.
554
+ """
555
+
556
+ if not token:
557
+ self.console.print()
558
+ self.console.print(Text("manual auth", style = "bold #60a5fa"))
559
+ self.console.print(
560
+ Text(
561
+ "Paste your bearer token below. It will not be echoed.",
562
+ style = "#93c5fd",
563
+ )
564
+ )
565
+ token = self.prompt.prompt(
566
+ [("#60a5fa bold", " token "), ("#93c5fd", "> ")],
567
+ is_password = True,
568
+ ).strip()
569
+ self.console.print()
570
+
571
+ if not token:
572
+ self._print_system("Token was empty. Nothing changed.", style = "yellow")
573
+ return
574
+
575
+ self.config.token = token
576
+ try:
577
+ self._boot_client(reset_chat = True)
578
+ except Exception as exc:
579
+ self._print_system(f"Failed to boot client: {exc}", style = "red")
580
+ self._select_auth_method()
581
+ return
582
+
583
+ self._print_system("Token updated for current session.", style = "green")
584
+
585
+ save_local = self._ask_yes_no(
586
+ "Save token to DeepWrap config file?",
587
+ default = True,
588
+ )
589
+
590
+ if save_local:
591
+ self.store.save(self.config)
592
+ self._print_system(f"Saved token to {self.store.path}", style = "green")
593
+
594
+ save_system = self._ask_yes_no(
595
+ f"Persist token to system environment variable {TOKEN_ENV_NAME}?",
596
+ default = False,
597
+ )
598
+
599
+ if save_system:
600
+ ok, message = self._persist_system_env(TOKEN_ENV_NAME, token)
601
+ self._print_system(message, style = "green" if ok else "yellow")
602
+
603
+ def _persist_system_env(self, key: str, value: str) -> tuple[bool, str]:
604
+ """
605
+ Persist a token as a user-level environment variable.
606
+ """
607
+
608
+ try:
609
+ if os.name == "nt":
610
+ subprocess.run(
611
+ ["setx", key, value],
612
+ check = True,
613
+ capture_output = True,
614
+ text = True,
615
+ )
616
+ return True, f"Saved {key} with setx. Restart your terminal to load it."
617
+
618
+ profile = Path.home() / ".profile"
619
+ line = f'export {key}="{value}"\n'
620
+
621
+ if profile.exists():
622
+ existing = profile.read_text(encoding = "utf-8")
623
+ updated = re.sub(
624
+ rf'^export\s+{re.escape(key)}=.*$\n?',
625
+ '',
626
+ existing,
627
+ flags = re.MULTILINE,
628
+ )
629
+ else:
630
+ updated = ""
631
+
632
+ updated += ("\n" if updated and not updated.endswith("\n") else "") + line
633
+ profile.write_text(updated, encoding = "utf-8")
634
+
635
+ return True, f"Saved {key} to {profile}. Restart your shell to load it."
636
+
637
+ except Exception as exc: # pragma: no cover
638
+ return False, f"Could not persist system env token: {exc}"
639
+
640
+ def _stream_assistant(self, prompt: str) -> None:
641
+ """
642
+ Render assistant output live with markdown formatting.
643
+
644
+ The live region is intentionally cropped near the terminal bottom edge
645
+ to avoid crashes or cursor corruption in small terminals. After the
646
+ streaming finishes, the final full content is printed statically.
647
+ """
648
+
649
+ thinking = ""
650
+ answer = ""
651
+ phase = "thinking"
652
+
653
+ with Live(
654
+ self._build_live_renderable(thinking, answer, phase),
655
+ console = self.console,
656
+ refresh_per_second = 18,
657
+ transient = True,
658
+ vertical_overflow = "ellipsis",
659
+ ) as live:
660
+ try:
661
+ for kind, chunk in self.chat.respond_structured(
662
+ prompt = prompt,
663
+ thinking = True,
664
+ search = self.config.search_enabled,
665
+ ):
666
+ if kind == "think":
667
+ thinking += chunk
668
+
669
+ if not answer:
670
+ phase = "thinking"
671
+
672
+ elif kind == "response":
673
+ answer += chunk
674
+ phase = "response"
675
+
676
+ live.update(
677
+ self._build_live_renderable(
678
+ thinking = thinking,
679
+ answer = answer,
680
+ phase = phase,
681
+ )
682
+ )
683
+
684
+ except KeyboardInterrupt:
685
+ live.stop()
686
+ self.console.print()
687
+ self._print_system(
688
+ "Generation stopped.",
689
+ style = "yellow",
690
+ )
691
+ return
692
+
693
+ except Exception as exc:
694
+ live.update(Text(str(exc), style = "bold red"))
695
+ return
696
+
697
+ self._print_assistant_final(thinking, answer)
698
+
699
+ def _build_live_renderable(
700
+ self,
701
+ thinking: str,
702
+ answer: str,
703
+ phase: str,
704
+ ):
705
+ """
706
+ Build the temporary live renderable shown while a response is streaming.
707
+ """
708
+
709
+ parts = []
710
+
711
+ if self.config.show_thinking:
712
+ if phase == "thinking":
713
+ parts.append(
714
+ Spinner(
715
+ "dots",
716
+ text = " thinking",
717
+ style = "bold #60a5fa",
718
+ )
719
+ )
720
+
721
+ elif thinking:
722
+ parts.append(Text("thinking", style = "bold #60a5fa"))
723
+
724
+ if thinking:
725
+ parts.append(Text(thinking, style = "dim #94a3b8"))
726
+
727
+ if thinking or phase == "thinking":
728
+ parts.append(Text(""))
729
+
730
+ else:
731
+ if phase == "thinking" and not answer:
732
+ parts.append(
733
+ Spinner(
734
+ "dots",
735
+ text = " DeepWrap is thinking",
736
+ style = "bold #60a5fa",
737
+ )
738
+ )
739
+ parts.append(Text(""))
740
+
741
+ if answer:
742
+ parts.append(Text("DeepWrap", style = "bold #3b82f6"))
743
+ parts.append(Markdown(answer))
744
+ else:
745
+ parts.append(Text("DeepWrap", style = "bold #3b82f6"))
746
+
747
+ return Group(*parts)
748
+
749
+ def _print_assistant_final(self, thinking: str, answer: str) -> None:
750
+ """
751
+ Print the final completed assistant output as normal static content.
752
+ """
753
+
754
+ self.console.print()
755
+
756
+ if self.config.show_thinking and thinking:
757
+ self.console.print(Text("thinking", style = "bold #60a5fa"))
758
+ self.console.print(Text(thinking, style = "dim #94a3b8"))
759
+ self.console.print()
760
+
761
+ self.console.print(Text("DeepWrap", style = "bold #3b82f6"))
762
+
763
+ if answer.strip():
764
+ self.console.print(Markdown(answer))
765
+ else:
766
+ self.console.print(Text("(empty response)", style = "dim"))
767
+
768
+ self.console.print()
769
+
770
+ def _render_header(self) -> None:
771
+ """
772
+ Render the top banner.
773
+ """
774
+
775
+ banner = Text()
776
+ banner.append("██████╗ ███████╗███████╗██████╗ ██╗ ██╗██████╗ █████╗ ██████╗ \n", style = "bold #3b82f6")
777
+ banner.append("██╔══██╗██╔════╝██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗\n", style = "bold #3b82f6")
778
+ banner.append("██║ ██║█████╗ █████╗ ██████╔╝██║ █╗ ██║██████╔╝███████║██████╔╝\n", style = "bold #3b82f6")
779
+ banner.append("██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ██║███╗██║██╔══██╗██╔══██║██╔═══╝ \n", style = "bold #3b82f6")
780
+ banner.append("██████╔╝███████╗███████╗██║ ╚███╔███╔╝██║ ██║██║ ██║██║ \n", style = "bold #3b82f6")
781
+ banner.append("╚═════╝ ╚══════╝╚══════╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ", style = "bold #3b82f6")
782
+
783
+ self.console.print()
784
+ self.console.print(banner)
785
+ self.console.print()
786
+
787
+ self.console.print(
788
+ Text(
789
+ "Do not pay in API calls for what a bad prompt can sabotage for free.",
790
+ style = "#94a3b8",
791
+ )
792
+ )
793
+ self.console.print(
794
+ Text("Type ", style = "#94a3b8")
795
+ + Text("/help", style = "bold #60a5fa")
796
+ + Text(" for commands.", style = "#94a3b8")
797
+ )
798
+ self.console.print()
799
+
800
+ def _render_intro(self) -> None:
801
+ """
802
+ Render current runtime state summary.
803
+ """
804
+
805
+ token_state = "set" if self.config.token else "missing"
806
+
807
+ self.console.print(Rule(style = "#1e3a8a"))
808
+ self.console.print(
809
+ Text(
810
+ f"model={self.config.model} | token={token_state} | thinking={'on' if self.config.show_thinking else 'off'} | search={'on' if self.config.search_enabled else 'off'} | god={'on' if self.config.god_mode else 'off'}",
811
+ style = "#93c5fd",
812
+ )
813
+ )
814
+ self.console.print()
815
+
816
+ def _render_help(self) -> None:
817
+ """
818
+ Render command help without borders.
819
+ """
820
+
821
+ self.console.print()
822
+ self.console.print(Text("help", style = "bold #60a5fa"))
823
+
824
+ for command, description in HELP_ITEMS:
825
+ line = Text()
826
+ line.append(" ")
827
+ line.append(command.ljust(20), style = "bold #bfdbfe")
828
+ line.append(description, style = "#cbd5e1")
829
+ self.console.print(line)
830
+
831
+ self.console.print()
832
+
833
+ def _render_status(self) -> None:
834
+ """
835
+ Render current runtime status without borders.
836
+ """
837
+
838
+ lines = [
839
+ f"model: {self.config.model}",
840
+ f"token: {'set' if self.config.token else 'missing'}",
841
+ f"thinking display: {'on' if self.config.show_thinking else 'off'}",
842
+ f"search: {'on' if self.config.search_enabled else 'off'}",
843
+ f"god mode: {'on' if self.config.god_mode else 'off'}",
844
+ f"chat session: {'active' if self.chat is not None else 'none'}",
845
+ f"config file: {self.store.path}",
846
+ ]
847
+
848
+ self.console.print()
849
+ self.console.print(Text("status", style = "bold #60a5fa"))
850
+ self.console.print(Text("\n".join(lines), style = "#dbeafe"))
851
+ self.console.print()
852
+
853
+ def _render_user(self, message: str) -> None:
854
+ """
855
+ Render a user message once, with a subtle blue background.
856
+ """
857
+
858
+ name = Text(" You ", style = "bold white on #2563eb")
859
+ body = Text(f" {message} ", style = "#dbeafe on #172554")
860
+
861
+ self.console.print()
862
+ self.console.print(name, end = " ")
863
+ self.console.print(body)
864
+ self.console.print()
865
+
866
+ def _print_system(self, message: str, style: str = "#94a3b8") -> None:
867
+ """
868
+ Render a system/status message.
869
+ """
870
+
871
+ self.console.print(Text(message, style = style))
872
+
873
+ def _prompt_text(self):
874
+ """
875
+ Return the prompt text shown for user input.
876
+ """
877
+
878
+ return [("#3b82f6 bold", "> ")]
879
+
880
+ def _boot_client(self, reset_chat: bool = True) -> None:
881
+ """
882
+ Create the root client from the current token.
883
+ """
884
+
885
+ if not self.config.token:
886
+ self.client = None
887
+ self.chat = None
888
+ return
889
+
890
+ try:
891
+ self.client = Client(api_key = self.config.token)
892
+
893
+ if reset_chat:
894
+ self._create_chat()
895
+
896
+ except Exception as exc:
897
+ self._print_system(f"Failed to initialize client: {exc}", style = "red")
898
+
899
+ if 'invalid token' in str(exc).lower():
900
+ self._clear_screen()
901
+ self._render_header()
902
+ self._render_intro()
903
+ self._print_system("The provided token is invalid. Please set a valid token.", style = "red")
904
+ self._offer_token_setup()
905
+
906
+ def _create_chat(self) -> None:
907
+ """
908
+ Create a new chat session using the current model and God Mode setting.
909
+ """
910
+
911
+ if self.client is None:
912
+ self.chat = None
913
+ return
914
+
915
+ self.chat = self.client.chats.create_session(
916
+ model = self.config.model,
917
+ god_mode = self.config.god_mode,
918
+ )
919
+
920
+ def _ensure_chat(self) -> bool:
921
+ """
922
+ Ensure a chat session exists before sending a prompt.
923
+ """
924
+
925
+ if self.client is None:
926
+ self._offer_token_setup()
927
+
928
+ if self.client is None:
929
+ self._print_system(
930
+ "No token configured. Use /token first.",
931
+ style = "yellow",
932
+ )
933
+ return False
934
+
935
+ if self.chat is None:
936
+ self._create_chat()
937
+
938
+ return self.chat is not None
939
+
940
+ def _ask_yes_no(self, prompt: str, default: bool) -> bool:
941
+ """
942
+ Ask a yes/no question interactively.
943
+ """
944
+
945
+ suffix = "[Y/n]" if default else "[y/N]"
946
+ reply = self.prompt.prompt(f"{prompt} {suffix} ").strip().lower()
947
+
948
+ if not reply:
949
+ return default
950
+
951
+ return reply in {"y", "yes"}
952
+
953
+ def _clear_screen(self) -> None:
954
+ """
955
+ Clear the terminal screen.
956
+ """
957
+
958
+ self.console.clear()
959
+
960
+
961
+ def main() -> None:
962
+ """
963
+ CLI entrypoint.
964
+ """
965
+
966
+ DeepWrapCLI().run()