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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- 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))
|