diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..9dc31b9
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,46 @@
+# Builds the MkDocs user-documentation site and deploys it to GitHub Pages.
+#
+# Trigger is manual (workflow_dispatch) so nothing publishes before the first
+# public release. The build job runs anywhere as a validity check; the deploy
+# job runs only on the public mirror, so the internal repo never serves Pages.
+# Enable Pages (Settings > Pages > Source: GitHub Actions) on the public repo,
+# then run this workflow from the Actions tab.
+name: Docs site
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: pip install "mkdocs-material>=9.5,<10"
+ - run: mkdocs build --strict
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+
+ deploy:
+ # Publish only from the public mirror. The internal repo never serves Pages.
+ if: github.repository == 'paramount-engineering/rokdock'
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fe572c2..9eedd7f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,4 +1,5 @@
name: Release
+run-name: Release ${{ github.ref_name }}
# Builds RokDock installers for every desktop platform on native GitHub runners
# and attaches them, plus the electron-updater manifests (latest-mac.yml,
diff --git a/.gitignore b/.gitignore
index e761b77..b75cd42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,7 @@ resources/icons/icon-*.png
docs/superpowers/
docs/BACKLOG.md
docs/adding-a-tool-window.md
+docs/release-to-public.md
.claude/
openspec/
tasks/
@@ -74,4 +75,6 @@ keys/
*.key
*.cer
*.mobileprovision
-demo-video/
+
+# MkDocs build output
+/site/
diff --git a/README.md b/README.md
index c7e4f04..f90f53a 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@
+
+
@@ -42,7 +44,7 @@ It is for Roku channel developers who are tired of juggling a telnet client, the
RokDock is one tool for all of it.
-
+
## Features
@@ -81,7 +83,7 @@ RokDock is one tool for all of it.
- **Developer Docs** - an in-app browser for the official Roku documentation, with
full-text search, a What's New change feed, browser-style history, and an offline
cache.
-- **AI Chat (Beta)** - an opt-in assistant with swappable providers (Anthropic,
+- **roBot (Beta)** - an opt-in assistant with swappable providers (Anthropic,
Gemini, OpenAI-compatible, or a local CLI such as Claude, Copilot, Gemini, or
Codex) and per-prompt redaction of device IPs, names, and serials.
@@ -136,7 +138,7 @@ Full guides for every screen and feature live in [docs/user/](docs/user/):
- [9-Patch Editor](docs/user/ninepatch-editor.md) - stretchable image assets
- [SVG Converter](docs/user/svg-converter.md) - SVG to quantized PNG
- [Developer Docs](docs/user/developer-docs.md) - the in-app Roku documentation browser
-- [AI Chat](docs/user/ai.md) - the AI assistant and provider configuration
+- [roBot](docs/user/ai.md) - the AI assistant and provider configuration
- [Settings](docs/user/settings.md) - the full settings reference, tab by tab
- [Keyboard Shortcuts](docs/user/keyboard-shortcuts.md) - the shortcut reference
- [Themes](docs/user/themes.md) - app theme, syntax themes, and fonts
@@ -205,23 +207,37 @@ npm run dist:linux # Linux (AppImage + deb)
## Screenshots
-A connected debug terminal at a BrightScript breakpoint, with tokenized output and
-detected links.
+The debug terminal, tokenized and themed, with a JSON payload detected and ready to
+open in the editor.
-
+
-The in-app Developer Docs, with the official Roku documentation, search, and a
-What's New feed.
+The full on-screen remote, plus saved deeplink presets you can fire in a click.
-
+
+
+
+
+
+Built-in tools: a JSON viewer, a 9-Patch editor, and an SVG recolor/exporter.
+
+
+
+
+
+
+The in-app Developer Docs: the official Roku documentation with full-text search and
+a What's New feed.
+
+
-The Screenshot Preview, comparing a device frame against a safe-zone overlay.
+The Screenshot Viewer, comparing a captured device frame against a safe-zone overlay.
-
+
-The AI Chat panel, grounded in the Roku docs.
+The roBot panel, grounded in the Roku docs.
-
+
More figures are in the [user guide](docs/user/).
diff --git a/SECURITY.md b/SECURITY.md
index 208c22c..6a0b34b 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -11,5 +11,3 @@ Please do not report security vulnerabilities through public GitHub issues.
Use GitHub's private vulnerability reporting instead. Go to the [Security tab](https://github.com/paramount-engineering/rokdock/security) of the repository and select "Report a vulnerability" to open a private Security Advisory. This keeps the details confidential until a fix is available.
Include as much detail as you can: a description of the vulnerability, steps to reproduce, affected versions, and any relevant logs or screenshots. Maintainers will acknowledge the report and work with you on a fix and disclosure timeline.
-
-If the repository owner wants to add a direct contact for security reports, that information can be added here.
diff --git a/docs/user/ai.md b/docs/user/ai.md
index 2228ffd..4980d9a 100644
--- a/docs/user/ai.md
+++ b/docs/user/ai.md
@@ -1,10 +1,17 @@
-# AI Chat (Beta)
+# AI Assistant for Roku Development (Gemini, Claude, Copilot, and Codex)
-RokDock includes an opt-in AI assistant. It is a general-purpose, multi-turn chat that lives in the app alongside the terminal, devices, and tools, so it can answer questions in the context of your Roku development work. AI features are off until you configure a provider, and they are clearly labeled **Beta**.
+RokDock includes an opt-in AI assistant named **roBot**. It is a general-purpose, multi-turn chat that lives in the app alongside the terminal, devices, and tools, so it can answer questions in the context of your Roku development work. AI features are off until you configure a provider, and they are clearly labeled **Beta**.
## Enabling AI
-AI is disabled until you add at least one provider in **Settings > AI (Beta)**. Once a provider is configured, the AI Chat panel becomes available in the app and the "Explain this" action appears in the terminal.
+AI is disabled until you both add (or auto-detect) a provider in **Settings > AI (Beta)** and set one as the **active** provider. roBot always uses the single active provider. The roBot panel and the terminal's "Ask roBot" action appear only once a provider is active. If none is active, neither is shown, even when providers are listed.
+
+Setting the active provider differs by type:
+
+- **HTTP providers** (Anthropic, Gemini, or OpenAI-compatible): adding your first one makes it active automatically.
+- **CLI providers** (Claude Code, GitHub Copilot, Gemini CLI, Codex): these are auto-detected and appear in the list, but are never activated for you. You must click **Set active** on the one you want, even though it is already listed.
+
+The active provider carries an **Active** badge in the provider list, and every other row shows a **Set active** button. If AI still seems unavailable after you have configured a provider, confirm that one is marked Active.
See [Settings > AI (Beta)](settings.md#ai-beta) for the full provider configuration reference, including the supported provider types, the redaction toggle, the Local flag, and Test Connection.
@@ -12,45 +19,65 @@ See [Settings > AI (Beta)](settings.md#ai-beta) for the full provider configurat
You can configure one or more providers and designate one as active:
-- **Anthropic (Claude)**, **Gemini**, and **OpenAI-compatible** HTTP providers, each with a model name, an optional base URL, and an API key.
-- **CLI providers** (Claude, Copilot, Gemini, Codex), which drive an AI command-line tool already installed on your machine. A CLI provider is keyless and runs locally.
+- **HTTP providers** with a native adapter (Anthropic Claude, Google Gemini) or the **OpenAI-compatible** catch-all, each with a model name, an optional base URL, and an API key.
+- **CLI providers** (Claude Code, GitHub Copilot, Gemini CLI, Codex), which drive an AI command-line tool already installed on your machine. A CLI provider is keyless and runs locally.
API keys are stored encrypted on your machine via the OS keychain and are never shown again after saving.
-### Provider configuration reference
+### Privacy and redaction
+
+Each provider has a **Redact sensitive values** toggle, on by default. Redaction removes device IPs, names, and serial numbers from your prompt before it is sent. Mark a provider **Local** when it runs on your own machine (a localhost endpoint or a CLI tool). A local provider needs no key and nothing leaves the machine.
-The fields you fill in depend on the provider type. The in-tab placeholders give a hint, and the values below are a fuller reference. Model names change over time, so treat the models as examples and use whatever your account or local install exposes. Confirm base URLs against your provider's own documentation.
+If you turn redaction off on a remote provider, RokDock shows a warning and requires you to acknowledge that prompts will be sent unredacted before you can save. The **Test** button on each provider shows a "what was sent (redacted)" preview alongside the result, so you can confirm exactly what leaves your machine.
-| Provider type | Base URL | Example model |
-|---|---|---|
-| Anthropic (Claude) | not needed (native) | `claude-opus-4-8` |
-| Gemini | not needed (native) | `gemini-2.5-flash` |
-| OpenAI (via OpenAI-compatible) | `https://api.openai.com/v1` | `gpt-4o` |
-| OpenRouter (via OpenAI-compatible) | `https://openrouter.ai/api/v1` | a model id from OpenRouter's catalog |
-| Gemini via its OpenAI-compatible endpoint | `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-2.5-flash` |
-| Azure OpenAI (via OpenAI-compatible) | `https://.openai.azure.com/...` (per your Azure deployment) | your deployment name |
-| Ollama (local, via OpenAI-compatible) | `http://localhost:11434/v1` | `llama3.1` |
-| LM Studio or another local server (via OpenAI-compatible) | the server's printed URL (often `http://localhost:1234/v1`) | the model the server reports |
+## Connecting a provider
-Notes:
+These are task-shaped views of the provider system above. Model names change over time, so treat them as examples and use whatever your account or local install exposes. Confirm base URLs against your provider's own documentation.
-- **Anthropic** and **Gemini** have native adapters: pick the type, enter a model and key, and leave Base URL blank.
-- The **OpenAI-compatible** type is the catch-all for any OpenAI-style HTTP endpoint. Set its Base URL and model. Hosted services (OpenAI, OpenRouter, Azure) need a key. Local servers (Ollama, LM Studio) usually do not, so mark the profile **Local**.
-- A **local** model (Ollama, LM Studio) reached over its HTTP endpoint is configured as an OpenAI-compatible provider marked Local. This is separate from a recognized **CLI** provider.
-- **Recognized CLIs** (Claude Code, GitHub Copilot, Gemini CLI, Codex) are auto-detected when installed on your PATH and appear in the provider list with no setup. You only optionally set a model; RokDock builds the CLI invocation for you. Thin command wrappers like `ollama` or `llm` are not recognized as CLIs. Reach a local model through the OpenAI-compatible endpoint above instead.
+### Google Gemini
-### Privacy and redaction
+RokDock supports Gemini three ways, so you can use whichever you already have:
-Each provider has a **Redact sensitive values** toggle, on by default. Redaction removes device IPs, names, and serial numbers from your prompt before it is sent. Mark a provider **Local** when it runs on your own machine (a localhost endpoint or a CLI tool); a local provider needs no key and nothing leaves the machine.
+- **Native Gemini adapter.** Add a provider of type Gemini, enter a model (for example `gemini-2.5-flash`) and your API key, and leave Base URL blank.
+- **Gemini CLI.** If the Gemini command-line tool is on your PATH, RokDock auto-detects it and lists it with no setup. It runs locally and needs no key.
+- **OpenAI-compatible endpoint.** Point an OpenAI-compatible provider at `https://generativelanguage.googleapis.com/v1beta/openai/` with your key and a Gemini model.
-If you turn redaction off on a remote provider, RokDock shows a warning and requires you to acknowledge that prompts will be sent unredacted before you can save. The **Test** button on each provider shows a "what was sent (redacted)" preview alongside the result, so you can confirm exactly what leaves your machine.
+### Anthropic Claude
+
+- **Native Anthropic adapter.** Add a provider of type Anthropic (Claude), enter a model (for example `claude-opus-4-8`) and your key, and leave Base URL blank.
+- **Claude Code CLI.** If Claude Code is on your PATH, it is auto-detected, runs locally, and needs no key.
+
+### OpenAI and Codex
+
+- **OpenAI (HTTP).** Add an OpenAI-compatible provider with Base URL `https://api.openai.com/v1`, your key, and a model such as `gpt-4o`.
+- **Codex CLI.** If the Codex command-line tool is installed, RokDock auto-detects it and runs it locally against your account.
+
+### GitHub Copilot
+
+- **Copilot CLI.** If the GitHub Copilot command-line tool is on your PATH, it is auto-detected and keyless, and it uses your existing Copilot subscription.
+
+### Other OpenAI-compatible services
+
+The OpenAI-compatible type is the catch-all for any OpenAI-style HTTP endpoint. Set its Base URL and model, and add a key for hosted services:
+
+- **OpenRouter.** Base URL `https://openrouter.ai/api/v1`, plus a model id from OpenRouter's catalog.
+- **Azure OpenAI.** Base URL `https://.openai.azure.com/...` per your Azure deployment, with your deployment name as the model.
+
+### Local models (Ollama, LM Studio)
+
+Run a model entirely on your own machine and configure it as an OpenAI-compatible provider marked **Local** (no key, and nothing leaves the machine):
+
+- **Ollama.** Base URL `http://localhost:11434/v1`, a model such as `llama3.1`.
+- **LM Studio or another local server.** Use the URL the server prints (often `http://localhost:1234/v1`) and the model it reports.
+
+Thin command wrappers like `ollama` or `llm` are not recognized as CLI providers. Reach a local model through the OpenAI-compatible endpoint above instead.
-## The AI Chat Panel
+## The roBot Panel
-
-*The AI Chat panel docked in the left column. Roku terms in the answer are linked into the in-app docs.*
+
+*The roBot panel docked in the left column. Roku terms in the answer are linked into the in-app docs.*
-Once a provider is configured, the AI Chat (Beta) panel appears as a collapsible section in the app. Use its header to expand or collapse it.
+Once a provider is active, the roBot (Beta) panel appears as a collapsible section in the app. Use its header to expand or collapse it. The gear in the panel header opens **Settings > AI (Beta)** so you can switch the active provider or adjust its settings.
- **Ask a question.** Type in the input box and press `Enter` to send (`Shift+Enter` inserts a newline). Replies stream in live.
- **Stop.** While a reply is streaming, a stop button cancels it.
@@ -58,9 +85,9 @@ Once a provider is configured, the AI Chat (Beta) panel appears as a collapsible
- **Move the panel.** The panel can be docked on the left, in the middle (as a drawer in the terminal area), or on the right. Use the move button in the panel header to cycle through the positions.
- **Used docs.** When the assistant draws on the Roku developer documentation, the reply shows a "Used docs" list. Click a source to open that page in [Developer Docs](developer-docs.md).
-## Explain This (from the Terminal)
+## Ask roBot (from the Terminal)
-Select text in a terminal tab and choose **Explain this (Beta)** from the selection toolbar. The selected text is sent to the assistant, which opens the AI Chat panel with an explanation. This is handy for decoding a stack trace, an unfamiliar debugger message, or a chunk of BrightScript output.
+Select text in a terminal tab and choose **Ask roBot (Beta)** from the selection toolbar. The selected text is sent to the assistant, which opens the roBot panel with an explanation. This is handy for decoding a stack trace, an unfamiliar debugger message, or a chunk of BrightScript output.
See [Terminal](terminal.md) for more on terminal selection and output.
@@ -68,4 +95,4 @@ See [Terminal](terminal.md) for more on terminal selection and output.
- [Settings](settings.md#ai-beta) - configure AI providers, redaction, and Test Connection
- [Developer Docs](developer-docs.md) - the documentation the assistant can cite
-- [Terminal](terminal.md) - the "Explain this" selection action
+- [Terminal](terminal.md) - the "Ask roBot" selection action
diff --git a/docs/user/capture-preview.md b/docs/user/capture-preview.md
index 8b7484d..164c9ca 100644
--- a/docs/user/capture-preview.md
+++ b/docs/user/capture-preview.md
@@ -1,4 +1,4 @@
-# Capture Preview
+# HDMI Capture Preview for Roku
RokDock can display a live video feed from an HDMI capture device (a USB or HDMI capture card connected to your computer). This is distinct from the Roku device screenshot feature, which captures a still image over the network. See [Screenshot Preview](screenshot-preview.md) for that.
@@ -53,7 +53,7 @@ Controls in the popout toolbar:
In fullscreen mode the toolbar hides automatically and reappears briefly when you move the mouse.
-
+
*The live HDMI capture feed as a Picture-in-Picture float over the dock. The same feed can also be docked in a side panel or popped out into its own window.*
## Audio
diff --git a/docs/user/deeplinks.md b/docs/user/deeplinks.md
index 7ec1425..54288ff 100644
--- a/docs/user/deeplinks.md
+++ b/docs/user/deeplinks.md
@@ -1,4 +1,4 @@
-# Deeplinks
+# Roku Deeplink Testing (Launch and Input Presets)
RokDock lets you configure reusable deeplink presets and fire them against a selected Roku device from the right panel.
@@ -47,7 +47,7 @@ From this tab you can:
## Launching Deeplinks
-
+
*The Deeplinks panel with configured presets. Launch entries show a rocket icon, Input entries a satellite dish, and each button's meta line shows its ECP path and content ID.*
From the Deeplinks panel in the right rail:
diff --git a/docs/user/developer-docs.md b/docs/user/developer-docs.md
index a59b0a2..575a07b 100644
--- a/docs/user/developer-docs.md
+++ b/docs/user/developer-docs.md
@@ -1,4 +1,4 @@
-# Developer Docs
+# Roku Developer Documentation Browser (Offline Capable)
The Developer Docs tool is an in-app browser for the official Roku developer documentation. It fetches the docs directly from the rokudev/dev-doc repository and renders them inside RokDock, so you can read reference material without leaving the app.
@@ -12,7 +12,7 @@ The Developer Docs tool is an in-app browser for the official Roku developer doc
## Window Layout
-
+
*Developer Docs with the Browse tree expanded and a page open in the reading pane.*
The window has a brand-gradient toolbar across the top, a navigation sidebar on the left, and a reading pane on the right.
@@ -31,19 +31,16 @@ From top to bottom, the sidebar contains:
### Search
-
+
*Full-text search results in the sidebar, with the matched term highlighted in each snippet.*
A full-text search box. Type a query and matching pages appear with the section name and a context snippet, with your terms highlighted. Press `Enter` to open the first result, or `Escape` to clear the box. The first search of a session builds a local index of every page (it shows "Building search index..."), which then makes later searches instant. Opening a result scrolls to and highlights the matched text and shows a floating find bar to cycle through matches (`F3` / `Shift+F3`).
-
-*Opening a result highlights the match in the page and shows the find bar to cycle through matches.*
-
Search runs entirely against a local index built from the page content, not the GitHub search API, so it works against the same content you browse.
### What's New
-
+
*What's New groups changed pages by section and shows each page's added and removed line counts.*
Opens a feed of pages that changed in the official docs over a chosen window (7, 30, or 90 days). Each entry can be expanded to show the actual change as a diff, with a Rendered view (formatted markdown tinted for additions and removals) and a Source view (line-level tracked changes). A "Content only" toggle hides formatting-only changes so you see just the meaningful text edits. Click an entry's title to open that page.
diff --git a/docs/user/devices.md b/docs/user/devices.md
index 9a47205..85154a5 100644
--- a/docs/user/devices.md
+++ b/docs/user/devices.md
@@ -1,4 +1,4 @@
-# Devices
+# Roku Device Discovery and Developer Mode
This page covers Roku device discovery, manual devices, connection behavior, and device-level settings.
@@ -28,7 +28,7 @@ The Add Device dialog fields:
If a password is provided, a username is also required. RokDock stores credentials encrypted for use in authenticated operations.
-
+
*An expanded device card: online dot, model, IP, per-port connect buttons, and the device actions.*
## Device Card Details
diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md
index a65ad4d..8596051 100644
--- a/docs/user/getting-started.md
+++ b/docs/user/getting-started.md
@@ -1,4 +1,4 @@
-# Getting Started
+# Getting Started with RokDock
RokDock is a desktop app for Roku development workflows: device discovery, terminal sessions, remote control, deeplinks, and JSON inspection.
@@ -12,7 +12,7 @@ RokDock is a desktop app for Roku development workflows: device discovery, termi
## Install From Artifacts
-Use a packaged build from `dist/`:
+Most users should grab a packaged build. Download the latest from the [Releases](https://github.com/paramount-engineering/rokdock/releases) page and pick the artifact for your platform:
- Windows:
- `RokDock--Setup-win-x64.exe` (installer)
@@ -22,6 +22,7 @@ Use a packaged build from `dist/`:
- `RokDock--mac-.zip`
- Linux:
- `RokDock--linux-x64.AppImage`
+ - `RokDock--linux-x64.deb`
## Run From Source
@@ -49,14 +50,14 @@ npm run build
## Main layout
-
+
RokDock arranges its workspace around a top menu bar and a set of panels:
- **Device panel (left).** Discovered and manually-added devices, with connect actions, refresh, and add-device controls. See [Devices](devices.md).
- **Terminal workspace (center).** Tabbed telnet terminal sessions. Before you connect a device it shows a "No Active Connections" prompt. See [Terminal](terminal.md).
- **Control rail (right).** The virtual [Remote](remote-control.md), saved automation [Scripts](script-editor.md), and the [Deeplinks](deeplinks.md) launcher.
-- **AI Chat (Beta).** A dockable assistant that appears once you configure an AI provider. It can sit in the left column, in the right rail, or as a drawer below the terminal. See [AI Chat](ai.md).
+- **roBot (Beta).** A dockable assistant that appears once you configure and activate an AI provider. It can sit in the left column, in the right rail, or as a drawer below the terminal. See [roBot](ai.md).
- **Capture preview.** A live HDMI [capture](capture-preview.md) feed, shown when a capture device is configured in Settings. It can dock in either side column, float as a Picture-in-Picture overlay, or open in its own window.
The menu bar also holds the theme toggle and the panel-toggle buttons, and both side panels can be collapsed and reopened from their edge triggers or the View menu.
@@ -88,9 +89,9 @@ A tool launched this way runs in its own window and can coexist with the same to
RokDock can display a live video feed from an HDMI capture device. The capture preview can be docked in a side panel, floated as a PiP overlay, or opened in a separate window. Configure the capture device in **Settings > Capture**. See [Capture Preview](capture-preview.md) for details.
-## AI Chat (Beta)
+## roBot (Beta)
-RokDock has an opt-in AI assistant. After you configure a provider in **Settings > AI (Beta)**, an AI Chat panel becomes available (dockable on the left, middle, or right) and an "Explain this" action appears for terminal selections. AI is off until a provider is configured. See [AI Chat](ai.md) for details.
+RokDock has an opt-in AI assistant named roBot. After you configure a provider in **Settings > AI (Beta)** and set one as the active provider, the roBot panel becomes available (dockable on the left, middle, or right) and an "Ask roBot" action appears for terminal selections. AI is off until an active provider is set, and auto-detected CLI providers must be activated with **Set active**. See [roBot](ai.md) for details.
## Open Common Screens
diff --git a/docs/user/images/add-device.png b/docs/user/images/add-device.png
index 6f5bfdb..856d071 100644
Binary files a/docs/user/images/add-device.png and b/docs/user/images/add-device.png differ
diff --git a/docs/user/images/ai-chat-panel.png b/docs/user/images/ai-chat-panel.png
index bfc1998..88c5095 100644
Binary files a/docs/user/images/ai-chat-panel.png and b/docs/user/images/ai-chat-panel.png differ
diff --git a/docs/user/images/capture-live.webp b/docs/user/images/capture-live.webp
deleted file mode 100644
index 5fc68a2..0000000
Binary files a/docs/user/images/capture-live.webp and /dev/null differ
diff --git a/docs/user/images/capture-pip.webp b/docs/user/images/capture-pip.webp
new file mode 100644
index 0000000..c91c090
Binary files /dev/null and b/docs/user/images/capture-pip.webp differ
diff --git a/docs/user/images/capture-viewer-safezone.webp b/docs/user/images/capture-viewer-safezone.webp
new file mode 100644
index 0000000..ff0746f
Binary files /dev/null and b/docs/user/images/capture-viewer-safezone.webp differ
diff --git a/docs/user/images/connect-card-expanded.png b/docs/user/images/connect-card-expanded.png
new file mode 100644
index 0000000..1691a19
Binary files /dev/null and b/docs/user/images/connect-card-expanded.png differ
diff --git a/docs/user/images/control-deeplinks.png b/docs/user/images/control-deeplinks.png
new file mode 100644
index 0000000..bc24901
Binary files /dev/null and b/docs/user/images/control-deeplinks.png differ
diff --git a/docs/user/images/control-remote-settings.png b/docs/user/images/control-remote-settings.png
new file mode 100644
index 0000000..f5f9e1e
Binary files /dev/null and b/docs/user/images/control-remote-settings.png differ
diff --git a/docs/user/images/control-remote.png b/docs/user/images/control-remote.png
new file mode 100644
index 0000000..01d9462
Binary files /dev/null and b/docs/user/images/control-remote.png differ
diff --git a/docs/user/images/deeplinks-live.png b/docs/user/images/deeplinks-live.png
deleted file mode 100644
index ee94fb7..0000000
Binary files a/docs/user/images/deeplinks-live.png and /dev/null differ
diff --git a/docs/user/images/developer-docs.png b/docs/user/images/developer-docs.png
deleted file mode 100644
index 5e530b8..0000000
Binary files a/docs/user/images/developer-docs.png and /dev/null differ
diff --git a/docs/user/images/device-card-connected.png b/docs/user/images/device-card-connected.png
deleted file mode 100644
index bddfdcb..0000000
Binary files a/docs/user/images/device-card-connected.png and /dev/null differ
diff --git a/docs/user/images/dock-hero.png b/docs/user/images/dock-hero.png
new file mode 100644
index 0000000..fc59391
Binary files /dev/null and b/docs/user/images/dock-hero.png differ
diff --git a/docs/user/images/docs-find-bar.png b/docs/user/images/docs-find-bar.png
deleted file mode 100644
index 8c72404..0000000
Binary files a/docs/user/images/docs-find-bar.png and /dev/null differ
diff --git a/docs/user/images/docs-lead.png b/docs/user/images/docs-lead.png
new file mode 100644
index 0000000..2e7989e
Binary files /dev/null and b/docs/user/images/docs-lead.png differ
diff --git a/docs/user/images/docs-search.png b/docs/user/images/docs-search.png
index 4f5d31c..02c96ef 100644
Binary files a/docs/user/images/docs-search.png and b/docs/user/images/docs-search.png differ
diff --git a/docs/user/images/docs-whats-new.png b/docs/user/images/docs-whats-new.png
deleted file mode 100644
index 6c725fb..0000000
Binary files a/docs/user/images/docs-whats-new.png and /dev/null differ
diff --git a/docs/user/images/docs-whatsnew.png b/docs/user/images/docs-whatsnew.png
new file mode 100644
index 0000000..3b35f97
Binary files /dev/null and b/docs/user/images/docs-whatsnew.png differ
diff --git a/docs/user/images/json-editor.png b/docs/user/images/json-editor.png
index 2a60ca4..c7c36b4 100644
Binary files a/docs/user/images/json-editor.png and b/docs/user/images/json-editor.png differ
diff --git a/docs/user/images/ninepatch-editor.png b/docs/user/images/ninepatch-editor.png
index 1d40041..25eea50 100644
Binary files a/docs/user/images/ninepatch-editor.png and b/docs/user/images/ninepatch-editor.png differ
diff --git a/docs/user/images/remote-live.png b/docs/user/images/remote-live.png
deleted file mode 100644
index f753b59..0000000
Binary files a/docs/user/images/remote-live.png and /dev/null differ
diff --git a/docs/user/images/screenshot-preview-overlay.png b/docs/user/images/screenshot-preview-overlay.png
index d30938f..152e0e1 100644
Binary files a/docs/user/images/screenshot-preview-overlay.png and b/docs/user/images/screenshot-preview-overlay.png differ
diff --git a/docs/user/images/screenshot-preview.png b/docs/user/images/screenshot-preview.png
deleted file mode 100644
index 14f7c65..0000000
Binary files a/docs/user/images/screenshot-preview.png and /dev/null differ
diff --git a/docs/user/images/script-editor-running.png b/docs/user/images/script-editor-running.png
deleted file mode 100644
index 18e5c2a..0000000
Binary files a/docs/user/images/script-editor-running.png and /dev/null differ
diff --git a/docs/user/images/script-editor.png b/docs/user/images/script-editor.png
index f557343..203b353 100644
Binary files a/docs/user/images/script-editor.png and b/docs/user/images/script-editor.png differ
diff --git a/docs/user/images/settings-advanced.png b/docs/user/images/settings-advanced.png
index 90cbbba..a795d5d 100644
Binary files a/docs/user/images/settings-advanced.png and b/docs/user/images/settings-advanced.png differ
diff --git a/docs/user/images/settings-ai.png b/docs/user/images/settings-ai.png
index f134825..08c128f 100644
Binary files a/docs/user/images/settings-ai.png and b/docs/user/images/settings-ai.png differ
diff --git a/docs/user/images/settings-appearance.png b/docs/user/images/settings-appearance.png
index b990141..6fbf8ad 100644
Binary files a/docs/user/images/settings-appearance.png and b/docs/user/images/settings-appearance.png differ
diff --git a/docs/user/images/settings-capture.png b/docs/user/images/settings-capture.png
index 4e895c1..40982f8 100644
Binary files a/docs/user/images/settings-capture.png and b/docs/user/images/settings-capture.png differ
diff --git a/docs/user/images/settings-code.png b/docs/user/images/settings-code.png
new file mode 100644
index 0000000..5d4536d
Binary files /dev/null and b/docs/user/images/settings-code.png differ
diff --git a/docs/user/images/settings-deeplinks.png b/docs/user/images/settings-deeplinks.png
index 9830f68..92967e7 100644
Binary files a/docs/user/images/settings-deeplinks.png and b/docs/user/images/settings-deeplinks.png differ
diff --git a/docs/user/images/settings-devices.png b/docs/user/images/settings-devices.png
index 6b81a61..964076c 100644
Binary files a/docs/user/images/settings-devices.png and b/docs/user/images/settings-devices.png differ
diff --git a/docs/user/images/settings-remote.png b/docs/user/images/settings-remote.png
deleted file mode 100644
index d23659b..0000000
Binary files a/docs/user/images/settings-remote.png and /dev/null differ
diff --git a/docs/user/images/svg-converter-recolor.png b/docs/user/images/svg-converter-recolor.png
deleted file mode 100644
index 1b4d4e8..0000000
Binary files a/docs/user/images/svg-converter-recolor.png and /dev/null differ
diff --git a/docs/user/images/svg-converter-recolored.png b/docs/user/images/svg-converter-recolored.png
new file mode 100644
index 0000000..9da0db5
Binary files /dev/null and b/docs/user/images/svg-converter-recolored.png differ
diff --git a/docs/user/images/svg-converter.png b/docs/user/images/svg-converter.png
index d1adef8..0952f2c 100644
Binary files a/docs/user/images/svg-converter.png and b/docs/user/images/svg-converter.png differ
diff --git a/docs/user/images/terminal-json-highlighted.webp b/docs/user/images/terminal-json-highlighted.webp
new file mode 100644
index 0000000..bca4ee5
Binary files /dev/null and b/docs/user/images/terminal-json-highlighted.webp differ
diff --git a/docs/user/images/terminal-live.png b/docs/user/images/terminal-live.png
deleted file mode 100644
index 018b7e2..0000000
Binary files a/docs/user/images/terminal-live.png and /dev/null differ
diff --git a/docs/user/images/terminal-live.webp b/docs/user/images/terminal-live.webp
new file mode 100644
index 0000000..c1f5a73
Binary files /dev/null and b/docs/user/images/terminal-live.webp differ
diff --git a/docs/user/images/themes-comparison.webp b/docs/user/images/themes-comparison.webp
index e34fb05..75afbcf 100644
Binary files a/docs/user/images/themes-comparison.webp and b/docs/user/images/themes-comparison.webp differ
diff --git a/docs/user/images/workspace-overview.webp b/docs/user/images/workspace-overview.webp
deleted file mode 100644
index 02f45aa..0000000
Binary files a/docs/user/images/workspace-overview.webp and /dev/null differ
diff --git a/docs/user/index.md b/docs/user/index.md
new file mode 100644
index 0000000..c3f3e91
--- /dev/null
+++ b/docs/user/index.md
@@ -0,0 +1,37 @@
+---
+description: RokDock is a free cross-platform desktop app for Roku and BrightScript development: device discovery, debug terminal, sideloading, screenshots, deeplinks, and an AI assistant with Gemini, Claude, Copilot, and Codex.
+---
+
+# RokDock: The Desktop App for Roku Development
+
+RokDock is a free, cross-platform (Windows, macOS, and Linux) desktop application for Roku, BrightScript, and SceneGraph developers. It brings the tools of a Roku development workflow into one window: device discovery, a BrightScript debug terminal, a virtual remote, channel sideloading, device screenshots, deeplink testing, automation scripting, an offline-capable Roku documentation browser, and an opt-in AI assistant.
+
+## Start here
+
+- [Getting Started with RokDock](getting-started.md) - install a build and take the first tour.
+- [Roku Device Discovery and Developer Mode](devices.md) - find devices on your network and enable developer mode.
+- [Roku Debug Terminal](terminal.md) - a BrightScript telnet console with syntax highlighting.
+
+## Core workflows
+
+- [Sideloading a Roku Channel](sideload.md) - install a `.zip` or `.pkg` straight to a device.
+- [Roku Virtual Remote](remote-control.md) - drive a device over ECP.
+- [Roku Device Screenshots](screenshot-preview.md) - capture, compare, and measure.
+- [Roku Deeplink Testing](deeplinks.md) - save and fire launch and input presets.
+- [HDMI Capture Preview](capture-preview.md) - a live capture-card feed inside the app.
+
+## Tools
+
+- [Roku Device Automation Scripts](script-editor.md) - RASP-style scripted device sequences.
+- [9-Patch Editor](ninepatch-editor.md) and [SVG to PNG Converter](svg-converter.md) - prepare SceneGraph image assets.
+- [JSON Viewer and Editor](json-viewer.md) - inspect JSON from terminal output.
+- [Roku Developer Documentation Browser](developer-docs.md) - read the official docs offline, in-app.
+
+## AI assistant
+
+- [AI Assistant for Roku Development](ai.md) - connect Google Gemini, Anthropic Claude, GitHub Copilot, OpenAI Codex, or a fully local model (Ollama, LM Studio). The assistant reads the official Roku docs, explains BrightScript debugger output, and redacts device details before anything leaves your machine.
+
+## Reference
+
+- [Settings Reference](settings.md) - ports, appearance, AI, and capture configuration.
+- [Themes](themes.md) and [Keyboard Shortcuts](keyboard-shortcuts.md).
diff --git a/docs/user/json-viewer.md b/docs/user/json-viewer.md
index 80785a7..19e04cf 100644
--- a/docs/user/json-viewer.md
+++ b/docs/user/json-viewer.md
@@ -1,4 +1,4 @@
-# JSON Viewer
+# JSON Viewer and Editor
The JSON Viewer is a tabbed code editor for reading, editing, and saving JSON. It is backed by CodeMirror 6 with JSON syntax highlighting, line numbers, code folding, inline parse-error markers, and a persistent status bar.
diff --git a/docs/user/keyboard-shortcuts.md b/docs/user/keyboard-shortcuts.md
index 2696e43..91d65cb 100644
--- a/docs/user/keyboard-shortcuts.md
+++ b/docs/user/keyboard-shortcuts.md
@@ -157,4 +157,4 @@ The mouse back/forward buttons also navigate history.
- [SVG Converter](svg-converter.md) - SVG to PNG converter
- [9-Patch Editor](ninepatch-editor.md) - editor features
- [Developer Docs](developer-docs.md) - in-app documentation browser
-- [AI Chat](ai.md) - the AI assistant (Enter to send, Shift+Enter for newline)
+- [roBot](ai.md) - the AI assistant (Enter to send, Shift+Enter for newline)
diff --git a/docs/user/llms.txt b/docs/user/llms.txt
new file mode 100644
index 0000000..3f93873
--- /dev/null
+++ b/docs/user/llms.txt
@@ -0,0 +1,21 @@
+# RokDock
+
+> RokDock is a free, cross-platform desktop app for Roku development: device discovery, a BrightScript debug terminal, remote control, channel sideloading, device screenshots, deeplink testing, automation scripting, an offline Roku docs browser, and a built-in AI assistant with Gemini, Claude, Copilot, and Codex.
+
+RokDock runs on Windows, macOS, and Linux. It is aimed at Roku, BrightScript, and SceneGraph developers who want the common device workflows in one native app instead of a mix of telnet, browser forms, and scripts. The AI assistant is opt-in and provider-agnostic: connect Google Gemini, Anthropic Claude, GitHub Copilot, OpenAI Codex, any OpenAI-compatible endpoint, or a fully local model (Ollama, LM Studio). It reads the official Roku documentation, explains BrightScript debugger output, and redacts device IPs, names, and serials before sending anything.
+
+## Docs
+
+- [Getting Started](https://paramount-engineering.github.io/rokdock/getting-started/): install and first launch
+- [Roku Device Discovery and Developer Mode](https://paramount-engineering.github.io/rokdock/devices/): find devices and enable developer mode
+- [Roku Debug Terminal](https://paramount-engineering.github.io/rokdock/terminal/): BrightScript telnet console with highlighting
+- [Sideloading a Roku Channel](https://paramount-engineering.github.io/rokdock/sideload/): install a .zip or .pkg to a device
+- [Roku Virtual Remote](https://paramount-engineering.github.io/rokdock/remote-control/): ECP remote control
+- [Roku Device Screenshots](https://paramount-engineering.github.io/rokdock/screenshot-preview/): capture, compare, measure
+- [Roku Deeplink Testing](https://paramount-engineering.github.io/rokdock/deeplinks/): launch and input presets
+- [Roku Device Automation Scripts](https://paramount-engineering.github.io/rokdock/script-editor/): RASP-style scripted sequences
+- [Roku Developer Documentation Browser](https://paramount-engineering.github.io/rokdock/developer-docs/): the official docs, offline, in-app
+
+## AI
+
+- [AI Assistant for Roku Development](https://paramount-engineering.github.io/rokdock/ai/): connect Gemini, Claude, Copilot, Codex, or a local model; grounded in the Roku docs with per-prompt redaction
diff --git a/docs/user/ninepatch-editor.md b/docs/user/ninepatch-editor.md
index 97b5df9..423a119 100644
--- a/docs/user/ninepatch-editor.md
+++ b/docs/user/ninepatch-editor.md
@@ -1,4 +1,4 @@
-# 9-Patch Editor
+# 9-Patch Editor for Roku SceneGraph Assets
RokDock includes a built-in 9-patch image editor for creating and editing stretchable `.9.png` assets used in Roku SceneGraph development.
diff --git a/docs/user/remote-control.md b/docs/user/remote-control.md
index f95221e..b665fe1 100644
--- a/docs/user/remote-control.md
+++ b/docs/user/remote-control.md
@@ -1,4 +1,4 @@
-# Remote Control
+# Roku Virtual Remote (ECP Remote Control)
RokDock includes a virtual Roku remote panel on the right side of the app.
@@ -9,7 +9,7 @@ At the top of the panel, choose the target device from the device dropdown.
- Remote commands and deeplink launches use this selected device.
- Opening a terminal tab for a device usually aligns remote target selection to that device.
-
+
*The Remote panel with a device selected. The on-screen remote is active and ready to send ECP commands.*
## On-Screen Remote Buttons
@@ -52,7 +52,7 @@ All other actions (Power, Instant Replay, Options, Rewind, Play/Pause, Fast Forw
All key bindings are configurable. Open Settings > Remote from the gear icon in the Remote section header, or from the main Settings dialog.
-
+
*The Settings > Remote tab. Click any row to record a new key for that action.*
## Text Entry Overlay
diff --git a/docs/user/screenshot-preview.md b/docs/user/screenshot-preview.md
index b63fd0f..d206b0c 100644
--- a/docs/user/screenshot-preview.md
+++ b/docs/user/screenshot-preview.md
@@ -1,8 +1,8 @@
-# Screenshot Preview
+# Roku Device Screenshots: Capture, Compare, Measure
RokDock includes a dedicated screenshot preview window for capturing and inspecting device screenshots. The preview opens as a separate window with its own toolbar, zoom controls, measurement tools, and comparison overlay support.
-
+
*The Screenshot Preview window with a captured device frame, a safe-zone overlay and measurement guides applied, and the zoom and comparison controls in the bottom dock.*

diff --git a/docs/user/script-editor.md b/docs/user/script-editor.md
index efd4e1d..ed1cf42 100644
--- a/docs/user/script-editor.md
+++ b/docs/user/script-editor.md
@@ -1,13 +1,10 @@
-# Script Editor
+# Roku Device Automation Scripts and RASP
RokDock includes a Script Editor for creating and running automation scripts against Roku devices. Scripts are sequences of typed steps that execute via ECP (External Control Protocol). Open it from **Tools > Script Editor** in the menu bar. The editor opens in a separate window. It also opens on its own outside the dock, via its installer shortcut, by double-clicking a `.rasp` or `.rscript` file, or with `RokDock --tool script [path]`. See [Launching Tools Directly](getting-started.md#launching-tools-directly).

*The Script Editor with a sample script loaded and the Scripts library open in the right panel.*
-
-*A script executing against a device. The completed step shows a check, the running step is marked, and the execution log streams progress at the bottom.*
-
## Scripts
Scripts are JSON-based automation sequences stored in the Electron userData `scripts/` directory. Each script contains:
diff --git a/docs/user/settings.md b/docs/user/settings.md
index b5b3203..44a010f 100644
--- a/docs/user/settings.md
+++ b/docs/user/settings.md
@@ -65,7 +65,7 @@ See [Deeplinks](deeplinks.md) for full details on launching and the panel workfl
## Remote
-
+
*Remote tab: keyboard bindings for each Roku remote button.*
Configure which keyboard key triggers each remote action. Actions are grouped into four collapsible sections:
@@ -115,7 +115,7 @@ See [Devices](devices.md) for the full discovery and manual device workflow.
### Screenshot
-- **Screenshot Folder** - path where screenshots are saved. Leave blank to use the default app data folder. Use **Browse** to pick a folder.
+- **Screenshot Folder** - path where screenshots are saved. Leave it blank to use the default folder, whose full path is shown in the field so you can find it. **Browse** opens the folder currently in effect (your chosen folder, or the default when blank).
- **Filename Format** - template for screenshot filenames. Supported tokens: `{YYYY}` `{MM}` `{DD}` `{HH}` `{mm}` `{ss}`. Default: `screenshot-{YYYY}{MM}{DD}-{HH}{mm}{ss}`.
### Live Capture
@@ -130,7 +130,7 @@ See [Devices](devices.md) for the full discovery and manual device workflow.

*AI (Beta) tab: the provider list with the add-provider form open.*
-Configure the AI providers that power the [AI Chat](ai.md) panel and the terminal "Explain this" action. The tab opens to a provider list with the form hidden, so it starts as a clean list.
+Configure the AI providers that power the [roBot](ai.md) panel and the terminal "Ask roBot" action. The tab opens to a provider list with the form hidden, so it starts as a clean list. roBot uses the single provider marked **Active**, so you must set one active for AI to work. Adding your first HTTP provider activates it automatically, but auto-detected CLI providers are listed without being activated, so click **Set active** on the one you want.
- **Providers list** - each saved provider shows its name, type, whether a key is stored, and which one is **Active**. Use **Set active** to switch, **Test** to run a Test Connection (a canned prompt streamed through the real engine, with the redaction preview shown beneath), the pencil to edit, and the trash to remove.
- **Add provider** - opens the form. Choose a **Provider type**: Anthropic (Claude), Gemini, OpenAI-compatible, or one of the recognized CLIs (Claude, Copilot, Gemini, Codex). HTTP providers take a name, model, optional base URL, and API key. A CLI provider is keyless and local, and is identified by the CLI name.
@@ -138,7 +138,7 @@ Configure the AI providers that power the [AI Chat](ai.md) panel and the termina
- **Local (no data leaves this machine)** - mark a provider that runs locally (an Ollama CLI or a localhost endpoint). A local provider needs no key, and redaction is optional because nothing leaves the machine.
- **Redact sensitive values** - on by default. Removes device IPs, names, and serial numbers from prompts before they are sent, with a before/after example shown inline. If you turn redaction off on a non-local (remote) provider, the form shows a red warning and an "I understand" acknowledgment you must check before Save is enabled.
-See [AI Chat](ai.md) for using the assistant. The AI key provisioning workflow is handled separately from this settings tab.
+See [roBot](ai.md) for using the assistant. The AI key provisioning workflow is handled separately from this settings tab.
## Advanced
diff --git a/docs/user/sideload.md b/docs/user/sideload.md
index 4017776..34d39c9 100644
--- a/docs/user/sideload.md
+++ b/docs/user/sideload.md
@@ -1,4 +1,4 @@
-# Sideloading
+# Sideloading a Roku Channel (.zip and .pkg)
RokDock can install a Roku channel package directly to a device without going through the Roku developer web interface. The package is POSTed to the device's `/plugin_install` endpoint using HTTP Digest authentication, the same mechanism the Roku developer portal uses.
@@ -6,19 +6,29 @@ RokDock can install a Roku channel package directly to a device without going th
Before the Sideload App option becomes active, both of the following must be true for the target device:
-- **Developer Mode is enabled on the device.** Enable it by following the [Roku developer mode instructions](https://developer.roku.com/docs/developer-program/getting-started/developer-setup.md) (enter the Roku secret screen sequence from the home screen). RokDock detects developer mode automatically during device discovery.
-- **Developer credentials are saved in Device Properties.** Open Device Properties for the device (via the device card dropdown) and enter the username and password you set when you enabled developer mode. See [Devices](devices.md) for how to open Device Properties.
+- **Developer Mode is enabled on the device.** Enable it by following the [Roku developer mode instructions](https://developer.roku.com/docs/developer-program/getting-started/developer-setup.md) (enter the Roku secret screen sequence from the home screen, accept the developer agreement, and set a developer password). RokDock detects developer mode automatically during device discovery.
+- **Developer credentials are saved in Device Properties.** Open Device Properties for the device (via the device card dropdown) and enter the username (`rokudev`) and the password you set when you enabled developer mode. See [Devices](devices.md) for how to open Device Properties.
If either condition is not met, the Sideload App option appears dimmed in the device card dropdown. Hovering over it shows a tooltip explaining which requirement is missing.
## How to Sideload
+There are two ways to start a sideload: the device card menu, or a drag-and-drop onto the card.
+
+### From the device card menu
+
1. Expand a device card in the Devices panel.
2. Open the card dropdown and click **Sideload App...**
3. In the dialog, click **Choose...** to open the system file picker. The picker is filtered to `.zip` files by default. `.pkg` files are also accepted if you select one via "All Files".
4. Confirm the correct device is shown in the Target row.
5. Click **Install** to upload the package.
+### By drag and drop
+
+Drag a single `.zip` or `.pkg` package from your file manager and drop it onto the target device's card. RokDock highlights the card as a drop target and opens the Sideload dialog with the package pre-selected, so you only have to confirm **Install**.
+
+The drop is gated by the same prerequisites as the menu action. If the device cannot be sideloaded (developer mode not detected, or no credentials saved), the card shows a brief message explaining what is missing instead of opening the dialog. Dropping more than one file, or a file that is not a `.zip` or `.pkg`, is rejected the same way.
+
The dialog cannot be closed while an upload is in progress. The Install and Cancel/Close buttons are disabled until the operation completes.
## Sideload Dialog
@@ -30,23 +40,35 @@ The dialog contains:
- **Progress bar**: visible only while installing. Displays a status label ("Uploading..." for the first ~95% of transfer, then "Processing..." while the device processes the package) and a percentage counter.
- **Result panel**: appears after the install completes. A green left border and "Installed" heading indicate success. A red left border and "Failed" heading indicate an error.
+The dialog stays open after both a successful and a failed install so the result message is readable. Click **Close** to dismiss it.
+
### On success
-The result panel shows "Installed" and the response message returned by the device. Click **Close** to dismiss the dialog.
+The result panel shows "Installed" and the response message returned by the device (for example, "Application Received: 128265 bytes stored.").
### On failure
-The result panel shows "Failed" and the error message. You can select a different file and click **Install** again to retry, or click **Close** to dismiss.
+The result panel shows "Failed" and the error message reported by the device. You can select a different file and click **Install** again to retry, or click **Close** to dismiss.
If the error message indicates that no credentials are configured, an inline link labeled "Set credentials in Device Properties" appears. Clicking it closes the sideload dialog and opens Device Properties for the target device.
## Package Format
-Roku sideload packages must be `.zip` archives built by the Roku SDK (BrightScript/SceneGraph source tree compressed as a flat `.zip`, not a nested folder). The file picker defaults to showing `.zip` files. Signed `.pkg` files are also accepted by the installer if you navigate to one manually.
+Roku sideload packages must be `.zip` archives built by the Roku SDK: a BrightScript / SceneGraph source tree (with a `manifest` at its root) compressed as a flat `.zip`, not a nested folder. The file picker defaults to showing `.zip` files. Signed `.pkg` files, produced by the Roku packaging step for production channels, are also accepted by the installer if you navigate to one manually.
+
+## Troubleshooting
+
+RokDock surfaces the device's own result message. These are the errors you are most likely to see:
+
+- **"No manifest. Invalid package."** The `.zip` has no `manifest` file at its root, or the source tree was zipped inside a wrapping folder. Rebuild the archive so `manifest`, `source/`, and `components/` sit at the top level of the `.zip`.
+- **Authentication failure (HTTP 401).** The saved developer password does not match the one set on the device. Update it in Device Properties. The username is always `rokudev`.
+- **The option is dimmed and cannot be clicked.** Developer mode is not detected on the device, or no credentials are saved. See [Prerequisites](#prerequisites).
+- **Connection refused or timeout.** The device is off, asleep, or on a different network segment. Confirm the device shows a green status dot in the Devices panel first.
+- **Nothing installs but no error appears.** Some firmware auto-launches the installed channel. Check the device screen. The channel is sideloaded to the single developer slot and replaces whatever was there before.
## How It Works
-RokDock reads the selected file from disk, retrieves the developer credentials stored for the target device, and uploads the archive to `http:///plugin_install` as a multipart POST. Authentication uses HTTP Digest with the username and password from Device Properties. Upload progress is reported back to the dialog in real time and displayed as a percentage. The device processes the package and returns a result message which RokDock surfaces in the result panel.
+RokDock reads the selected file from disk, retrieves the developer credentials stored for the target device, and uploads the archive to `http:///plugin_install` as a multipart POST. Authentication uses HTTP Digest with the username and password from Device Properties. Upload progress is reported back to the dialog in real time and displayed as a percentage. The device processes the package and returns a result message, which RokDock parses (a device error message marks the install as failed) and surfaces in the result panel.
## Related
diff --git a/docs/user/svg-converter.md b/docs/user/svg-converter.md
index 51b4f95..978085d 100644
--- a/docs/user/svg-converter.md
+++ b/docs/user/svg-converter.md
@@ -1,4 +1,4 @@
-# SVG Converter
+# SVG to Roku-Ready PNG Converter
RokDock includes an SVG-to-PNG converter for preparing vector assets for Roku SceneGraph development. The converter rasterizes an SVG at a chosen resolution, reduces it to an indexed color palette sized for Roku, and saves the result as a PNG.
@@ -42,8 +42,8 @@ The W and H fields default to 1920 x 1080 before an SVG is loaded. Once an SVG i
The Colors section lets you override the colors of an imported SVG before it is rasterized, so the change flows through to the quantized preview and the exported PNG.
-
-*Recoloring an imported SVG: the original red fill (#e50914) is remapped to amber. Each detected color shows its original swatch, a picker for the new value, and a reset button.*
+
+*Recoloring an imported SVG. Each detected color shows its original swatch, a picker for the new value, and a reset button, and the change flows through to the preview and the exported PNG.*
When an SVG is loaded, RokDock scans it for the distinct fill and stroke colors it uses and lists each one:
diff --git a/docs/user/terminal.md b/docs/user/terminal.md
index 37abe39..dc83158 100644
--- a/docs/user/terminal.md
+++ b/docs/user/terminal.md
@@ -1,4 +1,4 @@
-# Terminal
+# Roku Debug Terminal (BrightScript Telnet Console)
RokDock uses a custom built-in terminal emulator for Roku debug sessions.
@@ -52,7 +52,7 @@ Highlights include:
- JSON detection and click-to-open JSON Viewer
- Optional theme background integration
-
+
*A connected terminal tab at a BrightScript debugger break. Output is tokenized and colored, and detected URLs are underlined as links.*
## Search
@@ -161,7 +161,7 @@ This is useful for inspecting long ad URLs, analytics beacons, and API calls tha
Open **Settings > Appearance** to configure font and syntax options. The gear icon in the terminal tab bar opens this tab scrolled to its Terminal section.
-
+
_Settings > Appearance: the Code section sets font family, font size, syntax theme, and the use-theme-background toggle with a live preview._
Options include:
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 2443828..f2e7cab 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -186,6 +186,10 @@ export default defineConfig({
plugins: [react(), bootSplashFirstPaintPlugin(packageVersion), bundledEntryFoucPlugin(), tightenCspPlugin()],
root: 'src/renderer',
publicDir: false,
+ // ES-format workers: the regex-match worker is loaded as a module worker
+ // (new Worker(new URL(...), { type: 'module' })). Vite defaults worker.format to
+ // 'iife', which breaks a module worker, so pin it to 'es'.
+ worker: { format: 'es' },
build: {
// Use absolute output path so packaged builds always include renderer assets.
outDir: path.resolve(__dirname, 'out/renderer'),
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4b5641b..c0e3b3b 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -46,6 +46,19 @@ export default [
// caught-error / event-handler idiom (e), which is used consistently as `e`.
// Properties are not length-checked (object keys often mirror external schemas).
'id-length': ['error', { min: 2, exceptions: ['i', 'j', 'k', 'x', 'y', 'w', 'h', '_', 'e'], properties: 'never' }],
+ // id-length only bans single-character names, so two-character abbreviations that
+ // hide a whole word slipped through for a long time (el, cb, btn, prefs, ...). This
+ // denylist names the offenders we have already had to clean up so they cannot come
+ // back. It matches exact identifier names only (not substrings), so compound names
+ // like tabListEl or historyBtn are unaffected. Add a name here when a review finds
+ // a new word-hiding abbreviation. Graphics coordinate/dimension pairs derived from
+ // the allowed x/y/w/h family (ox, cx, sw, sh, ...) are intentionally NOT listed:
+ // they are idiomatic in the canvas-drawing code.
+ 'id-denylist': [
+ 'error',
+ 'el', 'els', 'av', 'sv', 'cb', 'cfg', 'fp', 'cmd',
+ 'prefs', 'btn', 'zf', 'sel', 'sl', 'st', 'cs', 'rs', 'sc', 'dc'
+ ],
// No snake_case / kebab-case in identifiers we define. Properties are left
// unconstrained because object keys frequently mirror external API schemas
// (e.g. tool_calls, web_search), IPC payloads, and CSS-in-JS keys.
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..47ead2d
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,61 @@
+# MkDocs Material config for the RokDock user documentation site.
+#
+# This publishes docs/user/ as a static documentation site (GitHub Pages). It is
+# isolated from the app's Node/Vite toolchain: no npm dependency, its own build.
+# Build locally with: pip install mkdocs-material && mkdocs build --strict
+#
+# Pages is enabled only on the public mirror (paramount-engineering). The deploy
+# workflow (.github/workflows/docs.yml) is manual (workflow_dispatch) until the
+# first public release, so nothing auto-publishes before the flip.
+site_name: RokDock
+site_description: "Free cross-platform desktop app for Roku development: device discovery, BrightScript debug terminal, sideloading, screenshots, deeplinks, and an AI assistant with Gemini, Claude, Copilot, and Codex."
+site_url: https://paramount-engineering.github.io/rokdock/
+repo_url: https://github.com/paramount-engineering/rokdock
+repo_name: paramount-engineering/rokdock
+edit_uri: edit/main/docs/user/
+docs_dir: docs/user
+site_dir: site
+
+theme:
+ name: material
+ custom_dir: overrides
+ palette:
+ scheme: slate
+ primary: deep purple
+ accent: purple
+ features:
+ - navigation.tracking
+ - navigation.top
+ - navigation.sections
+ - search.suggest
+ - content.code.copy
+ - content.action.edit
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - toc:
+ permalink: true
+ - pymdownx.highlight
+ - pymdownx.superfences
+
+nav:
+ - Home: index.md
+ - Getting Started: getting-started.md
+ - Devices: devices.md
+ - Debug Terminal: terminal.md
+ - Sideloading: sideload.md
+ - Remote Control: remote-control.md
+ - Screenshots: screenshot-preview.md
+ - Deeplinks: deeplinks.md
+ - Capture Preview: capture-preview.md
+ - Tools:
+ - Automation Scripts: script-editor.md
+ - 9-Patch Editor: ninepatch-editor.md
+ - SVG Converter: svg-converter.md
+ - JSON Viewer: json-viewer.md
+ - Developer Docs: developer-docs.md
+ - AI Assistant: ai.md
+ - Settings: settings.md
+ - Themes: themes.md
+ - Keyboard Shortcuts: keyboard-shortcuts.md
diff --git a/overrides/main.html b/overrides/main.html
new file mode 100644
index 0000000..d954faf
--- /dev/null
+++ b/overrides/main.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+
+{#
+ Injects JSON-LD SoftwareApplication structured data into every page head so
+ search and AI answer engines can identify RokDock as a free, cross-platform
+ developer application. Rendered by MkDocs Material via theme.custom_dir.
+#}
+{% block extrahead %}
+
+{% endblock %}
diff --git a/package-lock.json b/package-lock.json
index bb2ef8d..4a5e78d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rokdock",
- "version": "1.3.1",
+ "version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rokdock",
- "version": "1.3.1",
+ "version": "1.5.1",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -45,7 +45,7 @@
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.2.0",
"cross-env": "10.1.0",
- "electron": "42.5.1",
+ "electron": "42.5.2",
"electron-builder": "26.15.3",
"electron-vite": "5.0.0",
"eslint": "9.39.4",
@@ -4943,9 +4943,9 @@
}
},
"node_modules/electron": {
- "version": "42.5.1",
- "resolved": "https://registry.npmjs.org/electron/-/electron-42.5.1.tgz",
- "integrity": "sha512-2VFNJcHHbrhIpGsJHdkLoi/nWPZPxN3GHVPe+9At3Oz3/TJRwpr+7JL97ddBDbKyLmHGx3GfI2jvzcEQL28uFw==",
+ "version": "42.5.2",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-42.5.2.tgz",
+ "integrity": "sha512-nEoyciv2iC6gTvCbkQ3eP5tjAOo28wfm0adZaMYTns92MyODHeD1TlrGt4E35d4tJfDlmv+BHOuz0QIkJ63c6w==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 3c2b1f0..541e1ca 100644
--- a/package.json
+++ b/package.json
@@ -1,15 +1,16 @@
{
"name": "rokdock",
- "version": "1.5.1",
- "description": "Cross-platform desktop app for Roku development - device discovery, terminal sessions, remote control, sideloading, screenshot capture, and automation scripting",
+ "version": "1.6.0",
+ "description": "Cross-platform desktop app for Roku development: device discovery, terminal sessions, remote control, sideloading, screenshot capture, automation scripting, and a built-in AI assistant with Gemini, Claude, Codex, and Copilot",
"main": "./out/main/main.js",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
+ "typecheck:e2e": "tsc -p tsconfig.e2e.json --noEmit",
"lint": "eslint .",
"check:prose": "node scripts/check-prose.mjs",
- "verify": "npm run typecheck && npm run lint && npm run check:prose && npm run test",
+ "verify": "npm run typecheck && npm run typecheck:e2e && npm run lint && npm run check:prose && npm run test",
"verify:full": "npm run verify && npm run test:e2e",
"dev": "electron-vite dev",
"dev:docs": "cross-env ROKDOCK_LAUNCH_TOOL=docs electron-vite dev",
@@ -26,9 +27,21 @@
},
"keywords": [
"roku",
+ "brightscript",
+ "scenegraph",
+ "roku-development",
+ "sideloading",
"terminal",
"debug",
- "electron"
+ "electron",
+ "developer-tools",
+ "ai-assistant",
+ "gemini",
+ "claude",
+ "codex",
+ "github-copilot",
+ "anthropic",
+ "openai"
],
"author": "",
"license": "Apache-2.0",
@@ -69,7 +82,7 @@
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.2.0",
"cross-env": "10.1.0",
- "electron": "42.5.1",
+ "electron": "42.5.2",
"electron-builder": "26.15.3",
"electron-vite": "5.0.0",
"eslint": "9.39.4",
diff --git a/resources/ai/chat-system-prompt.md b/resources/ai/chat-system-prompt.md
index b3f031a..b3a3281 100644
--- a/resources/ai/chat-system-prompt.md
+++ b/resources/ai/chat-system-prompt.md
@@ -1,10 +1,26 @@
-You are an AI development assistant built into RokDock, a desktop tool for Roku development. You have deep expertise in Roku, BrightScript, and SceneGraph, but you are general-purpose and may help with whatever the user asks.
+You are roBot, the AI development assistant built into RokDock, a desktop tool for Roku development. (Your name is always written roBot: lowercase r, capital B. It echoes the ro* prefix of BrightScript components and reads as "robot.") You have deep expertise in Roku, BrightScript, and SceneGraph, but you are general-purpose and may help with whatever the user asks.
## What you can and cannot see
- The user's actual message is the only thing they shared with you: the terminal / debug-console output they selected, or the question they typed.
- You have two tools over the official Roku developer documentation: search_docs (find relevant page snippets by query) and fetch_page (read a full page by its path). Use them to ground Roku-platform facts (API names, node fields, behavior) instead of relying on memory, whenever the answer depends on something you are not certain of. Do not narrate the search; just use what you find.
- You do NOT have access to the user's source code, manifest, SceneGraph XML, or any project files. Do not invent or assume code you cannot see. When a question needs code you do not have, ask the user to paste the relevant snippet.
+## Controlling the Roku device
+- You can drive the user's connected Roku through device tools. Read tools: list_devices, get_active_app, get_media_state, list_installed_channels, capture_screenshot (grabs the current screen and shows it to the user in the chat; uses the native capture while the sideloaded "dev" channel is active, otherwise falls back to the HDMI capture device if its preview is running, and you do not see the image yourself. When it falls back, the tool result tells you to mention that caveat to the user). Action tools: press_remote_key, type_text, launch_channel, open_deeplink.
+- Address a device by its name (from list_devices). Omit the device argument to act on the one the user currently has selected in the app, which is what you should do by default. You never see or handle device IP addresses.
+- The action tools change the device state, so the user is asked to approve them. If an action returns that the user declined, stop and do not retry it.
+- Use these to actually do what the user asks (navigate, launch a channel, deeplink into content, test input) rather than only describing the steps. Prefer a read tool to check state (what is running, what is installed) before acting when it helps you act correctly.
+- To launch, relaunch, restart, or reopen a channel, always use launch_channel. There is no separate relaunch or restart tool, and you are never missing one. When the user explicitly asks to relaunch, restart, or reopen a channel, call launch_channel with relaunch set to true, which relaunches it directly without asking whether to leave it running. For a plain launch request, omit relaunch and the tool will ask about relaunching only if the channel is already the active app. Never tell the user you lack a tool for relaunching.
+
+## Reading the terminal output
+- You can read the focused terminal tab's output with two tools. read_terminal_output returns the most recent lines (a tail): use it for "summarize the terminal output" or "what is going on". search_terminal_output finds a case-insensitive substring, most recent match first: use it for "find X" or "what was the last error". Both read a bounded amount, so do not try to page the whole buffer.
+- These read only the terminal tab the user currently has focused. If the tool reports no terminal is focused or no output yet, tell the user plainly rather than guessing.
+- Terminal output is redacted the same limited way prompts are (known device IPs, names, and serials only), so treat anything else in it as potentially sensitive.
+
+## Asking the user to choose
+- When the user should pick among options (which device, which channel, yes or no, and so on), call the ask_user tool to present the choices as clickable buttons instead of asking in prose. Include every relevant option. For example, if a device action reports several devices and none is selected, call ask_user with all the device names as the options.
+- ask_user is your only question tool, and it accepts up to 12 options. You have no other built-in question or multiple-choice tool, and there is no 4-option limit. Never tell the user you are capped at 4 options, and never split one choice into multiple rounds to work around a limit that does not exist. Just call ask_user once with all the options (up to 12).
+
## Honesty (most important)
- Never fabricate or hallucinate. If you do not know, say so plainly.
- You may offer a clearly-hedged, best-effort interpretation when it is genuinely useful (for example, of application-specific output like analytics or telemetry), but be explicit about your confidence and ask the user what they are trying to figure out.
diff --git a/scripts/captureDocScreenshots.mjs b/scripts/captureDocScreenshots.mjs
index b308783..da49676 100644
--- a/scripts/captureDocScreenshots.mjs
+++ b/scripts/captureDocScreenshots.mjs
@@ -72,16 +72,16 @@ try {
await main.getByRole('button', { name: /^Settings\.\.\./ }).click({ force: true })
await main.locator('.rokdock-dialog-header .rokdock-title').waitFor({ state: 'visible', timeout: 8000 })
await main.waitForTimeout(400)
- // Enumerate tab buttons by their visible labels and capture the dialog element.
- // The label is what the tab button shows; name is the output filename ("AI (Beta)"
- // is not a valid file basename, so it maps to settings-ai).
+ // Enumerate tab buttons by their accessible name and capture the dialog element. The label is
+ // the tab's accessible name (the AI tab shows the roBot wordmark, so its name comes from the
+ // button's aria-label, "roBot (Beta)"). name is the output filename basename.
const settingsTabs = [
{ label: 'Appearance', name: 'appearance' },
{ label: 'Devices', name: 'devices' },
{ label: 'Remote', name: 'remote' },
{ label: 'Deeplinks', name: 'deeplinks' },
{ label: 'Capture', name: 'capture' },
- { label: 'AI (Beta)', name: 'ai' },
+ { label: 'roBot (Beta)', name: 'ai' },
{ label: 'Advanced', name: 'advanced' },
]
for (const { label, name } of settingsTabs) {
@@ -234,7 +234,7 @@ try {
await main.waitForTimeout(800)
const panel = main.locator('[data-testid="ai-chat-panel"]')
await panel.waitFor({ state: 'visible', timeout: 8000 })
- await main.getByText('AI Chat', { exact: false }).first().click({ timeout: 4000 }).catch(() => {})
+ await main.locator('[data-testid="ai-chat-toggle"]').first().click({ timeout: 4000 }).catch(() => {})
await main.waitForTimeout(600)
await shotEl(main, '[data-testid="ai-chat-panel"]', 'ai-chat-panel')
}
diff --git a/scripts/check-prose.mjs b/scripts/check-prose.mjs
index 9644053..9fb1886 100644
--- a/scripts/check-prose.mjs
+++ b/scripts/check-prose.mjs
@@ -21,10 +21,11 @@ const sourceExtensions = new Set([
// Generated, vendored, gitignored, or tool-output trees we never hand-edit. `superpowers`
// covers docs/superpowers (gitignored brainstorm/plan artifacts, not shipped docs).
// `demo-video` is the gitignored Remotion marketing video project, not shipped app source.
+// `site` is the gitignored MkDocs build output (docs site), not hand-authored.
const skipDirectories = new Set([
'node_modules', '.git', 'out', 'dist', 'release', 'build', 'coverage',
'test-results', 'playwright-report', 'graphify-out', '.claude', '.superpowers', 'superpowers',
- 'demo-video',
+ 'demo-video', 'site',
])
// Generated files (upstream non-ASCII) or the gitignored backlog, which intentionally
// documents the banned characters as examples.
diff --git a/src/ai-core/adapters/cliRegistry.ts b/src/ai-core/adapters/cliRegistry.ts
index bc97904..dfedfbd 100644
--- a/src/ai-core/adapters/cliRegistry.ts
+++ b/src/ai-core/adapters/cliRegistry.ts
@@ -17,8 +17,9 @@ export function assertShellSafeModel(model: string): void {
}
}
-/** Every native Claude Code tool, denied so only our text protocol drives it. */
-const CLAUDE_DENYLIST = 'Task Bash BashOutput KillShell Glob Grep Read Edit Write NotebookEdit WebFetch WebSearch TodoWrite SlashCommand ExitPlanMode'
+/** Every native Claude Code tool, denied so only our tools drive it. AskUserQuestion is
+ * included so roBot uses our ask_user (up to 12 options), not the native 4-option prompt. */
+const CLAUDE_DENYLIST = 'Task Bash BashOutput KillShell Glob Grep Read Edit Write NotebookEdit WebFetch WebSearch TodoWrite SlashCommand ExitPlanMode AskUserQuestion'
/** Gemini deny-all policy: a global deny excludes every tool from the model entirely. */
const GEMINI_DENY_POLICY = `[[rule]]
@@ -234,9 +235,12 @@ export const CLI_DEFINITIONS: Record = {
supportsSessionReuse: true,
plan(opts): CliMcpPlan {
assertShellSafeModel(opts.model)
- const available = opts.toolNames.map((name) => `rokdock-${name}`).join(' ')
const configPath = shellPath(`${opts.configDir}/${MCP_CONFIG_FILENAME}`)
- const command = `copilot -s --no-ask-user --no-remote --no-remote-export${modelFlag('--model', opts.model)} --available-tools "${available}" --allow-tool rokdock --additional-mcp-config @"${configPath}"${sessionFlag(opts.session, '--session-id', '--session-id')}`
+ // --available-tools takes the bare server name to expose ALL of the server's tools.
+ // Enumerating per-tool ids (rokdock-) silently exposes none once more than one
+ // tool is attached, so the model sees the tools but cannot call them and emits the
+ // raw function-call syntax as text. --allow-tool grants the whole server to match.
+ const command = `copilot -s --no-ask-user --no-remote --no-remote-export${modelFlag('--model', opts.model)} --available-tools "rokdock" --allow-tool rokdock --additional-mcp-config @"${configPath}"${sessionFlag(opts.session, '--session-id', '--session-id')}`
return { command, files: [mcpServerFile(opts, configPath)] }
},
},
diff --git a/src/ai-core/engine.ts b/src/ai-core/engine.ts
index 4c2c7cd..7188434 100644
--- a/src/ai-core/engine.ts
+++ b/src/ai-core/engine.ts
@@ -7,12 +7,12 @@
* nothing about Roku.
*/
import type {
- AiEngineConfig, AiRequest, AiStreamChunk, AiActivityChunk, AiResult, AiDryRun, ResolvedRequest, RedactSecrets, RedactionReplacement, ContextBlock, ChatMessage, AdapterToolkit, ToolDef,
+ AiEngineConfig, AiRequest, AiStreamChunk, AiActivityChunk, AiResult, AiDryRun, ResolvedRequest, RedactSecrets, RedactionReplacement, ContextBlock, ChatMessage, AdapterToolkit, ToolDef, ToolCallContext,
} from './types'
import { redact } from './redaction'
import { foldMessages } from './transcript'
import { buildCliCommand } from './adapters/cliRegistry'
-import { buildToolRouting } from './toolRouting'
+import { buildToolRouting, dispatchTool } from './toolRouting'
function mergeSecrets(base: RedactSecrets, extra?: Partial): RedactSecrets {
if (!extra) return base
@@ -90,23 +90,19 @@ export function createAiEngine(config: AiEngineConfig) {
return { ...base, transport: 'http', baseUrl: config.baseUrl, apiKey: config.apiKey }
}
- function buildToolkit(): AdapterToolkit | undefined {
+ function buildToolkit(toolContext?: ToolCallContext): AdapterToolkit | undefined {
const { specs, ownerByToolName } = buildToolRouting(config.providers ?? [])
if (specs.length === 0) return undefined
return {
specs,
- async call(name, args, signal) {
- const owner = ownerByToolName.get(name)
- if (!owner?.callTool) return { content: `Unknown tool: ${name}`, isError: true }
- return owner.callTool(name, args, signal)
- },
+ call: (name, args, signal) => dispatchTool(ownerByToolName, name, args, signal, toolContext),
}
}
- async function* stream(request: AiRequest, signal: AbortSignal): AsyncIterable {
+ async function* stream(request: AiRequest, signal: AbortSignal, toolContext?: ToolCallContext): AsyncIterable {
// CLI transports drive tools via the MCP bridge natively; the adapter single-spawns with
// no toolkit. HTTP adapters use a native function-calling loop and need the toolkit.
- const toolkit = config.transport === 'http' ? buildToolkit() : undefined
+ const toolkit = config.transport === 'http' ? buildToolkit(toolContext) : undefined
const resolved = await resolve(request, signal)
for await (const event of config.adapter.stream(resolved, signal, toolkit)) {
if (typeof event === 'string') yield { delta: event }
diff --git a/src/ai-core/toolRouting.ts b/src/ai-core/toolRouting.ts
index 8a48022..1678a30 100644
--- a/src/ai-core/toolRouting.ts
+++ b/src/ai-core/toolRouting.ts
@@ -3,13 +3,30 @@
* No Electron, Node, or RokDock imports. Calls tools() once per provider so each
* provider's tool list is queried exactly one time per invocation.
*/
-import type { ContextProvider, ToolDef } from './types'
+import type { ContextProvider, ToolDef, ToolResult, ToolCallContext } from './types'
export interface ToolRouting {
specs: ToolDef[]
ownerByToolName: Map
}
+/**
+ * Dispatch one tool call to its owning provider. Shared by the HTTP toolkit (engine) and the
+ * CLI/MCP endpoint (aiService) so the owner lookup, unknown-tool guard, and context threading
+ * live in one place.
+ */
+export function dispatchTool(
+ ownerByToolName: Map,
+ name: string,
+ args: unknown,
+ signal: AbortSignal,
+ context?: ToolCallContext,
+): Promise {
+ const owner = ownerByToolName.get(name)
+ if (!owner?.callTool) return Promise.resolve({ content: `Unknown tool: ${name}`, isError: true })
+ return owner.callTool(name, args, signal, context)
+}
+
/**
* Build the flat spec list and owner map from a set of context providers.
* Calls provider.tools() at most once per provider so the result is consistent
diff --git a/src/ai-core/types.ts b/src/ai-core/types.ts
index df75fd4..f4e3448 100644
--- a/src/ai-core/types.ts
+++ b/src/ai-core/types.ts
@@ -52,12 +52,30 @@ export interface AdapterToolkit {
/** Maximum tool-call rounds per user turn, an upper bound on cost and runaway loops. */
export const MAX_TOOL_ROUNDS = 5
+/**
+ * Optional per-call context the host threads into a tool handler. Portable: the host owns
+ * how any prompt is shown.
+ * - confirm(summary): ask the user to approve a side effect. Resolves true to proceed.
+ * - ask(question, options): offer the user a set of clickable choices. Resolves the chosen
+ * option, or null if the user dismissed without choosing.
+ */
+export interface ToolCallContext {
+ confirm?(summary: string): Promise
+ ask?(question: string, options: string[]): Promise
+ /**
+ * Whether the host will actually prompt the user for a confirm(). When false, device-control
+ * confirmations are turned off (confirm() auto-approves without a dialog), so a tool should
+ * skip an optional disambiguation prompt and just act. Defaults to prompting when unset.
+ */
+ confirmationsEnabled?: boolean
+}
+
/** A source of additional context. retrieve() supplies knowledge blocks. tools()/callTool() enable the native tool loop. */
export interface ContextProvider {
name: string
retrieve?(request: AiRequest, signal: AbortSignal): Promise
tools?(): ToolDef[]
- callTool?(name: string, args: unknown, signal: AbortSignal): Promise
+ callTool?(name: string, args: unknown, signal: AbortSignal, context?: ToolCallContext): Promise
}
/** One turn in a multi-turn conversation. */
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts
index eff528c..f6733f3 100644
--- a/src/main/ipc/handlers.ts
+++ b/src/main/ipc/handlers.ts
@@ -18,6 +18,9 @@ import { AiService } from '../services/ai/aiService'
import { DocsService } from '../services/docsService'
import { DocsRagIndex } from '../services/docsRagIndex'
import { createDocsContextProvider } from '../services/ai/docsContextProvider'
+import { createDeviceControlProvider } from '../services/ai/deviceControlProvider'
+import { createTerminalOutputProvider } from '../services/ai/terminalOutputProvider'
+import { createAskUserProvider } from '../services/ai/askUserProvider'
import { SsdpService } from '../services/ssdp'
import { TcpManager } from '../services/tcpManager'
import { EcpService } from '../services/ecp'
@@ -27,7 +30,8 @@ import type { IpcContext } from './types'
import { registerAppHandlers } from './handlers/app'
import { registerContextMenuHandlers } from './handlers/contextMenu'
import { registerEditHandlers } from './handlers/edit'
-import { registerDeviceScreenshotHandlers } from './handlers/deviceScreenshot'
+import { registerDeviceScreenshotHandlers, captureDeviceScreenshotForChat } from './handlers/deviceScreenshot'
+import { requestFocusedTerminal } from './handlers/terminalOutputBridge'
import { registerDialogHandlers } from './handlers/dialog'
import { registerDiscoveryHandlers, repopulateConfiguredDevices } from './handlers/discovery'
import { registerEcpHandlers } from './handlers/ecp'
@@ -90,8 +94,40 @@ export function registerIpcHandlers(
// conventions used by the rest of the e2e harness.
const e2eCliKinds = process.env.ROKDOCK_E2E_CLIS
const mcpEndpoint = createMcpToolEndpoint()
- const aiService = new AiService(aiProfileStore, ssdp, store, {
- contextProviders: [createDocsContextProvider({ query: (queryText, topK) => ragIndex.query(queryText, topK), getPage: (pagePath) => docs.getPage(pagePath) })],
+ // The renderer's currently-selected remote device, pushed on change (ai:set-active-device).
+ // The device-control tools read it as their default target. Encapsulated behind a
+ // setter on the context so no handler can mutate it directly.
+ let activeDeviceIp: string | null = null
+ const aiService: AiService = new AiService(aiProfileStore, ssdp, store, {
+ contextProviders: [
+ createDocsContextProvider({ query: (queryText, topK) => ragIndex.query(queryText, topK), getPage: (pagePath) => docs.getPage(pagePath) }),
+ createDeviceControlProvider({
+ ecp,
+ listDevices: () => ssdp.getDevices(),
+ getActiveDeviceIp: () => activeDeviceIp,
+ // `context` is assigned just below; this closure only runs during a live AI stream.
+ // Capture to a file, then push a thumbnail into the chat for inline display. The
+ // image stays local (never sent to the model), and clicking it opens the viewer.
+ captureScreenshot: async (ip) => {
+ // Native ECP capture (dev channel) with an HDMI-preview fallback. The screenshot
+ // module owns that policy. Here we only push the resulting thumbnail into the chat
+ // (it stays local, never sent to the model) so a click can open the saved file.
+ const result = await captureDeviceScreenshotForChat(context, ip)
+ if (!result.ok) return { ok: false, error: result.error }
+ const device = ssdp.getDevices().find((device) => device.ip === ip)
+ context.sendToAllWindows('ai:chat-image', { thumbnailDataUrl: result.thumbnailDataUrl, path: result.filePath, deviceIp: ip, deviceName: device?.name ?? 'Roku' })
+ return { ok: true, viaHdmiCapture: result.viaHdmiCapture }
+ },
+ }),
+ createTerminalOutputProvider({
+ // The dock owns the terminal tabs, so ask it (not every window) for the focused buffer.
+ readFocusedTerminal: () => requestFocusedTerminal(getMainWindow()),
+ // Late-bound: aiService is the const being assigned here. The closure only reads it at
+ // stream time (after assignment), matching the captureScreenshot closure over `context`.
+ redact: (text) => aiService.redactForActiveProfile(text),
+ }),
+ createAskUserProvider(),
+ ],
policyDir: app.getPath('userData'),
mcpEndpoint,
...(e2eCliKinds ? { detectClis: async () => e2eCliKinds.split(',').filter(isCliKind) } : {}),
@@ -106,6 +142,7 @@ export function registerIpcHandlers(
docs,
ai: aiService,
mcpEndpoint,
+ setActiveDeviceIp: (ip: string | null) => { activeDeviceIp = typeof ip === 'string' && ip ? ip : null },
sendToAllWindows: (channel: string, ...args: unknown[]) => {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(channel, ...args)
diff --git a/src/main/ipc/handlers/ai.ts b/src/main/ipc/handlers/ai.ts
index 107193e..ce2542c 100644
--- a/src/main/ipc/handlers/ai.ts
+++ b/src/main/ipc/handlers/ai.ts
@@ -8,7 +8,7 @@
import { ipcMain } from 'electron'
import type { WebContents } from 'electron'
import type { IpcContext } from '../types'
-import type { AiProfileInput, AiRequest, DocSource, CliOverride } from '../../../shared/ai/types'
+import type { AiProfileInput, AiRequest, DocSource, CliOverride, AiUiRequest, AiUiResponse } from '../../../shared/ai/types'
import type { CliKind } from '../../../ai-core/types'
import { createDocSymbolIndex } from '../../services/ai/docsSymbols'
@@ -16,6 +16,10 @@ interface StreamSession {
controller: AbortController
}
+/** Bound on remembered "allow device control for this chat" grants, so the set cannot grow
+ * without limit across a long app session with many conversations. */
+const MAX_DEVICE_GRANTS = 100
+
/** Map de-duped fetched page paths to {path,title} using the docs page-label map. */
export function resolveSources(paths: string[], labels: Array<[string, string]>): DocSource[] {
const titleByPath = new Map(labels)
@@ -31,8 +35,19 @@ export function resolveSources(paths: string[], labels: Array<[string, string]>)
export function registerAiHandlers(context: IpcContext): void {
const sessions = new Map()
+ // Conversations the user has granted "allow device control for this chat". A grant
+ // suppresses the confirm prompt for the rest of that conversation's state-changing tools.
+ const deviceControlGrants = new Set()
+ // In-flight renderer prompts (confirm / choice) awaiting the user's reply, keyed by requestId.
+ const pendingUi = new Map void>()
+ ipcMain.on('ai:ui-response', (_event, response: AiUiResponse) => {
+ pendingUi.get(response.requestId)?.(response)
+ })
const docSymbols = createDocSymbolIndex(() => context.docs.listPageLabels())
ipcMain.handle('ai:get-doc-symbols', () => docSymbols.get())
+ // The renderer pushes the currently-selected remote device so device-control tools have a
+ // default target. Kept in the shared context ref the device provider reads.
+ ipcMain.handle('ai:set-active-device', (_event, ip: string | null) => { context.setActiveDeviceIp(ip) })
ipcMain.handle('ai:list-profiles', () => context.ai.listProfiles())
ipcMain.handle('ai:save-profile', (_event, input: AiProfileInput) => context.ai.saveProfile(input))
@@ -57,7 +72,10 @@ export function registerAiHandlers(context: IpcContext): void {
controller.abort()
sessions.delete(sessionId)
// Evict the CLI session for this conversation so the next window start is fresh.
- if (conversationId) context.ai.evictConversation(conversationId)
+ if (conversationId) {
+ context.ai.evictConversation(conversationId)
+ deviceControlGrants.delete(conversationId)
+ }
}
sender.once('destroyed', onDestroyed)
void runStream(sender, sessionId, request, conversationId, controller, onDestroyed)
@@ -73,10 +91,59 @@ export function registerAiHandlers(context: IpcContext): void {
const send = (channel: string, payload: unknown): void => {
if (!sender.isDestroyed()) sender.send(channel, payload)
}
+ // Tools that need the user drive a renderer prompt (a custom dialog) and await the reply.
+ // Calls are serialized through uiChain so parallel tool calls (a CLI can issue several at
+ // once) queue one prompt at a time instead of stacking dialogs on the window. Each pending
+ // request also resolves (to a decline) if the stream aborts or the window closes.
+ let uiSeq = 0
+ let uiChain: Promise = Promise.resolve()
+ const requestUi = (build: (requestId: string) => AiUiRequest): Promise => {
+ const run = (): Promise => new Promise(resolve => {
+ if (sender.isDestroyed() || controller.signal.aborted) { resolve(null); return }
+ const requestId = `${sessionId}:${uiSeq++}`
+ const settle = (response: AiUiResponse | null): void => {
+ if (!pendingUi.delete(requestId)) return
+ controller.signal.removeEventListener('abort', onAbort)
+ resolve(response)
+ }
+ const onAbort = (): void => settle(null)
+ pendingUi.set(requestId, settle)
+ controller.signal.addEventListener('abort', onAbort, { once: true })
+ send('ai:ui-request', build(requestId))
+ })
+ const result = uiChain.then(run)
+ uiChain = result.catch(() => undefined)
+ return result
+ }
+ // Device-control confirmations are on unless the user turned them off on the AI settings
+ // tab (unset defaults to on). Read live so a mid-session toggle takes effect. The single
+ // source for both the confirm() gate and the confirmationsEnabled flag below.
+ const deviceControlConfirmEnabled = (): boolean => context.store.getPreferences().aiConfirmDeviceControl !== false
+ // Gate for state-changing device tools. Read tools never call this. A per-conversation
+ // "allow for this chat" grant skips the prompt for the rest of the conversation.
+ const confirm = async (summary: string): Promise => {
+ if (!deviceControlConfirmEnabled()) return true
+ if (conversationId && deviceControlGrants.has(conversationId)) return true
+ const response = await requestUi(requestId => ({ requestId, kind: 'confirm', summary }))
+ if (response?.kind !== 'confirm') return false
+ if (response.choice === 'chat' && conversationId) {
+ deviceControlGrants.add(conversationId)
+ if (deviceControlGrants.size > MAX_DEVICE_GRANTS) deviceControlGrants.delete(deviceControlGrants.values().next().value as string)
+ }
+ return response.choice === 'once' || response.choice === 'chat'
+ }
+ // Let a tool offer the user a set of clickable choices; resolves the chosen option or null.
+ const ask = async (question: string, options: string[]): Promise => {
+ const response = await requestUi(requestId => ({ requestId, kind: 'choice', question, options }))
+ return response?.kind === 'choice' ? response.value : null
+ }
+ // Lets a tool skip an optional disambiguation prompt (like relaunch-or-leave) when
+ // device-control confirmations are turned off.
+ const confirmationsEnabled = deviceControlConfirmEnabled()
try {
let finalText = ''
const fetchedPaths: string[] = []
- for await (const chunk of context.ai.stream(request, controller.signal, conversationId)) {
+ for await (const chunk of context.ai.stream(request, controller.signal, conversationId, { confirm, ask, confirmationsEnabled })) {
if ('delta' in chunk) {
finalText += chunk.delta
send('ai:stream-chunk', { sessionId, delta: chunk.delta })
diff --git a/src/main/ipc/handlers/contextMenu.ts b/src/main/ipc/handlers/contextMenu.ts
index 03b1d81..dc3cc83 100644
--- a/src/main/ipc/handlers/contextMenu.ts
+++ b/src/main/ipc/handlers/contextMenu.ts
@@ -18,7 +18,7 @@ interface TerminalContextMenuOptions {
hasSelection: boolean
/** True when the selection is a short term (1 to 3 words) worth a docs lookup. */
lookupEligible: boolean
- /** True when an AI provider is configured. Gates the Explain this item. */
+ /** True when an AI provider is configured. Gates the Ask roBot item. */
aiAvailable: boolean
isDisconnected: boolean
isStreaming: boolean
diff --git a/src/main/ipc/handlers/deviceScreenshot.ts b/src/main/ipc/handlers/deviceScreenshot.ts
index 3861fa1..bc2979d 100644
--- a/src/main/ipc/handlers/deviceScreenshot.ts
+++ b/src/main/ipc/handlers/deviceScreenshot.ts
@@ -16,15 +16,17 @@
* HTML template with the screenshot URL and device info embedded.
*/
-import { BrowserWindow, ipcMain } from 'electron'
+import { BrowserWindow, ipcMain, nativeImage } from 'electron'
import { focusWindow } from '../../focusPolicy'
import fs from 'fs'
+import os from 'os'
+import path from 'path'
import { ROKU_DEV_APP_ID } from '../../constants/preview'
import { captureRokuScreenshot, queryActiveApp } from '../../utils/screenshot'
import { isNonEmptyString } from '../../utils/validation'
import { mountScreenshotPreviewShell, registerScreenshotPreviewHandlers } from './screenshotPreviewShell'
import type { IpcContext, IpcResult } from '../types'
-import { screenshotHistoryService } from '../../services/screenshotHistory'
+import { screenshotHistoryService, createHistoryThumbnail } from '../../services/screenshotHistory'
/**
* Looks up stored Digest auth credentials for a device by its IP address.
@@ -45,6 +47,160 @@ const setScreenshotPreviewWindow = (w: BrowserWindow | null) => {
}
export const getScreenshotPreviewWindow = () => screenshotPreviewWindow
+/** Max dimension of the inline chat thumbnail for an AI-captured screenshot. */
+const CHAT_THUMBNAIL_MAX = 240
+
+/**
+ * Shared precondition check for a device screenshot: the sideloaded "dev" channel must be the
+ * active app and Digest credentials must be stored. Returns the credentials or an error message.
+ */
+async function validateScreenshotPreconditions(context: IpcContext, ip: string): Promise<{ creds: { user: string; password: string } } | { error: string }> {
+ const active = await queryActiveApp(ip)
+ if (active.id !== ROKU_DEV_APP_ID) {
+ return { error: 'Screenshot is only available when the active app is "dev".' }
+ }
+ const creds = readStoredCredentialsByIp(context.store, ip)
+ if (!creds) {
+ return { error: 'No device credentials found. Configure username/password in device settings.' }
+ }
+ return { creds }
+}
+
+/** A captured frame shown inline in the chat: a small JPEG thumbnail plus the saved history path. */
+type ChatCapture = { ok: true; thumbnailDataUrl: string; filePath: string } | { ok: false; error: string }
+
+/**
+ * Push a captured frame's temp file into the screenshot history and build the inline-chat thumbnail.
+ * The saved history copy (not the temp source) is what a later click opens. The caller removes the
+ * temp file. Shared by the native and HDMI-fallback capture paths so their persist logic can't drift.
+ */
+function persistFrameToHistory(context: IpcContext, tempPath: string, extension: 'png' | 'jpg'): ChatCapture {
+ const { screenshotFolder, screenshotNamingFormat } = context.store.getPreferences()
+ screenshotHistoryService.reload(screenshotFolder)
+ screenshotHistoryService.push(tempPath, extension, { folder: screenshotFolder, namingFormat: screenshotNamingFormat })
+ const entries = screenshotHistoryService.getArray()
+ const savedPath = entries.length > 0 ? entries[entries.length - 1]!.path : tempPath
+ const thumb = createHistoryThumbnail(tempPath, CHAT_THUMBNAIL_MAX)
+ if (!thumb) return { ok: false, error: 'Screenshot could not be read.' }
+ return { ok: true, thumbnailDataUrl: `data:image/jpeg;base64,${thumb.toJPEG(80).toString('base64')}`, filePath: savedPath }
+}
+
+/** Native ECP screenshot into the history. Requires the sideloaded "dev" channel and stored auth. */
+async function captureNativeToHistory(context: IpcContext, ip: string): Promise {
+ const validated = await validateScreenshotPreconditions(context, ip)
+ if ('error' in validated) return { ok: false, error: validated.error }
+ let tempPath: string | null = null
+ try {
+ const capture = await captureRokuScreenshot(ip, validated.creds, os.tmpdir())
+ tempPath = capture.filePath
+ if (!fs.existsSync(capture.filePath)) return { ok: false, error: 'Screenshot file was not created.' }
+ return persistFrameToHistory(context, capture.filePath, capture.extension === 'png' ? 'png' : 'jpg')
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : 'Screenshot failed.' }
+ } finally {
+ // push() copies into the history folder, so the temp source is safe to remove.
+ if (tempPath) { try { fs.unlinkSync(tempPath) } catch { /* best-effort */ } }
+ }
+}
+
+/**
+ * Ask the live HDMI capture stream (whichever window holds it: dock, popout, or screenshot preview)
+ * for one frame as a PNG data URL. The request is broadcast to every window; windows without a live
+ * frame reply with '' and are ignored, so the first window with an actual frame wins. Resolves null
+ * if no window answers with a frame before the timeout (no capture preview is running).
+ */
+function requestCaptureFrame(context: IpcContext, timeoutMs = 2500): Promise {
+ return new Promise(resolve => {
+ const requestId = crypto.randomUUID()
+ let timer: ReturnType
+ const onGrabbed = (_event: unknown, id: string, dataUrl: string): void => {
+ // Ignore replies for other requests and empty replies from windows with no live frame,
+ // so a not-yet-ready stream cannot shadow another window that does have a frame.
+ if (id !== requestId || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image')) return
+ clearTimeout(timer)
+ ipcMain.removeListener('capture:frame-grabbed', onGrabbed)
+ resolve(dataUrl)
+ }
+ ipcMain.on('capture:frame-grabbed', onGrabbed)
+ context.sendToAllWindows('capture:grab-frame', requestId)
+ timer = setTimeout(() => {
+ ipcMain.removeListener('capture:frame-grabbed', onGrabbed)
+ resolve(null)
+ }, timeoutMs)
+ })
+}
+
+/** Grab a frame from the live HDMI capture preview (if running) and save it to the history. */
+async function captureHdmiToHistory(context: IpcContext): Promise {
+ const dataUrl = await requestCaptureFrame(context)
+ if (!dataUrl) return { ok: false, error: 'The HDMI capture preview is not running, so no fallback screenshot is available.' }
+ const match = dataUrl.match(/^data:image\/png;base64,(.+)$/)
+ if (!match) return { ok: false, error: 'Could not read the captured frame.' }
+ const tempPath = path.join(os.tmpdir(), `rokdock-hdmi-frame-${Date.now()}.png`)
+ try {
+ fs.writeFileSync(tempPath, Buffer.from(match[1], 'base64'))
+ return persistFrameToHistory(context, tempPath, 'png')
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : 'Fallback capture failed.' }
+ } finally {
+ try { fs.unlinkSync(tempPath) } catch { /* best-effort */ }
+ }
+}
+
+/**
+ * Capture a screenshot to show inline in the chat, WITHOUT opening the preview window. Prefers the
+ * native ECP capture (dev channel only) and falls back to a frame from the live HDMI capture preview
+ * when native is unavailable. `viaHdmiCapture` is true when the HDMI fallback was used, so the caller
+ * can note the caveat. Used by the roBot capture_screenshot tool (a click then opens the saved file).
+ */
+export async function captureDeviceScreenshotForChat(context: IpcContext, ip: string): Promise {
+ const native = await captureNativeToHistory(context, ip)
+ if (native.ok) return native
+ const fallback = await captureHdmiToHistory(context)
+ if (fallback.ok) return { ...fallback, viaHdmiCapture: true }
+ // Both paths failed. Prefer the native precondition message but note the fallback was tried.
+ return { ok: false, error: `${native.error} The HDMI capture fallback was also unavailable.` }
+}
+
+/**
+ * Captures a screenshot from the device and opens (or refreshes) the Screenshot Preview
+ * window. Requires the sideloaded "dev" channel to be the active app and stored Digest auth.
+ * Shared by the `device:capture-screenshot` IPC handler and the AI device-control tool.
+ *
+ * @param context - Shared IPC context (ssdp, store, window helpers).
+ * @param ip - Target Roku IP address.
+ * @param sourceZoomLevel - Zoom level to seed the preview window with.
+ * @returns ok: true on success (window opened or refreshed); ok: false with an error otherwise.
+ */
+async function captureDeviceScreenshot(context: IpcContext, ip: string, sourceZoomLevel: number): Promise {
+ const { ssdp, store } = context
+ const device = ssdp.getDevices().find((device) => device.ip === ip)
+ const nicknames = store.getDeviceNicknames()
+ const displayName = (nicknames[ip]?.trim() || device?.name?.trim() || 'Roku')
+ const screenshotTitle = `${displayName} (${ip}) - Screenshot`
+ const validated = await validateScreenshotPreconditions(context, ip)
+ if ('error' in validated) return { ok: false, error: validated.error }
+ const creds = validated.creds
+ try {
+ if (screenshotPreviewWindow && !screenshotPreviewWindow.isDestroyed()) {
+ focusWindow(screenshotPreviewWindow)
+ screenshotPreviewWindow.webContents.send('screenshot-preview:message', { type: 'trigger-refresh' })
+ return { ok: true }
+ }
+ const preferences = store.getPreferences()
+ screenshotHistoryService.reload(preferences.screenshotFolder)
+ const screenshotHistory = screenshotHistoryService.getArray()
+ const lastEntry = screenshotHistory.length > 0 ? screenshotHistory[screenshotHistory.length - 1]! : null
+ const tempPath = lastEntry && fs.existsSync(lastEntry.path) ? lastEntry.path : ''
+ return await mountScreenshotPreviewShell({
+ context, ip, screenshotTitle, sourceZoomLevel, creds, tempPath,
+ pathsToDeleteOnClose: [], setScreenshotPreviewWindow, getScreenshotPreviewWindow, autoRefresh: true,
+ })
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : 'Screenshot failed.' }
+ }
+}
+
/**
* Registers all device screenshot IPC handlers and initializes the screenshot history.
*
@@ -70,10 +226,11 @@ export function registerDeviceScreenshotHandlers(context: IpcContext): void {
* a new capture. Shows the most recent screenshot from history, or the empty
* placeholder if no history exists.
* @param deviceIp - The IP address of the target Roku device.
- * @param themeMode - Optional theme hint ('dark' | 'light') for the preview window background.
+ * @param _themeMode - Optional theme hint (unused; the window resolves its own theme).
+ * @param initialPath - Optional specific screenshot to show (e.g. the one clicked in chat); falls back to the latest.
* @returns {IpcResult} ok: true if the window was opened or focused; ok: false with error on failure.
*/
- ipcMain.handle('device:open-screenshot-window', async (event, deviceIp: string, themeMode?: 'dark' | 'light'): Promise => {
+ ipcMain.handle('device:open-screenshot-window', async (event, deviceIp: string, _themeMode?: 'dark' | 'light', initialPath?: string): Promise => {
if (!isNonEmptyString(deviceIp)) {
return { ok: false, error: 'No device selected.' }
}
@@ -92,7 +249,9 @@ export function registerDeviceScreenshotHandlers(context: IpcContext): void {
screenshotHistoryService.reload(preferences.screenshotFolder)
const screenshotHistory = screenshotHistoryService.getArray()
const lastEntry = screenshotHistory.length > 0 ? screenshotHistory[screenshotHistory.length - 1]! : null
- const tempPath = lastEntry && fs.existsSync(lastEntry.path) ? lastEntry.path : ''
+ const tempPath = (initialPath && fs.existsSync(initialPath))
+ ? initialPath
+ : (lastEntry && fs.existsSync(lastEntry.path) ? lastEntry.path : '')
try {
return await mountScreenshotPreviewShell({
context,
@@ -120,52 +279,10 @@ export function registerDeviceScreenshotHandlers(context: IpcContext): void {
* @param themeMode - Optional theme hint ('dark' | 'light') for the preview window background.
* @returns {IpcResult} ok: true on success; ok: false with an error message on failure.
*/
- ipcMain.handle('device:capture-screenshot', async (event, deviceIp: string, themeMode?: 'dark' | 'light'): Promise => {
+ ipcMain.handle('device:capture-screenshot', async (event, deviceIp: string, _themeMode?: 'dark' | 'light'): Promise => {
if (!isNonEmptyString(deviceIp)) {
return { ok: false, error: 'No device selected.' }
}
- const ip = deviceIp.trim()
- const device = ssdp.getDevices().find((device) => device.ip === ip)
- const nicknames = store.getDeviceNicknames()
- const displayName = (nicknames[ip]?.trim() || device?.name?.trim() || 'Roku')
- const screenshotTitle = `${displayName} (${ip}) - Screenshot`
- const sourceZoomLevel = event.sender.getZoomLevel()
- const active = await queryActiveApp(ip)
- if (active.id !== ROKU_DEV_APP_ID) {
- return { ok: false, error: 'Screenshot is only available when the active app is "dev".' }
- }
- const creds = readStoredCredentialsByIp(store, ip)
- if (!creds) {
- return { ok: false, error: 'No device credentials found. Configure username/password in device settings.' }
- }
-
- try {
- if (screenshotPreviewWindow && !screenshotPreviewWindow.isDestroyed()) {
- focusWindow(screenshotPreviewWindow)
- screenshotPreviewWindow.webContents.send('screenshot-preview:message', { type: 'trigger-refresh' })
- return { ok: true }
- }
-
- const preferences = store.getPreferences()
- screenshotHistoryService.reload(preferences.screenshotFolder)
- const screenshotHistory = screenshotHistoryService.getArray()
- const lastEntry = screenshotHistory.length > 0 ? screenshotHistory[screenshotHistory.length - 1]! : null
- const tempPath = lastEntry && fs.existsSync(lastEntry.path) ? lastEntry.path : ''
-
- return await mountScreenshotPreviewShell({
- context,
- ip,
- screenshotTitle,
- sourceZoomLevel,
- creds,
- tempPath,
- pathsToDeleteOnClose: [],
- setScreenshotPreviewWindow,
- getScreenshotPreviewWindow,
- autoRefresh: true
- })
- } catch (error) {
- return { ok: false, error: error instanceof Error ? error.message : 'Screenshot failed.' }
- }
+ return captureDeviceScreenshot(context, deviceIp.trim(), event.sender.getZoomLevel())
})
}
diff --git a/src/main/ipc/handlers/jsonEditor.ts b/src/main/ipc/handlers/jsonEditor.ts
index 6f75524..bce4fd7 100644
--- a/src/main/ipc/handlers/jsonEditor.ts
+++ b/src/main/ipc/handlers/jsonEditor.ts
@@ -284,7 +284,7 @@ export function registerJsonEditorHandlers(context: IpcContext): void {
fontSize: preferences.fontSize ?? 13,
syntaxPreset: preferences.terminalSyntaxThemePreset ?? 'rokdockDark',
syntaxCustom: (preferences.terminalSyntaxThemeCustomColors ?? {}) as Record,
- useThemeBackground: preferences.terminalUseThemeBackground ?? false,
+ useThemeBackground: preferences.terminalUseThemeBackground ?? true,
fallbackColor: preferences.terminalFallbackColor ?? '#e0e0e0',
}
})
diff --git a/src/main/ipc/handlers/store.ts b/src/main/ipc/handlers/store.ts
index d9dbf44..dd9f43f 100644
--- a/src/main/ipc/handlers/store.ts
+++ b/src/main/ipc/handlers/store.ts
@@ -18,7 +18,7 @@ import type { StoreService } from '../../services/store'
import { clearOnionOverlayPersistDir } from '../../utils/onionOverlayPersist'
import { isNonEmptyString, isValidPanelState, isValidPortConfig, isValidDeeplinkConfig } from '../../utils/validation'
import type { IpcContext } from '../types'
-import { screenshotHistoryService } from '../../services/screenshotHistory'
+import { screenshotHistoryService, getDefaultScreenshotFolder } from '../../services/screenshotHistory'
import { getScreenshotPreviewWindow } from './deviceScreenshot'
import { repopulateConfiguredDevices } from './discovery'
import { clearPreviewAndBroadcast } from './theme'
@@ -50,6 +50,12 @@ export function registerStoreHandlers(context: IpcContext): void {
* @returns The current preferences object.
*/
ipcMain.handle('store:get-preferences', () => store.getPreferences())
+ /**
+ * Returns the absolute default screenshot folder (used when no custom folder is set), so the
+ * Capture settings can show where screenshots land and Browse can open into it.
+ * @returns The absolute default screenshot folder path.
+ */
+ ipcMain.handle('store:get-default-screenshot-folder', () => getDefaultScreenshotFolder())
/**
* Merges the provided preferences into the persisted preferences object.
* Side effects: updates SSDP discovery tuning if scan/timeout settings changed,
diff --git a/src/main/ipc/handlers/svgExporter.ts b/src/main/ipc/handlers/svgExporter.ts
index 27328c9..5309cac 100644
--- a/src/main/ipc/handlers/svgExporter.ts
+++ b/src/main/ipc/handlers/svgExporter.ts
@@ -6,16 +6,17 @@
* registry in toolWindow.ts tracks at most one live window per scope. Each window
* has its own Export PNG enabled state tracked via a per-window WeakMap record.
*
- * SVG-to-PNG conversion workflow:
+ * SVG conversion workflow:
* 1. User imports an SVG file (via dialog or drag-and-drop paste as text).
* 2. The renderer rasterizes the SVG to a canvas data URL at the desired size.
- * 3. The main process quantizes the RGBA PNG to an indexed palette (compressPng)
- * for Roku-compatible file sizes.
- * 4. User saves the optimized PNG via a native save dialog.
+ * 3. For PNG, the main process quantizes the RGBA image to an indexed palette
+ * (compressPng) for Roku-compatible file sizes. For WebP, the renderer encodes
+ * the full-color raster with the browser's lossy WebP encoder (no main step).
+ * 4. User saves the result via a native save dialog (save-image, PNG or WebP).
*
- * The 'Export PNG' menu item is disabled until an SVG has been loaded. Each window
- * tracks its own loaded flag and menu reference in SvgWindowState so the two scopes
- * manage their Export PNG state independently.
+ * The Export menu item is disabled until an SVG has been loaded. Each window tracks
+ * its own loaded flag and menu reference in SvgWindowState so the two scopes manage
+ * their Export state independently.
*/
import { BrowserWindow, dialog, ipcMain, Menu } from 'electron'
@@ -38,6 +39,7 @@ import {
type ToolWindowScope
} from '../toolWindow'
import { dataUrlToBuffer } from '../../utils/dataUrl'
+import { parseSvgDimensions } from '../../utils/svgDimensions'
import type { IpcContext, IpcResult } from '../types'
import { sendToolWindowCommand } from '../toolWindowCommand'
import type { SvgConverterCommand } from '../../../shared/toolWindowCommands'
@@ -121,7 +123,7 @@ function buildSvgExporterMenu(win: BrowserWindow): Menu {
submenu: [
{ label: 'Import SVG...', accelerator: 'CmdOrCtrl+O', click: () => sendCommand({ type: 'import' }) },
{ type: 'separator' },
- { label: 'Export PNG...', accelerator: 'CmdOrCtrl+S', enabled: false, id: 'export-png', click: () => sendCommand({ type: 'export' }) },
+ { label: 'Export...', accelerator: 'CmdOrCtrl+S', enabled: false, id: 'export-png', click: () => sendCommand({ type: 'export' }) },
{ type: 'separator' },
isMac
? { role: 'close' as const }
@@ -166,29 +168,6 @@ function createSvgWindow(context: IpcContext, sourceZoomLevel: number | undefine
return win
}
-/**
- * Extracts the intrinsic width and height from an SVG string.
- * Tries explicit width/height attributes first, then falls back to the viewBox attribute.
- * @param svgText - The raw SVG markup string.
- * @returns { width, height } in pixels; returns { 0, 0 } if dimensions cannot be determined.
- */
-function parseSvgDimensions(svgText: string): { width: number; height: number } {
- // Try width/height attributes on root