cct-cli 0.7.9.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.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2018 @@
1
+ """
2
+ DEPRECATED — Developer Tools / diagnostic text browser. NOT the normal Browser Mode.
3
+
4
+ This module is a text-based HTML parser for developer diagnostics only.
5
+ It does NOT render real HTML/CSS/JavaScript — it scrapes HTML and converts
6
+ it to terminal text. This is NOT a browser.
7
+
8
+ Normal Browser Mode uses calc_terminal.host.desktop.EmbeddedBrowserPane
9
+ (QWebEngineView = real Chromium) instead.
10
+
11
+ This module remains available for:
12
+ - Developer diagnostics (CAT_BROWSER_DEBUG=1)
13
+ - Text extraction / page analysis
14
+ - Offline fallback when PySide6 is not installed
15
+
16
+ Do NOT use this as the primary browser renderer.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import html
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ import textwrap
27
+ import time
28
+ import urllib.parse
29
+ import urllib.request
30
+ import urllib.error
31
+ import http.cookiejar
32
+ from dataclasses import dataclass, field
33
+ from html.parser import HTMLParser
34
+ from pathlib import Path
35
+ from typing import Dict, List, Optional, Tuple
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Optional `requests` — nicer redirect/cookie/gzip handling; fallback is stdlib.
39
+ # ---------------------------------------------------------------------------
40
+ try:
41
+ import requests # type: ignore
42
+ _HAS_REQUESTS = True
43
+ except ImportError:
44
+ requests = None # type: ignore
45
+ _HAS_REQUESTS = False
46
+
47
+ # Optional imaging + Chromium for visual browsing (images / WebGL / 3D)
48
+ try:
49
+ from PIL import Image as _PILImage # type: ignore
50
+ _HAS_PIL = True
51
+ except ImportError:
52
+ _PILImage = None # type: ignore
53
+ _HAS_PIL = False
54
+
55
+ try:
56
+ from .browser.engine import BrowserEngine, BrowserUnavailableError, PLAYWRIGHT_AVAILABLE # type: ignore
57
+ _HAS_CHROMIUM = PLAYWRIGHT_AVAILABLE
58
+ except Exception:
59
+ BrowserEngine = None # type: ignore
60
+ BrowserUnavailableError = RuntimeError # type: ignore
61
+ _HAS_CHROMIUM = False
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Paths — bookmarks + history (append-only, bounded).
65
+ # ---------------------------------------------------------------------------
66
+
67
+ _BOOKMARKS_PATH = Path.home() / ".cat_browser_bookmarks.json"
68
+ _HISTORY_PATH = Path.home() / ".cat_browser_history.json"
69
+ _DOWNLOAD_DIR = Path.home() / "Downloads"
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # URL helpers
73
+ # ---------------------------------------------------------------------------
74
+
75
+ _HOME_HTML = """\
76
+ <!doctype html>
77
+ <title>CAT Browser — Home</title>
78
+ <h1>CAT Browser — Software-grade Terminal Browser</h1>
79
+ <p>No external Chrome needed. This is your full terminal browser — search, images, 3D/WebGL all inside CAT.</p>
80
+ <h2>Quick actions</h2>
81
+ <ul>
82
+ <li>Type a URL: <code>open https://example.com</code> or just <code>https://example.com</code></li>
83
+ <li>Search: <code>search cats on mars</code> (DuckDuckGo) or <code>search google cats</code> / <code>g cats</code> (Google)</li>
84
+ <li>Follow a link: type its number (e.g. <code>3</code>) or <code>open 3</code></li>
85
+ <li>Navigate: <code>back</code> <code>forward</code> <code>reload</code> <code>home</code></li>
86
+ <li>Images: <code>img 2</code> renders image #2 inline (png/jpg/webp/gif/svg/avif) — any format</li>
87
+ <li>3D/WebGL: <code>visual</code> renders the page via Chromium → ANSI (for three.js / maps / heavy canvas)</li>
88
+ <li>Inspect: <code>links</code> <code>find cats</code> <code>source</code> <code>save page.html</code></li>
89
+ <li>Bookmarks: <code>bookmark</code> <code>bookmarks</code> <code>open bookmark 1</code></li>
90
+ <li>History: <code>history</code> — Quit: <code>q</code></li>
91
+ </ul>
92
+ <h2>Suggestions — try 3D!</h2>
93
+ <ul>
94
+ <li><a href="https://example.com">example.com — test page</a></li>
95
+ <li><a href="https://threejs.org/examples/#webgl_animation_keyframes">three.js — WebGL animation (3D test)</a></li>
96
+ <li><a href="https://news.ycombinator.com">Hacker News</a></li>
97
+ <li><a href="https://en.wikipedia.org/wiki/Main_Page">Wikipedia</a></li>
98
+ <li><a href="https://duckduckgo.com">DuckDuckGo</a> — <a href="https://www.google.com">Google</a></li>
99
+ </ul>
100
+ """
101
+
102
+ _ABOUT_HOME_URL = "about:home"
103
+ _ABOUT_BLANK_URL = "about:blank"
104
+
105
+ _DDG_HTML_URL = "https://html.duckduckgo.com/html/?q={q}"
106
+ _GOOGLE_HTML_URL = "https://www.google.com/search?q={q}&hl=en&gbv=1"
107
+ # CAT Browser supports both search engines; default is duckduckgo (privacy),
108
+ # `search google cats` or `g cats` forces Google.
109
+ _DEFAULT_SEARCH_ENGINE = "duckduckgo" # or "google"
110
+
111
+
112
+ def normalize_url(raw: str, base: Optional[str] = None) -> str:
113
+ """Turn user input into an absolute URL or raise ValueError."""
114
+ raw = (raw or "").strip()
115
+ if not raw:
116
+ raise ValueError("empty url")
117
+ if raw.lower() in ("home", "about:home", _ABOUT_HOME_URL):
118
+ return _ABOUT_HOME_URL
119
+ if raw.lower() in ("about:blank", "blank"):
120
+ return _ABOUT_BLANK_URL
121
+ # If input has no dot/slash/scheme and is not about:, treat as search.
122
+ # Caller can force a real URL with `open`.
123
+ if base:
124
+ # Resolve relative against base (for link following).
125
+ if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", raw) and not raw.startswith("about:"):
126
+ return urllib.parse.urljoin(base, raw)
127
+ # Absolute with scheme?
128
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", raw):
129
+ return raw
130
+ if raw.startswith("about:"):
131
+ return raw
132
+ # Looks like a domain or host:port without scheme?
133
+ # Heuristic: contains a dot OR colon+port OR starts with localhost.
134
+ if re.match(r"^(localhost(:\d+)?)(/|$)", raw, re.I) or "." in raw.split("/")[0]:
135
+ # If it has no scheme but looks like a host, assume https.
136
+ # Localhost gets http by default.
137
+ if raw.lower().startswith("localhost"):
138
+ return "http://" + raw
139
+ return "https://" + raw
140
+ raise ValueError(f"not a URL: {raw!r}")
141
+
142
+
143
+ def _is_search_query(text: str) -> bool:
144
+ t = text.strip()
145
+ if not t:
146
+ return False
147
+ # Obvious URL-ish -> not a query
148
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", t):
149
+ return False
150
+ if t.lower().startswith("about:"):
151
+ return False
152
+ # Host-like (has dot before any space) -> not a query
153
+ first_token = t.split()[0] if " " in t else t
154
+ if "." in first_token and " " not in first_token and "/" not in t.split(" ")[0]:
155
+ # But "cats vs dogs" has no dot -> query; "example.com" -> url
156
+ # Check TLD-ish: dot with 2+ char suffix
157
+ if re.search(r"\.[a-z]{2,}($|/|:)", first_token, re.I):
158
+ return False
159
+ # Default: if it contains a space, it's a query. Single word without
160
+ # dot is ambiguous — treat as query so the browser is forgiving.
161
+ return True
162
+
163
+
164
+ def _search_url(query: str, engine: Optional[str] = None) -> str:
165
+ q = urllib.parse.quote_plus(query.strip())
166
+ eng = (engine or _DEFAULT_SEARCH_ENGINE).lower().strip()
167
+ if eng in ("google", "g", "goog"):
168
+ return _GOOGLE_HTML_URL.format(q=q)
169
+ # duckduckgo, ddg, d, duck
170
+ return _DDG_HTML_URL.format(q=q)
171
+
172
+
173
+ def _detect_search_engine(raw: str) -> Tuple[str, str]:
174
+ """Return (engine, query) from raw search input.
175
+
176
+ Supports:
177
+ search cats -> (ddg, cats)
178
+ search google cats -> (google, cats)
179
+ g cats -> (google, cats)
180
+ ddg cats -> (ddg, cats)
181
+ """
182
+ s = raw.strip()
183
+ low = s.lower()
184
+ # Explicit engine prefix
185
+ for prefix, eng in [("google ", "google"), ("g ", "google"), ("goog ", "google"),
186
+ ("duckduckgo ", "duckduckgo"), ("ddg ", "duckduckgo"), ("d ", "duckduckgo")]:
187
+ if low.startswith(prefix):
188
+ return eng, s[len(prefix):].strip()
189
+ # "search google cats" pattern (already stripped leading search/s)
190
+ if low.startswith("google "):
191
+ return "google", s[7:].strip()
192
+ if low.startswith("ddg "):
193
+ return "duckduckgo", s[4:].strip()
194
+ return _DEFAULT_SEARCH_ENGINE, s
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Fetch — unified over requests / urllib, with cookies + UA.
199
+ # ---------------------------------------------------------------------------
200
+
201
+ _UA = "CAT-Browser/0.7.10 (+terminal; like Lynx/2.9)"
202
+
203
+ _cookie_jar = http.cookiejar.CookieJar()
204
+ _opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_cookie_jar))
205
+
206
+ _session = None
207
+ if _HAS_REQUESTS:
208
+ try:
209
+ _session = requests.Session()
210
+ _session.headers.update({"User-Agent": _UA})
211
+ except Exception:
212
+ _session = None
213
+
214
+
215
+ @dataclass
216
+ class FetchResult:
217
+ url: str # final URL after redirects
218
+ requested_url: str # original request
219
+ status: int
220
+ headers: Dict[str, str]
221
+ body: bytes
222
+ text: str # decoded
223
+ error: Optional[str] = None
224
+ elapsed_ms: int = 0
225
+ from_cache: bool = False
226
+
227
+
228
+ def _decode_body(body: bytes, headers: Dict[str, str]) -> str:
229
+ # Try charset from Content-Type, else utf-8 with replacement.
230
+ ctype = headers.get("content-type", "") or headers.get("Content-Type", "")
231
+ charset = "utf-8"
232
+ m = re.search(r"charset=([^\s;\"']+)", ctype, re.I)
233
+ if m:
234
+ charset = m.group(1).strip().strip('"').strip("'")
235
+ for cand in (charset, "utf-8", "windows-1252", "iso-8859-1"):
236
+ try:
237
+ return body.decode(cand)
238
+ except Exception:
239
+ continue
240
+ return body.decode("utf-8", errors="replace")
241
+
242
+
243
+ def fetch_url(url: str, timeout: int = 18) -> FetchResult:
244
+ """Fetch one URL. about: urls synthesize a local page without network."""
245
+ req_url = url
246
+ if url in (_ABOUT_HOME_URL, _ABOUT_BLANK_URL):
247
+ body = _HOME_HTML.encode("utf-8") if url == _ABOUT_HOME_URL else b"<title>blank</title>"
248
+ return FetchResult(
249
+ url=url, requested_url=req_url, status=200,
250
+ headers={"content-type": "text/html; charset=utf-8"},
251
+ body=body, text=body.decode("utf-8"),
252
+ )
253
+ t0 = time.monotonic()
254
+ # Gate: Fomoji must still be valid at fetch time (not just at launch).
255
+ # Whitelist: the Fomoji server itself (localhost) must remain browsable
256
+ # even before a CAT session exists — otherwise the device flow's own
257
+ # verification page would be blocked by the gate that it's trying to
258
+ # solve (chicken-and-egg). Same for about: URLs (handled above).
259
+ try:
260
+ from . import fomoji_auth as _fa # pylint: disable=import-outside-toplevel
261
+ is_fomoji_local = False
262
+ try:
263
+ parsed_u = urllib.parse.urlparse(url)
264
+ host = (parsed_u.hostname or "").lower()
265
+ fomoji_host = urllib.parse.urlparse(_fa.get_fomoji_url()).hostname or ""
266
+ if host and fomoji_host and host == fomoji_host.lower():
267
+ is_fomoji_local = True
268
+ if host in ("localhost", "127.0.0.1", "::1"):
269
+ is_fomoji_local = True
270
+ except Exception:
271
+ pass
272
+ if not is_fomoji_local and not _fa.is_skip_enabled() and _fa.status() != "connected":
273
+ return FetchResult(
274
+ url=url, requested_url=req_url, status=0,
275
+ headers={}, body=b"", text="",
276
+ error="Fomoji session lost — run `cat --auth login` and reopen the browser.",
277
+ elapsed_ms=int((time.monotonic() - t0) * 1000),
278
+ )
279
+ except Exception:
280
+ pass # fomoji_auth missing in some test sandboxes — don't block fetching
281
+
282
+ # Prefer requests (cleaner gzip/deflate + redirects)
283
+ if _HAS_REQUESTS and _session is not None:
284
+ try:
285
+ resp = _session.get(url, timeout=timeout, allow_redirects=True)
286
+ hdrs = {k.lower(): v for k, v in resp.headers.items()}
287
+ body = resp.content or b""
288
+ text = resp.text if resp.text else _decode_body(body, hdrs)
289
+ return FetchResult(
290
+ url=str(resp.url), requested_url=req_url,
291
+ status=int(resp.status_code), headers=hdrs,
292
+ body=body, text=text,
293
+ elapsed_ms=int((time.monotonic() - t0) * 1000),
294
+ error=None if resp.ok else f"HTTP {resp.status_code}",
295
+ )
296
+ except Exception as e:
297
+ # Fall through to urllib for a second try on some errors.
298
+ if isinstance(e, KeyboardInterrupt):
299
+ raise
300
+ # If requests fails outright (no network, DNS...), try urllib once
301
+ # before surfacing — urllib sometimes has different proxy handling.
302
+ pass
303
+
304
+ # urllib fallback
305
+ try:
306
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
307
+ with _opener.open(req, timeout=timeout) as resp: # type: ignore
308
+ status = int(getattr(resp, "status", 200) or 200)
309
+ hdrs = {k.lower(): v for k, v in (getattr(resp, "headers", {}) or {}).items()} # type: ignore
310
+ body = resp.read()
311
+ text = _decode_body(body, hdrs)
312
+ final_url = getattr(resp, "url", url) or url
313
+ return FetchResult(
314
+ url=str(final_url), requested_url=req_url,
315
+ status=status, headers=hdrs, body=body, text=text,
316
+ elapsed_ms=int((time.monotonic() - t0) * 1000),
317
+ )
318
+ except urllib.error.HTTPError as e:
319
+ hdrs = {k.lower(): v for k, v in (getattr(e, "headers", {}) or {}).items()}
320
+ body = e.read() if hasattr(e, "read") else b""
321
+ text = _decode_body(body, hdrs) if body else ""
322
+ return FetchResult(
323
+ url=url, requested_url=req_url, status=int(e.code),
324
+ headers=hdrs, body=body, text=text,
325
+ elapsed_ms=int((time.monotonic() - t0) * 1000),
326
+ error=f"HTTP {e.code} {e.reason}",
327
+ )
328
+ except urllib.error.URLError as e:
329
+ return FetchResult(
330
+ url=url, requested_url=req_url, status=0,
331
+ headers={}, body=b"", text="",
332
+ error=f"Network error: {e.reason}",
333
+ elapsed_ms=int((time.monotonic() - t0) * 1000),
334
+ )
335
+ except Exception as e:
336
+ return FetchResult(
337
+ url=url, requested_url=req_url, status=0,
338
+ headers={}, body=b"", text="",
339
+ error=f"{type(e).__name__}: {e}",
340
+ elapsed_ms=int((time.monotonic() - t0) * 1000),
341
+ )
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Visual helpers — inline images (any format) + 3D/WebGL via Chromium screenshot
346
+ # ---------------------------------------------------------------------------
347
+
348
+ def _term_supports_truecolor() -> bool:
349
+ return True # we emit 24-bit and terminals that don't support it degrade gracefully
350
+
351
+
352
+ def render_image_to_ansi(image_bytes: bytes, term_width: int = 60, max_height: int = 24) -> str:
353
+ """Convert any image format (png/jpg/webp/gif/svg…) to half-block ANSI art.
354
+
355
+ Uses PIL when available (handles png/jpg/webp/gif/bmp; SVG via cairosvg
356
+ fallback to placeholder). Falls back to a placeholder when PIL missing.
357
+ WebGL/3D sites are handled by Chromium screenshot → this same path.
358
+ """
359
+ if not _HAS_PIL or not image_bytes:
360
+ return " [image: PIL not installed — pip install pillow to render images inline]"
361
+ try:
362
+ import io
363
+ # Handle SVG separately — try cairosvg, else placeholder
364
+ # Detect SVG by content
365
+ head = image_bytes[:512].lstrip().lower()
366
+ if head.startswith(b"<svg") or b"<svg" in head[:1024]:
367
+ try:
368
+ import cairosvg # type: ignore
369
+ png_bytes = cairosvg.svg2png(bytestring=image_bytes)
370
+ img = _PILImage.open(io.BytesIO(png_bytes)).convert("RGBA")
371
+ except Exception:
372
+ return " [svg image — install cairosvg to render inline]"
373
+ else:
374
+ img = _PILImage.open(io.BytesIO(image_bytes)).convert("RGBA")
375
+ # Handle GIF animation — use first frame
376
+ try:
377
+ img.seek(0)
378
+ except Exception:
379
+ pass
380
+ # Composite RGBA over white (or terminal bg) for ANSI
381
+ bg = _PILImage.new("RGBA", img.size, (255, 255, 255, 255))
382
+ try:
383
+ bg.paste(img, (0, 0), img)
384
+ except Exception:
385
+ bg = img.convert("RGB") # type: ignore
386
+ else:
387
+ bg = bg.convert("RGB")
388
+ img = bg
389
+ # Target size — preserve aspect, use half-block (2 pixels per row)
390
+ w, h = img.size
391
+ aspect = h / max(1, w)
392
+ target_w = min(term_width, max(20, term_width - 4))
393
+ target_h = int(target_w * aspect * 0.5) # 0.5 because '▀' is half-height
394
+ target_h = min(max_height * 2, max(6, target_h))
395
+ # Ensure even height for pairing
396
+ if target_h % 2 == 1:
397
+ target_h += 1
398
+ img = img.resize((target_w, target_h), _PILImage.LANCZOS) # type: ignore
399
+ pixels = img.load()
400
+ lines: List[str] = []
401
+ for y in range(0, target_h, 2):
402
+ line = " "
403
+ for x in range(target_w):
404
+ r1, g1, b1 = pixels[x, y] # type: ignore
405
+ r2, g2, b2 = pixels[x, y + 1] # type: ignore
406
+ # Use foreground for upper pixel, background for lower, char '▀'
407
+ line += f"\x1b[38;2;{r1};{g1};{b1}m\x1b[48;2;{r2};{g2};{b2}m▀"
408
+ line += "\x1b[0m"
409
+ lines.append(line)
410
+ return "\n".join(lines)
411
+ except Exception as e:
412
+ return f" [image render failed: {e}]"
413
+
414
+
415
+ def _fetch_image_bytes(url: str, timeout: int = 12) -> Optional[bytes]:
416
+ try:
417
+ if _HAS_REQUESTS and _session is not None:
418
+ r = _session.get(url, timeout=timeout)
419
+ if r.ok and r.content:
420
+ ctype = r.headers.get("content-type", "").lower()
421
+ if "image" in ctype or len(r.content) > 100:
422
+ return r.content
423
+ # urllib fallback
424
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
425
+ with _opener.open(req, timeout=timeout) as resp: # type: ignore
426
+ data = resp.read(5 * 1024 * 1024) # cap 5MB
427
+ return data
428
+ except Exception:
429
+ return None
430
+ return None
431
+
432
+
433
+ class VisualBrowser:
434
+ """Chromium-backed visual browser for 3D/WebGL and pixel-perfect screenshots.
435
+
436
+ Wraps `BrowserEngine` (Playwright) to load any remote URL, wait for
437
+ WebGL/JS to settle, capture a PNG screenshot, and convert it to ANSI via
438
+ `render_image_to_ansi`. Falls back to text mode when Chromium not installed.
439
+ """
440
+
441
+ def __init__(self):
442
+ self._engine = None
443
+ self._ready = False
444
+
445
+ def available(self) -> bool:
446
+ return _HAS_CHROMIUM and BrowserEngine is not None # type: ignore
447
+
448
+ def ensure(self) -> bool:
449
+ if not self.available():
450
+ return False
451
+ if self._ready and self._engine and getattr(self._engine, "available", False):
452
+ return True
453
+ try:
454
+ eng = BrowserEngine(headless=True) # type: ignore
455
+ eng.start(timeout=15)
456
+ self._engine = eng
457
+ self._ready = True
458
+ return True
459
+ except Exception:
460
+ self._ready = False
461
+ return False
462
+
463
+ def screenshot_ansi(self, url: str, term_width: int = 72, timeout: int = 20) -> Optional[str]:
464
+ if not self.ensure():
465
+ return None
466
+ try:
467
+ eng = self._engine
468
+ # Navigate
469
+ snap = eng.navigate(url, timeout=timeout * 1000) # type: ignore
470
+ # Take screenshot to temp file
471
+ import tempfile
472
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf:
473
+ tmp_path = tf.name
474
+ ok = eng.screenshot(tmp_path) # type: ignore
475
+ if not ok:
476
+ try:
477
+ Path(tmp_path).unlink(missing_ok=True)
478
+ except Exception:
479
+ pass
480
+ return None
481
+ try:
482
+ data = Path(tmp_path).read_bytes()
483
+ ansi = render_image_to_ansi(data, term_width=term_width, max_height=28)
484
+ return ansi
485
+ finally:
486
+ try:
487
+ Path(tmp_path).unlink(missing_ok=True)
488
+ except Exception:
489
+ pass
490
+ except Exception:
491
+ return None
492
+
493
+ def close(self):
494
+ if self._engine:
495
+ try:
496
+ self._engine.close() # type: ignore
497
+ except Exception:
498
+ pass
499
+ self._engine = None
500
+ self._ready = False
501
+
502
+
503
+ # Singleton visual browser (lazy)
504
+ _visual_browser: Optional[VisualBrowser] = None
505
+
506
+ def get_visual_browser() -> VisualBrowser:
507
+ global _visual_browser
508
+ if _visual_browser is None:
509
+ _visual_browser = VisualBrowser()
510
+ return _visual_browser
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # HTML → structured page (stdlib html.parser only — no bs4 required)
515
+ # ---------------------------------------------------------------------------
516
+
517
+ @dataclass
518
+ class Link:
519
+ href: str
520
+ text: str
521
+ absolute: str # resolved against base URL
522
+
523
+
524
+ @dataclass
525
+ class ParsedPage:
526
+ title: str
527
+ text_lines: List[str] = field(default_factory=list)
528
+ links: List[Link] = field(default_factory=list)
529
+ headings: List[Tuple[int, str]] = field(default_factory=list)
530
+ images: List[Dict[str, str]] = field(default_factory=list)
531
+ meta_desc: str = ""
532
+ raw_text: str = ""
533
+ warnings: List[str] = field(default_factory=list)
534
+
535
+
536
+ class _CatHTMLParser(HTMLParser):
537
+ """Tiny readable extractor — not a full layout engine. Good enough for
538
+ a terminal browser's text view and for AI / humans to follow links."""
539
+
540
+ def __init__(self, base_url: str):
541
+ super().__init__(convert_charrefs=True)
542
+ self.base_url = base_url
543
+ self.title_parts: List[str] = []
544
+ self.in_title = False
545
+ self.in_script = False
546
+ self.in_style = False
547
+ self.in_head = False
548
+ self.current_heading: Optional[int] = None
549
+ self.heading_buf = ""
550
+ self.block_stack: List[str] = []
551
+ self.line_buf = ""
552
+ self.lines: List[str] = []
553
+ self.links: List[Link] = []
554
+ self._link_href: Optional[str] = None
555
+ self._link_text_parts: List[str] = []
556
+ self._in_anchor = False
557
+ self.headings: List[Tuple[int, str]] = []
558
+ self.images: List[Dict[str, str]] = []
559
+ self.meta_desc = ""
560
+ self.warnings: List[str] = []
561
+
562
+ # ---- helpers ----------------------------------------------------------
563
+ def _flush_line(self):
564
+ line = self.line_buf.strip()
565
+ if line:
566
+ # Collapse internal whitespace to single spaces for terminal.
567
+ line = re.sub(r"\s+", " ", line)
568
+ self.lines.append(line)
569
+ self.line_buf = ""
570
+
571
+ def _push_block(self, tag: str):
572
+ if tag in ("p", "div", "section", "article", "li", "tr", "br",
573
+ "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre"):
574
+ self._flush_line()
575
+ self.block_stack.append(tag)
576
+
577
+ def _pop_block(self, tag: str):
578
+ if tag in ("p", "div", "section", "article", "li", "ul", "ol",
579
+ "blockquote", "pre", "table"):
580
+ self._flush_line()
581
+ if self.block_stack and self.block_stack[-1] == tag:
582
+ self.block_stack.pop()
583
+
584
+ # ---- parser callbacks -------------------------------------------------
585
+ def handle_starttag(self, tag, attrs):
586
+ tag = tag.lower()
587
+ ad = {k.lower(): (v or "") for k, v in attrs}
588
+ if tag == "head":
589
+ self.in_head = True
590
+ if tag == "title":
591
+ self.in_title = True
592
+ if tag == "script":
593
+ self.in_script = True
594
+ self.warnings.append("page contains JavaScript (not executed in terminal)")
595
+ if tag == "style":
596
+ self.in_style = True
597
+ if tag in ("script", "style", "noscript"):
598
+ return
599
+
600
+ # Head metadata
601
+ if tag == "meta":
602
+ name = (ad.get("name", "") or ad.get("property", "")).lower()
603
+ if name in ("description", "og:description"):
604
+ self.meta_desc = ad.get("content", "")[:300]
605
+
606
+ # Images — any format (png/jpg/webp/gif/svg/avif) captured for inline ANSI
607
+ if tag == "img":
608
+ src = ad.get("src", "")
609
+ alt = ad.get("alt", "") or ad.get("title", "") or (src.split("/")[-1].split("?")[0] if src else "")
610
+ self.images.append({"src": src, "alt": alt[:120]})
611
+ # Inline an image placeholder into the text flow
612
+ label = f"[img: {alt[:40]}]" if alt else "[img]"
613
+ self.line_buf += f" {label} "
614
+ # Canvas / WebGL detection — hint to use visual mode for 3D
615
+ if tag == "canvas":
616
+ self.warnings.append("3D canvas detected — type `visual` for WebGL rendering (Chromium)")
617
+ if tag == "script":
618
+ # Quick heuristic for WebGL/three.js
619
+ src_attr = ad.get("src", "").lower()
620
+ if any(k in src_attr for k in ("three", "webgl", "babylon", "playcanvas")):
621
+ self.warnings.append("3D/WebGL script detected — `visual` will render it correctly")
622
+
623
+ if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
624
+ self._flush_line()
625
+ self.current_heading = int(tag[1])
626
+ self.heading_buf = ""
627
+
628
+ if tag == "a":
629
+ href = ad.get("href", "").strip()
630
+ # Skip fragment-only, javascript:, mailto:
631
+ if href and not href.lower().startswith(("javascript:", "mailto:", "tel:")):
632
+ # Resolve now against base URL.
633
+ try:
634
+ abs_href = urllib.parse.urljoin(self.base_url, href)
635
+ except Exception:
636
+ abs_href = href
637
+ self._link_href = abs_href
638
+ self._link_text_parts = []
639
+ self._in_anchor = True
640
+ # Put a marker in the line so link text is visible inline.
641
+ self.line_buf += " "
642
+
643
+ if tag == "li":
644
+ self.line_buf += " • "
645
+
646
+ if tag == "br":
647
+ self._flush_line()
648
+
649
+ self._push_block(tag)
650
+
651
+ def handle_endtag(self, tag):
652
+ tag = tag.lower()
653
+ if tag == "head":
654
+ self.in_head = False
655
+ if tag == "title":
656
+ self.in_title = False
657
+ if tag == "script":
658
+ self.in_script = False
659
+ if tag == "style":
660
+ self.in_style = False
661
+ if tag in ("script", "style"):
662
+ return
663
+
664
+ if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
665
+ text = self.heading_buf.strip()
666
+ if text:
667
+ text = re.sub(r"\s+", " ", text)
668
+ lvl = self.current_heading or 2
669
+ self.headings.append((lvl, text))
670
+ self.lines.append(text) # also in flow for now; renderer prefixes #/##
671
+ self.lines.append("") # breathing room
672
+ self.current_heading = None
673
+ self.heading_buf = ""
674
+
675
+ if tag == "a" and self._in_anchor:
676
+ link_text = re.sub(r"\s+", " ", "".join(self._link_text_parts).strip())
677
+ if not link_text:
678
+ link_text = self._link_href or ""
679
+ link_text = link_text.split("/")[-1].split("?")[0][:40] or link_text[:40]
680
+ if self._link_href:
681
+ self.links.append(Link(href=self._link_href, text=link_text[:120], absolute=self._link_href))
682
+ # Update the last line fragment to carry a visible marker — the
683
+ # renderer turns this into `text [n]` inline.
684
+ # We do it by appending a marker token that render() can count.
685
+ # Instead of post-processing, keep it simple: leave link_text
686
+ # in the line_buf and let render enumerate links separately.
687
+ pass
688
+ self._in_anchor = False
689
+ self._link_href = None
690
+ self._link_text_parts = []
691
+
692
+ self._pop_block(tag)
693
+
694
+ def handle_data(self, data):
695
+ if self.in_script or self.in_style:
696
+ return
697
+ if self.in_title:
698
+ self.title_parts.append(data)
699
+ return
700
+ if self.current_heading is not None:
701
+ self.heading_buf += data
702
+ return
703
+ if self._in_anchor:
704
+ self._link_text_parts.append(data)
705
+ self.line_buf += data
706
+ return
707
+ # Whitelist: only emit text outside head (or in body fallback)
708
+ # Some pages omit explicit <body> — just accept data anywhere not in head/title.
709
+ if self.in_head:
710
+ return
711
+ self.line_buf += data
712
+
713
+ def handle_entityref(self, name):
714
+ # html.parser convert_charrefs handles most; keep for numeric.
715
+ self.handle_data(html.unescape(f"&{name};"))
716
+
717
+ def close(self):
718
+ super().close()
719
+ self._flush_line()
720
+ # Deduplicate warnings
721
+ seen = set()
722
+ uniq = []
723
+ for w in self.warnings:
724
+ if w not in seen:
725
+ seen.add(w)
726
+ uniq.append(w)
727
+ self.warnings = uniq
728
+
729
+
730
+ def parse_html(html_text: str, base_url: str) -> ParsedPage:
731
+ p = _CatHTMLParser(base_url)
732
+ try:
733
+ p.feed(html_text)
734
+ p.close()
735
+ except Exception as e:
736
+ p.warnings.append(f"parse warning: {e}")
737
+ try:
738
+ p.close()
739
+ except Exception:
740
+ pass
741
+
742
+ title = re.sub(r"\s+", " ", "".join(p.title_parts).strip())[:180]
743
+ if not title:
744
+ # Fallback: first heading
745
+ title = p.headings[0][1][:80] if p.headings else ""
746
+ # Filter empty lines, cap
747
+ text_lines: List[str] = []
748
+ for ln in p.lines:
749
+ s = ln.strip()
750
+ if not s:
751
+ if text_lines and text_lines[-1] != "":
752
+ text_lines.append("")
753
+ continue
754
+ text_lines.append(s)
755
+ # Trim trailing empties, cap at 800 lines (terminal can't show more usefully)
756
+ while text_lines and text_lines[-1] == "":
757
+ text_lines.pop()
758
+ if len(text_lines) > 800:
759
+ text_lines = text_lines[:800] + ["", "… (truncated — save to view full source)"]
760
+
761
+ return ParsedPage(
762
+ title=title or "(untitled)",
763
+ text_lines=text_lines,
764
+ links=p.links[:120],
765
+ headings=p.headings[:24],
766
+ images=p.images[:48],
767
+ meta_desc=p.meta_desc,
768
+ raw_text="\n".join(text_lines),
769
+ warnings=p.warnings[:6],
770
+ )
771
+
772
+
773
+ # ---------------------------------------------------------------------------
774
+ # Browser session — history, bookmarks, rendering, I/O
775
+ # ---------------------------------------------------------------------------
776
+
777
+ @dataclass
778
+ class HistoryEntry:
779
+ url: str
780
+ title: str
781
+ fetched_at: float
782
+
783
+
784
+ @dataclass
785
+ class Bookmark:
786
+ url: str
787
+ title: str
788
+ added_at: float
789
+
790
+
791
+ class BrowserSession:
792
+ """One browser session: one cookie jar, one history stack, one view."""
793
+
794
+ def __init__(self):
795
+ self.history: List[HistoryEntry] = []
796
+ self.history_index: int = -1 # -1 = empty, 0..len-1
797
+ self.bookmarks: List[Bookmark] = []
798
+ self._cache: Dict[str, FetchResult] = {} # url -> last fetch
799
+ self._parsed_cache: Dict[str, ParsedPage] = {}
800
+ self.current_url: str = ""
801
+ self.current_result: Optional[FetchResult] = None
802
+ self.current_parsed: Optional[ParsedPage] = None
803
+ self.last_find_hits: List[int] = [] # line numbers
804
+ self._load_bookmarks()
805
+ self._load_history()
806
+
807
+ # ---- persistence ------------------------------------------------------
808
+ def _load_bookmarks(self):
809
+ try:
810
+ if _BOOKMARKS_PATH.exists():
811
+ raw = json.loads(_BOOKMARKS_PATH.read_text(encoding="utf-8"))
812
+ for item in raw if isinstance(raw, list) else []:
813
+ if isinstance(item, dict) and item.get("url"):
814
+ self.bookmarks.append(Bookmark(
815
+ url=item["url"], title=item.get("title", item["url"]),
816
+ added_at=item.get("added_at", time.time()),
817
+ ))
818
+ except Exception:
819
+ pass
820
+
821
+ def _save_bookmarks(self):
822
+ try:
823
+ data = [{"url": b.url, "title": b.title, "added_at": b.added_at} for b in self.bookmarks]
824
+ _BOOKMARKS_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
825
+ except Exception:
826
+ pass
827
+
828
+ def _load_history(self):
829
+ try:
830
+ if _HISTORY_PATH.exists():
831
+ raw = json.loads(_HISTORY_PATH.read_text(encoding="utf-8"))
832
+ if isinstance(raw, list):
833
+ # Only keep last 200
834
+ for item in raw[-200:]:
835
+ if isinstance(item, dict) and item.get("url"):
836
+ self.history.append(HistoryEntry(
837
+ url=item["url"], title=item.get("title", ""),
838
+ fetched_at=item.get("fetched_at", time.time()),
839
+ ))
840
+ if self.history:
841
+ self.history_index = len(self.history) - 1
842
+ self.current_url = self.history[-1].url
843
+ except Exception:
844
+ pass
845
+
846
+ def _persist_history(self):
847
+ try:
848
+ # Keep last 200 entries
849
+ data = [{"url": h.url, "title": h.title, "fetched_at": h.fetched_at} for h in self.history[-200:]]
850
+ _HISTORY_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
851
+ except Exception:
852
+ pass
853
+
854
+ # ---- navigation -------------------------------------------------------
855
+ def navigate(self, url: str, push_history: bool = True, use_cache: bool = False) -> FetchResult:
856
+ url = url.strip()
857
+ # Allow search queries to auto-become DDG URLs when the caller
858
+ # passes them through open/search.
859
+ if _is_search_query(url) and not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url) and not url.lower().startswith("about:"):
860
+ # Only auto-search when called via search path; navigate() itself
861
+ # is for real URLs. Callers that want search should call search().
862
+ pass
863
+ # about: handling — no network
864
+ if url in (_ABOUT_HOME_URL, _ABOUT_BLANK_URL):
865
+ result = fetch_url(url)
866
+ parsed = parse_html(result.text, url)
867
+ else:
868
+ # Cache hit?
869
+ if use_cache and url in self._cache:
870
+ result = self._cache[url]
871
+ parsed = self._parsed_cache.get(url) or parse_html(result.text, result.url)
872
+ else:
873
+ result = fetch_url(url)
874
+ if result.text and result.status in (200, 304):
875
+ parsed = parse_html(result.text, result.url)
876
+ else:
877
+ # Error page: still try to parse body if any, else synthetic
878
+ if result.text:
879
+ parsed = parse_html(result.text, result.url)
880
+ else:
881
+ parsed = ParsedPage(title=f"Error — {result.error or 'no content'}",
882
+ text_lines=[result.error or "No content.",
883
+ f"Requested: {result.requested_url}",
884
+ f"Final: {result.url}",
885
+ f"Status: {result.status}"],
886
+ warnings=[])
887
+
888
+ final_url = result.url or url
889
+ self.current_url = final_url
890
+ self.current_result = result
891
+ self.current_parsed = parsed
892
+ self._cache[final_url] = result
893
+ self._parsed_cache[final_url] = parsed
894
+
895
+ if push_history:
896
+ # Truncate forward history on new navigation (standard browser)
897
+ if self.history_index < len(self.history) - 1:
898
+ self.history = self.history[:self.history_index + 1]
899
+ self.history.append(HistoryEntry(url=final_url, title=parsed.title, fetched_at=time.time()))
900
+ self.history_index = len(self.history) - 1
901
+ self._persist_history()
902
+ else:
903
+ # Replace current entry's URL/title in place (back/forward)
904
+ if 0 <= self.history_index < len(self.history):
905
+ self.history[self.history_index] = HistoryEntry(url=final_url, title=parsed.title, fetched_at=time.time())
906
+ self._persist_history()
907
+
908
+ self.last_find_hits = []
909
+ return result
910
+
911
+ def search(self, query: str, engine: Optional[str] = None) -> FetchResult:
912
+ # Support `google cats` prefix inside query itself
913
+ eng, q = _detect_search_engine(query)
914
+ if engine:
915
+ eng = engine
916
+ else:
917
+ # If caller passed "google cats" we already split; use detected
918
+ query = q if q else query
919
+ eng = eng if q != query else (engine or _DEFAULT_SEARCH_ENGINE)
920
+ # Re-detect if original query had no prefix but we used fallback
921
+ if q == query and engine is None:
922
+ eng, q2 = _detect_search_engine(query)
923
+ if q2 != query:
924
+ query = q2
925
+ eng = eng
926
+ return self.navigate(_search_url(query, engine=eng), push_history=True)
927
+
928
+ def back(self) -> Optional[FetchResult]:
929
+ if self.history_index <= 0:
930
+ return None
931
+ self.history_index -= 1
932
+ url = self.history[self.history_index].url
933
+ return self.navigate(url, push_history=False, use_cache=True)
934
+
935
+ def forward(self) -> Optional[FetchResult]:
936
+ if self.history_index >= len(self.history) - 1:
937
+ return None
938
+ self.history_index += 1
939
+ url = self.history[self.history_index].url
940
+ return self.navigate(url, push_history=False, use_cache=True)
941
+
942
+ def reload(self) -> FetchResult:
943
+ url = self.current_url or _ABOUT_HOME_URL
944
+ # Bust cache
945
+ self._cache.pop(url, None)
946
+ self._parsed_cache.pop(url, None)
947
+ return self.navigate(url, push_history=False)
948
+
949
+ def can_back(self) -> bool:
950
+ return self.history_index > 0
951
+
952
+ def can_forward(self) -> bool:
953
+ return 0 <= self.history_index < len(self.history) - 1
954
+
955
+ # ---- bookmarks --------------------------------------------------------
956
+ def add_bookmark(self, url: Optional[str] = None, title: Optional[str] = None) -> Bookmark:
957
+ u = url or self.current_url
958
+ if not u or u in (_ABOUT_BLANK_URL, ""):
959
+ raise ValueError("no page to bookmark")
960
+ t = title or (self.current_parsed.title if self.current_parsed else u) or u
961
+ # Deduplicate by URL
962
+ for b in self.bookmarks:
963
+ if b.url == u:
964
+ return b
965
+ bm = Bookmark(url=u, title=t, added_at=time.time())
966
+ self.bookmarks.append(bm)
967
+ self._save_bookmarks()
968
+ return bm
969
+
970
+ def remove_bookmark(self, index: int) -> Optional[Bookmark]:
971
+ if 0 <= index < len(self.bookmarks):
972
+ bm = self.bookmarks.pop(index)
973
+ self._save_bookmarks()
974
+ return bm
975
+ return None
976
+
977
+ def find_on_page(self, query: str) -> List[int]:
978
+ if not self.current_parsed:
979
+ return []
980
+ q = query.lower()
981
+ hits = [i for i, ln in enumerate(self.current_parsed.text_lines) if q in ln.lower()]
982
+ self.last_find_hits = hits
983
+ return hits
984
+
985
+ # ---- save -------------------------------------------------------------
986
+ def save_current(self, path: str) -> str:
987
+ if not self.current_result or not self.current_result.body:
988
+ raise ValueError("nothing to save")
989
+ p = Path(os.path.expanduser(path)).resolve()
990
+ if p.is_dir():
991
+ # Derive filename from URL
992
+ name = urllib.parse.urlparse(self.current_url).path.split("/")[-1] or "page.html"
993
+ if not name.endswith((".html", ".htm", ".txt")):
994
+ name += ".html"
995
+ p = p / name
996
+ p.parent.mkdir(parents=True, exist_ok=True)
997
+ p.write_bytes(self.current_result.body)
998
+ return str(p)
999
+
1000
+
1001
+ def _render_images_inline(session: BrowserSession, cw: int, use_color: bool) -> List[str]:
1002
+ """Try to render a few page images inline as ANSI art (pillow + fetch).
1003
+
1004
+ Returns lines to inject into the page render. Fails gracefully —
1005
+ text placeholders are already in the main render, so this is additive.
1006
+ """
1007
+ lines: List[str] = []
1008
+ if not session.current_parsed or not session.current_parsed.images:
1009
+ return lines
1010
+ # Limit to first 3 images to keep terminal responsive
1011
+ for im in session.current_parsed.images[:3]:
1012
+ src = im.get("src", "")
1013
+ if not src:
1014
+ continue
1015
+ # Resolve against page URL
1016
+ try:
1017
+ abs_url = urllib.parse.urljoin(session.current_url, src)
1018
+ except Exception:
1019
+ abs_url = src
1020
+ # Only attempt http(s) images (skip data: for now)
1021
+ if not abs_url.lower().startswith(("http://", "https://")):
1022
+ continue
1023
+ # Skip tiny icons / tracking pixels quickly
1024
+ # Fetch
1025
+ data = _fetch_image_bytes(abs_url)
1026
+ if not data or len(data) < 200:
1027
+ continue
1028
+ # Check if it's actually an image (avoid fetching large non-image)
1029
+ # Quick mime check via header would be better, but we already filtered.
1030
+ # Render
1031
+ try:
1032
+ # Check if visual_browser available for heavy images? Use ANSI path regardless.
1033
+ ansi = render_image_to_ansi(data, term_width=min(60, cw), max_height=18)
1034
+ if ansi and "PIL not installed" not in ansi:
1035
+ alt = im.get("alt", "")[:36]
1036
+ lines.append("")
1037
+ lines.append(f" [image: {alt} — {abs_url[:48]}]")
1038
+ lines.append(ansi)
1039
+ else:
1040
+ # Fallback text line already shown elsewhere, but still note
1041
+ if "PIL not installed" in ansi:
1042
+ lines.append(f" [image: {im.get('alt','')} — {abs_url}] (pip install pillow to see inline)")
1043
+ except Exception:
1044
+ continue
1045
+ return lines
1046
+
1047
+
1048
+ # ---------------------------------------------------------------------------
1049
+ # Rendering — terminal ANSI via theme (or plain fallback when not a tty)
1050
+ # ---------------------------------------------------------------------------
1051
+
1052
+ def _term_width(fallback: int = 88) -> int:
1053
+ try:
1054
+ import shutil
1055
+ return max(60, shutil.get_terminal_size(fallback=(fallback, 24)).columns)
1056
+ except Exception:
1057
+ return fallback
1058
+
1059
+
1060
+ def render_page(session: BrowserSession, width: Optional[int] = None) -> str:
1061
+ """Render the current page as ANSI text for the terminal."""
1062
+ if session.current_parsed is None or session.current_result is None:
1063
+ return " (no page loaded)\n"
1064
+
1065
+ result = session.current_result
1066
+ parsed = session.current_parsed
1067
+ w = width or _term_width()
1068
+ # Content width leaves margins for rail/borders.
1069
+ cw = max(40, min(92, w - 6))
1070
+
1071
+ try:
1072
+ from . import theme as _theme # pylint: disable=import-outside-toplevel
1073
+ _theme.enable_windows_ansi()
1074
+ use_color = sys.stdout.isatty()
1075
+ except Exception:
1076
+ _theme = None # type: ignore
1077
+ use_color = False
1078
+
1079
+ def _c(text, color=None, bold=False):
1080
+ if not use_color or _theme is None or color is None:
1081
+ return text
1082
+ try:
1083
+ return _theme.fg(text, color, bold=bold)
1084
+ except Exception:
1085
+ return text
1086
+
1087
+ def _dim(text):
1088
+ return _c(text, _theme.DIM if _theme else None) # type: ignore
1089
+
1090
+ def _wrap(text, indent=" ", w=cw):
1091
+ if not text.strip():
1092
+ return []
1093
+ return textwrap.wrap(text, width=w - len(indent),
1094
+ break_long_words=False, break_on_hyphens=False)
1095
+
1096
+ lines: List[str] = []
1097
+
1098
+ # -- Chrome: top bar ----------------------------------------------------
1099
+ # Identity line — who is browsing (Fomoji session).
1100
+ try:
1101
+ from .fomoji_auth import get_identity as _get_ident # pylint: disable=import-outside-toplevel
1102
+ ident = _get_ident()
1103
+ if ident:
1104
+ who = f"{ident.get('name','?')} ({ident.get('fomojiId','?')})"
1105
+ else:
1106
+ who = "Fomoji session"
1107
+ except Exception:
1108
+ who = "CAT"
1109
+
1110
+ # URL bar
1111
+ url_display = session.current_url or "(no url)"
1112
+ if len(url_display) > cw + 12:
1113
+ url_display = url_display[:cw + 9] + "…"
1114
+
1115
+ # Status badges: HTTP status + timings + link counts
1116
+ status_color = None
1117
+ status_text = str(result.status) if result.status else "—"
1118
+ if result.status and 200 <= result.status < 300:
1119
+ status_color = _theme.GREEN if _theme else None # type: ignore
1120
+ elif result.status and 300 <= result.status < 400:
1121
+ status_color = _theme.ORANGE if _theme else None # type: ignore
1122
+ elif result.status:
1123
+ status_color = _theme.RED if _theme else None # type: ignore
1124
+
1125
+ title_line = parsed.title or "(untitled)"
1126
+ if _theme and use_color:
1127
+ lines.append(_theme.panel([
1128
+ _c(title_line, _theme.CYAN, bold=True), # type: ignore
1129
+ _dim(f"↗ {url_display}"),
1130
+ _dim(f"{who} · HTTP {status_text} · {result.elapsed_ms} ms · {len(parsed.links)} links"
1131
+ + (f" · {len(parsed.images)} images" if parsed.images else "")),
1132
+ ], title="CAT Browser", color=_theme.CYAN, width=cw + 4)) # type: ignore
1133
+ else:
1134
+ lines.append("┌" + "─" * (cw + 2) + "┐")
1135
+ lines.append(f"│ {title_line[:cw].ljust(cw)} │")
1136
+ lines.append(f"│ {url_display[:cw].ljust(cw)} │")
1137
+ lines.append("└" + "─" * (cw + 2) + "┘")
1138
+
1139
+ # Error banner if fetch failed
1140
+ if result.error and result.status not in (200, 304):
1141
+ err = result.error
1142
+ if _theme and use_color:
1143
+ lines.append("")
1144
+ lines.append(_theme.panel([_c(err, _theme.RED, bold=True)], # type: ignore
1145
+ title="error", color=_theme.RED, width=cw + 4)) # type: ignore
1146
+ else:
1147
+ lines.append("")
1148
+ lines.append(f" ! {err}")
1149
+
1150
+ # Warnings (JS notice etc.)
1151
+ if parsed.warnings:
1152
+ for wmsg in parsed.warnings[:2]:
1153
+ lines.append(_dim(f" ⚠ {wmsg}"))
1154
+ lines.append("")
1155
+
1156
+ # Meta description
1157
+ if parsed.meta_desc:
1158
+ lines.append(_dim(" " + "─" * min(cw, 48)))
1159
+ for wl in _wrap(parsed.meta_desc, indent=" "):
1160
+ lines.append(_dim(wl))
1161
+ lines.append(_dim(" " + "─" * min(cw, 48)))
1162
+
1163
+ # Body text — headings get #/## prefix, paragraphs flow, links enumerated.
1164
+ # Build a line-index -> link-indices map later; for now inline numbers.
1165
+ lines.append("")
1166
+ link_counter = 0
1167
+ # We will render body, then a numbered link list at the bottom. Inline
1168
+ # link markers `[n]` are added as trailing references on lines that contained
1169
+ # that link's text — heuristic: if a body's line contains the exact link
1170
+ # text, append the index. It's approximate but good enough for terminal.
1171
+ body_link_markers: Dict[int, List[int]] = {} # line_idx -> [link_n]
1172
+
1173
+ # Prepare a quick lookup: link text -> indices (may duplicate)
1174
+ link_text_to_nums: Dict[str, List[int]] = {}
1175
+ for idx, lk in enumerate(parsed.links, 1):
1176
+ key = lk.text.strip().lower()[:40]
1177
+ link_text_to_nums.setdefault(key, []).append(idx)
1178
+
1179
+ rendered_body: List[str] = []
1180
+ heading_texts = {h[1] for h in parsed.headings}
1181
+ for ln in parsed.text_lines:
1182
+ if not ln.strip():
1183
+ rendered_body.append("")
1184
+ continue
1185
+ is_heading = ln.strip() in heading_texts
1186
+ # Find heading level for prefix
1187
+ prefix = ""
1188
+ if is_heading:
1189
+ for lvl, htxt in parsed.headings:
1190
+ if htxt == ln.strip():
1191
+ if lvl == 1:
1192
+ prefix = "# "
1193
+ elif lvl == 2:
1194
+ prefix = "## "
1195
+ elif lvl == 3:
1196
+ prefix = "### "
1197
+ break
1198
+ # Color headings
1199
+ if is_heading and _theme and use_color:
1200
+ body_line = _c(prefix + ln.strip(), _theme.PURPLE, bold=True) # type: ignore
1201
+ rendered_body.append(" " + body_line)
1202
+ continue
1203
+ for wl in _wrap(ln, indent=" "):
1204
+ rendered_body.append(wl)
1205
+
1206
+ # Append body
1207
+ lines.extend(rendered_body)
1208
+
1209
+ # Link list footer — always show when there are links, it's the main nav
1210
+ if parsed.links:
1211
+ lines.append("")
1212
+ sep = "─" * min(cw, 48)
1213
+ lines.append(_dim(f" {sep}"))
1214
+ lines.append(_c(f" Links ({len(parsed.links)}):", _theme.CYAN if _theme else None, bold=True)) # type: ignore
1215
+ for idx, lk in enumerate(parsed.links, 1):
1216
+ txt = lk.text.strip() or "(no text)"
1217
+ if len(txt) > 48:
1218
+ txt = txt[:47] + "…"
1219
+ href = lk.href
1220
+ # Shorten href for display
1221
+ if len(href) > 56:
1222
+ href = href[:53] + "…"
1223
+ # Color: internal vs external vs anchor
1224
+ if use_color and _theme:
1225
+ num = _c(f"[{idx:>2}]", _theme.CYAN, bold=True) # type: ignore
1226
+ txt_c = _c(txt, _theme.TEXT) # type: ignore
1227
+ href_c = _dim(href)
1228
+ lines.append(f" {num} {txt_c} {href_c}")
1229
+ else:
1230
+ lines.append(f" [{idx:>2}] {txt} — {href}")
1231
+ lines.append(_dim(f" {sep}"))
1232
+ lines.append(_dim(f' Type a number (e.g. 3) or "open 3" to follow a link. "links" to re-show this list.'))
1233
+
1234
+ # Images — try inline ANSI rendering (any format via PIL), fallback to list
1235
+ inline_img_lines = _render_images_inline(session, cw, use_color) if use_color else []
1236
+ if inline_img_lines:
1237
+ lines.extend(inline_img_lines)
1238
+ # Images summary (collapsed — don't flood)
1239
+ if parsed.images:
1240
+ lines.append("")
1241
+ lines.append(_dim(f" Images ({len(parsed.images)}): (type `img <num>` to view full-size)"))
1242
+ for idx, im in enumerate(parsed.images[:10], 1):
1243
+ alt = im.get("alt", "")[:48]
1244
+ src = im.get("src", "")[:42]
1245
+ lines.append(_dim(f" [{idx}] • {alt or '(no alt)'} — {src}"))
1246
+ if len(parsed.images) > 10:
1247
+ lines.append(_dim(f" … +{len(parsed.images) - 10} more (save page to inspect)"))
1248
+
1249
+ # Find hits highlight (if active)
1250
+ if session.last_find_hits:
1251
+ lines.append("")
1252
+ lines.append(_c(f" / find: {len(session.last_find_hits)} match(es) on lines {', '.join(str(i+1) for i in session.last_find_hits[:10])}",
1253
+ _theme.ORANGE if _theme else None)) # type: ignore
1254
+
1255
+ # Bottom chrome: status + hints + identity
1256
+ lines.append("")
1257
+ nav = []
1258
+ nav.append("back" if session.can_back() else _dim("back"))
1259
+ nav.append("forward" if session.can_forward() else _dim("forward"))
1260
+ nav.append("reload")
1261
+ nav.append("home")
1262
+ nav_line = " " + " · ".join(str(x) for x in nav)
1263
+ lines.append(nav_line)
1264
+ lines.append(_dim(' Type "open <url>", "search <query>", number, or "help". "q" to quit.'))
1265
+ return "\n".join(lines)
1266
+
1267
+
1268
+ # ---------------------------------------------------------------------------
1269
+ # Browser REPL — the main terminal UI loop
1270
+ # ---------------------------------------------------------------------------
1271
+
1272
+ _HELP_TEXT = """
1273
+ CAT Browser — terminal software browser (no external Chrome needed)
1274
+ ────────────────────────────────────
1275
+ Navigation
1276
+ open <url> | go <url> | <url> open a URL
1277
+ <number> | open <number> follow link #number
1278
+ back | b go back
1279
+ forward | f | fwd go forward
1280
+ reload | r | refresh reload current page
1281
+ home | h CAT Browser home
1282
+ history | hist show navigation history
1283
+ search <query> | s <query> web search (DuckDuckGo by default)
1284
+ search google <query> | g <query> Google search
1285
+ search ddg <query> DuckDuckGo search
1286
+ find <text> | /<text> find text on current page
1287
+
1288
+ Visual / Images / 3D
1289
+ visual | v render page visually via Chromium (screenshots → ANSI)
1290
+ — needed for WebGL / 3D / canvas-heavy sites
1291
+ img <num> render image #num inline (png/jpg/webp/gif/svg/avif)
1292
+ images re-show image list
1293
+ screenshot | shot save Chromium screenshot to ~/Downloads
1294
+
1295
+ Page
1296
+ links | l re-show numbered link list
1297
+ source | src show raw HTML source
1298
+ text | view re-render current page (text mode)
1299
+ save [path] | download [path] save current page to file
1300
+ copy <num> copy link #num URL to clipboard (if available)
1301
+
1302
+ Bookmarks
1303
+ bookmark | bm bookmark current page
1304
+ bookmarks | bms list bookmarks
1305
+ open bookmark <num> | bm open <n> open bookmark #num
1306
+ bookmark rm <num> | bm rm <num> remove bookmark #num
1307
+
1308
+ Session
1309
+ info | status show status (url, title, auth, stats)
1310
+ help | ? this help
1311
+ quit | q | exit | bye leave browser (returns to CAT)
1312
+
1313
+ Tips
1314
+ • Paste a full URL and press Enter — it opens immediately.
1315
+ • Type a phrase without a scheme (e.g. "cats on mars") to search.
1316
+ • The browser respects your Fomoji identity — no login = no fetch.
1317
+ """.strip()
1318
+
1319
+
1320
+ def _print_help():
1321
+ try:
1322
+ from . import theme as _theme # pylint: disable=import-outside-toplevel
1323
+ _theme.enable_windows_ansi()
1324
+ print(_theme.panel(_HELP_TEXT.split("\n"), title="browser help", color=_theme.CYAN, width=78))
1325
+ except Exception:
1326
+ print(_HELP_TEXT)
1327
+ print()
1328
+
1329
+
1330
+ def _print_status(session: BrowserSession):
1331
+ try:
1332
+ from . import theme as _theme
1333
+ _theme.enable_windows_ansi()
1334
+ ident = None
1335
+ try:
1336
+ from .fomoji_auth import get_identity # pylint: disable=import-outside-toplevel
1337
+ ident = get_identity()
1338
+ except Exception:
1339
+ pass
1340
+ who = f"{ident['name']} ({ident['fomojiId']})" if ident else "no identity"
1341
+ lines = [
1342
+ _theme.text("CAT Browser — status", bold=True),
1343
+ f" URL: {_theme.cyan(session.current_url or '(none)')}",
1344
+ f" Title: {session.current_parsed.title if session.current_parsed else '(none)'}",
1345
+ f" Auth: {_theme.green(who, bold=True) if ident else _theme.red(who, bold=True)}",
1346
+ f" History: {len(session.history)} page(s), index {session.history_index + 1}",
1347
+ f" Bookmarks: {len(session.bookmarks)}",
1348
+ f" Links: {len(session.current_parsed.links) if session.current_parsed else 0}",
1349
+ f" Engine: terminal (no JS) · Fetcher: {'requests' if _HAS_REQUESTS else 'urllib'}",
1350
+ ]
1351
+ print(_theme.panel(lines, title="status", color=_theme.PURPLE, width=76))
1352
+ except Exception:
1353
+ print(f" URL: {session.current_url}")
1354
+ print(f" History: {len(session.history)} @ {session.history_index}")
1355
+
1356
+
1357
+ def launch_browser(start_url: Optional[str] = None, require_auth: bool = True) -> int:
1358
+ """
1359
+ Main terminal browser loop. Returns exit code for `cat browse` (0 = ok).
1360
+
1361
+ This is the ONLY browser entrypoint for `cat browse` and for `/browser`
1362
+ inside the CAT REPL (which calls this while the Textual UI is suspended).
1363
+
1364
+ The loop is intentionally a simple blocking input() loop — it mirrors
1365
+ CAT's own fallback CLI so it works identically whether the primary
1366
+ Textual UI is running or not.
1367
+
1368
+ `require_auth=False` is used for the Fomoji verification flow itself
1369
+ (so the user can open the Fomoji login/approve page even before CAT is
1370
+ authenticated — otherwise we'd block the very page that grants auth).
1371
+ """
1372
+ # -- Gate (again, belt-and-suspenders — cli.main already gates, but a
1373
+ # direct `from cat_browser import launch_browser` should also fail closed).
1374
+ # For the Fomoji verification flow we intentionally skip the gate when
1375
+ # the start URL is a localhost Fomoji URL.
1376
+ _is_fomoji_verification = False
1377
+ if start_url:
1378
+ try:
1379
+ from .fomoji_auth import get_fomoji_url as _get_url
1380
+ fomoji_host = urllib.parse.urlparse(_get_url()).hostname or ""
1381
+ parsed = urllib.parse.urlparse(start_url)
1382
+ if parsed.hostname and fomoji_host and parsed.hostname.lower() == fomoji_host.lower():
1383
+ _is_fomoji_verification = True
1384
+ if parsed.hostname in ("localhost", "127.0.0.1", "::1"):
1385
+ _is_fomoji_verification = True
1386
+ except Exception:
1387
+ pass
1388
+ if require_auth and not _is_fomoji_verification:
1389
+ try:
1390
+ from .fomoji_auth import enforce_or_exit # pylint: disable=import-outside-toplevel
1391
+ enforce_or_exit(interactive=True, auto_prompt=True)
1392
+ except SystemExit as e:
1393
+ return int(e.code) if e.code is not None else 1
1394
+ except Exception as e:
1395
+ print(f"CAT Browser: auth gate error ({e}) — refusing to launch.", file=sys.stderr)
1396
+ return 1
1397
+
1398
+ try:
1399
+ from . import theme as _theme
1400
+ _theme.enable_windows_ansi()
1401
+ _theme.clear_screen()
1402
+ # Identity banner
1403
+ try:
1404
+ from .fomoji_auth import get_identity as _get_ident
1405
+ ident = _get_ident()
1406
+ who = f"{ident['name']} ({ident['fomojiId']})" if ident else "unknown"
1407
+ except Exception:
1408
+ who = "CAT"
1409
+ ident = None
1410
+ print()
1411
+ print(_theme.panel([
1412
+ _theme.text("CAT Browser", bold=True) + _theme.dim(" — full terminal web browser"),
1413
+ _theme.dim(f"Signed in as {who} · Fomoji-protected"),
1414
+ "",
1415
+ _theme.dim("Type a URL or search query and press Enter. Type `help` for commands."),
1416
+ ], title="browser", color=_theme.CYAN, width=78))
1417
+ print()
1418
+ except Exception:
1419
+ print("\n CAT Browser — terminal web browser\n")
1420
+
1421
+ session = BrowserSession()
1422
+
1423
+ # Initial navigation: explicit URL > last history entry > home
1424
+ first = (start_url or "").strip()
1425
+ if first:
1426
+ # Allow bare search query as first arg via `cat browse cats on mars`
1427
+ # — normalize_url will turn it into https://cats... which is wrong,
1428
+ # so treat multi-word first args as search? But launch_browser only
1429
+ # gets one url arg, so we handle it faithfully: single-token with dot
1430
+ # or scheme => navigate, else search.
1431
+ try:
1432
+ # If it parses as a URL, navigate; otherwise search.
1433
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", first) or first.lower().startswith("about:"):
1434
+ session.navigate(normalize_url(first))
1435
+ elif _is_search_query(first) and " " in first:
1436
+ session.search(first)
1437
+ else:
1438
+ session.navigate(normalize_url(first))
1439
+ except ValueError:
1440
+ # Fallback to search for ambiguous input
1441
+ try:
1442
+ session.search(first)
1443
+ except Exception:
1444
+ session.navigate(_ABOUT_HOME_URL)
1445
+ except Exception as e:
1446
+ print(f" Could not open {first}: {e}")
1447
+ session.navigate(_ABOUT_HOME_URL)
1448
+ else:
1449
+ session.navigate(_ABOUT_HOME_URL)
1450
+
1451
+ # First render
1452
+ print(render_page(session))
1453
+ print()
1454
+
1455
+ while True:
1456
+ # Address prompt — show truncated URL + history position
1457
+ url_hint = session.current_url or "about:home"
1458
+ if len(url_hint) > 42:
1459
+ url_hint = "…" + url_hint[-41:]
1460
+ hist_hint = f"[{session.history_index + 1}/{len(session.history)}]" if session.history else ""
1461
+ try:
1462
+ raw = input(f" \x1b[2m{url_hint} {hist_hint}\x1b[0m \x1b[1m▶\x1b[0m ").strip()
1463
+ except (EOFError, KeyboardInterrupt):
1464
+ print("\n Leaving CAT Browser.\n")
1465
+ break
1466
+
1467
+ if not raw:
1468
+ continue
1469
+
1470
+ low = raw.lower().strip()
1471
+ first_tok = low.split()[0] if low else ""
1472
+
1473
+ # -- Quit ----------------------------------------------------------
1474
+ if low in ("q", "quit", "exit", "bye", ":q", ":quit"):
1475
+ print("\n Leaving CAT Browser.\n")
1476
+ break
1477
+
1478
+ # -- Help ----------------------------------------------------------
1479
+ if low in ("help", "?", "h help", "--help"):
1480
+ _print_help()
1481
+ continue
1482
+
1483
+ # -- Status / info -------------------------------------------------
1484
+ if low in ("info", "status", ":info"):
1485
+ _print_status(session)
1486
+ print()
1487
+ continue
1488
+
1489
+ # -- View / re-render ----------------------------------------------
1490
+ if low in ("view", "text", "render", "show"):
1491
+ print(render_page(session))
1492
+ print()
1493
+ continue
1494
+
1495
+ # -- Source ---------------------------------------------------------
1496
+ if low in ("source", "src", "html", "view source", "view-source"):
1497
+ if not session.current_result or not session.current_result.text:
1498
+ print(" (no source — page had no body)\n")
1499
+ else:
1500
+ src = session.current_result.text
1501
+ # Paginate: 40 lines at a time
1502
+ lines = src.splitlines()
1503
+ for i in range(0, len(lines), 40):
1504
+ chunk = "\n".join(lines[i:i + 40])
1505
+ print(chunk)
1506
+ if i + 40 < len(lines):
1507
+ try:
1508
+ more = input(" -- more -- (Enter to continue, q to stop) ").strip().lower()
1509
+ if more in ("q", "quit"):
1510
+ break
1511
+ except (EOFError, KeyboardInterrupt):
1512
+ break
1513
+ print()
1514
+ continue
1515
+
1516
+ # -- Links ----------------------------------------------------------
1517
+ if low in ("links", "l", "list links", "show links"):
1518
+ if not session.current_parsed or not session.current_parsed.links:
1519
+ print(" (no links on this page)\n")
1520
+ else:
1521
+ # Re-print link section only (avoid re-rendering whole page)
1522
+ session.last_find_hits = []
1523
+ print(render_page(session))
1524
+ print()
1525
+ continue
1526
+
1527
+ # -- History --------------------------------------------------------
1528
+ if low in ("history", "hist", "h"):
1529
+ if not session.history:
1530
+ print(" (no history)\n")
1531
+ else:
1532
+ try:
1533
+ from . import theme as _theme
1534
+ _theme.enable_windows_ansi()
1535
+ lines = [_theme.text("Navigation history", bold=True), ""]
1536
+ for idx, h in enumerate(session.history):
1537
+ marker = "▶" if idx == session.history_index else " "
1538
+ title = h.title or h.url
1539
+ if len(title) > 52:
1540
+ title = title[:51] + "…"
1541
+ url_s = h.url
1542
+ if len(url_s) > 56:
1543
+ url_s = url_s[:55] + "…"
1544
+ lines.append(f"{marker} {idx + 1:>2}. {_theme.cyan(title)} {_theme.dim(url_s)}")
1545
+ print(_theme.panel(lines, title="history", color=_theme.PURPLE, width=78))
1546
+ print(_theme.dim(' Type "open <num>" with the history number, or "back"/"forward".'))
1547
+ print()
1548
+ except Exception:
1549
+ for idx, h in enumerate(session.history):
1550
+ print(f" {idx + 1:>2}. {h.title} — {h.url}")
1551
+ print()
1552
+ continue
1553
+
1554
+ # Numeric history access: `open 12` when history exists — disambiguate
1555
+ # from link numbers by requiring >=2 digits? No — better to handle via
1556
+ # explicit `history open <n>` or keep `open <num>` meaning link first.
1557
+ # We keep link semantics for plain numbers.
1558
+
1559
+ # -- Visual / Images / Screenshot / 3D -------------------------------
1560
+ if low in ("visual", "v", "chromium", "render", "3d"):
1561
+ # Visual mode uses real Chromium to render JS/WebGL, then shows screenshot as ANSI
1562
+ vb = get_visual_browser()
1563
+ if not vb.available():
1564
+ print(" Visual mode needs Chromium: pip install playwright && playwright install chromium\n")
1565
+ print(" Falling back to text mode — try `img <n>` for individual images.\n")
1566
+ continue
1567
+ print(f" ◉ Rendering {session.current_url} visually (Chromium + WebGL)...\n")
1568
+ ansi = vb.screenshot_ansi(session.current_url, term_width=_term_width() - 2)
1569
+ if ansi:
1570
+ print(ansi)
1571
+ print()
1572
+ # Also show text overlay below for navigation (links still work)
1573
+ print(render_page(session))
1574
+ print()
1575
+ print(" Visual render above — links still work by number, `text` to return to text-only.\n")
1576
+ else:
1577
+ print(" Visual render failed (Chromium error or timeout). Text mode still available.\n")
1578
+ print(render_page(session))
1579
+ print()
1580
+ continue
1581
+ if first_tok in ("img", "image", "images", "showimg"):
1582
+ # `images` alone -> list, `img 3` -> render image #3 inline
1583
+ if low in ("images", "showimg"):
1584
+ if not session.current_parsed or not session.current_parsed.images:
1585
+ print(" (no images on this page)\n")
1586
+ else:
1587
+ for idx, im in enumerate(session.current_parsed.images, 1):
1588
+ print(f" [{idx}] {im.get('alt','')[:48]} — {im.get('src','')[:64]}")
1589
+ print(' Use `img <num>` to render that image inline.\n')
1590
+ continue
1591
+ arg = raw.split(None, 1)[1].strip() if " " in raw else ""
1592
+ if not arg.isdigit():
1593
+ print(" Usage: img <num> (e.g. img 2) — or `images` to list all\n")
1594
+ continue
1595
+ idx = int(arg) - 1
1596
+ if not session.current_parsed or not (0 <= idx < len(session.current_parsed.images)):
1597
+ print(" No such image.\n")
1598
+ continue
1599
+ src = session.current_parsed.images[idx].get("src", "")
1600
+ try:
1601
+ abs_url = urllib.parse.urljoin(session.current_url, src)
1602
+ except Exception:
1603
+ abs_url = src
1604
+ print(f" Fetching image {idx+1}: {abs_url[:80]} ...\n")
1605
+ data = _fetch_image_bytes(abs_url)
1606
+ if not data:
1607
+ print(" Could not fetch image.\n")
1608
+ continue
1609
+ ansi = render_image_to_ansi(data, term_width=_term_width() - 4, max_height=28)
1610
+ print(ansi)
1611
+ print(f"\n Image {idx+1}: {session.current_parsed.images[idx].get('alt','')}\n")
1612
+ continue
1613
+ if low in ("screenshot", "shot", "capture"):
1614
+ vb = get_visual_browser()
1615
+ if not vb.available():
1616
+ print(" Screenshot needs Chromium: pip install playwright && playwright install chromium\n")
1617
+ continue
1618
+ print(f" Capturing {session.current_url} ...\n")
1619
+ ansi = vb.screenshot_ansi(session.current_url, term_width=_term_width() - 2)
1620
+ if ansi:
1621
+ print(ansi)
1622
+ # Also save to file
1623
+ try:
1624
+ import tempfile
1625
+ import datetime
1626
+ out = Path.home() / "Downloads" / f"cat_screenshot_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
1627
+ # Also save raw PNG via engine screenshot
1628
+ vb.ensure()
1629
+ if vb._engine:
1630
+ ok = vb._engine.screenshot(str(out)) # type: ignore
1631
+ if ok:
1632
+ print(f"\n Saved PNG to {out}\n")
1633
+ except Exception:
1634
+ pass
1635
+ else:
1636
+ print(" Screenshot failed.\n")
1637
+ continue
1638
+
1639
+ # -- Back / forward / reload / home --------------------------------
1640
+ if low in ("back", "b", "<", "prev", "previous"):
1641
+ res = session.back()
1642
+ if res is None:
1643
+ print(" (already at oldest page)\n")
1644
+ else:
1645
+ print(render_page(session))
1646
+ print()
1647
+ continue
1648
+ if low in ("forward", "fwd", "f", ">", "next"):
1649
+ res = session.forward()
1650
+ if res is None:
1651
+ print(" (already at newest page)\n")
1652
+ else:
1653
+ print(render_page(session))
1654
+ print()
1655
+ continue
1656
+ if low in ("reload", "r", "refresh", "again"):
1657
+ print(" ⟳ Reloading...\n")
1658
+ session.reload()
1659
+ print(render_page(session))
1660
+ print()
1661
+ continue
1662
+ if low in ("home", "about:home"):
1663
+ session.navigate(_ABOUT_HOME_URL)
1664
+ print(render_page(session))
1665
+ print()
1666
+ continue
1667
+
1668
+ # -- Find on page ---------------------------------------------------
1669
+ if first_tok in ("find", "search-on-page") or low.startswith("/"):
1670
+ if low.startswith("/"):
1671
+ query = raw[1:].strip()
1672
+ else:
1673
+ query = raw.split(None, 1)[1].strip() if " " in raw else ""
1674
+ if not query:
1675
+ print(" Usage: find <text> or /<text>\n")
1676
+ continue
1677
+ hits = session.find_on_page(query)
1678
+ if not hits:
1679
+ print(f" No matches for {query!r} on this page.\n")
1680
+ else:
1681
+ print(f" Found {len(hits)} match(es) for {query!r}:")
1682
+ for ln_idx in hits[:12]:
1683
+ line = session.current_parsed.text_lines[ln_idx] if session.current_parsed else ""
1684
+ # Highlight query
1685
+ try:
1686
+ from . import theme as _theme
1687
+ hi = _theme.fg(query, _theme.ORANGE, bold=True)
1688
+ shown = re.sub(re.escape(query), hi, line, flags=re.I)
1689
+ print(f" line {ln_idx + 1:>3}: {shown}")
1690
+ except Exception:
1691
+ print(f" line {ln_idx + 1:>3}: {line}")
1692
+ print()
1693
+ # Keep marks for next render
1694
+ continue
1695
+
1696
+ # -- Search (web) ---------------------------------------------------
1697
+ if first_tok in ("search", "s", "query", "google", "ddg", "g"):
1698
+ # Support `search google cats` / `g cats` / `search cats`
1699
+ rest = raw.split(None, 1)[1].strip() if " " in raw else ""
1700
+ if not rest:
1701
+ print(" Usage: search <query> · search google <query> · g <query>\n")
1702
+ continue
1703
+ # Detect engine prefix inside rest (e.g. `google cats`)
1704
+ eng, q = _detect_search_engine(rest)
1705
+ # If user typed `g cats` where g was the first_tok, rest is already `cats`
1706
+ # but _detect would default to ddg; we need to propagate engine from first_tok
1707
+ if first_tok == "g" and eng == _DEFAULT_SEARCH_ENGINE:
1708
+ eng = "google"
1709
+ if first_tok == "google" and eng == _DEFAULT_SEARCH_ENGINE:
1710
+ eng = "google"
1711
+ if not q:
1712
+ q = rest
1713
+ print(f" Searching {eng} for {q!r}...\n")
1714
+ try:
1715
+ session.search(q, engine=eng)
1716
+ print(render_page(session))
1717
+ except Exception as e:
1718
+ print(f" Search failed: {e}\n")
1719
+ print()
1720
+ continue
1721
+
1722
+ # -- Bookmarks ------------------------------------------------------
1723
+ if first_tok in ("bookmark", "bm", "bookmarks", "bms"):
1724
+ parts = low.split()
1725
+ # `bookmarks` / `bms` alone -> list
1726
+ if first_tok in ("bookmarks", "bms") and len(parts) == 1:
1727
+ if not session.bookmarks:
1728
+ print(" (no bookmarks yet — `bookmark` to save current page)\n")
1729
+ else:
1730
+ try:
1731
+ from . import theme as _theme
1732
+ _theme.enable_windows_ansi()
1733
+ lines = [_theme.text("Bookmarks", bold=True), ""]
1734
+ for idx, bm in enumerate(session.bookmarks, 1):
1735
+ title = bm.title[:48] + ("…" if len(bm.title) > 48 else "")
1736
+ lines.append(f" {idx:>2}. {_theme.cyan(title)} {_theme.dim(bm.url)}")
1737
+ print(_theme.panel(lines, title="bookmarks", color=_theme.PURPLE, width=78))
1738
+ print(_theme.dim(' open bookmark <num> · bookmark rm <num>'))
1739
+ print()
1740
+ except Exception:
1741
+ for idx, bm in enumerate(session.bookmarks, 1):
1742
+ print(f" {idx:>2}. {bm.title} — {bm.url}")
1743
+ print()
1744
+ continue
1745
+ # `bookmark` alone -> add
1746
+ if low in ("bookmark", "bm"):
1747
+ try:
1748
+ bm = session.add_bookmark()
1749
+ print(f" ★ Bookmarked: {bm.title} — {bm.url}\n")
1750
+ except Exception as e:
1751
+ print(f" Could not bookmark: {e}\n")
1752
+ continue
1753
+ # `bookmark rm <n>` / `bm rm <n>` -> remove
1754
+ if len(parts) >= 2 and parts[1] in ("rm", "remove", "del", "delete"):
1755
+ if len(parts) < 3 or not parts[2].isdigit():
1756
+ print(" Usage: bookmark rm <num>\n")
1757
+ continue
1758
+ idx = int(parts[2]) - 1
1759
+ bm = session.remove_bookmark(idx)
1760
+ if bm:
1761
+ print(f" Removed bookmark: {bm.title}\n")
1762
+ else:
1763
+ print(" No such bookmark.\n")
1764
+ continue
1765
+ # `open bookmark <n>` alternate form handled below; fall through
1766
+ if low in ("bookmarks", "bms"):
1767
+ continue
1768
+ print(' Usage: bookmark | bookmarks | bookmark rm <num> | open bookmark <num>\n')
1769
+ continue
1770
+
1771
+ if low.startswith("open bookmark") or low.startswith("bm open") or low.startswith("open bm"):
1772
+ # Extract number after "bookmark"
1773
+ m = re.search(r"bookmark\s+(\d+)", low)
1774
+ if not m:
1775
+ print(' Usage: open bookmark <num>\n')
1776
+ continue
1777
+ idx = int(m.group(1)) - 1
1778
+ if not (0 <= idx < len(session.bookmarks)):
1779
+ print(" No such bookmark.\n")
1780
+ continue
1781
+ url = session.bookmarks[idx].url
1782
+ print(f" Opening bookmark {idx + 1}: {url}\n")
1783
+ try:
1784
+ session.navigate(url)
1785
+ print(render_page(session))
1786
+ except Exception as e:
1787
+ print(f" Failed to open bookmark: {e}\n")
1788
+ print()
1789
+ continue
1790
+
1791
+ # -- Open bookmark short: `bm 3` -> bookmark #3
1792
+ if first_tok == "bm" and len(raw.split()) == 2 and raw.split()[1].isdigit():
1793
+ idx = int(raw.split()[1]) - 1
1794
+ if 0 <= idx < len(session.bookmarks):
1795
+ url = session.bookmarks[idx].url
1796
+ session.navigate(url)
1797
+ print(render_page(session))
1798
+ print()
1799
+ continue
1800
+ print(" No such bookmark.\n")
1801
+ continue
1802
+
1803
+ # -- Save / download -----------------------------------------------
1804
+ if first_tok in ("save", "download", "write"):
1805
+ dest = raw.split(None, 1)[1].strip().strip('"').strip("'") if " " in raw else ""
1806
+ if not dest:
1807
+ dest = str(_DOWNLOAD_DIR)
1808
+ print(f" Saving to {dest}/ ...")
1809
+ try:
1810
+ saved = session.save_current(dest)
1811
+ print(f" ✓ Saved to {saved}\n")
1812
+ except Exception as e:
1813
+ print(f" Save failed: {e}\n")
1814
+ continue
1815
+
1816
+ # -- Copy link -------------------------------------------------------
1817
+ if first_tok in ("copy", "cp"):
1818
+ arg = raw.split(None, 1)[1].strip() if " " in raw else ""
1819
+ if not arg.isdigit():
1820
+ print(" Usage: copy <link-number>\n")
1821
+ continue
1822
+ idx = int(arg) - 1
1823
+ if not session.current_parsed or not (0 <= idx < len(session.current_parsed.links)):
1824
+ print(" No such link.\n")
1825
+ continue
1826
+ url = session.current_parsed.links[idx].href
1827
+ # Try clipboard, then fallback to printing
1828
+ copied = False
1829
+ try:
1830
+ import pyperclip # type: ignore
1831
+ pyperclip.copy(url)
1832
+ copied = True
1833
+ except Exception:
1834
+ pass
1835
+ if not copied:
1836
+ # Try native OS clipboards before giving up
1837
+ try:
1838
+ import subprocess
1839
+ if sys.platform == "win32":
1840
+ subprocess.run("clip", input=url.encode("utf-8"), check=False)
1841
+ copied = True
1842
+ elif sys.platform == "darwin":
1843
+ subprocess.run(["pbcopy"], input=url.encode("utf-8"), check=False)
1844
+ copied = True
1845
+ except Exception:
1846
+ pass
1847
+ if copied:
1848
+ print(f" ✓ Copied to clipboard: {url}\n")
1849
+ else:
1850
+ print(f" Link URL (copy manually): {url}\n")
1851
+ continue
1852
+
1853
+ # -- Open / navigate -------------------------------------------------
1854
+ # Forms:
1855
+ # open <url|num> / go <url|num> / o <url>
1856
+ # bare <url> (input itself is a URL)
1857
+ # bare number (follow link)
1858
+ if first_tok in ("open", "go", "o", "visit", "navigate"):
1859
+ target = raw.split(None, 1)[1].strip() if " " in raw else ""
1860
+ if not target:
1861
+ print(" Usage: open <url> or open <number>\n")
1862
+ continue
1863
+ # `open <number>` -> follow link
1864
+ if target.isdigit():
1865
+ idx = int(target) - 1
1866
+ if not session.current_parsed or not (0 <= idx < len(session.current_parsed.links)):
1867
+ print(f" No link #{target} on this page.\n")
1868
+ continue
1869
+ href = session.current_parsed.links[idx].absolute or session.current_parsed.links[idx].href
1870
+ print(f" → Following [{target}] {session.current_parsed.links[idx].text[:48]} ...\n")
1871
+ try:
1872
+ session.navigate(href)
1873
+ print(render_page(session))
1874
+ except Exception as e:
1875
+ print(f" Navigation failed: {e}\n")
1876
+ print()
1877
+ continue
1878
+ # `open bookmark <n>` already handled above; check again quickly
1879
+ if target.lower().startswith("bookmark"):
1880
+ m = re.search(r"(\d+)", target)
1881
+ if m:
1882
+ idx = int(m.group(1)) - 1
1883
+ if 0 <= idx < len(session.bookmarks):
1884
+ session.navigate(session.bookmarks[idx].url)
1885
+ print(render_page(session))
1886
+ print()
1887
+ continue
1888
+ # Otherwise treat as URL or search query
1889
+ # If it looks like a search phrase (spaces + no scheme/dot-TLD), search
1890
+ if " " in target and _is_search_query(target):
1891
+ print(f" Searching for {target!r}...\n")
1892
+ try:
1893
+ session.search(target)
1894
+ print(render_page(session))
1895
+ except Exception as e:
1896
+ print(f" Search failed: {e}\n")
1897
+ print()
1898
+ continue
1899
+ try:
1900
+ url = normalize_url(target, base=session.current_url)
1901
+ session.navigate(url)
1902
+ print(render_page(session))
1903
+ except ValueError as e:
1904
+ print(f" Could not parse URL: {e}\n")
1905
+ except Exception as e:
1906
+ print(f" Navigation failed: {e}\n")
1907
+ print()
1908
+ continue
1909
+
1910
+ # Bare number -> follow link
1911
+ if raw.strip().isdigit():
1912
+ idx = int(raw.strip()) - 1
1913
+ if not session.current_parsed or not (0 <= idx < len(session.current_parsed.links)):
1914
+ print(f" No link #{raw.strip()} on this page. Type `links` to see links.\n")
1915
+ continue
1916
+ href = session.current_parsed.links[idx].absolute or session.current_parsed.links[idx].href
1917
+ print(f" → Following [{raw.strip()}] {session.current_parsed.links[idx].text[:48]} ...\n")
1918
+ try:
1919
+ session.navigate(href)
1920
+ print(render_page(session))
1921
+ except Exception as e:
1922
+ print(f" Navigation failed: {e}\n")
1923
+ print()
1924
+ continue
1925
+
1926
+ # Bare input that looks like a URL -> navigate directly
1927
+ # (convenience so you can paste https://example.com and hit Enter)
1928
+ bare = raw.strip()
1929
+ looks_like_url = (
1930
+ re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", bare) or
1931
+ bare.lower().startswith("about:") or
1932
+ ("." in bare.split("/")[0].split(" ")[0] and " " not in bare and "/" in bare) or
1933
+ re.match(r"^(localhost(:\d+)?)(/|$)", bare, re.I)
1934
+ )
1935
+ if looks_like_url:
1936
+ try:
1937
+ url = normalize_url(bare, base=session.current_url)
1938
+ session.navigate(url)
1939
+ print(render_page(session))
1940
+ print()
1941
+ continue
1942
+ except Exception as e:
1943
+ print(f" Could not open {bare!r}: {e}\n")
1944
+ continue
1945
+
1946
+ # Bare search-ish phrase (contains space, not a URL) -> web search
1947
+ if " " in bare and _is_search_query(bare):
1948
+ print(f" Searching for {bare!r}...\n")
1949
+ try:
1950
+ session.search(bare)
1951
+ print(render_page(session))
1952
+ except Exception as e:
1953
+ print(f" Search failed: {e}\n")
1954
+ print()
1955
+ continue
1956
+
1957
+ # Single-token without scheme: try URL, fallback to search
1958
+ if " " not in bare and "." in bare:
1959
+ # Looks host-like, try as https URL
1960
+ try:
1961
+ url = normalize_url(bare)
1962
+ session.navigate(url)
1963
+ print(render_page(session))
1964
+ print()
1965
+ continue
1966
+ except Exception:
1967
+ pass
1968
+
1969
+ # Unknown command — treat as search from anywhere (forgiving), otherwise help
1970
+ if len(bare.split()) >= 1:
1971
+ # One-word unknown: show help rather than blind search to avoid
1972
+ # surprise navigation on a typo like "helo".
1973
+ if " " not in bare and len(bare) < 20 and bare.isalpha():
1974
+ print(f" Unknown command: {bare!r} — type `help` for commands, or `search {bare}` to search.\n")
1975
+ continue
1976
+ # Multi-word unknown outside known commands -> search fallback
1977
+ if " " in bare:
1978
+ print(f" Searching for {bare!r} (type `help` to see commands)...\n")
1979
+ try:
1980
+ session.search(bare)
1981
+ print(render_page(session))
1982
+ except Exception as e:
1983
+ print(f" Search failed: {e}\n")
1984
+ print()
1985
+ continue
1986
+
1987
+ print(f" Unknown command: {bare!r} — type `help`.\n")
1988
+
1989
+ return 0
1990
+
1991
+
1992
+ # ---------------------------------------------------------------------------
1993
+ # Entry for `python -m calc_terminal.cat_browser` — DEPRECATED CLI
1994
+ # Normal browser is graphical (browser_shell.py). This CLI is now
1995
+ # Developer Tools only (hidden). Use `cat browse` for the graphical browser.
1996
+ # To force the old text CLI, set CAT_BROWSER_DEBUG=1.
1997
+ # ---------------------------------------------------------------------------
1998
+
1999
+ if __name__ == "__main__":
2000
+ if os.environ.get("CAT_BROWSER_DEBUG", "").lower() not in ("1","true","yes"):
2001
+ # Normal users should never see numbered Links. Redirect to graphical.
2002
+ try:
2003
+ from .host.launcher import launch_cat_host, can_launch_host
2004
+ if can_launch_host():
2005
+ url_arg = sys.argv[1] if len(sys.argv) > 1 else "about:home"
2006
+ raise SystemExit(launch_cat_host(start_browser_url=url_arg, start_mode="browser", block=True))
2007
+ except SystemExit:
2008
+ raise
2009
+ except Exception:
2010
+ pass
2011
+ # Fallback message when Textual missing
2012
+ print("CAT Browser — graphical mode is the normal browser.")
2013
+ print("The text/CLI browser (Links, numbered navigation) is Developer Tools only.")
2014
+ print("Run: cat browse https://example.com")
2015
+ print("Or set CAT_BROWSER_DEBUG=1 to force the old CLI (not recommended).")
2016
+ raise SystemExit(1)
2017
+ url_arg = sys.argv[1] if len(sys.argv) > 1 else None
2018
+ raise SystemExit(launch_browser(start_url=url_arg))