diff --git a/README.md b/README.md
index 22945259..a8da77ed 100644
--- a/README.md
+++ b/README.md
@@ -59,13 +59,6 @@ OpenBrowser is a framework for intelligent browser automation. It combines direc
## Installation
-### Homebrew (macOS / Linux)
-
-```bash
-brew tap billy-enrizky/openbrowser
-brew install openbrowser-ai
-```
-
### Quick install (macOS / Linux)
```bash
@@ -75,11 +68,10 @@ curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/i
### Quick install (Windows PowerShell)
```powershell
-# Windows (PowerShell)
irm https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.ps1 | iex
```
-Detects `uv`, `pipx`, or `pip` and installs OpenBrowser + Chromium automatically.
+Detects `uv`, `pipx`, or `pip` and installs OpenBrowser automatically.
Install to `~/.local/bin` without sudo:
@@ -87,6 +79,13 @@ Install to `~/.local/bin` without sudo:
curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.sh | sh -s -- --local
```
+### Homebrew (macOS / Linux)
+
+```bash
+brew tap billy-enrizky/openbrowser
+brew install openbrowser-ai
+```
+
### pip
```bash
@@ -138,13 +137,7 @@ pip install openbrowser-ai[azure] # Azure OpenAI
pip install openbrowser-ai[video] # Video recording support
```
-### Install Browser
-
-```bash
-uvx openbrowser-ai install
-# or
-playwright install chromium
-```
+> **No separate browser install needed.** OpenBrowser auto-detects any installed Chromium-based browser (Chrome, Edge, Brave, Chromium) and uses it directly. If none is found and `uvx` is available, Chromium is installed automatically on first run. To pre-install manually (requires `uvx`): `openbrowser-ai install`
## Quick Start
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index b34a2157..7e4ef67b 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -9,24 +9,20 @@ To get started with OpenBrowser you need to install the package and set up an AP
## 1. Installing OpenBrowser
-
+
**macOS / Linux**
```bash
- brew tap billy-enrizky/openbrowser
- brew install openbrowser-ai
+ curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.sh | sh
```
- Installs OpenBrowser and Chromium into a Homebrew-managed virtualenv.
-
-
- **macOS / Linux**
+ **Windows (PowerShell)**
- ```bash
- curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.sh | sh
+ ```powershell
+ irm https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.ps1 | iex
```
- Detects `uv`, `pipx`, or `pip` and installs OpenBrowser + Chromium automatically.
+ Detects `uv`, `pipx`, or `pip` and installs OpenBrowser automatically.
Install to `~/.local/bin` without sudo:
@@ -34,6 +30,16 @@ To get started with OpenBrowser you need to install the package and set up an AP
curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.sh | sh -s -- --local
```
+
+ **macOS / Linux**
+
+ ```bash
+ brew tap billy-enrizky/openbrowser
+ brew install openbrowser-ai
+ ```
+
+ Installs OpenBrowser into a Homebrew-managed virtualenv.
+
```bash
pip install openbrowser-ai
@@ -87,15 +93,9 @@ To get started with OpenBrowser you need to install the package and set up an AP
-### Install Browser
-
-After installing, set up Chromium:
-
-```bash
-uvx openbrowser-ai install
-# or
-playwright install chromium
-```
+
+**No separate browser install needed.** OpenBrowser auto-detects any installed Chromium-based browser (Chrome, Edge, Brave, Chromium) and uses it directly. If none is found and `uvx` is available, Chromium is installed automatically on first run. To pre-install manually (requires `uvx`): `openbrowser-ai install`
+
## 2. Choose your favorite LLM
Create a `.env` file and add your API key.
diff --git a/install.ps1 b/install.ps1
index 5d3eaabc..2403d41b 100644
--- a/install.ps1
+++ b/install.ps1
@@ -161,22 +161,14 @@ if (-not $SkipBrowser) {
}
catch {
try {
- $null = Get-Command 'playwright' -ErrorAction Stop
- & playwright install chromium 2>$null
+ $null = Get-Command 'uvx' -ErrorAction Stop
+ & uvx playwright install chromium 2>$null
if ($LASTEXITCODE -ne 0) {
- Write-Warn "Chromium install failed (run 'playwright install chromium' manually)"
+ Write-Warn "Chromium install failed (run 'openbrowser-ai install' manually)"
}
}
catch {
- try {
- Invoke-Python -Arguments @('-m', 'playwright', 'install', 'chromium') 2>$null
- if ($LASTEXITCODE -ne 0) {
- Write-Warn "Chromium install skipped (run 'openbrowser-ai install' manually)"
- }
- }
- catch {
- Write-Warn "Chromium install skipped (run 'openbrowser-ai install' manually)"
- }
+ Write-Warn "Chromium install skipped. Please run 'openbrowser-ai install' manually after installation completes."
}
}
}
diff --git a/install.sh b/install.sh
index f63bfe2b..e6e63bcf 100755
--- a/install.sh
+++ b/install.sh
@@ -157,10 +157,10 @@ if [ "$SKIP_BROWSER" = false ]; then
info "Installing Chromium browser..."
if command -v openbrowser-ai >/dev/null 2>&1; then
openbrowser-ai install 2>/dev/null || warn "Chromium install failed (run 'openbrowser-ai install' manually)"
- elif command -v playwright >/dev/null 2>&1; then
- playwright install chromium 2>/dev/null || warn "Chromium install failed (run 'playwright install chromium' manually)"
+ elif command -v uvx >/dev/null 2>&1; then
+ uvx playwright install chromium 2>/dev/null || warn "Chromium install failed (run 'openbrowser-ai install' manually)"
else
- "$PYTHON" -m playwright install chromium 2>/dev/null || warn "Chromium install skipped (run 'openbrowser-ai install' manually)"
+ warn "Chromium install skipped. Please run 'openbrowser-ai install' manually after installation completes."
fi
fi
diff --git a/pyproject.toml b/pyproject.toml
index 274caa78..6ad7c261 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,6 @@ dependencies = [
# Core browser automation
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
- "playwright",
"python-dotenv",
"httpx>=0.28.1",
"websockets>=15.0.1",
@@ -40,7 +39,6 @@ agent = [
"boto3>=1.36.0",
"reportlab",
]
-cli = ["textual>=3.2.0"]
video = ["imageio[ffmpeg]", "numpy"]
anthropic = ["anthropic>=0.68.0"]
groq = ["groq>=0.30.0"]
@@ -57,7 +55,7 @@ dev = [
"mcp>=1.0.0",
]
all = [
- "openbrowser-ai[agent,cli,video,anthropic,groq,ollama,aws,azure,pdf]",
+ "openbrowser-ai[agent,video,anthropic,groq,ollama,aws,azure,pdf]",
]
[project.scripts]
diff --git a/src/openbrowser/cli.py b/src/openbrowser/cli.py
index 86805fec..39ed4409 100644
--- a/src/openbrowser/cli.py
+++ b/src/openbrowser/cli.py
@@ -13,7 +13,7 @@
logging.disable(logging.CRITICAL)
# Early exit: run MCP server directly without loading heavy CLI dependencies
- # (anthropic, openai, textual, etc. are not needed for MCP server mode)
+ # (anthropic, openai, etc. are not needed for MCP server mode)
try:
from openbrowser.telemetry import CLITelemetryEvent, ProductTelemetry
from openbrowser.utils import get_openbrowser_version
@@ -57,14 +57,24 @@
from openbrowser.daemon.client import DaemonClient
client = DaemonClient()
- status = asyncio.run(client.status())
- if status.success:
+ try:
+ status = asyncio.run(client.status())
+ except TimeoutError:
+ # Daemon is alive but slow to respond -- show compact help
+ print(EXECUTE_CODE_DESCRIPTION_COMPACT)
+ sys.exit(0)
+ except (OSError, asyncio.CancelledError, ValueError):
+ status = None
+ if status and status.success:
# Daemon already running (warm) -- compact description
print(EXECUTE_CODE_DESCRIPTION_COMPACT)
else:
# Daemon not running (cold) -- verbose description, then start it
print(EXECUTE_CODE_DESCRIPTION)
- asyncio.run(client._start_daemon())
+ try:
+ asyncio.run(client._start_daemon())
+ except (TimeoutError, OSError, asyncio.CancelledError):
+ pass # Best-effort; daemon may still be starting in background
sys.exit(0)
from openbrowser.daemon.client import execute_code_via_daemon
@@ -164,7 +174,7 @@ async def _restart():
sys.exit(1)
sys.exit(0)
-# Check for init subcommand early to avoid loading TUI dependencies
+# Check for init subcommand early to avoid loading heavy dependencies
if 'init' in sys.argv:
from openbrowser.init_cmd import INIT_TEMPLATES
from openbrowser.init_cmd import main as init_main
@@ -190,7 +200,7 @@ async def _restart():
init_main()
sys.exit(0)
-# Check for --template flag early to avoid loading TUI dependencies
+# Check for --template flag early to avoid loading heavy dependencies
if '--template' in sys.argv:
from pathlib import Path
@@ -278,32 +288,18 @@ async def _restart():
load_dotenv()
-from openbrowser import Agent, Controller
-from openbrowser.agent.views import AgentSettings
+try:
+ from openbrowser import Agent, Controller
+ from openbrowser.agent.views import AgentSettings
+except ImportError:
+ Agent = None # type: ignore[assignment,misc]
+ Controller = None # type: ignore[assignment,misc]
+ AgentSettings = None # type: ignore[assignment,misc]
from openbrowser.browser import BrowserProfile, BrowserSession
-from openbrowser.logging_config import addLoggingLevel
from openbrowser.telemetry import CLITelemetryEvent, ProductTelemetry
from openbrowser.utils import get_openbrowser_version
-try:
- import click
- from textual import events
- from textual.app import App, ComposeResult
- from textual.binding import Binding
- from textual.containers import Container, HorizontalGroup, VerticalScroll
- from textual.widgets import Footer, Header, Input, Label, Link, RichLog, Static
-except ImportError:
- print('CLI addon is not installed. Please install it with: `pip install "openbrowser-ai[cli]"` and try again.')
- sys.exit(1)
-
-
-try:
- import readline
-
- READLINE_AVAILABLE = True
-except ImportError:
- # readline not available on Windows by default
- READLINE_AVAILABLE = False
+import click
os.environ['OPENBROWSER_LOGGING_LEVEL'] = 'result'
@@ -320,35 +316,6 @@ async def _restart():
# Default User settings
MAX_HISTORY_LENGTH = 100
-# Directory setup will happen in functions that need CONFIG
-
-
-# Logo components with styling for rich panels
-BROWSER_LOGO = """
- [white] ++++++ +++++++++ [/]
- [white] +++ +++++ +++ [/]
- [white] ++ ++++ ++ ++ [/]
- [white] ++ +++ +++ ++ [/]
- [white] ++++ +++ [/]
- [white] +++ +++ [/]
- [white] +++ +++ [/]
- [white] ++ +++ +++ ++ [/]
- [white] ++ ++++ ++ ++ [/]
- [white] +++ ++++++ +++ [/]
- [white] ++++++ +++++++ [/]
-
-[darkorange] ██████╗ ██████╗ ███████╗███╗ ██╗[/][white]██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗███████╗██████╗[/]
-[darkorange]██╔═══██╗██╔══██╗██╔════╝████╗ ██║[/][white]██╔══██╗██╔══██╗██╔═══██╗██║ ██║██╔════╝██╔════╝██╔══██╗[/]
-[darkorange]██║ ██║██████╔╝█████╗ ██╔██╗ ██║[/][white]██████╔╝██████╔╝██║ ██║██║ █╗ ██║███████╗█████╗ ██████╔╝[/]
-[darkorange]██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║[/][white]██╔══██╗██╔══██╗██║ ██║██║███╗██║╚════██║██╔══╝ ██╔══██╗[/]
-[darkorange]╚██████╔╝██║ ███████╗██║ ╚████║[/][white]██████╔╝██║ ██║╚██████╔╝╚███╔███╔╝███████║███████╗██║ ██║[/]
-[darkorange] ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝[/][white]╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝ ╚══════╝╚══════╝╚═╝ ╚═╝[/]
-"""
-
-
-# Common UI constants
-TEXTUAL_BORDER_STYLES = {'logo': 'blue', 'info': 'blue', 'input': 'orange3', 'working': 'yellow', 'completion': 'green'}
-
def get_default_config() -> dict[str, Any]:
"""Return default configuration dictionary using the new config system."""
@@ -461,15 +428,6 @@ def update_config_with_click_args(config: dict[str, Any], ctx: click.Context) ->
return config
-def setup_readline_history(history: list[str]) -> None:
- """Set up readline with command history."""
- if not READLINE_AVAILABLE:
- return
-
- # Add history items to readline
- for item in history:
- readline.add_history(item)
-
def get_llm(config: dict[str, Any]):
"""Get the language model based on config and available API keys."""
@@ -530,1093 +488,7 @@ def get_llm(config: dict[str, Any]):
sys.exit(1)
-class RichLogHandler(logging.Handler):
- """Custom logging handler that redirects logs to a RichLog widget."""
-
- def __init__(self, rich_log: RichLog):
- super().__init__()
- self.rich_log = rich_log
-
- def emit(self, record):
- try:
- msg = self.format(record)
- self.rich_log.write(msg)
- except Exception:
- self.handleError(record)
-
-
-class OpenBrowserApp(App):
- """OpenBrowser TUI application."""
-
- # Make it an inline app instead of fullscreen
- # MODES = {"light"} # Ensure app is inline, not fullscreen
-
- CSS = """
- #main-container {
- height: 100%;
- layout: vertical;
- }
-
- #logo-panel, #links-panel, #paths-panel, #info-panels {
- border: solid $primary;
- margin: 0 0 0 0;
- padding: 0;
- }
-
- #info-panels {
- display: none;
- layout: vertical;
- height: auto;
- min-height: 5;
- margin: 0 0 1 0;
- }
-
- #top-panels {
- layout: horizontal;
- height: auto;
- width: 100%;
- }
-
- #browser-panel, #model-panel {
- width: 1fr;
- height: 100%;
- padding: 1;
- border-right: solid $primary;
- }
-
- #model-panel {
- border-right: none;
- }
-
- #tasks-panel {
- height: auto;
- max-height: 10;
- overflow-y: scroll;
- padding: 1;
- border-top: solid $primary;
- }
-
- #browser-info, #model-info, #tasks-info {
- height: auto;
- margin: 0;
- padding: 0;
- background: transparent;
- overflow-y: auto;
- min-height: 3;
- }
-
- #three-column-container {
- height: 1fr;
- layout: horizontal;
- width: 100%;
- display: none;
- }
-
- #main-output-column {
- width: 1fr;
- height: 100%;
- border: solid $primary;
- padding: 0;
- margin: 0 1 0 0;
- }
-
- #events-column {
- width: 1fr;
- height: 100%;
- border: solid $warning;
- padding: 0;
- margin: 0 1 0 0;
- }
-
- #cdp-column {
- width: 1fr;
- height: 100%;
- border: solid $accent;
- padding: 0;
- margin: 0;
- }
-
- #main-output-log, #events-log, #cdp-log {
- height: 100%;
- overflow-y: scroll;
- background: $surface;
- color: $text;
- width: 100%;
- padding: 1;
- }
-
- #events-log {
- color: $warning;
- }
-
- #cdp-log {
- color: $accent-lighten-2;
- }
-
- #logo-panel {
- width: 100%;
- height: auto;
- content-align: center middle;
- text-align: center;
- }
-
- #links-panel {
- width: 100%;
- padding: 1;
- border: solid $primary;
- height: auto;
- }
-
- .link-white {
- color: white;
- }
-
- .link-purple {
- color: purple;
- }
-
- .link-magenta {
- color: magenta;
- }
-
- .link-green {
- color: green;
- }
-
- HorizontalGroup {
- height: auto;
- }
-
- .link-label {
- width: auto;
- }
-
- .link-url {
- width: auto;
- }
-
- .link-row {
- width: 100%;
- height: auto;
- }
-
- #paths-panel {
- color: $text-muted;
- }
-
- #task-input-container {
- border: solid $accent;
- padding: 1;
- margin-bottom: 1;
- height: auto;
- dock: bottom;
- }
-
- #task-label {
- color: $accent;
- padding-bottom: 1;
- }
-
- #task-input {
- width: 100%;
- }
- """
-
- BINDINGS = [
- Binding('ctrl+c', 'quit', 'Quit', priority=True, show=True),
- Binding('ctrl+q', 'quit', 'Quit', priority=True),
- Binding('ctrl+d', 'quit', 'Quit', priority=True),
- Binding('up', 'input_history_prev', 'Previous command', show=False),
- Binding('down', 'input_history_next', 'Next command', show=False),
- ]
-
- def __init__(self, config: dict[str, Any], *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.config = config
- self.browser_session: BrowserSession | None = None # Will be set before app.run_async()
- self.controller: Controller | None = None # Will be set before app.run_async()
- self.agent: Agent | None = None
- self.llm: Any | None = None # Will be set before app.run_async()
- self.task_history = config.get('command_history', [])
- # Track current position in history for up/down navigation
- self.history_index = len(self.task_history)
- # Initialize telemetry
- self._telemetry = ProductTelemetry()
- # Store for event bus handler
- self._event_bus_handler_id = None
- self._event_bus_handler_func = None
- # Timer for info panel updates
- self._info_panel_timer = None
-
- def setup_richlog_logging(self) -> None:
- """Set up logging to redirect to RichLog widget instead of stdout."""
- # Try to add RESULT level if it doesn't exist
- try:
- addLoggingLevel('RESULT', 35)
- except AttributeError:
- pass # Level already exists, which is fine
-
- # Get the main output RichLog widget
- rich_log = self.query_one('#main-output-log', RichLog)
-
- # Create and set up the custom handler
- log_handler = RichLogHandler(rich_log)
- log_type = os.getenv('OPENBROWSER_LOGGING_LEVEL', 'result').lower()
-
- class OpenBrowserFormatter(logging.Formatter):
- def format(self, record):
- # if isinstance(record.name, str) and record.name.startswith('openbrowser.'):
- # record.name = record.name.split('.')[-2]
- return super().format(record)
-
- # Set up the formatter based on log type
- if log_type == 'result':
- log_handler.setLevel('RESULT')
- log_handler.setFormatter(OpenBrowserFormatter('%(message)s'))
- else:
- log_handler.setFormatter(OpenBrowserFormatter('%(levelname)-8s [%(name)s] %(message)s'))
-
- # Configure root logger - Replace ALL handlers, not just stdout handlers
- root = logging.getLogger()
-
- # Clear all existing handlers to prevent output to stdout/stderr
- root.handlers = []
- root.addHandler(log_handler)
-
- # Set log level based on environment variable
- if log_type == 'result':
- root.setLevel('RESULT')
- elif log_type == 'debug':
- root.setLevel(logging.DEBUG)
- else:
- root.setLevel(logging.INFO)
-
- # Configure openbrowser logger and all its sub-loggers
- openbrowser_logger = logging.getLogger('openbrowser')
- openbrowser_logger.propagate = False # Don't propagate to root logger
- openbrowser_logger.handlers = [log_handler] # Replace any existing handlers
- openbrowser_logger.setLevel(root.level)
-
- # Also ensure agent loggers go to the main output
- # Use a wildcard pattern to catch all agent-related loggers
- for logger_name in ['openbrowser.Agent', 'openbrowser.controller', 'openbrowser.agent', 'openbrowser.agent.service']:
- agent_logger = logging.getLogger(logger_name)
- agent_logger.propagate = False
- agent_logger.handlers = [log_handler]
- agent_logger.setLevel(root.level)
-
- # Also catch any dynamically created agent loggers with task IDs
- for name, logger in logging.Logger.manager.loggerDict.items():
- if isinstance(name, str) and 'openbrowser.Agent' in name:
- if isinstance(logger, logging.Logger):
- logger.propagate = False
- logger.handlers = [log_handler]
- logger.setLevel(root.level)
-
- # Silence third-party loggers but keep them using our handler
- for logger_name in [
- 'WDM',
- 'httpx',
- 'selenium',
- 'playwright',
- 'urllib3',
- 'asyncio',
- 'openai',
- 'httpcore',
- 'charset_normalizer',
- 'anthropic._base_client',
- 'PIL.PngImagePlugin',
- 'trafilatura.htmlprocessing',
- 'trafilatura',
- 'groq',
- 'portalocker',
- 'portalocker.utils',
- ]:
- third_party = logging.getLogger(logger_name)
- third_party.setLevel(logging.ERROR)
- third_party.propagate = False
- third_party.handlers = [log_handler] # Use our handler to prevent stdout/stderr leakage
-
- def on_mount(self) -> None:
- """Set up components when app is mounted."""
- # We'll use a file logger since stdout is now controlled by Textual
- logger = logging.getLogger('openbrowser.on_mount')
- logger.debug('on_mount() method started')
-
- # Step 1: Set up custom logging to RichLog
- logger.debug('Setting up RichLog logging...')
- try:
- self.setup_richlog_logging()
- logger.debug('RichLog logging set up successfully')
- except Exception as e:
- logger.error(f'Error setting up RichLog logging: {str(e)}', exc_info=True)
- raise RuntimeError(f'Failed to set up RichLog logging: {str(e)}')
-
- # Step 2: Set up input history
- logger.debug('Setting up readline history...')
- try:
- if READLINE_AVAILABLE and self.task_history:
- for item in self.task_history:
- readline.add_history(item)
- logger.debug(f'Added {len(self.task_history)} items to readline history')
- else:
- logger.debug('No readline history to set up')
- except Exception as e:
- logger.error(f'Error setting up readline history: {str(e)}', exc_info=False)
- # Non-critical, continue
-
- # Step 3: Focus the input field
- logger.debug('Focusing input field...')
- try:
- input_field = self.query_one('#task-input', Input)
- input_field.focus()
- logger.debug('Input field focused')
- except Exception as e:
- logger.error(f'Error focusing input field: {str(e)}', exc_info=True)
- # Non-critical, continue
-
- # Step 5: Setup CDP logger and event bus listener if browser session is available
- logger.debug('Setting up CDP logging and event bus listener...')
- try:
- self.setup_cdp_logger()
- if self.browser_session:
- self.setup_event_bus_listener()
- logger.debug('CDP logging and event bus setup complete')
- except Exception as e:
- logger.error(f'Error setting up CDP logging/event bus: {str(e)}', exc_info=True)
- # Non-critical, continue
-
- # Capture telemetry for CLI start
- self._telemetry.capture(
- CLITelemetryEvent(
- version=get_openbrowser_version(),
- action='start',
- mode='interactive',
- model=self.llm.model if self.llm and hasattr(self.llm, 'model') else None,
- model_provider=self.llm.provider if self.llm and hasattr(self.llm, 'provider') else None,
- )
- )
-
- logger.debug('on_mount() completed successfully')
-
- def on_input_key_up(self, event: events.Key) -> None:
- """Handle up arrow key in the input field."""
- # For textual key events, we need to check focus manually
- input_field = self.query_one('#task-input', Input)
- if not input_field.has_focus:
- return
-
- # Only process if we have history
- if not self.task_history:
- return
-
- # Move back in history if possible
- if self.history_index > 0:
- self.history_index -= 1
- task_input = self.query_one('#task-input', Input)
- task_input.value = self.task_history[self.history_index]
- # Move cursor to end of text
- task_input.cursor_position = len(task_input.value)
-
- # Prevent default behavior (cursor movement)
- event.prevent_default()
- event.stop()
-
- def on_input_key_down(self, event: events.Key) -> None:
- """Handle down arrow key in the input field."""
- # For textual key events, we need to check focus manually
- input_field = self.query_one('#task-input', Input)
- if not input_field.has_focus:
- return
-
- # Only process if we have history
- if not self.task_history:
- return
-
- # Move forward in history or clear input if at the end
- if self.history_index < len(self.task_history) - 1:
- self.history_index += 1
- task_input = self.query_one('#task-input', Input)
- task_input.value = self.task_history[self.history_index]
- # Move cursor to end of text
- task_input.cursor_position = len(task_input.value)
- elif self.history_index == len(self.task_history) - 1:
- # At the end of history, go to "new line" state
- self.history_index += 1
- self.query_one('#task-input', Input).value = ''
-
- # Prevent default behavior (cursor movement)
- event.prevent_default()
- event.stop()
-
- async def on_key(self, event: events.Key) -> None:
- """Handle key events at the app level to ensure graceful exit."""
- # Handle Ctrl+C, Ctrl+D, and Ctrl+Q for app exit
- if event.key == 'ctrl+c' or event.key == 'ctrl+d' or event.key == 'ctrl+q':
- await self.action_quit()
- event.stop()
- event.prevent_default()
-
- def on_input_submitted(self, event: Input.Submitted) -> None:
- """Handle task input submission."""
- if event.input.id == 'task-input':
- task = event.input.value
- if not task.strip():
- return
-
- # Add to history if it's new
- if task.strip() and (not self.task_history or task != self.task_history[-1]):
- self.task_history.append(task)
- self.config['command_history'] = self.task_history
- save_user_config(self.config)
-
- # Reset history index to point past the end of history
- self.history_index = len(self.task_history)
-
- # Hide logo, links, and paths panels
- self.hide_intro_panels()
-
- # Process the task
- self.run_task(task)
-
- # Clear the input
- event.input.value = ''
-
- def hide_intro_panels(self) -> None:
- """Hide the intro panels, show info panels and the three-column view."""
- try:
- # Get the panels
- logo_panel = self.query_one('#logo-panel')
- links_panel = self.query_one('#links-panel')
- paths_panel = self.query_one('#paths-panel')
- info_panels = self.query_one('#info-panels')
- three_column = self.query_one('#three-column-container')
-
- # Hide intro panels if they're visible and show info panels + three-column view
- if logo_panel.display:
- logging.debug('Hiding intro panels and showing info panels + three-column view')
-
- logo_panel.display = False
- links_panel.display = False
- paths_panel.display = False
-
- # Show info panels and three-column container
- info_panels.display = True
- three_column.display = True
-
- # Start updating info panels
- self.update_info_panels()
-
- logging.debug('Info panels and three-column view should now be visible')
- except Exception as e:
- logging.error(f'Error in hide_intro_panels: {str(e)}')
-
- def setup_event_bus_listener(self) -> None:
- """Setup listener for browser session event bus."""
- if not self.browser_session or not self.browser_session.event_bus:
- return
-
- # Clean up any existing handler before registering a new one
- if self._event_bus_handler_func is not None:
- try:
- # Remove handler from the event bus's internal handlers dict
- if hasattr(self.browser_session.event_bus, 'handlers'):
- # Find and remove our handler function from all event patterns
- for event_type, handler_list in list(self.browser_session.event_bus.handlers.items()):
- # Remove our specific handler function object
- if self._event_bus_handler_func in handler_list:
- handler_list.remove(self._event_bus_handler_func)
- logging.debug(f'Removed old handler from event type: {event_type}')
- except Exception as e:
- logging.debug(f'Error cleaning up event bus handler: {e}')
- self._event_bus_handler_func = None
- self._event_bus_handler_id = None
-
- try:
- # Get the events log widget
- events_log = self.query_one('#events-log', RichLog)
- except Exception:
- # Widget not ready yet
- return
-
- # Create handler to log all events
- def log_event(event):
- event_name = event.__class__.__name__
- # Format event data nicely
- try:
- if hasattr(event, 'model_dump'):
- event_data = event.model_dump(exclude_unset=True)
- # Remove large fields
- if 'screenshot' in event_data:
- event_data['screenshot'] = ''
- if 'dom_state' in event_data:
- event_data['dom_state'] = ''
- event_str = str(event_data) if event_data else ''
- else:
- event_str = str(event)
-
- # Truncate long strings
- if len(event_str) > 200:
- event_str = event_str[:200] + '...'
-
- events_log.write(f'[yellow]→ {event_name}[/] {event_str}')
- except Exception as e:
- events_log.write(f'[red]→ {event_name}[/] (error formatting: {e})')
-
- # Store the handler function before registering it
- self._event_bus_handler_func = log_event
- self._event_bus_handler_id = id(log_event)
-
- # Register wildcard handler for all events
- self.browser_session.event_bus.on('*', log_event)
- logging.debug(f'Registered new event bus handler with id: {self._event_bus_handler_id}')
-
- def setup_cdp_logger(self) -> None:
- """Setup CDP message logger to capture already-transformed CDP logs."""
- # No need to configure levels - setup_logging() already handles that
- # We just need to capture the transformed logs and route them to the CDP pane
-
- # Get the CDP log widget
- cdp_log = self.query_one('#cdp-log', RichLog)
-
- # Create custom handler for CDP logging
- class CDPLogHandler(logging.Handler):
- def __init__(self, rich_log: RichLog):
- super().__init__()
- self.rich_log = rich_log
-
- def emit(self, record):
- try:
- msg = self.format(record)
- # Truncate very long messages
- if len(msg) > 300:
- msg = msg[:300] + '...'
- # Color code by level
- if record.levelno >= logging.ERROR:
- self.rich_log.write(f'[red]{msg}[/]')
- elif record.levelno >= logging.WARNING:
- self.rich_log.write(f'[yellow]{msg}[/]')
- else:
- self.rich_log.write(f'[cyan]{msg}[/]')
- except Exception:
- self.handleError(record)
-
- # Setup handler for cdp_use loggers
- cdp_handler = CDPLogHandler(cdp_log)
- cdp_handler.setFormatter(logging.Formatter('%(message)s'))
- cdp_handler.setLevel(logging.DEBUG)
-
- # Route CDP logs to the CDP pane
- # These are already transformed by cdp_use and at the right level from setup_logging
- for logger_name in ['websockets.client', 'cdp_use', 'cdp_use.client', 'cdp_use.cdp', 'cdp_use.cdp.registry']:
- logger = logging.getLogger(logger_name)
- # Add our handler (don't replace - keep existing console handler too)
- if cdp_handler not in logger.handlers:
- logger.addHandler(cdp_handler)
-
- def scroll_to_input(self) -> None:
- """Scroll to the input field to ensure it's visible."""
- input_container = self.query_one('#task-input-container')
- input_container.scroll_visible()
-
- def run_task(self, task: str) -> None:
- """Launch the task in a background worker."""
- # Create or update the agent
- agent_settings = AgentSettings.model_validate(self.config.get('agent', {}))
-
- # Get the logger
- logger = logging.getLogger('openbrowser.app')
-
- # Make sure intro is hidden and log is ready
- self.hide_intro_panels()
-
- # Clear the main output log to start fresh
- rich_log = self.query_one('#main-output-log', RichLog)
- rich_log.clear()
-
- if self.agent is None:
- if not self.llm:
- raise RuntimeError('LLM not initialized')
- self.agent = Agent(
- task=task,
- llm=self.llm,
- controller=self.controller if self.controller else Controller(),
- browser_session=self.browser_session,
- source='cli',
- **agent_settings.model_dump(),
- )
- # Update our browser_session reference to point to the agent's
- if hasattr(self.agent, 'browser_session'):
- self.browser_session = self.agent.browser_session
- # Set up event bus listener (will clean up any old handler first)
- self.setup_event_bus_listener()
- else:
- self.agent.add_new_task(task)
-
- # Let the agent run in the background
- async def agent_task_worker() -> None:
- logger.debug('\n🚀 Working on task: %s', task)
-
- # Set flags to indicate the agent is running
- if self.agent:
- self.agent.running = True # type: ignore
- self.agent.last_response_time = 0 # type: ignore
-
- # Panel updates are already happening via the timer in update_info_panels
-
- task_start_time = time.time()
- error_msg = None
-
- try:
- # Capture telemetry for message sent
- self._telemetry.capture(
- CLITelemetryEvent(
- version=get_openbrowser_version(),
- action='message_sent',
- mode='interactive',
- model=self.llm.model if self.llm and hasattr(self.llm, 'model') else None,
- model_provider=self.llm.provider if self.llm and hasattr(self.llm, 'provider') else None,
- )
- )
-
- # Run the agent task, redirecting output to RichLog through our handler
- if self.agent:
- await self.agent.run()
- except Exception as e:
- error_msg = str(e)
- logger.error('\nError running agent: %s', str(e))
- finally:
- # Clear the running flag
- if self.agent:
- self.agent.running = False # type: ignore
-
- # Capture telemetry for task completion
- duration = time.time() - task_start_time
- self._telemetry.capture(
- CLITelemetryEvent(
- version=get_openbrowser_version(),
- action='task_completed' if error_msg is None else 'error',
- mode='interactive',
- model=self.llm.model if self.llm and hasattr(self.llm, 'model') else None,
- model_provider=self.llm.provider if self.llm and hasattr(self.llm, 'provider') else None,
- duration_seconds=duration,
- error_message=error_msg,
- )
- )
-
- logger.debug('\n✅ Task completed!')
-
- # Make sure the task input container is visible
- task_input_container = self.query_one('#task-input-container')
- task_input_container.display = True
-
- # Refocus the input field
- input_field = self.query_one('#task-input', Input)
- input_field.focus()
-
- # Ensure the input is visible by scrolling to it
- self.call_after_refresh(self.scroll_to_input)
-
- # Run the worker
- self.run_worker(agent_task_worker, name='agent_task')
-
- def action_input_history_prev(self) -> None:
- """Navigate to the previous item in command history."""
- # Only process if we have history and input is focused
- input_field = self.query_one('#task-input', Input)
- if not input_field.has_focus or not self.task_history:
- return
-
- # Move back in history if possible
- if self.history_index > 0:
- self.history_index -= 1
- input_field.value = self.task_history[self.history_index]
- # Move cursor to end of text
- input_field.cursor_position = len(input_field.value)
-
- def action_input_history_next(self) -> None:
- """Navigate to the next item in command history or clear input."""
- # Only process if we have history and input is focused
- input_field = self.query_one('#task-input', Input)
- if not input_field.has_focus or not self.task_history:
- return
-
- # Move forward in history or clear input if at the end
- if self.history_index < len(self.task_history) - 1:
- self.history_index += 1
- input_field.value = self.task_history[self.history_index]
- # Move cursor to end of text
- input_field.cursor_position = len(input_field.value)
- elif self.history_index == len(self.task_history) - 1:
- # At the end of history, go to "new line" state
- self.history_index += 1
- input_field.value = ''
-
- async def action_quit(self) -> None:
- """Quit the application and clean up resources."""
- # Note: We don't need to close the browser session here because:
- # 1. If an agent exists, it already called browser_session.stop() in its run() method
- # 2. If keep_alive=True (default), we want to leave the browser running anyway
- # This prevents the duplicate "stop() called" messages in the logs
-
- # Flush telemetry before exiting
- self._telemetry.flush()
-
- # Exit the application
- self.exit()
- print('\nTry running tasks at: https://github.com/billy-enrizky/openbrowser-ai')
-
- def compose(self) -> ComposeResult:
- """Create the UI layout."""
- yield Header()
-
- # Main container for app content
- with Container(id='main-container'):
- # Logo panel
- yield Static(BROWSER_LOGO, id='logo-panel', markup=True)
-
- # Links panel with URLs
- with Container(id='links-panel'):
- with HorizontalGroup(classes='link-row'):
- yield Static('GitHub Repository: ', markup=True, classes='link-label')
- yield Link('https://github.com/billy-enrizky/openbrowser-ai', url='https://github.com/billy-enrizky/openbrowser-ai', classes='link-white link-url')
-
- yield Static('') # Empty line
-
- with HorizontalGroup(classes='link-row'):
- yield Static('Chat & share on Discord: ', markup=True, classes='link-label')
- yield Link(
- 'https://discord.gg/YRXzbJjq9K', url='https://discord.gg/YRXzbJjq9K', classes='link-purple link-url'
- )
-
- with HorizontalGroup(classes='link-row'):
- yield Static('Get prompt inspiration: 🦸 ', markup=True, classes='link-label')
- yield Link(
- 'https://github.com/billy-enrizky/openbrowser-ai',
- url='https://github.com/billy-enrizky/openbrowser-ai',
- classes='link-magenta link-url',
- )
-
- with HorizontalGroup(classes='link-row'):
- yield Static('[dim]Report any issues:[/] 🐛 ', markup=True, classes='link-label')
- yield Link(
- 'https://github.com/billy-enrizky/openbrowser-ai/issues',
- url='https://github.com/billy-enrizky/openbrowser-ai/issues',
- classes='link-green link-url',
- )
-
- # Paths panel
- yield Static(
- f' ⚙️ Settings saved to: {str(CONFIG.OPENBROWSER_CONFIG_FILE.resolve()).replace(str(Path.home()), "~")}\n'
- f' 📁 Outputs & recordings saved to: {str(Path(".").resolve()).replace(str(Path.home()), "~")}',
- id='paths-panel',
- markup=True,
- )
-
- # Info panels (hidden by default, shown when task starts)
- with Container(id='info-panels'):
- # Top row with browser and model panels side by side
- with Container(id='top-panels'):
- # Browser panel
- with Container(id='browser-panel'):
- yield RichLog(id='browser-info', markup=True, highlight=True, wrap=True)
-
- # Model panel
- with Container(id='model-panel'):
- yield RichLog(id='model-info', markup=True, highlight=True, wrap=True)
-
- # Tasks panel (full width, below browser and model)
- with VerticalScroll(id='tasks-panel'):
- yield RichLog(id='tasks-info', markup=True, highlight=True, wrap=True, auto_scroll=True)
-
- # Three-column container (hidden by default)
- with Container(id='three-column-container'):
- # Column 1: Main output
- with VerticalScroll(id='main-output-column'):
- yield RichLog(highlight=True, markup=True, id='main-output-log', wrap=True, auto_scroll=True)
-
- # Column 2: Event bus events
- with VerticalScroll(id='events-column'):
- yield RichLog(highlight=True, markup=True, id='events-log', wrap=True, auto_scroll=True)
-
- # Column 3: CDP messages
- with VerticalScroll(id='cdp-column'):
- yield RichLog(highlight=True, markup=True, id='cdp-log', wrap=True, auto_scroll=True)
-
- # Task input container (now at the bottom)
- with Container(id='task-input-container'):
- yield Label('🔍 What would you like me to do on the web?', id='task-label')
- yield Input(placeholder='Enter your task...', id='task-input')
-
- yield Footer()
-
- def update_info_panels(self) -> None:
- """Update all information panels with current state."""
- try:
- # Update actual content
- self.update_browser_panel()
- self.update_model_panel()
- self.update_tasks_panel()
- except Exception as e:
- logging.error(f'Error in update_info_panels: {str(e)}')
- finally:
- # Always schedule the next update - will update at 1-second intervals
- # This ensures continuous updates even if agent state changes
- self.set_timer(1.0, self.update_info_panels)
-
- def update_browser_panel(self) -> None:
- """Update browser information panel with details about the browser."""
- browser_info = self.query_one('#browser-info', RichLog)
- browser_info.clear()
-
- # Try to use the agent's browser session if available
- browser_session = self.browser_session
- if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'browser_session'):
- browser_session = self.agent.browser_session
-
- if browser_session:
- try:
- # Check if browser session has a CDP client
- if not hasattr(browser_session, 'cdp_client') or browser_session.cdp_client is None:
- browser_info.write('[yellow]Browser session created, waiting for browser to launch...[/]')
- return
-
- # Update our reference if we're using the agent's session
- if browser_session != self.browser_session:
- self.browser_session = browser_session
-
- # Get basic browser info from browser_profile
- browser_type = 'Chromium'
- headless = browser_session.browser_profile.headless
-
- # Determine connection type based on config
- connection_type = 'playwright' # Default
- if browser_session.cdp_url:
- connection_type = 'CDP'
- elif browser_session.browser_profile.executable_path:
- connection_type = 'user-provided'
-
- # Get window size details from browser_profile
- window_width = None
- window_height = None
- if browser_session.browser_profile.viewport:
- window_width = browser_session.browser_profile.viewport.width
- window_height = browser_session.browser_profile.viewport.height
-
- # Try to get browser PID
- browser_pid = 'Unknown'
- connected = False
- browser_status = '[red]Disconnected[/]'
-
- try:
- # Check if browser PID is available
- # Check if we have a CDP client
- if browser_session.cdp_client is not None:
- connected = True
- browser_status = '[green]Connected[/]'
- browser_pid = 'N/A'
- except Exception as e:
- browser_pid = f'Error: {str(e)}'
-
- # Display browser information
- browser_info.write(f'[bold cyan]Chromium[/] Browser ({browser_status})')
- browser_info.write(
- f'Type: [yellow]{connection_type}[/] [{"green" if not headless else "red"}]{" (headless)" if headless else ""}[/]'
- )
- browser_info.write(f'PID: [dim]{browser_pid}[/]')
- browser_info.write(f'CDP Port: {browser_session.cdp_url}')
-
- if window_width and window_height:
- browser_info.write(f'Window: [blue]{window_width}[/] × [blue]{window_height}[/]')
-
- # Include additional information about the browser if needed
- if connected and hasattr(self, 'agent') and self.agent:
- try:
- # Show when the browser was connected
- timestamp = int(time.time())
- current_time = time.strftime('%H:%M:%S', time.localtime(timestamp))
- browser_info.write(f'Last updated: [dim]{current_time}[/]')
- except Exception:
- pass
-
- # Show the agent's current page URL if available
- if browser_session.agent_focus:
- current_url = (
- browser_session.agent_focus.url.replace('https://', '')
- .replace('http://', '')
- .replace('www.', '')[:36]
- + '…'
- )
- browser_info.write(f'👁️ [green]{current_url}[/]')
- except Exception as e:
- browser_info.write(f'[red]Error updating browser info: {str(e)}[/]')
- else:
- browser_info.write('[red]Browser not initialized[/]')
-
- def update_model_panel(self) -> None:
- """Update model information panel with details about the LLM."""
- model_info = self.query_one('#model-info', RichLog)
- model_info.clear()
-
- if self.llm:
- # Get model details
- model_name = 'Unknown'
- if hasattr(self.llm, 'model_name'):
- model_name = self.llm.model_name
- elif hasattr(self.llm, 'model'):
- model_name = self.llm.model
-
- # Show model name
- if self.agent:
- temp_str = f'{self.llm.temperature}ºC ' if self.llm.temperature else ''
- vision_str = '+ vision ' if self.agent.settings.use_vision else ''
- model_info.write(
- f'[white]LLM:[/] [blue]{self.llm.__class__.__name__} [yellow]{model_name}[/] {temp_str}{vision_str}'
- )
- else:
- model_info.write(f'[white]LLM:[/] [blue]{self.llm.__class__.__name__} [yellow]{model_name}[/]')
-
- # Show token usage statistics if agent exists and has history
- if self.agent and hasattr(self.agent, 'state') and hasattr(self.agent.state, 'history'):
- # Calculate tokens per step
- num_steps = len(self.agent.history.history)
-
- # Get the last step metadata to show the most recent LLM response time
- if num_steps > 0 and self.agent.history.history[-1].metadata:
- last_step = self.agent.history.history[-1]
- if last_step.metadata:
- step_duration = last_step.metadata.duration_seconds
- else:
- step_duration = 0
-
- # Show total duration
- total_duration = self.agent.history.total_duration_seconds()
- if total_duration > 0:
- model_info.write(f'[white]Total Duration:[/] [magenta]{total_duration:.2f}s[/]')
-
- # Calculate response time metrics
- model_info.write(f'[white]Last Step Duration:[/] [magenta]{step_duration:.2f}s[/]')
-
- # Add current state information
- if hasattr(self.agent, 'running'):
- if getattr(self.agent, 'running', False):
- model_info.write('[yellow]LLM is thinking[blink]...[/][/]')
- elif hasattr(self.agent, 'state') and hasattr(self.agent.state, 'paused') and self.agent.state.paused:
- model_info.write('[orange]LLM paused[/]')
- else:
- model_info.write('[red]Model not initialized[/]')
-
- def update_tasks_panel(self) -> None:
- """Update tasks information panel with details about the tasks and steps hierarchy."""
- tasks_info = self.query_one('#tasks-info', RichLog)
- tasks_info.clear()
-
- if self.agent:
- # Check if agent has tasks
- task_history = []
- message_history = []
-
- # Try to extract tasks by looking at message history
- if hasattr(self.agent, '_message_manager') and self.agent._message_manager:
- message_history = self.agent._message_manager.state.history.get_messages()
-
- # Extract original task(s)
- original_tasks = []
- for msg in message_history:
- if hasattr(msg, 'content'):
- content = msg.content
- if isinstance(content, str) and 'Your ultimate task is:' in content:
- task_text = content.split('"""')[1].strip()
- original_tasks.append(task_text)
-
- if original_tasks:
- tasks_info.write('[bold green]TASK:[/]')
- for i, task in enumerate(original_tasks, 1):
- # Only show latest task if multiple task changes occurred
- if i == len(original_tasks):
- tasks_info.write(f'[white]{task}[/]')
- tasks_info.write('')
-
- # Get current state information
- current_step = self.agent.state.n_steps if hasattr(self.agent, 'state') else 0
-
- # Get all agent history items
- history_items = []
- if hasattr(self.agent, 'state') and hasattr(self.agent.state, 'history'):
- history_items = self.agent.history.history
-
- if history_items:
- tasks_info.write('[bold yellow]STEPS:[/]')
-
- for idx, item in enumerate(history_items, 1):
- # Determine step status
- step_style = '[green]✓[/]'
-
- # For the current step, show it as in progress
- if idx == current_step:
- step_style = '[yellow]⟳[/]'
-
- # Check if this step had an error
- if item.result and any(result.error for result in item.result):
- step_style = '[red]✗[/]'
-
- # Show step number
- tasks_info.write(f'{step_style} Step {idx}/{current_step}')
-
- # Show goal if available
- if item.model_output and hasattr(item.model_output, 'current_state'):
- # Show goal for this step
- goal = item.model_output.current_state.next_goal
- if goal:
- # Take just the first line for display
- goal_lines = goal.strip().split('\n')
- goal_summary = goal_lines[0]
- tasks_info.write(f' [cyan]Goal:[/] {goal_summary}')
-
- # Show evaluation of previous goal (feedback)
- eval_prev = item.model_output.current_state.evaluation_previous_goal
- if eval_prev and idx > 1: # Only show for steps after the first
- eval_lines = eval_prev.strip().split('\n')
- eval_summary = eval_lines[0]
- eval_summary = eval_summary.replace('Success', '✅ ').replace('Failed', '❌ ').strip()
- tasks_info.write(f' [tan]Evaluation:[/] {eval_summary}')
-
- # Show actions taken in this step
- if item.model_output and item.model_output.action:
- tasks_info.write(' [purple]Actions:[/]')
- for action_idx, action in enumerate(item.model_output.action, 1):
- action_type = action.__class__.__name__
- if hasattr(action, 'model_dump'):
- # For proper actions, show the action type
- action_dict = action.model_dump(exclude_unset=True)
- if action_dict:
- action_name = list(action_dict.keys())[0]
- tasks_info.write(f' {action_idx}. [blue]{action_name}[/]')
-
- # Show results or errors from this step
- if item.result:
- for result in item.result:
- if result.error:
- error_text = result.error
- tasks_info.write(f' [red]Error:[/] {error_text}')
- elif result.extracted_content:
- content = result.extracted_content
- tasks_info.write(f' [green]Result:[/] {content}')
-
- # Add a space between steps for readability
- tasks_info.write('')
-
- # If agent is actively running, show a status indicator
- if hasattr(self.agent, 'running') and getattr(self.agent, 'running', False):
- tasks_info.write('[yellow]Agent is actively working[blink]...[/][/]')
- elif hasattr(self.agent, 'state') and hasattr(self.agent.state, 'paused') and self.agent.state.paused:
- tasks_info.write('[orange]Agent is paused (press Enter to resume)[/]')
- else:
- tasks_info.write('[dim]Agent not initialized[/]')
- # Force scroll to bottom
- tasks_panel = self.query_one('#tasks-panel')
- tasks_panel.scroll_end(animate=False)
async def run_prompt_mode(prompt: str, ctx: click.Context, debug: bool = False):
@@ -1742,122 +614,6 @@ async def run_prompt_mode(prompt: str, ctx: click.Context, debug: bool = False):
await asyncio.gather(*tasks, return_exceptions=True)
-async def textual_interface(config: dict[str, Any]):
- """Run the Textual interface."""
- # Prevent openbrowser from setting up logging at import time
- os.environ['OPENBROWSER_SETUP_LOGGING'] = 'false'
-
- logger = logging.getLogger('openbrowser.startup')
-
- # Set up logging for Textual UI - prevent any logging to stdout
- def setup_textual_logging():
- # Replace all handlers with null handler
- root_logger = logging.getLogger()
- for handler in root_logger.handlers:
- root_logger.removeHandler(handler)
-
- # Add null handler to ensure no output to stdout/stderr
- null_handler = logging.NullHandler()
- root_logger.addHandler(null_handler)
- logger.debug('Logging configured for Textual UI')
-
- logger.debug('Setting up Browser, Controller, and LLM...')
-
- # Step 1: Initialize BrowserSession with config
- logger.debug('Initializing BrowserSession...')
- try:
- # Get browser config from the config dict
- browser_config = config.get('browser', {})
-
- logger.info('Browser type: chromium') # BrowserSession only supports chromium
- if browser_config.get('executable_path'):
- logger.info(f'Browser binary: {browser_config["executable_path"]}')
- if browser_config.get('headless'):
- logger.info('Browser mode: headless')
- else:
- logger.info('Browser mode: visible')
-
- # Create BrowserSession directly with config parameters
- # Remove None values from browser_config
- browser_config = {k: v for k, v in browser_config.items() if v is not None}
- # Create BrowserProfile with user_data_dir
- profile = BrowserProfile(user_data_dir=str(USER_DATA_DIR), **browser_config)
- browser_session = BrowserSession(
- browser_profile=profile,
- )
- logger.debug('BrowserSession initialized successfully')
-
- # Set up FIFO logging pipes for streaming logs to UI
- try:
- from openbrowser.logging_config import setup_log_pipes
-
- setup_log_pipes(session_id=browser_session.id)
- logger.debug(f'FIFO logging pipes set up for session {browser_session.id[-4:]}')
- except Exception as e:
- logger.debug(f'Could not set up FIFO logging pipes: {e}')
-
- # Browser version logging not available with CDP implementation
- except Exception as e:
- logger.error(f'Error initializing BrowserSession: {str(e)}', exc_info=True)
- raise RuntimeError(f'Failed to initialize BrowserSession: {str(e)}')
-
- # Step 3: Initialize Controller
- logger.debug('Initializing Controller...')
- try:
- controller = Controller()
- logger.debug('Controller initialized successfully')
- except Exception as e:
- logger.error(f'Error initializing Controller: {str(e)}', exc_info=True)
- raise RuntimeError(f'Failed to initialize Controller: {str(e)}')
-
- # Step 4: Get LLM
- logger.debug('Getting LLM...')
- try:
- # Ensure setup_logging is not called when importing modules
- os.environ['OPENBROWSER_SETUP_LOGGING'] = 'false'
- llm = get_llm(config)
- # Log LLM details
- model_name = getattr(llm, 'model_name', None) or getattr(llm, 'model', 'Unknown model')
- provider = llm.__class__.__name__
- temperature = getattr(llm, 'temperature', 0.0)
- logger.info(f'LLM: {provider} ({model_name}), temperature: {temperature}')
- logger.debug(f'LLM initialized successfully: {provider}')
- except Exception as e:
- logger.error(f'Error getting LLM: {str(e)}', exc_info=True)
- raise RuntimeError(f'Failed to initialize LLM: {str(e)}')
-
- logger.debug('Initializing OpenBrowserApp instance...')
- try:
- app = OpenBrowserApp(config)
- # Pass the initialized components to the app
- app.browser_session = browser_session
- app.controller = controller
- app.llm = llm
-
- # Set up event bus listener now that browser session is available
- # Note: This needs to be called before run_async() but after browser_session is set
- # We'll defer this to on_mount() since it needs the widgets to be available
-
- # Configure logging for Textual UI before going fullscreen
- setup_textual_logging()
-
- # Log browser and model configuration that will be used
- browser_type = 'Chromium' # BrowserSession only supports Chromium
- model_name = config.get('model', {}).get('name', 'auto-detected')
- headless = config.get('browser', {}).get('headless', False)
- headless_str = 'headless' if headless else 'visible'
-
- logger.info(f'Preparing {browser_type} browser ({headless_str}) with {model_name} LLM')
-
- logger.debug('Starting Textual app with run_async()...')
- # No more logging after this point as we're in fullscreen mode
- await app.run_async()
- except Exception as e:
- logger.error(f'Error in textual_interface: {str(e)}', exc_info=True)
- # Note: We don't close the browser session here to avoid duplicate stop() calls
- # The browser session will be cleaned up by its __del__ method if needed
- raise
-
@click.group(invoke_without_command=True)
@click.option('--version', is_flag=True, help='Print version and exit')
@@ -1882,17 +638,19 @@ def setup_textual_logging():
@click.option('--no-proxy', type=str, help='Comma-separated hosts to bypass proxy (e.g. localhost,127.0.0.1,*.internal)')
@click.option('--proxy-username', type=str, help='Proxy auth username')
@click.option('--proxy-password', type=str, help='Proxy auth password')
-@click.option('-p', '--prompt', type=str, help='Run a single task without the TUI (headless mode)')
+@click.option('-p', '--prompt', type=str, help='Run a single task in one-shot mode')
@click.option('--mcp', is_flag=True, help='Run as MCP server (exposes JSON RPC via stdin/stdout)')
@click.pass_context
def main(ctx: click.Context, debug: bool = False, **kwargs):
"""OpenBrowser - AI Agent for Web Automation
- Run without arguments to start the interactive TUI.
-
Examples:
- uvx openbrowser-ai --template default
- uvx openbrowser-ai --template advanced --output my_script.py
+
+ \b
+ openbrowser-ai -p "Search for the latest AI news"
+ openbrowser-ai -c "await navigate('https://example.com')"
+ openbrowser-ai --mcp
+ openbrowser-ai --template default
"""
# Handle template generation
@@ -1906,7 +664,7 @@ def main(ctx: click.Context, debug: bool = False, **kwargs):
def run_main_interface(ctx: click.Context, debug: bool = False, **kwargs):
- """Run the main openbrowser interface"""
+ """Run the main openbrowser interface."""
if kwargs['version']:
from importlib.metadata import version
@@ -1937,89 +695,18 @@ def run_main_interface(ctx: click.Context, debug: bool = False, **kwargs):
# Check if prompt mode is activated
if kwargs.get('prompt'):
+ if Agent is None:
+ print('Error: Agent dependencies not installed.')
+ print('Install with: pip install "openbrowser-ai[agent]"')
+ sys.exit(1)
# Set environment variable for prompt mode before running
os.environ['OPENBROWSER_LOGGING_LEVEL'] = 'result'
# Run in non-interactive mode
asyncio.run(run_prompt_mode(kwargs['prompt'], ctx, debug))
return
- # Configure console logging
- console_handler = logging.StreamHandler(sys.stdout)
- console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', '%H:%M:%S'))
-
- # Configure root logger
- root_logger = logging.getLogger()
- root_logger.setLevel(logging.INFO if not debug else logging.DEBUG)
- root_logger.addHandler(console_handler)
-
- logger = logging.getLogger('openbrowser.startup')
- logger.info('Starting OpenBrowser initialization')
- if debug:
- logger.debug(f'System info: Python {sys.version.split()[0]}, Platform: {sys.platform}')
-
- logger.debug('Loading environment variables from .env file...')
- load_dotenv()
- logger.debug('Environment variables loaded')
-
- # Load user configuration
- logger.debug('Loading user configuration...')
- try:
- config = load_user_config()
- logger.debug(f'User configuration loaded from {CONFIG.OPENBROWSER_CONFIG_FILE}')
- except Exception as e:
- logger.error(f'Error loading user configuration: {str(e)}', exc_info=True)
- print(f'Error loading configuration: {str(e)}')
- sys.exit(1)
-
- # Update config with command-line arguments
- logger.debug('Updating configuration with command line arguments...')
- try:
- config = update_config_with_click_args(config, ctx)
- logger.debug('Configuration updated')
- except Exception as e:
- logger.error(f'Error updating config with command line args: {str(e)}', exc_info=True)
- print(f'Error updating configuration: {str(e)}')
- sys.exit(1)
-
- # Save updated config
- logger.debug('Saving user configuration...')
- try:
- save_user_config(config)
- logger.debug('Configuration saved')
- except Exception as e:
- logger.error(f'Error saving user configuration: {str(e)}', exc_info=True)
- print(f'Error saving configuration: {str(e)}')
- sys.exit(1)
-
- # Setup handlers for console output before entering Textual UI
- logger.debug('Setting up handlers for Textual UI...')
-
- # Log browser and model configuration that will be used
- browser_type = 'Chromium' # BrowserSession only supports Chromium
- model_name = config.get('model', {}).get('name', 'auto-detected')
- headless = config.get('browser', {}).get('headless', False)
- headless_str = 'headless' if headless else 'visible'
-
- logger.info(f'Preparing {browser_type} browser ({headless_str}) with {model_name} LLM')
-
- try:
- # Run the Textual UI interface - now all the initialization happens before we go fullscreen
- logger.debug('Starting Textual UI interface...')
- asyncio.run(textual_interface(config))
- except Exception as e:
- # Restore console logging for error reporting
- root_logger.setLevel(logging.INFO)
- for handler in root_logger.handlers:
- root_logger.removeHandler(handler)
- root_logger.addHandler(console_handler)
-
- logger.error(f'Error initializing OpenBrowser: {str(e)}', exc_info=debug)
- print(f'\nError launching OpenBrowser: {str(e)}')
- if debug:
- import traceback
-
- traceback.print_exc()
- sys.exit(1)
+ # No specific mode requested -- show help
+ click.echo(ctx.get_help())
@main.command()
diff --git a/src/openbrowser/daemon/server.py b/src/openbrowser/daemon/server.py
index 8f3e6054..fc658433 100644
--- a/src/openbrowser/daemon/server.py
+++ b/src/openbrowser/daemon/server.py
@@ -315,8 +315,8 @@ async def run(self):
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, self._signal_shutdown)
- except NotImplementedError:
- pass # Windows
+ except (NotImplementedError, RuntimeError):
+ pass # Windows or non-main thread
# Start idle timeout checker
idle_task = asyncio.create_task(self._idle_check_loop())
diff --git a/src/openbrowser/mcp/server.py b/src/openbrowser/mcp/server.py
index 7724da76..3459c84d 100644
--- a/src/openbrowser/mcp/server.py
+++ b/src/openbrowser/mcp/server.py
@@ -448,7 +448,10 @@ async def _execute_code(self, code: str) -> str:
self._last_activity = time.time()
# Pre-flight: check if CDP is still alive and recover if needed
- if not await self._is_cdp_alive():
+ # Only attempt recovery when we already had a browser session (i.e. it died).
+ # If browser_session is None, _ensure_namespace hasn't launched one yet
+ # or the code doesn't need a browser -- skip recovery to avoid hanging.
+ if self.browser_session and not await self._is_cdp_alive():
try:
await self._recover_browser_session()
except Exception as recovery_err:
diff --git a/tests/test_advanced_features.py b/tests/test_advanced_features.py
index 46c26e8e..99c1472d 100644
--- a/tests/test_advanced_features.py
+++ b/tests/test_advanced_features.py
@@ -178,24 +178,28 @@ class TestNewLLMProviders:
def test_chat_browser_use_import(self):
"""Test ChatBrowserUse import."""
+ pytest.importorskip("litellm")
from openbrowser.llm import ChatBrowserUse
assert ChatBrowserUse is not None
def test_chat_google_import(self):
"""Test ChatGoogle import."""
+ pytest.importorskip("google.genai")
from openbrowser.llm import ChatGoogle
assert ChatGoogle is not None
def test_chat_openai_import(self):
"""Test ChatOpenAI import."""
+ pytest.importorskip("openai")
from openbrowser.llm import ChatOpenAI
assert ChatOpenAI is not None
def test_chat_anthropic_import(self):
"""Test ChatAnthropic import."""
+ pytest.importorskip("anthropic")
from openbrowser.llm import ChatAnthropic
assert ChatAnthropic is not None
diff --git a/tests/test_cli_c_help.py b/tests/test_cli_c_help.py
index 3e0fe549..eba857ce 100644
--- a/tests/test_cli_c_help.py
+++ b/tests/test_cli_c_help.py
@@ -1,19 +1,27 @@
# tests/test_cli_c_help.py
"""Tests for `openbrowser-ai -c` (no argument) self-documenting behaviour."""
+import asyncio
+import os
import subprocess
import sys
+import threading
+import time
+import uuid
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
import pytest
-def _run_cli(*args: str) -> subprocess.CompletedProcess:
+def _run_cli(*args: str, env: dict | None = None) -> subprocess.CompletedProcess:
"""Run the CLI entry-point as a subprocess."""
return subprocess.run(
[sys.executable, '-m', 'openbrowser.cli', *args],
capture_output=True,
text=True,
timeout=30,
+ env=env,
)
@@ -78,7 +86,68 @@ def test_c_no_arg_compact_when_daemon_running(self):
)
def test_c_with_code_still_works(self):
- """openbrowser-ai -c 'print(1+1)' should still execute code."""
- result = _run_cli('-c', 'print(1+1)')
- assert result.returncode == 0
- assert '2' in result.stdout
+ """openbrowser-ai -c 'print(1+1)' should execute code via a mock daemon."""
+ from openbrowser.code_use.executor import CodeExecutor
+ from openbrowser.daemon.server import DaemonServer
+
+ # Set up a unique temp socket for isolation
+ test_id = uuid.uuid4().hex[:8]
+ tmp_dir = Path(f'/tmp/ob_test_{test_id}')
+ tmp_dir.mkdir(parents=True, exist_ok=True)
+ sock = tmp_dir / 'd.sock'
+ sock_str = str(sock)
+
+ # Build a daemon with pre-initialized executor (no browser needed)
+ server = DaemonServer(idle_timeout=60)
+ executor = CodeExecutor()
+ executor.set_namespace({
+ 'json': __import__('json'),
+ 'asyncio': __import__('asyncio'),
+ })
+ server._executor = executor
+
+ loop = asyncio.new_event_loop()
+
+ def run_daemon():
+ asyncio.set_event_loop(loop)
+ os.environ['OPENBROWSER_SOCKET'] = sock_str
+ try:
+ loop.run_until_complete(server.run())
+ finally:
+ os.environ.pop('OPENBROWSER_SOCKET', None)
+
+ t = threading.Thread(target=run_daemon, daemon=True)
+ t.start()
+
+ # Wait for socket to appear
+ for _ in range(50):
+ if sock.exists():
+ break
+ time.sleep(0.05)
+ else:
+ server._running = False
+ server._stop_event.set()
+ t.join(timeout=5)
+ pytest.fail('Mock daemon socket never appeared')
+
+ try:
+ env = {**os.environ, 'OPENBROWSER_SOCKET': sock_str}
+ result = subprocess.run(
+ [sys.executable, '-m', 'openbrowser.cli', '-c', 'print(1+1)'],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ env=env,
+ )
+ assert result.returncode == 0, f'stderr: {result.stderr}'
+ assert '2' in result.stdout
+ finally:
+ server._running = False
+ server._stop_event.set()
+ t.join(timeout=5)
+ sock.unlink(missing_ok=True)
+ (tmp_dir / 'd.pid').unlink(missing_ok=True)
+ try:
+ tmp_dir.rmdir()
+ except OSError:
+ pass
diff --git a/tests/test_daemon_integration.py b/tests/test_daemon_integration.py
index a69fef6f..48b4dfdd 100644
--- a/tests/test_daemon_integration.py
+++ b/tests/test_daemon_integration.py
@@ -25,7 +25,6 @@ def daemon_env():
sock = tmp_dir / 'd.sock'
with patch.dict(os.environ, {'OPENBROWSER_SOCKET': str(sock)}), \
- patch('openbrowser.daemon.server.DAEMON_DIR', tmp_dir), \
patch('openbrowser.daemon.client.DAEMON_DIR', tmp_dir):
yield sock
diff --git a/uv.lock b/uv.lock
index 2eeb8ccc..a2338c73 100644
--- a/uv.lock
+++ b/uv.lock
@@ -705,45 +705,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/93/94bc7a89ef4e7ed3666add55cd859d1483a22737251df659bf1aa46e9405/google_genai-1.56.0-py3-none-any.whl", hash = "sha256:9e6b11e0c105ead229368cb5849a480e4d0185519f8d9f538d61ecfcf193b052", size = 426563, upload-time = "2025-12-17T12:35:03.717Z" },
]
-[[package]]
-name = "greenlet"
-version = "3.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
- { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
- { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
- { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
- { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
- { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
- { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
- { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" },
- { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
- { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
- { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
- { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
- { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
- { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
- { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
- { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
- { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
- { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
- { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
- { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
- { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
- { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
- { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
- { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
- { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
- { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
- { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
- { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
- { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
- { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
- { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
-]
-
[[package]]
name = "groq"
version = "1.0.0"
@@ -1165,18 +1126,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/8a/d9bc95607846bc82fbe0b98d2592ffb5e036c97a362735ae926e3d519df7/langsmith-0.5.0-py3-none-any.whl", hash = "sha256:a83750cb3dccb33148d4ffe005e3e03080fad13e01671efbb74c9a68813bfef8", size = 273711, upload-time = "2025-12-16T17:35:37.165Z" },
]
-[[package]]
-name = "linkify-it-py"
-version = "2.0.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "uc-micro-py" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
-]
-
[[package]]
name = "litellm"
version = "1.80.0"
@@ -1212,14 +1161,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
-[package.optional-dependencies]
-linkify = [
- { name = "linkify-it-py" },
-]
-plugins = [
- { name = "mdit-py-plugins" },
-]
-
[[package]]
name = "markdownify"
version = "1.2.2"
@@ -1321,18 +1262,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
-[[package]]
-name = "mdit-py-plugins"
-version = "0.5.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markdown-it-py" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
-]
-
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -1545,7 +1474,6 @@ dependencies = [
{ name = "httpx" },
{ name = "mcp" },
{ name = "pillow" },
- { name = "playwright" },
{ name = "posthog" },
{ name = "psutil" },
{ name = "pydantic" },
@@ -1589,7 +1517,6 @@ all = [
{ name = "pypdf" },
{ name = "reportlab" },
{ name = "rich" },
- { name = "textual" },
]
anthropic = [
{ name = "anthropic" },
@@ -1600,9 +1527,6 @@ aws = [
azure = [
{ name = "openai" },
]
-cli = [
- { name = "textual" },
-]
dev = [
{ name = "mcp" },
{ name = "pytest" },
@@ -1676,7 +1600,6 @@ requires-dist = [
{ name = "pandas", marker = "extra == 'agent'", specifier = ">=2.2.0" },
{ name = "pandas", marker = "extra == 'all'", specifier = ">=2.2.0" },
{ name = "pillow", specifier = ">=11.0.0" },
- { name = "playwright" },
{ name = "posthog", specifier = ">=3.7.0" },
{ name = "posthog", marker = "extra == 'mcp'", specifier = ">=3.7.0" },
{ name = "posthog", marker = "extra == 'telemetry'", specifier = ">=3.7.0" },
@@ -1696,11 +1619,9 @@ requires-dist = [
{ name = "reportlab", marker = "extra == 'pdf'" },
{ name = "rich", marker = "extra == 'agent'", specifier = ">=13.0.0" },
{ name = "rich", marker = "extra == 'all'", specifier = ">=13.0.0" },
- { name = "textual", marker = "extra == 'all'", specifier = ">=3.2.0" },
- { name = "textual", marker = "extra == 'cli'", specifier = ">=3.2.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
-provides-extras = ["agent", "all", "anthropic", "aws", "azure", "cli", "dev", "groq", "mcp", "ollama", "pdf", "telemetry", "video"]
+provides-extras = ["agent", "all", "anthropic", "aws", "azure", "dev", "groq", "mcp", "ollama", "pdf", "telemetry", "video"]
[[package]]
name = "orjson"
@@ -1918,34 +1839,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
]
-[[package]]
-name = "platformdirs"
-version = "4.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
-]
-
-[[package]]
-name = "playwright"
-version = "1.57.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "greenlet" },
- { name = "pyee" },
-]
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" },
- { url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" },
- { url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" },
- { url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" },
- { url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" },
- { url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" },
- { url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" },
- { url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" },
-]
-
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2226,18 +2119,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
-[[package]]
-name = "pyee"
-version = "13.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
-]
-
[[package]]
name = "pygments"
version = "2.19.2"
@@ -2744,22 +2625,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
]
-[[package]]
-name = "textual"
-version = "6.2.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markdown-it-py", extra = ["linkify", "plugins"] },
- { name = "platformdirs" },
- { name = "pygments" },
- { name = "rich" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a2/30/38b615f7d4b16f6fdd73e4dcd8913e2d880bbb655e68a076e3d91181a7ee/textual-6.2.1.tar.gz", hash = "sha256:4699d8dfae43503b9c417bd2a6fb0da1c89e323fe91c4baa012f9298acaa83e1", size = 1570645, upload-time = "2025-10-01T16:11:24.467Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/93/02c7adec57a594af28388d85da9972703a4af94ae1399542555cd9581952/textual-6.2.1-py3-none-any.whl", hash = "sha256:3c7190633cd4d8bfe6049ae66808b98da91ded2edb85cef54e82bf77b03d2a54", size = 710702, upload-time = "2025-10-01T16:11:22.161Z" },
-]
-
[[package]]
name = "tiktoken"
version = "0.12.0"
@@ -2887,15 +2752,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
-[[package]]
-name = "uc-micro-py"
-version = "1.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
-]
-
[[package]]
name = "urllib3"
version = "2.6.2"