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 @@ Claude Gemini OpenAI + Codex + GitHub Copilot Ollama

@@ -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.

- The RokDock workspace: the device panel, AI Chat panel, and HDMI capture preview on the left, a BrightScript debug terminal in the center, and the Remote / Scripts / Deeplinks rail on the right + The RokDock workspace: the device panel, roBot panel, and HDMI capture preview on the left, a BrightScript debug terminal in the center, and the Remote / Scripts / Deeplinks rail on the right

## 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. -![Debug terminal](docs/user/images/terminal-live.png) +![Debug terminal with a highlighted, clickable JSON payload](docs/user/images/terminal-json-highlighted.webp) -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. -![Developer Docs](docs/user/images/developer-docs.png) +

+ The on-screen Roku remote + Saved deeplink presets +

+ +Built-in tools: a JSON viewer, a 9-Patch editor, and an SVG recolor/exporter. + +

+ 9-Patch editor + SVG converter with recoloring +

+ +The in-app Developer Docs: the official Roku documentation with full-text search and +a What's New feed. + +![In-app Developer Docs with search results](docs/user/images/docs-search.png) -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. -![Screenshot Preview](docs/user/images/screenshot-preview.png) +![Screenshot Viewer with a safe-zone overlay](docs/user/images/capture-viewer-safezone.webp) -The AI Chat panel, grounded in the Roku docs. +The roBot panel, grounded in the Roku docs. -![AI Chat](docs/user/images/ai-chat-panel.png) +![The roBot AI panel](docs/user/images/ai-chat-panel.png) 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 (Beta) panel showing a question ("what is a SceneGraph roSGScreen?") and the assistant's answer, with Roku terms linkified into the docs, plus the move and new-chat controls and an "Ask anything..." input](images/ai-chat-panel.png) -*The AI Chat panel docked in the left column. Roku terms in the answer are linked into the in-app docs.* +![The roBot (Beta) panel: an assistant answer explaining a BrightScript node-field initialization warning, with Roku terms and a pkg: source path highlighted as links into the docs, plus the move, new-chat, and settings controls in the header and an "Ask roBot anything..." input](images/ai-chat-panel.png) +*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 shown as a Picture-in-Picture float over the dock: a floating panel in the lower right with its own toolbar (mute, pop out, dock) showing the Roku home screen streamed from the capture device](images/capture-live.webp) +![The live HDMI capture feed as a Picture-in-Picture float with its own toolbar (mute, pop out, dock), showing the device feed streamed from the capture card](images/capture-pip.webp) *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 three preset buttons: Launch Dev Channel and Play Test Movie (rocket icon, launch/dev path), and Send Refresh Input (satellite-dish icon, input path)](images/deeplinks-live.png) +![The Deeplinks panel with preset buttons: Launch: Craig Venter (episode), Launch from ad campaign, and Launch with custom params (rocket icon, launch/dev path), and Input: resume at 15:00 (satellite-dish icon, input path)](images/control-deeplinks.png) *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 window: brand-gradient toolbar, a navigation sidebar with search, What's New, Favorites, and the Browse tree, and a reading pane showing a rendered Roku documentation page](images/developer-docs.png) +![Developer Docs window: brand-gradient toolbar, a navigation sidebar with search, What's New, Favorites, and the Browse tree, and a reading pane showing a rendered Roku documentation page](images/docs-lead.png) *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 -![Developer Docs sidebar showing search results for "deeplink": each result has a page title, its section, and a snippet with the matched term highlighted](images/docs-search.png) +![Developer Docs with a full-text search in the sidebar: each result shows a page title, its section, and a snippet with the matched term highlighted, and the selected page open in the reading pane](images/docs-search.png) *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`). -![An opened doc page with the matched term highlighted in the body and the floating find bar showing the match count and prev/next/close controls](images/docs-find-bar.png) -*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 -![The What's New feed: 7/30/90-day window controls, Rendered/Source and Content-only toggles, and changed pages grouped by section with per-page added/removed line counts](images/docs-whats-new.png) +![The What's New feed: 7/30/90-day window controls, Rendered/Source and Content-only toggles, and changed pages grouped by section with per-page added/removed line counts](images/docs-whatsnew.png) *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. -![The Devices panel with several discovered Rokus; the Roku Ultra card is expanded, showing a green online dot, model label, IP, the per-port connect buttons (BrightScript Debug 8085, Commands 8080, Screensaver 8087), and Connect Remote Panel, Sideload App, Properties, and Remove actions](images/device-card-connected.png) +![The Devices panel with several discovered Rokus; the Roku Ultra card is expanded, showing a green online dot, model label, IP, the per-port connect buttons (BrightScript Debug 8085, Commands 8080, Screensaver 8087), and Connect Remote Panel, Sideload App, Properties, and Remove actions](images/connect-card-expanded.png) *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 -![The RokDock workspace: the device panel, AI Chat panel, and docked HDMI capture preview on the left, a tabbed BrightScript debug terminal in the center, and the Remote / Scripts / Deeplinks control rail on the right](images/workspace-overview.webp) +![The RokDock workspace: the device panel, roBot panel, and docked HDMI capture preview on the left, a tabbed BrightScript debug terminal in the center, and the Remote / Scripts / Deeplinks control rail on the right](images/dock-hero.png) 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: a full-color Roku remote with power, back, home, the directional pad with OK, playback controls, and a "Type to send..." text input](images/remote-live.png) +![The Remote panel with a device selected: a full-color Roku remote with power, back, home, the directional pad with OK, playback controls, and a "Type to send..." text input](images/control-remote.png) *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. -![Settings > Remote tab showing the keyboard binding editor](images/settings-remote.png) +![Settings > Remote tab showing the keyboard binding editor](images/control-remote-settings.png) *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 showing a captured device frame (a channel grid UI): the top toolbar with refresh, auto-refresh, save, copy, measure, overlays, capture-feed, and history controls, a Full HD safe-zone overlay with measurement guides over the image, and the zoom dock with the zoom and comparison-opacity sliders at the bottom](images/screenshot-preview.png) +![The Screenshot Preview window showing a captured device frame: the top toolbar with refresh, auto-refresh, save, copy, measure, overlays, capture-feed, and history controls, a Full HD safe-zone overlay with measurement guides over the image, and the zoom dock with the zoom and comparison-opacity sliders at the bottom](images/capture-viewer-safezone.webp) *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.* ![The Screenshot Preview with the Overlays dropdown open over a device frame: the menu shows Load image, Built-in, Recent, and Screenshot History, with the Built-in submenu expanded to TV safe zones, Rule of thirds, Aspect ratio, and Column grid (each in 1080p and 720p), alongside the comparison-opacity slider in the bottom dock](images/screenshot-preview-overlay.png) 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). ![Script Editor window showing a Steps list with PRESS Home, PAUSE, LAUNCH app:12, SCREEN, and PLAYER steps; toolbar with New, Save, Import, Export, Paste RASP, Copy RASP, Record, Play, and Stop buttons; Record Delays toggle and Key Wait control above the step list; and Variables and Scripts panels on the right sidebar.](images/script-editor.png) *The Script Editor with a sample script loaded and the Scripts library open in the right panel.* -![The Script Editor running a script against a device: the step list shows step 1 (Press Home) complete and step 2 (Pause) running, the device bar has the Roku Ultra selected with the embedded remote, and the execution log at the bottom streams "Starting playback", "Running step 1", "Step 1 complete", "Running step 2"](images/script-editor-running.png) -*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 -![Settings dialog open on the Remote tab](images/settings-remote.png) +![Settings dialog open on the Remote tab](images/control-remote-settings.png) *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. ![Settings dialog open on the AI (Beta) tab](images/settings-ai.png) *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. -![SVG Converter Colors section with one color overridden](images/svg-converter-recolor.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.* +![SVG Converter with an imported glyph recolored: the Colors section lists each detected fill and stroke with its original swatch, a color picker, and a reset button, and the change shows in the quantized preview](images/svg-converter-recolored.png) +*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 streaming BrightScript Micro Debugger output: syntax-highlighted source around the break, a Source Digest, a backtrace, local variables with types and refcounts, the thread list, and the "Brightscript Debugger>" prompt, with a detected URL underlined at the top](images/terminal-live.png) +![A connected terminal tab streaming BrightScript Micro Debugger output: syntax-highlighted source around the break, a Source Digest, a backtrace, local variables with types and refcounts, the thread list, and the "Brightscript Debugger>" prompt, with a detected URL underlined at the top](images/terminal-live.webp) *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 tab showing the Theme section plus the shared Code section with font family, font size, syntax theme selector, use-theme-background toggle, and live BrightScript preview](images/settings-appearance.png) +![Settings > Appearance tab scrolled to the shared Code section: font family, code font size, syntax theme selector, use-theme-background toggle, and a live BrightScript preview](images/settings-code.png) _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 - const widthMatch = svgText.match(/]*\bwidth=["'](\d+(?:\.\d+)?)(px)?["']/i) - const heightMatch = svgText.match(/]*\bheight=["'](\d+(?:\.\d+)?)(px)?["']/i) - if (widthMatch && heightMatch) { - return { width: parseFloat(widthMatch[1]), height: parseFloat(heightMatch[1]) } - } - - // Fallback to viewBox - const vbMatch = svgText.match(/]*\bviewBox=["'][\s]*[\d.]+[\s]+[\d.]+[\s]+([\d.]+)[\s]+([\d.]+)["']/i) - if (vbMatch) { - return { width: parseFloat(vbMatch[1]), height: parseFloat(vbMatch[2]) } - } - - return { width: 0, height: 0 } -} - /** * Registers all SVG Converter IPC handlers. * @@ -323,32 +302,36 @@ export function registerSvgExporterHandlers(context: IpcContext): void { }) /** - * Saves an already-quantized PNG to disk via a native Save dialog. + * Saves an already-encoded image (PNG or WebP) to disk via a native Save dialog. * The bytes are written directly from the data URL - no re-compression is applied, * so the saved file size exactly matches the size shown in the UI. - * @param pngDataUrl - Base64 PNG data URL of the quantized image to save. + * @param dataUrl - Base64 image data URL (PNG or WebP) to save. * @param defaultName - Default filename for the Save dialog. + * @param format - The image format, which selects the dialog file filter. * @returns {IpcResult} ok: true on success; ok: false if canceled, invalid data, or write fails. */ - ipcMain.handle('svg-exporter:save-png', async (event, pngDataUrl: unknown, defaultName: unknown): Promise => { + ipcMain.handle('svg-exporter:save-image', async (event, dataUrl: unknown, defaultName: unknown, format: unknown): Promise => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return { ok: false, error: 'SVG Converter window is not open.' } const state = getWindowState(win) if (state.dialogInFlight) return { ok: false, error: 'A dialog is already open.' } state.dialogInFlight = true try { - if (typeof pngDataUrl !== 'string' || !pngDataUrl.startsWith('data:image')) { + if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image')) { return { ok: false, error: 'Invalid image data.' } } - const buffer = dataUrlToBuffer(pngDataUrl) + const buffer = dataUrlToBuffer(dataUrl) - const saveName = typeof defaultName === 'string' && defaultName ? defaultName : 'export.png' + const isWebp = format === 'webp' + const saveName = typeof defaultName === 'string' && defaultName + ? defaultName + : (isWebp ? 'export.webp' : 'export.png') + const filter = isWebp + ? { name: 'WebP Images', extensions: ['webp'] } + : { name: 'PNG Images', extensions: ['png'] } const result = await dialog.showSaveDialog(win, { defaultPath: saveName, - filters: [ - { name: 'PNG Images', extensions: ['png'] }, - { name: 'All Files', extensions: ['*'] } - ] + filters: [filter, { name: 'All Files', extensions: ['*'] }] }) if (result.canceled || !result.filePath) return { ok: false, error: 'Export canceled.' } diff --git a/src/main/ipc/handlers/terminalOutputBridge.ts b/src/main/ipc/handlers/terminalOutputBridge.ts new file mode 100644 index 0000000..d09f0b5 --- /dev/null +++ b/src/main/ipc/handlers/terminalOutputBridge.ts @@ -0,0 +1,29 @@ +/** + * Ask the dock window for the focused terminal tab's line buffer, for roBot's terminal-output + * tools. Single-window request/response keyed by a requestId (mirrors the HDMI frame-grab + * pattern, but sends to the dock only rather than broadcasting: terminal tabs live only in the + * dock). Resolves null if the window is absent or does not answer before the timeout. + */ +import { ipcMain, type BrowserWindow } from 'electron' +import crypto from 'crypto' +import type { FocusedTerminalPayload } from '../../../shared/terminal' + +export function requestFocusedTerminal(win: BrowserWindow | null, timeoutMs = 2000): Promise { + if (!win || win.isDestroyed()) return Promise.resolve(null) + return new Promise(resolve => { + const requestId = crypto.randomUUID() + let timer: ReturnType + const onResponse = (_event: unknown, id: string, payload: FocusedTerminalPayload | null): void => { + if (id !== requestId) return + clearTimeout(timer) + ipcMain.removeListener('terminal-output:response', onResponse) + resolve(payload ?? null) + } + ipcMain.on('terminal-output:response', onResponse) + win.webContents.send('terminal-output:request', requestId) + timer = setTimeout(() => { + ipcMain.removeListener('terminal-output:response', onResponse) + resolve(null) + }, timeoutMs) + }) +} diff --git a/src/main/ipc/handlers/theme.ts b/src/main/ipc/handlers/theme.ts index 0fa35dc..719bcdb 100644 --- a/src/main/ipc/handlers/theme.ts +++ b/src/main/ipc/handlers/theme.ts @@ -60,7 +60,7 @@ function persistedAppearance(preferences: AppPreferences): AppearanceDraft { 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/types.ts b/src/main/ipc/types.ts index cedc0d5..c876027 100644 --- a/src/main/ipc/types.ts +++ b/src/main/ipc/types.ts @@ -37,6 +37,12 @@ export interface IpcContext { ai: AiService /** MCP tool endpoint: loopback HTTP server that bridges tool calls from the CLI to providers. */ mcpEndpoint: McpToolEndpoint + /** + * Record the IP of the device the user currently has selected (the remote target), or null. + * The renderer pushes it on change. The AI device-control tools read it as their default + * target, so most actions need no explicit device argument. + */ + setActiveDeviceIp: (ip: string | null) => void /** * Broadcasts a message to all open BrowserWindows (main + any tool windows). * @param channel - The IPC channel name. diff --git a/src/main/main.ts b/src/main/main.ts index ef085ad..59d9a35 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -32,6 +32,14 @@ import { app, BrowserWindow, Menu } from 'electron' import { revealWindow } from './focusPolicy' import { installGlobalErrorHandlers, registerRendererErrorBridge } from './utils/errorReporting' +// From-source (non-packaged) runs get a distinct userData folder ("RokDock Dev") so a dev +// build's state (config, sessions, caches) can never scribble on the installed app's, and +// the two are never confused (the old implicit "rokdock" vs "RokDock" split was a case +// near-collision). Packaged builds keep "RokDock" via electron-builder's productName. This +// must run before the single-instance lock and any getPath('userData'), or the prior name +// is already baked into the resolved path. +if (!app.isPackaged) app.setName('RokDock Dev') + // Register uncaughtException/unhandledRejection handlers before any other setup // so errors during startup are captured and shown as a friendly dialog instead of // Electron's raw "A JavaScript error occurred in the main process" crash dialog. diff --git a/src/main/services/ai/aiService.ts b/src/main/services/ai/aiService.ts index 32966be..547c660 100644 --- a/src/main/services/ai/aiService.ts +++ b/src/main/services/ai/aiService.ts @@ -16,11 +16,11 @@ import os from 'os' import { createAiEngine, cliAdapter, anthropicAdapter, geminiAdapter, openAiCompatibleAdapter, - CLI_DEFINITIONS, isCliKind, + CLI_DEFINITIONS, isCliKind, redact, } from '../../../ai-core' import type { AiEngine } from '../../../ai-core' -import type { AiAdapter, AiEngineConfig, CliEngineConfig, AiRequest, AiStreamChunk, AiActivityChunk, ContextProvider, RedactSecrets, ToolDef, ToolResult } from '../../../ai-core/types' -import { buildToolRouting } from '../../../ai-core/toolRouting' +import type { AiAdapter, AiEngineConfig, CliEngineConfig, AiRequest, AiStreamChunk, AiActivityChunk, ContextProvider, RedactSecrets, ToolDef, ToolResult, ToolCallContext } from '../../../ai-core/types' +import { buildToolRouting, dispatchTool } from '../../../ai-core/toolRouting' import type { CliKind } from '../../../ai-core/types' import type { AiProfile, AiProfileInput, AiTestResult, RedactionPreview, AiCliOverrides, CliOverride } from '../../../shared/ai/types' import type { AiProfileStore } from './aiProfileStore' @@ -247,7 +247,7 @@ export class AiService { } /** The engine-agnostic seam: a stream of deltas for the active profile. */ - async *stream(request: AiRequest, signal: AbortSignal, conversationId?: string): AsyncIterable { + async *stream(request: AiRequest, signal: AbortSignal, conversationId?: string, toolContext?: ToolCallContext): AsyncIterable { const profile = await this.requireActive() const withSystem: AiRequest = { ...request, system: request.system ?? this.chatSystemPrompt } @@ -261,13 +261,13 @@ export class AiService { ) { const { specs, ownerByToolName } = buildToolRouting(this.contextProviders) if (specs.length > 0) { - yield* this.streamWithMcp(profile, withSystem, signal, specs, ownerByToolName, conversationId) + yield* this.streamWithMcp(profile, withSystem, signal, specs, ownerByToolName, conversationId, toolContext) return } } const engine = this.createEngine(await this.configForProfile(profile, true)) - yield* engine.stream(withSystem, signal) + yield* engine.stream(withSystem, signal, toolContext) } /** Remove a directory tree, swallowing any error (cleanup is always best-effort). */ @@ -305,6 +305,7 @@ export class AiService { specs: ToolDef[], ownerByToolName: Map, conversationId?: string, + toolContext?: ToolCallContext, ): AsyncIterable { const endpoint = this.mcpEndpoint! const { url } = await endpoint.start() @@ -343,7 +344,7 @@ export class AiService { ? prior!.dir! : fs.mkdtempSync(path.join(this.resolveMcpRoot(), stableDir ? 'conv-' : 'req-')) - const spawnContext = { mcp, baseConfig, configDir, codexHome, toolNames, url, request, signal, specs, ownerByToolName } + const spawnContext = { mcp, baseConfig, configDir, codexHome, toolNames, url, request, signal, specs, ownerByToolName, toolContext } const recordSession = (handle: string): void => { this.conversations.set(conversationId!, { cliKind, model: profile.model, handle, @@ -426,6 +427,7 @@ export class AiService { signal: AbortSignal specs: ToolDef[] ownerByToolName: Map + toolContext?: ToolCallContext }): AsyncIterable { const endpoint = this.mcpEndpoint! const plan = opts.mcp.plan({ @@ -459,11 +461,8 @@ export class AiService { ) endpoint.registerSession(opts.token, { tools: opts.specs, - async call(name: string, args: unknown, callSignal: AbortSignal): Promise { - const owner = opts.ownerByToolName.get(name) - if (!owner?.callTool) return { content: `Unknown tool: ${name}`, isError: true } - return owner.callTool(name, args, callSignal) - }, + call: (name: string, args: unknown, callSignal: AbortSignal): Promise => + dispatchTool(opts.ownerByToolName, name, args, callSignal, opts.toolContext), onActivity(activity) { queue.push({ activity }) }, signal: opts.signal, }) @@ -529,4 +528,19 @@ export class AiService { const text = redacted.system ? `${redacted.system}\n\n${redacted.prompt}` : redacted.prompt return { text, replacements: redacted.replacements } } + + /** + * Redact a tool result for the active profile before it reaches the model. Runs the same + * pure device-values-only redact() pass the outbound prompt uses, gated on the active + * profile's redactionEnabled flag. Returns the text unchanged only when an active profile + * explicitly has redaction off. Fails closed otherwise: an unresolvable active profile still + * gets scrubbed, matching previewRedaction and avoiding a raw leak if the profile changes + * mid-stream. Backs the terminal-output provider's redact dependency, so terminal text is + * scrubbed on both the HTTP and CLI/MCP transports. + */ + async redactForActiveProfile(text: string): Promise { + const profile = await this.resolveProvider(this.profileStore.getActiveId()) + if (profile && !profile.redactionEnabled) return text + return redact(text, this.secrets(), { enabled: true }).text + } } diff --git a/src/main/services/ai/askUserProvider.ts b/src/main/services/ai/askUserProvider.ts new file mode 100644 index 0000000..6ff2b79 --- /dev/null +++ b/src/main/services/ai/askUserProvider.ts @@ -0,0 +1,38 @@ +/** + * A single tool that lets roBot ask the user a multiple-choice question and get the answer as + * clickable options, instead of asking in prose and waiting for the user to type a reply. The + * host presents the choices (a dialog) via the tool-call context's ask() hook. + */ +import type { ContextProvider, ToolDef, ToolResult, ToolCallContext } from '../../../ai-core/types' + +const ASK_USER: ToolDef = { + name: 'ask_user', + description: 'Ask the user a question and offer clickable answer choices, then receive their pick. Prefer this over asking in prose whenever the user should choose among options (which device, which channel, yes or no, and so on). Offer ALL the relevant options, not only a few: this tool accepts up to 12 choices and renders each as a button. This is RokDock\'s own tool, unrelated to any 2-to-4-option limit you may assume from elsewhere.', + parameters: { + type: 'object', + properties: { + question: { type: 'string', description: 'The question to show the user.' }, + options: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 12, description: 'The answer choices to render as buttons. Include every relevant option (2 to 12), do not trim the list to a handful.' }, + }, + required: ['question', 'options'], + }, +} + +export function createAskUserProvider(): ContextProvider { + return { + name: 'user-interaction', + tools: () => [ASK_USER], + async callTool(name: string, args: unknown, _signal: AbortSignal, context?: ToolCallContext): Promise { + if (name !== 'ask_user') return { content: `Unknown tool: ${name}`, isError: true } + const record = (args ?? {}) as Record + const question = typeof record.question === 'string' ? record.question.trim() : '' + const options = Array.isArray(record.options) ? record.options.map(option => String(option)).filter(option => option.trim()) : [] + if (!question) return { content: 'ask_user requires a question.', isError: true } + if (options.length === 0) return { content: 'ask_user requires at least one option.', isError: true } + if (!context?.ask) return { content: 'Cannot show a choice prompt in this context. Ask the user in plain text instead.', isError: true } + const answer = await context.ask(question, options) + if (answer === null) return { content: 'The user dismissed the question without choosing.', isError: true } + return { content: `The user chose: ${answer}` } + }, + } +} diff --git a/src/main/services/ai/chatSystemPrompt.ts b/src/main/services/ai/chatSystemPrompt.ts index 80c6c79..11d0d58 100644 --- a/src/main/services/ai/chatSystemPrompt.ts +++ b/src/main/services/ai/chatSystemPrompt.ts @@ -11,7 +11,7 @@ export const DEFAULT_CHAT_PROMPT_PATH = path.join(__dirname, '../../resources/ai /** Minimal built-in prompt used only if the packaged file cannot be read (packaging mishap). */ export const FALLBACK_CHAT_SYSTEM_PROMPT = - 'You are a helpful development assistant inside RokDock with strong Roku, BrightScript, and SceneGraph expertise. ' + + 'You are roBot, a helpful development assistant inside RokDock with strong Roku, BrightScript, and SceneGraph expertise. ' + "Never fabricate answers: if you do not know, say so. You can see only the text the user shares (terminal output and pasted text) " + "and any provided documentation excerpts. You have no access to the user's source code or project files. Be concise." diff --git a/src/main/services/ai/deviceControlProvider.ts b/src/main/services/ai/deviceControlProvider.ts new file mode 100644 index 0000000..2ee49b5 --- /dev/null +++ b/src/main/services/ai/deviceControlProvider.ts @@ -0,0 +1,317 @@ +/** + * RokDock device-control tools for the assistant (roBot). Read tools (list devices, active + * app, media state, installed channels) run silently. State-changing tools (press a remote + * key, type text, launch a channel, open a deeplink) are the first side-effecting tools, so + * each asks the host to confirm via the tool-call context before it acts. + * + * Privacy: tools address devices by NAME and never expose or accept an IP or serial, so a + * device's IP address and serial never enter the model transcript (device names still do). + * The default target is the device the user currently has selected in the app, so most + * actions need no device argument. + */ +import type { ContextProvider, ToolDef, ToolResult, ToolCallContext } from '../../../ai-core/types' +import type { DeviceInfo } from '../../../shared/device' +import type { EcpService } from '../ecp' +import { ROKU_DEV_APP_ID } from '../../constants/preview' + +/** ECP keys roBot may send: the standard navigation, playback, and volume set. */ +const REMOTE_KEYS = [ + 'Home', 'Back', 'Select', 'Up', 'Down', 'Left', 'Right', + 'InstantReplay', 'Info', 'Rev', 'Play', 'Fwd', 'Backspace', 'Enter', + 'VolumeUp', 'VolumeDown', 'VolumeMute', 'Power', 'PowerOff', 'PowerOn', +] +const MAX_KEY_REPEAT = 20 + +/** How long to wait after a Home keypress for the transition to settle before launching, so the + * launch does not land while the app is still in the foreground (where /launch is a no-op). */ +const HOME_SETTLE_MS = 1000 + +const delay = (ms: number): Promise => new Promise(resolve => { setTimeout(resolve, ms) }) + +interface DeviceControlDeps { + ecp: EcpService + /** All known devices (SSDP-discovered plus manually added). */ + listDevices: () => DeviceInfo[] + /** IP of the device the user currently has selected in the app, or null. */ + getActiveDeviceIp: () => string | null + /** + * Capture a screenshot and show it inline in the chat. Prefers the native ECP capture (dev + * channel only). `viaHdmiCapture` is true when it fell back to a frame from the HDMI capture device. + */ + captureScreenshot: (ip: string) => Promise<{ ok: boolean; error?: string; viaHdmiCapture?: boolean }> +} + +type ResolvedDevice = { ip: string; name: string } + +function asRecord(args: unknown): Record { + return (args ?? {}) as Record +} + +/** Case-insensitive exact-then-substring match over a named list. */ +function matchByName(items: T[], nameArg: string, nameOf: (item: T) => string): T | undefined { + const needle = nameArg.trim().toLowerCase() + return items.find(item => nameOf(item).toLowerCase() === needle) + ?? items.find(item => nameOf(item).toLowerCase().includes(needle)) +} + +export function createDeviceControlProvider(deps: DeviceControlDeps): ContextProvider { + /** Resolve the target device from an optional name, else the selected device, else the sole device. */ + function resolveDevice(nameArg?: string): ResolvedDevice | { error: string } { + const devices = deps.listDevices() + const knownNames = (): string => devices.map(device => `"${device.name}"`).join(', ') + if (devices.length === 0) return { error: 'No Roku devices are known. Add or discover a device first.' } + if (typeof nameArg === 'string' && nameArg.trim()) { + const needle = nameArg.trim().toLowerCase() + const exact = devices.find(device => device.name.toLowerCase() === needle) + if (exact) return { ip: exact.ip, name: exact.name } + // Substring is a convenience fallback, but only when it is unambiguous: acting on the + // wrong device (reads have no confirm) would be worse than asking for the exact name. + const partial = devices.filter(device => device.name.toLowerCase().includes(needle)) + if (partial.length === 1) return { ip: partial[0].ip, name: partial[0].name } + if (partial.length > 1) return { error: `Device name ${JSON.stringify(nameArg)} is ambiguous. Matches: ${partial.map(device => `"${device.name}"`).join(', ')}. Use the exact name.` } + return { error: `No device named ${JSON.stringify(nameArg)}. Known devices: ${knownNames()}.` } + } + const activeIp = deps.getActiveDeviceIp() + const active = activeIp ? devices.find(device => device.ip === activeIp) : undefined + if (active) return { ip: active.ip, name: active.name } + const reachable = devices.filter(device => device.reachable) + if (reachable.length === 1) return { ip: reachable[0].ip, name: reachable[0].name } + if (devices.length === 1) return { ip: devices[0].ip, name: devices[0].name } + return { error: `Multiple devices are available and none is selected. Specify one by name: ${knownNames()}.` } + } + + /** Resolve a channel argument (numeric id or channel name) to an app id on the device. */ + async function resolveChannelId(ip: string, channelArg: string): Promise<{ id: string; name: string } | { error: string }> { + const trimmed = channelArg.trim() + if (/^\d+$/.test(trimmed)) return { id: trimmed, name: trimmed } + const channels = await deps.ecp.queryApps(ip) + // Match by exact app id first so non-numeric ids launch (e.g. the sideloaded "dev" + // channel, or tvinput.* sources), then fall back to matching by channel name. + const byId = channels.find(channel => channel.id.toLowerCase() === trimmed.toLowerCase()) + if (byId) return byId + const byName = matchByName(channels, channelArg, channel => channel.name) + if (!byName) return { error: `No installed channel matches ${JSON.stringify(channelArg)}.` } + return byName + } + + /** Ask the host to confirm a side-effecting action. Absent confirm hook = deny (fail-safe). */ + async function confirmed(context: ToolCallContext | undefined, summary: string): Promise { + if (!context?.confirm) return false + return context.confirm(summary) + } + + const DENIED: ToolResult = { content: 'The user declined the action.', isError: true } + const ABORTED: ToolResult = { content: 'The action was cancelled before it ran.', isError: true } + + const tools: ToolDef[] = [ + { + name: 'list_devices', + description: 'List the Roku devices RokDock knows about, with model, reachability, and the app each is running. Use this to pick a device by name for the other tools.', + parameters: { type: 'object', properties: {} }, + }, + { + name: 'get_active_app', + description: 'Get the app/channel currently running on a device.', + parameters: { type: 'object', properties: { device: { type: 'string', description: 'Device name. Omit to use the selected device.' } } }, + }, + { + name: 'get_media_state', + description: 'Get the media player state (playing/paused/etc., with position and duration when available) on a device.', + parameters: { type: 'object', properties: { device: { type: 'string', description: 'Device name. Omit to use the selected device.' } } }, + }, + { + name: 'list_installed_channels', + description: 'List the channels installed on a device, each with its name and app id.', + parameters: { type: 'object', properties: { device: { type: 'string', description: 'Device name. Omit to use the selected device.' } } }, + }, + { + name: 'capture_screenshot', + description: 'Capture a screenshot of the device screen and show it to the user in the chat. Uses the native capture when the sideloaded "dev" channel is active, otherwise falls back to the HDMI capture device if its preview is running. You do not receive the image yourself.', + parameters: { type: 'object', properties: { device: { type: 'string', description: 'Device name. Omit to use the selected device.' } } }, + }, + { + name: 'press_remote_key', + description: `Press a remote-control key on a device. Valid keys: ${REMOTE_KEYS.join(', ')}. Optionally repeat it.`, + parameters: { + type: 'object', + properties: { + key: { type: 'string', description: `One of: ${REMOTE_KEYS.join(', ')}.` }, + count: { type: 'number', description: `How many times to press it (1-${MAX_KEY_REPEAT}). Default 1.` }, + device: { type: 'string', description: 'Device name. Omit to use the selected device.' }, + }, + required: ['key'], + }, + }, + { + name: 'type_text', + description: 'Type a string on a device (e.g. into a search box), one character at a time.', + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'The text to type.' }, + device: { type: 'string', description: 'Device name. Omit to use the selected device.' }, + }, + required: ['text'], + }, + }, + { + name: 'launch_channel', + description: 'Launch a channel on a device, by channel name or app id. Always call this to launch OR relaunch a channel. Do not check whether it is already running and skip. When the user explicitly asks to relaunch, restart, or reopen a channel, set relaunch to true so the tool relaunches it directly instead of asking. When relaunch is not set and the channel is already the active app, the tool asks the user whether to relaunch.', + parameters: { + type: 'object', + properties: { + channel: { type: 'string', description: 'Channel name (matched against installed channels) or numeric app id.' }, + device: { type: 'string', description: 'Device name. Omit to use the selected device.' }, + relaunch: { type: 'boolean', description: 'Set true when the user explicitly asked to relaunch, restart, or reopen the channel, so the tool relaunches without asking whether to leave it running.' }, + }, + required: ['channel'], + }, + }, + { + name: 'open_deeplink', + description: 'Launch a channel into specific content via a deeplink (contentId + mediaType).', + parameters: { + type: 'object', + properties: { + channel: { type: 'string', description: 'Channel name or numeric app id.' }, + contentId: { type: 'string', description: 'The deeplink content id.' }, + mediaType: { type: 'string', description: 'The deeplink media type, e.g. "movie", "series", "episode", "season".' }, + device: { type: 'string', description: 'Device name. Omit to use the selected device.' }, + }, + required: ['channel', 'contentId', 'mediaType'], + }, + }, + ] + + async function callTool(name: string, args: unknown, signal: AbortSignal, context?: ToolCallContext): Promise { + const record = asRecord(args) + const deviceArg = typeof record.device === 'string' ? record.device : undefined + + if (name === 'list_devices') { + const devices = deps.listDevices().map(device => ({ + name: device.name, model: device.model || 'Roku', reachable: device.reachable, + runningApp: device.activeAppName || undefined, + })) + return { content: JSON.stringify(devices) } + } + + const target = resolveDevice(deviceArg) + if ('error' in target) return { content: target.error, isError: true } + + try { + switch (name) { + case 'get_active_app': { + const app = await deps.ecp.queryActiveApp(target.ip) + return { content: JSON.stringify({ device: target.name, app: app.name || '(home)', appId: app.id || null }) } + } + case 'get_media_state': { + const media = await deps.ecp.queryMediaPlayer(target.ip) + return { content: JSON.stringify({ device: target.name, ...media }) } + } + case 'list_installed_channels': { + const channels = await deps.ecp.queryApps(target.ip) + return { content: JSON.stringify({ device: target.name, channels }) } + } + case 'capture_screenshot': { + const result = await deps.captureScreenshot(target.ip) + if (!result.ok) return { content: result.error ?? 'Screenshot failed.', isError: true } + const caveat = result.viaHdmiCapture + ? ' The native (dev channel) screenshot was unavailable, so this was captured via the HDMI capture device. Mention this caveat to the user.' + : '' + return { content: `Captured a screenshot of ${target.name} and showed it to the user in the chat.${caveat}` } + } + case 'press_remote_key': { + const key = REMOTE_KEYS.find(valid => valid.toLowerCase() === String(record.key ?? '').toLowerCase()) + if (!key) return { content: `Unknown key ${JSON.stringify(record.key)}. Valid keys: ${REMOTE_KEYS.join(', ')}.`, isError: true } + const count = Math.min(MAX_KEY_REPEAT, Math.max(1, Math.floor(Number(record.count) || 1))) + const label = count > 1 ? `Press "${key}" ${count} times` : `Press "${key}"` + if (!await confirmed(context, `${label} on "${target.name}"?`)) return DENIED + let sent = 0 + for (let i = 0; i < count; i++) { + if (signal.aborted) break + await deps.ecp.keypress(target.ip, key) + sent++ + } + if (sent === 0) return ABORTED + return { content: `Pressed ${key}${sent > 1 ? ` x${sent}` : ''} on ${target.name}.` } + } + case 'type_text': { + const text = String(record.text ?? '') + if (!text) return { content: 'type_text requires a non-empty text string.', isError: true } + if (!await confirmed(context, `Type ${JSON.stringify(text)} on "${target.name}"?`)) return DENIED + if (signal.aborted) return ABORTED + await deps.ecp.sendText(target.ip, text) + return { content: `Typed ${JSON.stringify(text)} on ${target.name}.` } + } + case 'launch_channel': { + const channel = await resolveChannelId(target.ip, String(record.channel ?? '')) + if ('error' in channel) return { content: channel.error, isError: true } + const active = await deps.ecp.queryActiveApp(target.ip) + // channel.id is a resolved, non-empty id, so a blank active.id can never match. + const alreadyActive = active.id === channel.id + + if (!alreadyActive) { + if (!await confirmed(context, `Launch "${channel.name}" on "${target.name}"?`)) return DENIED + if (signal.aborted) return ABORTED + await deps.ecp.launchApp(target.ip, channel.id) + return { content: `Launched ${channel.name} on ${target.name}.` } + } + + // The channel is already the active app. Offer the relaunch-or-leave choice only + // for a plain launch while confirmations are on, and that ask is itself the + // approval. When the user explicitly asked to relaunch, or confirmations are off + // (so prompting is unwanted), relaunch directly, gated by the normal confirm. + if (record.relaunch !== true && context?.ask && context.confirmationsEnabled !== false) { + const answer = await context.ask(`${channel.name} is already running on ${target.name}. Relaunch it?`, ['Relaunch', 'Leave it running']) + if (answer !== 'Relaunch') return { content: `Left ${channel.name} running on ${target.name}.` } + } else if (!await confirmed(context, `Relaunch "${channel.name}" on "${target.name}"?`)) { + return DENIED + } + if (signal.aborted) return ABORTED + + // A plain /launch is a no-op on the already-active app, so the app has to leave the + // foreground first. exit-app only terminates apps under your developer account (the + // sideloaded "dev" channel), so it restarts dev but no-ops on a store app. For a store + // app, a Home keypress backgrounds it. Wait for the Home transition to settle first, or + // the following launch would land while the app is still foreground and no-op too. + if (channel.id === ROKU_DEV_APP_ID) { + try { await deps.ecp.exitApp(target.ip, channel.id) } catch { /* older firmware or app not running */ } + } else { + await deps.ecp.keypress(target.ip, 'Home') + await delay(HOME_SETTLE_MS) + if (signal.aborted) return ABORTED + } + await deps.ecp.launchApp(target.ip, channel.id) + return { content: `Relaunched ${channel.name} on ${target.name}.` } + } + case 'open_deeplink': { + const channel = await resolveChannelId(target.ip, String(record.channel ?? '')) + if ('error' in channel) return { content: channel.error, isError: true } + const contentId = String(record.contentId ?? '') + const mediaType = String(record.mediaType ?? '') + if (!contentId || !mediaType) return { content: 'open_deeplink requires contentId and mediaType.', isError: true } + if (!await confirmed(context, `Open ${mediaType} ${JSON.stringify(contentId)} in "${channel.name}" on "${target.name}"?`)) return DENIED + if (signal.aborted) return ABORTED + await deps.ecp.launchDeeplink(target.ip, channel.id, { contentId, mediaType }) + return { content: `Opened a ${mediaType} deeplink in ${channel.name} on ${target.name}.` } + } + default: + return { content: `Unknown tool: ${name}`, isError: true } + } + } catch (err) { + return { content: `Device action failed: ${err instanceof Error ? err.message : String(err)}`, isError: true } + } + } + + // Tell the model, each turn, which device the user is working with (from the terminal/remote + // selection), so it acts on that device by default instead of asking which one to use. + async function retrieve(): Promise> { + const activeIp = deps.getActiveDeviceIp() + if (!activeIp) return [] + const device = deps.listDevices().find(entry => entry.ip === activeIp) + if (!device) return [] + return [{ text: `The device the user currently has selected in the app is "${device.name}". Treat it as the default target for device tools (omit the device argument). Do not ask which device to use unless the user refers to a different one.` }] + } + + return { name: 'roku-device', tools: () => tools, retrieve, callTool } +} diff --git a/src/main/services/ai/terminalOutputProvider.ts b/src/main/services/ai/terminalOutputProvider.ts new file mode 100644 index 0000000..587107a --- /dev/null +++ b/src/main/services/ai/terminalOutputProvider.ts @@ -0,0 +1,177 @@ +/** + * roBot terminal-output tools. Two read-only tools over the FOCUSED terminal tab's scrollback: + * read_terminal_output tails the recent lines ("summarize the terminal output"), and + * search_terminal_output finds a substring most-recent-first ("what was the last error?"). + * + * The whole buffer is fetched from the dock renderer via readFocusedTerminal. Slicing and + * scanning happen here with pure helpers so they are unit-testable without IPC. Every + * content-bearing result is passed through redact (device-values-only) before returning, + * which is the only place terminal text is scrubbed on its way to the model. + */ +import type { ContextProvider, ToolDef, ToolResult } from '../../../ai-core/types' +import type { FocusedTerminalPayload } from '../../../shared/terminal' + +const DEFAULT_TAIL = 200 +const MAX_TAIL = 1000 +const DEFAULT_MAX_MATCHES = 10 +const MAX_MAX_MATCHES = 30 +const DEFAULT_CONTEXT_LINES = 2 +const MAX_CONTEXT_LINES = 5 +/** + * Total character budget for a single tool result (about 40 KB), to keep one huge JSON-blob line + * from blowing the token budget. At least the most recent line (tail) or match block (search) is + * always kept, so a single line or block that alone exceeds this is returned whole rather than + * dropped to nothing. + */ +const MAX_OUTPUT_CHARS = 40000 +/** Separator between search-result blocks. Its length feeds the char-budget accounting below. */ +const MATCH_SEPARATOR = '\n---\n' + +interface TerminalOutputDeps { + readFocusedTerminal: () => Promise + redact: (text: string) => Promise +} + +function asRecord(args: unknown): Record { + return (args ?? {}) as Record +} + +function clamp(value: number, min: number, max: number, fallback: number): number { + const num = Math.floor(Number(value)) + if (!Number.isFinite(num)) return fallback + return Math.min(max, Math.max(min, num)) +} + +/** Strip C0 control characters (keep tab) that could confuse the model. Lines are already newline-split. */ +function stripControl(text: string): string { + return text.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '') +} + +/** + * Keep the last `limit` lines, then drop from the front until the joined text fits MAX_OUTPUT_CHARS. + * Always keeps at least the most recent line, so a single oversized line is returned whole rather + * than dropped to an empty result. Returns the kept lines (most recent) and whether older lines + * were dropped to fit. + */ +export function tailWithinBudget(lines: string[], limit: number): { kept: string[]; charTruncated: boolean } { + let kept = lines.slice(Math.max(0, lines.length - limit)) + let charTruncated = false + while (kept.length > 1 && kept.join('\n').length > MAX_OUTPUT_CHARS) { + kept = kept.slice(1) + charTruncated = true + } + return { kept, charTruncated } +} + +/** + * Find substring matches (case-insensitive), most-recent-first, each with contextLines of + * surrounding lines. Stops at maxMatches or when adding the next block would exceed the char cap. + */ +export function searchWithinBudget( + lines: string[], pattern: string, maxMatches: number, contextLines: number, +): { blocks: string[]; totalMatches: number; charTruncated: boolean } { + const needle = pattern.toLowerCase() + const matchIndexes: number[] = [] + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].toLowerCase().includes(needle)) matchIndexes.push(i) + } + const totalMatches = matchIndexes.length + const blocks: string[] = [] + let used = 0 + let charTruncated = false + for (const index of matchIndexes) { + if (blocks.length >= maxMatches) break + const from = Math.max(0, index - contextLines) + const to = Math.min(lines.length - 1, index + contextLines) + const bodyLines = lines.slice(from, to + 1).map((text, offset) => { + const lineNumber = from + offset + 1 + const marker = from + offset === index ? '>' : ' ' + return `${marker} ${lineNumber}: ${text}` + }) + const block = bodyLines.join('\n') + if (used + block.length + MATCH_SEPARATOR.length > MAX_OUTPUT_CHARS && blocks.length > 0) { charTruncated = true; break } + blocks.push(block) + used += block.length + MATCH_SEPARATOR.length + } + return { blocks, totalMatches, charTruncated } +} + +const tools: ToolDef[] = [ + { + name: 'read_terminal_output', + description: 'Read the most recent lines of the focused terminal tab (a tail). Use for "summarize the terminal output" or "what is happening". Reads a bounded amount, not the whole buffer.', + parameters: { + type: 'object', + properties: { + limit: { type: 'number', description: `How many recent lines to read (1-${MAX_TAIL}). Default ${DEFAULT_TAIL}.` }, + }, + }, + }, + { + name: 'search_terminal_output', + description: 'Search the focused terminal tab for a substring (case-insensitive), most recent match first. Use for "find X" or "what was the last error". Not a regex.', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Substring to search for (case-insensitive).' }, + maxMatches: { type: 'number', description: `Maximum matches to return (1-${MAX_MAX_MATCHES}). Default ${DEFAULT_MAX_MATCHES}.` }, + contextLines: { type: 'number', description: `Lines of surrounding context per match (0-${MAX_CONTEXT_LINES}). Default ${DEFAULT_CONTEXT_LINES}.` }, + }, + required: ['pattern'], + }, + }, +] + +/** + * Fetch the focused terminal's control-stripped lines, or a plain message to return when no tab + * is focused or the buffer is empty. Shared by both tools so the fetch/clean/empty checks live once. + */ +async function loadCleanedLines( + deps: TerminalOutputDeps, +): Promise<{ label: string; cleaned: string[] } | { message: string }> { + const payload = await deps.readFocusedTerminal() + if (!payload) return { message: 'No terminal tab is focused.' } + const cleaned = payload.lines.map(stripControl) + if (cleaned.length === 0) return { message: 'The focused terminal has no output yet.' } + return { label: payload.label, cleaned } +} + +export function createTerminalOutputProvider(deps: TerminalOutputDeps): ContextProvider { + /** Produce the raw (unredacted) tool result. callTool redacts its content in one place below. */ + async function routeTool(name: string, record: Record): Promise { + if (name === 'search_terminal_output') { + const pattern = typeof record.pattern === 'string' ? record.pattern.trim() : '' + if (!pattern) return { content: 'search_terminal_output requires a non-empty pattern.', isError: true } + const loaded = await loadCleanedLines(deps) + if ('message' in loaded) return { content: loaded.message } + const maxMatches = clamp(record.maxMatches as number, 1, MAX_MAX_MATCHES, DEFAULT_MAX_MATCHES) + const contextLines = clamp(record.contextLines as number, 0, MAX_CONTEXT_LINES, DEFAULT_CONTEXT_LINES) + const { blocks, totalMatches, charTruncated } = searchWithinBudget(loaded.cleaned, pattern, maxMatches, contextLines) + const shown = blocks.length < totalMatches ? `, showing ${blocks.length}` : '' + const omitted = charTruncated ? ' (older matches omitted to fit)' : '' + const header = `Terminal "${loaded.label}": ${loaded.cleaned.length} total lines, ${totalMatches} match(es) for "${pattern}" (most recent first)${shown}${omitted}.` + const body = blocks.length > 0 ? `\n${blocks.join(MATCH_SEPARATOR)}` : '' + return { content: `${header}${body}` } + } + + if (name === 'read_terminal_output') { + const loaded = await loadCleanedLines(deps) + if ('message' in loaded) return { content: loaded.message } + const limit = clamp(record.limit as number, 1, MAX_TAIL, DEFAULT_TAIL) + const { kept, charTruncated } = tailWithinBudget(loaded.cleaned, limit) + const omitted = charTruncated ? ' (older lines omitted to fit)' : '' + const header = `Terminal "${loaded.label}": ${loaded.cleaned.length} total lines, showing the last ${kept.length}${omitted}.` + return { content: `${header}\n${kept.join('\n')}` } + } + + return { content: `Unknown tool: ${name}`, isError: true } + } + + // Redact every content-bearing result in one place, so no branch in routeTool can forget to. + async function callTool(name: string, args: unknown): Promise { + const result = await routeTool(name, asRecord(args)) + return { ...result, content: await deps.redact(result.content) } + } + + return { name: 'roku-terminal', tools: () => tools, callTool } +} diff --git a/src/main/services/ecp.ts b/src/main/services/ecp.ts index e05a927..0b59f42 100644 --- a/src/main/services/ecp.ts +++ b/src/main/services/ecp.ts @@ -33,6 +33,12 @@ export interface ActiveAppInfo { name: string } +/** An installed channel: its ECP app id and display name. */ +export interface InstalledApp { + id: string + name: string +} + const ECP_REQUEST_TIMEOUT_MS = 5000 /** @@ -112,6 +118,16 @@ export function ecpRequest(ip: string, method: string, path: string): Promise` node (the shape shared by + * /query/active-app and /query/apps). A bare string node is a name with no id. + */ +function parseAppNode(node: unknown): InstalledApp { + if (typeof node === 'string') return { id: '', name: node } + const record = (node ?? {}) as Record + return { id: String(record['@_id'] ?? record.id ?? ''), name: String(record['#text'] ?? record.name ?? '') } +} + /** * High-level ECP client that wraps `ecpRequest` with convenience methods for * common Roku remote-control and query operations. @@ -232,9 +248,22 @@ export class EcpService { const xml = await ecpRequest(ip, 'GET', '/query/active-app') const parsed = xmlParser.parse(xml) const appNode = parsed?.['active-app']?.app ?? parsed?.['active-app']?.App ?? {} - const id = String(appNode?.['@_id'] ?? appNode?.id ?? '') - const name = typeof appNode === 'string' ? appNode : (appNode?.['#text'] ?? appNode?.name ?? '') - return { id, name: String(name) } + return parseAppNode(appNode) + } + + /** + * Queries the channels installed on the device from `/query/apps`, returning each app's + * id and display name. Shared by the AI device tools, the script editor, and the deeplink UI. + * + * @param ip - Target Roku IP address. + * @returns Installed apps as `{ id, name }`, excluding any entry missing an id. + */ + async queryApps(ip: string): Promise { + const xml = await ecpRequest(ip, 'GET', '/query/apps') + const parsed = xmlParser.parse(xml) as { apps?: { app?: unknown } } + const raw = parsed?.apps?.app + const list = Array.isArray(raw) ? raw : raw ? [raw] : [] + return list.map(parseAppNode).filter(app => app.id) } /** @@ -247,6 +276,22 @@ export class EcpService { await ecpRequest(ip, 'POST', `/launch/${encodeURIComponent(String(channelId))}`) } + /** + * Terminates a running channel via ECP `/exit-app//true` (the `/true` forces a full + * terminate even for Instant Resume apps). Requires Roku OS 13.0 or later with "Control by + * mobile apps" enabled. Per the official ECP docs it only acts on apps installed under your + * developer account (the sideloaded "dev" channel, or a production/beta app linked to that + * account), so it is a no-op for store apps you do not own (observed: it does not terminate + * Netflix). Pair with launchApp to cold-restart an already-running channel you own. For a + * store app, background it with a Home keypress before launching instead. + * + * @param ip - Target Roku IP address. + * @param channelId - The channel ID to terminate. + */ + async exitApp(ip: string, channelId: string | number): Promise { + await ecpRequest(ip, 'POST', `/exit-app/${encodeURIComponent(String(channelId))}/true`) + } + /** * Verifies that the device is reachable by fetching `/query/device-info`. * Throws if the device does not respond within the ECP timeout. diff --git a/src/main/services/screenshotHistory.ts b/src/main/services/screenshotHistory.ts index 3b921ae..0092463 100644 --- a/src/main/services/screenshotHistory.ts +++ b/src/main/services/screenshotHistory.ts @@ -35,6 +35,16 @@ function getScreenshotHistoryDir(folder?: string): string { return dir } +/** + * The absolute folder screenshots are saved to when no custom folder is configured, created if + * missing so the Settings UI can show it and Browse can open into it. Single source of truth for + * the default, shared by the save path (getScreenshotHistoryDir) and the Capture settings field. + * @returns The absolute path to the default screenshot folder. + */ +export function getDefaultScreenshotFolder(): string { + return getScreenshotHistoryDir() +} + /** * Returns the absolute path to the screenshot history index JSON file, * stored in the app's userData directory. @@ -119,18 +129,14 @@ export function formatHistoryLabel(timestamp: number): string { * @param filePath - Absolute path to the source screenshot file. * @returns A scaled NativeImage, or undefined if the file cannot be read. */ -export function createHistoryThumbnail(filePath: string): Electron.NativeImage | undefined { +export function createHistoryThumbnail(filePath: string, maxSize = SCREENSHOT_HISTORY_MENU_ICON_SIZE): Electron.NativeImage | undefined { try { if (!fs.existsSync(filePath)) return undefined const img = nativeImage.createFromPath(filePath) if (img.isEmpty()) return undefined const size = img.getSize() if (size.width < 1 || size.height < 1) return undefined - const scale = Math.min( - SCREENSHOT_HISTORY_MENU_ICON_SIZE / size.width, - SCREENSHOT_HISTORY_MENU_ICON_SIZE / size.height, - 1 - ) + const scale = Math.min(maxSize / size.width, maxSize / size.height, 1) const w = Math.round(size.width * scale) const h = Math.round(size.height * scale) return img.resize({ width: w, height: h }) @@ -229,8 +235,11 @@ export class ScreenshotHistoryService { /** * Loads the screenshot history from disk into the in-memory list on first call. * Scans the history directory for image files, sorts them by modification time, - * keeps only the most recent SCREENSHOT_HISTORY_MAX entries, then prunes duplicates. - * Subsequent calls are no-ops (guarded by the loaded flag). + * and keeps only the most recent SCREENSHOT_HISTORY_MAX entries. This is a metadata-only + * scan (readdir + stat): it deliberately does NOT decode or hash images. Pixel-duplicate + * detection is a capture-time concern (see push -> findPixelIdenticalEntry), and running it + * here decoded up to 20 full-size images synchronously on the launch path (load runs before + * the window exists), which stalled startup. Subsequent calls are no-ops (loaded flag). * @param folder - Optional user-configured screenshot folder to scan. */ load(folder?: string): void { @@ -250,7 +259,6 @@ export class ScreenshotHistoryService { } catch { this.history = [] } - this.prunePixelDuplicates() } /** Resets the loaded flag and clears the list, then reloads from the given folder. */ diff --git a/src/main/services/store.ts b/src/main/services/store.ts index fc57b53..0cfcdf7 100644 --- a/src/main/services/store.ts +++ b/src/main/services/store.ts @@ -88,7 +88,7 @@ const defaults: StoreSchema = { fontSize: 12, fontFamily: '', terminalFallbackColor: '#e0e0e0', - terminalUseThemeBackground: false, + terminalUseThemeBackground: true, terminalSyntaxThemePreset: 'rokdockDark', terminalSyntaxThemeCustomColors: {}, terminalCommandHistory: [], @@ -98,6 +98,7 @@ const defaults: StoreSchema = { aiProfiles: [], aiActiveProfileId: null, aiCliOverrides: {}, + aiConfirmDeviceControl: true, discoveryScanIntervalMs: 60000, discoveryRequestTimeoutMs: 5000, devAppPollIntervalMs: 3000, @@ -110,6 +111,7 @@ const defaults: StoreSchema = { screenshotOnionOverlayHistory: [], splitRatio: 0.5, collapsedPanels: ['scripts'], + expandedPanels: [], captureDeviceId: null, captureDeviceLabel: null, captureMuted: true, @@ -274,11 +276,11 @@ export class StoreService { /** * Merges partial preference updates into the stored preferences object. * - * @param prefs - Partial preferences to apply. + * @param preferences - Partial preferences to apply. */ - setPreferences(prefs: Partial) { + setPreferences(preferences: Partial) { const current = this.getPreferences() - this.store.set('preferences', { ...current, ...prefs }) + this.store.set('preferences', { ...current, ...preferences }) } /** diff --git a/src/main/services/telnetSession.ts b/src/main/services/telnetSession.ts index 05e950b..4466aa8 100644 --- a/src/main/services/telnetSession.ts +++ b/src/main/services/telnetSession.ts @@ -20,7 +20,7 @@ import net from 'net' import { EventEmitter } from 'events' -import { tokenizeTerminalLine } from '../utils/terminalTokenizer' +import { tokenizeTerminalLine } from '../../shared/terminalTokenizer' import type { TerminalLineChunk } from '../../shared/terminal' const SOCKET_CONNECT_TIMEOUT_MS = 10000 @@ -31,8 +31,13 @@ const READ_BUFFER_MAX_CHARS = 2_000_000 /** A line of five or more '=' (with optional surrounding space) opening/closing a block. */ const DIAGNOSTIC_RULE_RE = /^\s*={5,}\s*$/ -/** The header that opens a firmware diagnostic block, e.g. "Warning occurred while...". */ -const DIAGNOSTIC_HEADER_RE = /^(Warning|Error|Fatal) occurred\b/i +/** + * The header that opens a firmware diagnostic block. Both the singular "Warning occurred + * while setting a field..." and the plural "Warnings occurred while creating XML component..." + * forms appear, so the trailing 's' is optional. It sits outside the capture group, so match[1] + * is always the bare severity word. + */ +const DIAGNOSTIC_HEADER_RE = /^(Warning|Error|Fatal)s? occurred\b/i /** Close a block after about this many lines if the closing rule never arrives. */ const DIAGNOSTIC_BLOCK_MAX_LINES = 30 diff --git a/src/main/utils/onionOverlayPersist.ts b/src/main/utils/onionOverlayPersist.ts index 5b9462e..391906c 100644 --- a/src/main/utils/onionOverlayPersist.ts +++ b/src/main/utils/onionOverlayPersist.ts @@ -154,8 +154,8 @@ export function clearOnionOverlayPersistDir(): void { if (!fs.existsSync(dir)) return for (const name of fs.readdirSync(dir)) { try { - const fp = path.join(dir, name) - if (fs.statSync(fp).isFile()) fs.unlinkSync(fp) + const filePath = path.join(dir, name) + if (fs.statSync(filePath).isFile()) fs.unlinkSync(filePath) } catch { // best-effort } diff --git a/src/main/utils/screenshot.ts b/src/main/utils/screenshot.ts index 1709895..ed6fcda 100644 --- a/src/main/utils/screenshot.ts +++ b/src/main/utils/screenshot.ts @@ -35,26 +35,57 @@ export interface ScreenshotCaptureResult { } /** - * Extracts the human-readable status message from a /plugin_inspect or /plugin_install - * HTML response. Newer Roku firmware embeds a JSON payload via JSON.parse(); older - * firmware uses a element. Returns undefined if neither pattern matches. - * - * @param html - Raw HTML body returned by the Roku plugin endpoint. - * @returns Status message string, or undefined if no message was found. + * Parses a Roku plugin_install / plugin_inspect response into its message list. + * Modern firmware embeds a `JSON.parse('{...}')` blob with a `messages` array of + * `{ text, type }`, where type is 'success', 'info', or 'error'. Older firmware only + * emits `` lines. Red font is Roku's failure indicator, so those + * legacy lines are tagged type 'error'. HTML tags are stripped from each text. */ -export function parsePluginInspectMessage(html: string): string | undefined { - const match = html.match(/JSON\.parse\('(?[\s\S]*?)'\);/) - if (!match?.groups?.json) { - const legacy = html.match(/\(?.*?)\<\/font\>/i)?.groups?.response - if (!legacy) return undefined - return legacy.replace(/<[^>]+>/g, '').trim() - } - try { - const parsed = JSON.parse(match.groups.json) as { messages?: Array<{ type?: string; text?: string }> } - return parsed.messages?.[0]?.text?.replace(/<[^>]+>/g, '').trim() - } catch { - return undefined +export function parsePluginMessages(html: string): Array<{ type?: string; text: string }> { + const jsonMatch = html.match(/JSON\.parse\('(?[\s\S]*?)'\);/) + if (jsonMatch?.groups?.json) { + try { + const parsed = JSON.parse(jsonMatch.groups.json) as { messages?: Array<{ type?: string; text?: string }> } + return (parsed.messages ?? []) + .map(entry => ({ type: entry.type, text: (entry.text ?? '').replace(/<[^>]+>/g, '').trim() })) + .filter(entry => entry.text.length > 0) + } catch { + return [] + } } + return Array.from( + html.matchAll(/(?.*?)<\/font>/gi), + match => ({ type: 'error', text: (match.groups?.text ?? '').replace(/<[^>]+>/g, '').trim() }) + ).filter(entry => entry.text.length > 0) +} + +/** + * Returns the most significant message text from a plugin response: an error message + * if present, otherwise the first message, or undefined if none. Used to surface a + * failure reason (e.g. from a failed screenshot capture). + */ +export function parsePluginInspectMessage(html: string): string | undefined { + const messages = parsePluginMessages(html) + const error = messages.find(entry => entry.type === 'error') + return (error ?? messages[0])?.text +} + +/** + * Determines the outcome of a /plugin_install response. A failed install is reported as a + * message with type 'error' (e.g. "Install Failure: No manifest. Invalid package."), which + * covers both modern firmware and legacy red-font lines (tagged 'error' by parsePluginMessages). + * A typeless message whose text reads like a failure is caught as a defensive fallback. The + * device's "Application Received: N bytes stored" is a benign upload-received line, not install + * success on its own, so success surfaces the final message rather than that one. + */ +export function pluginInstallResult(html: string): { ok: boolean; message: string } { + const messages = parsePluginMessages(html) + const failure = messages.find(entry => + entry.type === 'error' + || (entry.type === undefined && /failure|invalid|no manifest|compil|failed/i.test(entry.text)) + ) + const message = failure?.text ?? messages[messages.length - 1]?.text ?? 'Application installed.' + return { ok: !failure, message } } /** @@ -264,9 +295,5 @@ export async function pluginInstall( }) onProgress?.(100) - const message = parsePluginInspectMessage(html) - // If parsePluginInspectMessage returns undefined, no error indicator was found = success. - // If it returns text, check for known success phrases from Roku firmware responses. - const ok = message === undefined || /bytes stored|success/i.test(message) - return { ok, message: message ?? 'Application installed successfully.' } + return pluginInstallResult(html) } diff --git a/src/main/utils/svgDimensions.ts b/src/main/utils/svgDimensions.ts new file mode 100644 index 0000000..da76db7 --- /dev/null +++ b/src/main/utils/svgDimensions.ts @@ -0,0 +1,21 @@ +/** + * Parse an SVG's intrinsic pixel dimensions from its markup. Prefers explicit pixel width/height on + * the root , then falls back to the viewBox. Non-pixel width/height (percentages, em, and so + * on) do not match, so an SVG sized "100%" correctly falls through to the viewBox. The viewBox is + * "min-x min-y width height": min-x/min-y may be negative, and the four values may be separated by + * whitespace or commas, so both are allowed. Returns { 0, 0 } when neither source is present. + */ +export function parseSvgDimensions(svgText: string): { width: number; height: number } { + const widthMatch = svgText.match(/]*\bwidth=["'](\d+(?:\.\d+)?)(px)?["']/i) + const heightMatch = svgText.match(/]*\bheight=["'](\d+(?:\.\d+)?)(px)?["']/i) + if (widthMatch && heightMatch) { + return { width: parseFloat(widthMatch[1]), height: parseFloat(heightMatch[1]) } + } + + const vbMatch = svgText.match(/]*\bviewBox=["']\s*-?[\d.]+[\s,]+-?[\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i) + if (vbMatch) { + return { width: parseFloat(vbMatch[1]), height: parseFloat(vbMatch[2]) } + } + + return { width: 0, height: 0 } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index aa701e6..a6fab33 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -18,10 +18,10 @@ * the window.rokdock global in the renderer. */ -import { contextBridge, ipcRenderer, webFrame } from 'electron' +import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import type { DeviceInfo } from '../shared/device' import type { AppPreferences, DeeplinkConfig, DeviceAuth, IpcResult, PanelState, SettingsUpdate, StoreSettings, ThemeVars } from '../shared/types' -import type { TerminalLineChunk } from '../shared/terminal' +import type { TerminalLineChunk, FocusedTerminalPayload } from '../shared/terminal' import type { ScreenshotHistoryEntryForPreview, ScreenshotPreviewImageResult, @@ -153,8 +153,10 @@ const api = { ipcRenderer.invoke('store:set-panel-state', state), getPreferences: (): Promise => ipcRenderer.invoke('store:get-preferences'), - setPreferences: (prefs: Partial): Promise => - ipcRenderer.invoke('store:set-preferences', prefs), + getDefaultScreenshotFolder: (): Promise => + ipcRenderer.invoke('store:get-default-screenshot-folder'), + setPreferences: (preferences: Partial): Promise => + ipcRenderer.invoke('store:set-preferences', preferences), getManualDevices: (): Promise> => ipcRenderer.invoke('store:get-manual-devices'), getLastConnected: (): Promise> => @@ -265,8 +267,8 @@ const api = { ipcRenderer.invoke('device:get-active-app', deviceIp), captureScreenshot: (deviceIp: string, themeMode?: 'dark' | 'light'): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('device:capture-screenshot', deviceIp, themeMode), - openScreenshotWindow: (deviceIp: string, themeMode?: 'dark' | 'light'): Promise<{ ok: boolean; error?: string }> => - ipcRenderer.invoke('device:open-screenshot-window', deviceIp, themeMode) + openScreenshotWindow: (deviceIp: string, themeMode?: 'dark' | 'light', initialPath?: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('device:open-screenshot-window', deviceIp, themeMode, initialPath) }, // External @@ -324,8 +326,8 @@ const api = { prime: (): Promise => ipcRenderer.invoke('docs:prime') as Promise, // A nudge (no payload) telling the window to drain the pending lookup // term via getPendingLookup. Returns an unsubscribe function. - onLookupQuery: (cb: () => void): (() => void) => { - const handler = (): void => cb() + onLookupQuery: (callback: () => void): (() => void) => { + const handler = (): void => callback() ipcRenderer.on('docs:lookup-query', handler) return () => ipcRenderer.removeListener('docs:lookup-query', handler) }, @@ -341,8 +343,8 @@ const api = { ipcRenderer.invoke('svg-exporter:import-svg-text', svgText, fileName) as Promise, quantize: (dataUrl: string, colors: number, dither: boolean): Promise => ipcRenderer.invoke('svg-exporter:quantize', dataUrl, colors, dither) as Promise, - savePng: (pngDataUrl: string, defaultName: string): Promise => - ipcRenderer.invoke('svg-exporter:save-png', pngDataUrl, defaultName) as Promise, + saveImage: (dataUrl: string, defaultName: string, format: 'png' | 'webp'): Promise => + ipcRenderer.invoke('svg-exporter:save-image', dataUrl, defaultName, format) as Promise, getInitialData: (): Promise<{ data: { svgText: string; fileName: string; intrinsicWidth: number; intrinsicHeight: number } | null; error: string | null }> => ipcRenderer.invoke('svg-exporter:get-initial-data') as Promise<{ data: { svgText: string; fileName: string; intrinsicWidth: number; intrinsicHeight: number } | null; error: string | null }> }, @@ -571,6 +573,14 @@ const api = { ipcRenderer.invoke('capture:set-mode', mode), saveFrame: (dataUrl: string): Promise }> => ipcRenderer.invoke('capture:save-frame', dataUrl), + /** Main asks the active capture stream for a single frame (roBot's screenshot fallback). */ + onGrabFrame: (callback: (requestId: string) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, requestId: string): void => callback(requestId) + ipcRenderer.on('capture:grab-frame', handler) + return () => ipcRenderer.removeListener('capture:grab-frame', handler) + }, + /** Return a grabbed frame (a PNG data URL, or '' if none) for the given request. */ + frameGrabbed: (requestId: string, dataUrl: string): void => ipcRenderer.send('capture:frame-grabbed', requestId, dataUrl), onModeChanged: (callback: (mode: string) => void) => { const handler = (_event: Electron.IpcRendererEvent, mode: string) => callback(mode) ipcRenderer.on('capture:mode-changed', handler) @@ -578,12 +588,28 @@ const api = { } }, + // Terminal Output (roBot tools) + terminalOutput: { + /** Main asks the dock for the focused terminal tab's buffer (roBot's terminal-output tools). */ + onRequest: (callback: (requestId: string) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, requestId: string): void => callback(requestId) + ipcRenderer.on('terminal-output:request', handler) + return () => ipcRenderer.removeListener('terminal-output:request', handler) + }, + /** Return the focused terminal payload (or null if none) for the given request. */ + respond: (requestId: string, payload: FocusedTerminalPayload | null): void => + ipcRenderer.send('terminal-output:response', requestId, payload), + }, + // Sideload sideload: { pickFile: (): Promise<{ ok: boolean; filePath?: string; fileName?: string }> => ipcRenderer.invoke('sideload:pick-file'), install: (ip: string, filePath: string): Promise => ipcRenderer.invoke('sideload:install', ip, filePath) as Promise, + // Electron 42 removed File.path. webUtils.getPathForFile resolves a dropped file's + // absolute path here in the preload so a drag-drop sideload reuses sideload:install. + getDroppedFilePath: (file: File): string => webUtils.getPathForFile(file), onProgress: (callback: (data: { percent: number; status: string }) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: { percent: number; status: string }) => callback(data) ipcRenderer.on('sideload:progress', handler) @@ -635,8 +661,8 @@ const api = { getHistory: (): Promise => ipcRenderer.invoke('screenshot-preview:get-history'), /** Persist preview preferences (zoom, auto-refresh, overlay opacity). */ - savePrefs: (prefs: ScreenshotPreviewPrefs): Promise => - ipcRenderer.invoke('screenshot-preview:prefs', prefs), + savePrefs: (preferences: ScreenshotPreviewPrefs): Promise => + ipcRenderer.invoke('screenshot-preview:prefs', preferences), /** Push live UI state so main's right-click menu reflects it (fire-and-forget). */ pushState: (state: ScreenshotPreviewState): void => ipcRenderer.send('screenshot-preview:set-state', state), @@ -681,27 +707,43 @@ const api = { previewRedaction: (request: AiRequest, profileId?: string): Promise => ipcRenderer.invoke('ai:preview-redaction', request, profileId), startStream: (request: AiRequest, conversationId?: string): Promise<{ sessionId: string }> => ipcRenderer.invoke('ai:start-stream', request, conversationId), cancelStream: (sessionId: string): void => ipcRenderer.send('ai:cancel-stream', sessionId), - onStreamChunk: (cb: (data: { sessionId: string; delta: string }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { sessionId: string; delta: string }): void => cb(data) + onStreamChunk: (callback: (data: { sessionId: string; delta: string }) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: { sessionId: string; delta: string }): void => callback(data) ipcRenderer.on('ai:stream-chunk', handler) return () => ipcRenderer.removeListener('ai:stream-chunk', handler) }, - onStreamActivity: (cb: (data: { sessionId: string; name: string; args: Record }) => void): (() => void) => { - const handler = (_e: Electron.IpcRendererEvent, data: { sessionId: string; name: string; args: Record }): void => cb(data) + onStreamActivity: (callback: (data: { sessionId: string; name: string; args: Record }) => void): (() => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: { sessionId: string; name: string; args: Record }): void => callback(data) ipcRenderer.on('ai:stream-activity', handler) return () => ipcRenderer.removeListener('ai:stream-activity', handler) }, - onStreamDone: (cb: (data: { sessionId: string; finalText: string; sources: import('../shared/ai/types').DocSource[] }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { sessionId: string; finalText: string; sources: import('../shared/ai/types').DocSource[] }): void => cb(data) + onStreamDone: (callback: (data: { sessionId: string; finalText: string; sources: import('../shared/ai/types').DocSource[] }) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: { sessionId: string; finalText: string; sources: import('../shared/ai/types').DocSource[] }): void => callback(data) ipcRenderer.on('ai:stream-done', handler) return () => ipcRenderer.removeListener('ai:stream-done', handler) }, - onStreamError: (cb: (data: { sessionId: string; message: string }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { sessionId: string; message: string }): void => cb(data) + onStreamError: (callback: (data: { sessionId: string; message: string }) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: { sessionId: string; message: string }): void => callback(data) ipcRenderer.on('ai:stream-error', handler) return () => ipcRenderer.removeListener('ai:stream-error', handler) }, getDocSymbols: (): Promise> => ipcRenderer.invoke('ai:get-doc-symbols'), + /** Tell main which device the user has selected, so device-control tools default to it. */ + setActiveDevice: (ip: string | null): Promise => ipcRenderer.invoke('ai:set-active-device', ip), + /** Subscribe to prompts (confirm / choice) the AI stream asks the user to answer. */ + onUiRequest: (callback: (request: import('../shared/ai/types').AiUiRequest) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, request: import('../shared/ai/types').AiUiRequest): void => callback(request) + ipcRenderer.on('ai:ui-request', handler) + return () => ipcRenderer.removeListener('ai:ui-request', handler) + }, + /** Reply to an AI prompt by its requestId. */ + respondUi: (response: import('../shared/ai/types').AiUiResponse): void => ipcRenderer.send('ai:ui-response', response), + /** Subscribe to screenshots roBot captured, for inline display in the chat. */ + onChatImage: (callback: (image: import('../shared/ai/types').AiChatImage) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, image: import('../shared/ai/types').AiChatImage): void => callback(image) + ipcRenderer.on('ai:chat-image', handler) + return () => ipcRenderer.removeListener('ai:chat-image', handler) + }, getCliOverrides: (): Promise => ipcRenderer.invoke('ai:get-cli-overrides'), setCliOverride: (kind: import('../ai-core/types').CliKind, override: import('../shared/ai/types').CliOverride): Promise => ipcRenderer.invoke('ai:set-cli-override', kind, override), refreshCliDetection: (): Promise => ipcRenderer.invoke('ai:refresh-cli-detection'), diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index b9bfdcc..8ee9b1d 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -26,6 +26,7 @@ import DevicePropertiesDialog from './components/devicePropertiesDialog' import SettingsDialog from './components/settingsDialog' import AboutDialog from './components/aboutDialog' import UpdatesDialog from './components/updatesDialog' +import AiUiPrompt from './components/ai/aiUiPrompt' import CustomMenuBar from './components/customMenuBar' import type { UpdateCheckResult } from '@shared/updates' import type { AppearanceDraft } from '@shared/appearanceDraft' @@ -33,6 +34,7 @@ import type { PanelState } from '@shared/types' import { setAppZoomLevel, stepAppZoom } from './utils/appZoom' import CaptureFloat from './components/captureFloat' import AiChatPanel from './components/ai/aiChatPanel' +import { useTerminalOutputResponder } from './hooks/useTerminalOutputResponder' const BOOT_SPLASH_MIN_DURATION_MS = 1000 const BOOT_FALLBACK_TIMEOUT_MS = 1500 @@ -80,6 +82,7 @@ export default function App() { const setRightPanelWidth = useAppStore(state => state.setRightPanelWidth) const setLeftSplitRatio = useAppStore(state => state.setLeftSplitRatio) const initAiChatStream = useAppStore(state => state.initAiChatStream) + useTerminalOutputResponder() const [aboutOpen, setAboutOpen] = useState(false) const [updatesOpen, setUpdatesOpen] = useState(false) const [updateResult, setUpdateResult] = useState(null) @@ -126,7 +129,10 @@ export default function App() { root.setProperty('--splash-card-start', 'var(--rokdock-bg-panel)') root.setProperty('--splash-card-end', 'var(--rokdock-bg-surface)') root.setProperty('--splash-card-border', 'var(--rokdock-border-light)') + root.setProperty('--splash-title', 'var(--rokdock-text-bright)') root.setProperty('--splash-subtitle', 'var(--rokdock-text-dim)') + root.setProperty('--splash-chips', 'var(--rokdock-text-dim)') + root.setProperty('--splash-status', 'var(--rokdock-text-muted)') root.setProperty('--splash-overlay-bg', 'var(--rokdock-overlay-bg)') void window.rokdock.window.setAuxThemeMode(themeMode).catch((err: unknown) => { console.error('Failed to sync aux window theme mode:', err) @@ -541,6 +547,7 @@ export default function App() { {updatesOpen && ( setUpdatesOpen(false)} onRetry={runUpdateCheck} /> )} + ) diff --git a/src/renderer/capturePreview.ts b/src/renderer/capturePreview.ts index 8c4c4a9..85b07c4 100644 --- a/src/renderer/capturePreview.ts +++ b/src/renderer/capturePreview.ts @@ -28,6 +28,7 @@ import { } from '@fortawesome/free-solid-svg-icons' import { faSvg } from '@shared/icons' import { findMatchingAudioDevice } from '@shared/captureDeviceMatch' +import { videoFrameToPngDataUrl } from './utils/videoFrame' // Apply theme and await fonts before the body is revealed. void bootBundledTheme() @@ -272,16 +273,17 @@ pinBtn.addEventListener('click', () => { screenshotBtn.addEventListener('click', () => { if (!videoEl.srcObject) return - const canvas = document.createElement('canvas') - canvas.width = videoEl.videoWidth - canvas.height = videoEl.videoHeight - const ctx = canvas.getContext('2d') - if (!ctx) return - ctx.drawImage(videoEl, 0, 0) - const dataUrl = canvas.toDataURL('image/png') + const dataUrl = videoFrameToPngDataUrl(videoEl) + if (!dataUrl) return void window.rokdock.capture.saveFrame(dataUrl) }) +// Answer roBot's HDMI screenshot-fallback frame grabs with this popout's current frame (or '' if +// the stream is not live), so the fallback works while the capture is floated out of the dock. +window.rokdock.capture.onGrabFrame((requestId: string) => { + window.rokdock.capture.frameGrabbed(requestId, videoEl.srcObject ? videoFrameToPngDataUrl(videoEl) : '') +}) + // -- Close --------------------------------------------------------------------- closeBtn.addEventListener('click', () => { diff --git a/src/renderer/codeFence.ts b/src/renderer/codeFence.ts new file mode 100644 index 0000000..e139a5e --- /dev/null +++ b/src/renderer/codeFence.ts @@ -0,0 +1,20 @@ +/** + * Wrap text in a Markdown fenced code block so a chat / markdown renderer shows it + * verbatim and monospaced instead of re-flowing it as prose. Used by the terminal + * "Ask roBot" action so pasted debug output keeps its line breaks and spacing. + * + * The fence grows past the longest backtick run in the text: CommonMark requires an + * opening fence to be longer than any backtick run it encloses, so output that + * itself contains backticks still fences correctly. + * + * Pass `language` to tag the opening fence (e.g. 'brightscript') so the renderer + * syntax-highlights the block instead of showing it as plain monospaced text. + */ +export function wrapInCodeFence(text: string, language = ''): string { + const longestBacktickRun = Math.max( + 0, + ...Array.from(text.matchAll(/`+/g), match => match[0].length), + ) + const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1)) + return `${fence}${language}\n${text}\n${fence}` +} diff --git a/src/renderer/components/aboutDialog.tsx b/src/renderer/components/aboutDialog.tsx index b985422..3dcce92 100644 --- a/src/renderer/components/aboutDialog.tsx +++ b/src/renderer/components/aboutDialog.tsx @@ -8,9 +8,18 @@ import React, { useEffect, useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import type { IconDefinition } from '@fortawesome/fontawesome-svg-core' -import { faHexagon, faKeyboard, faGamepad, faXmark, faVideo, faBookOpen, faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons' +import { faHexagon, faKeyboard, faGamepad, faXmark, faVideo, faBookOpen } from '@fortawesome/free-solid-svg-icons' import type { CSSProperties } from 'react' import DialogFrame from './common/dialogFrame' +import { roBot } from './ai/roBotMark' +import { AI_CHAT_TITLE, withBeta } from '../../shared/ai/labels' +import appIconRaw from '../../../resources/icons/icon.svg?raw' + +// The canonical RokDock app icon (same asset that ships as the window/taskbar icon), +// sized to fill its host rather than its native pixel size. Keep the source at +// resources/icons/icon.svg as the single logo of record. The width/height match is +// value-tolerant so a re-exported icon at a different native size still fills the host. +const APP_ICON_SVG = appIconRaw.replace(/width="\d+" height="\d+"/, 'width="100%" height="100%"') const OVERLAY_STYLE: CSSProperties = { zIndex: 2000, @@ -55,6 +64,13 @@ const LOGO_CONTAINER_STYLE: CSSProperties = { justifyContent: 'center', } +const LOGO_MARK_STYLE: CSSProperties = { + width: 64, + height: 64, + display: 'inline-flex', + filter: 'drop-shadow(0 4px 10px rgba(22, 10, 51, 0.4))', +} + const TITLE_GROUP_STYLE: CSSProperties = { display: 'flex', alignItems: 'baseline', @@ -162,39 +178,7 @@ export default function AboutDialog({ onClose }: { onClose: () => void }) {
- - - - - - - - - - - - - - - - - {'>_'} - - +
RokDock @@ -204,7 +188,8 @@ export default function AboutDialog({ onClose }: { onClose: () => void }) {

A desktop workbench for Roku development: discovery, debugging, remote - control, sideloading, screenshots, automation, asset tools, and in-app docs. + control, sideloading, screenshots, automation, asset tools, in-app docs, + and more...

@@ -223,7 +208,7 @@ export default function AboutDialog({ onClose }: { onClose: () => void }) { - + } text={withBeta(AI_CHAT_TITLE)} />
@@ -257,8 +242,8 @@ function Badge({ label }: { label: string }) { ) } -/** Renders a single feature row with a FontAwesome icon and descriptive text. */ -function Feature({ icon, text }: { icon: IconDefinition; text: string }) { +/** Renders a single feature row with an icon (a FontAwesome icon or a custom glyph node) and text. */ +function Feature({ icon, glyph, text }: { icon?: IconDefinition; glyph?: React.ReactNode; text: string }) { return (
- - + + {glyph ?? (icon && )} {text}
diff --git a/src/renderer/components/ai/aiChat.css b/src/renderer/components/ai/aiChat.css index 31a2c93..7df3420 100644 --- a/src/renderer/components/ai/aiChat.css +++ b/src/renderer/components/ai/aiChat.css @@ -190,6 +190,20 @@ font-size: var(--rokdock-font-sm); font-style: italic; } +/* Animated trailing dots so the line reads as live (the tool is still working). */ +.ai-chat-activity::after { + content: ''; + animation: ai-chat-activity-dots 1.4s steps(4, end) infinite; +} +@keyframes ai-chat-activity-dots { + 0% { content: ''; } + 25% { content: '.'; } + 50% { content: '..'; } + 75%, 100% { content: '...'; } +} +@media (prefers-reduced-motion: reduce) { + .ai-chat-activity::after { content: '...'; animation: none; } +} /* "Used docs (N)" collapsible chip shown below assistant messages that consulted pages. */ .ai-chat-sources { margin-top: 6px; } diff --git a/src/renderer/components/ai/aiChatPanel.tsx b/src/renderer/components/ai/aiChatPanel.tsx index 47778a0..f13635a 100644 --- a/src/renderer/components/ai/aiChatPanel.tsx +++ b/src/renderer/components/ai/aiChatPanel.tsx @@ -5,14 +5,42 @@ import React, { useEffect, useRef, useState } from 'react' import { useAppStore } from '../../store/appStore' import ChatMarkdown from './chatMarkdown' -import { AI_CHAT_TITLE, withBeta } from '../../../shared/ai/labels' +import { roBot } from './roBotMark' +import { AI_CHAT_TITLE, AI_BETA_SUFFIX } from '../../../shared/ai/labels' import type { DocSource } from '../../../shared/ai/types' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faTableColumns, faPenToSquare, faPaperPlane, faStop, faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons' +import { faArrowRightArrowLeft, faPenToSquare, faPaperPlane, faStop, faChevronDown, faChevronRight, faGear } from '@fortawesome/free-solid-svg-icons' import IconButton from '../common/iconButton' import CollapsibleSection from '../common/collapsibleSection' import './aiChat.css' +/** + * An inline screenshot roBot captured. Renders a clickable thumbnail that opens the exact shot + * in the full viewer; if the thumbnail fails to load, it degrades to a clickable text fallback. + */ +function ChatScreenshot({ image }: { image: { thumbnailDataUrl: string; deviceIp: string; path: string } }): React.JSX.Element { + const [failed, setFailed] = useState(false) + const open = (): void => { void window.rokdock.device.openScreenshotWindow(image.deviceIp, undefined, image.path) } + if (failed) { + return ( + + ) + } + return ( + Device screenshot setFailed(true)} + /> + ) +} + function MessageSources({ sources }: { sources: DocSource[] }): React.JSX.Element { const [open, setOpen] = useState(false) return ( @@ -45,13 +73,27 @@ export default function AiChatPanel({ flow = false }: { flow?: boolean } = {}): const toggleAiChat = useAppStore(state => state.toggleAiChat) const aiChatDock = useAppStore(state => state.aiChatDock) const cycleAiChatDock = useAppStore(state => state.cycleAiChatDock) + const setSettingsDialogOpen = useAppStore(state => state.setSettingsDialogOpen) const [draft, setDraft] = useState('') + // roBot's inline "pick one" question. Held in the store (set by the global prompt subscriber) + // so it survives a dock switch that remounts this panel, and cleared when the turn ends. + const choice = useAppStore(state => state.aiChatChoice) + const setAiChatChoice = useAppStore(state => state.setAiChatChoice) + const appendChoiceExchange = useAppStore(state => state.appendChoiceExchange) const listRef = useRef(null) useEffect(() => { - const el = listRef.current - if (el?.scrollTo) el.scrollTo({ top: el.scrollHeight }) - }, [messages, streaming]) + const element = listRef.current + if (element?.scrollTo) element.scrollTo({ top: element.scrollHeight }) + }, [messages, streaming, choice]) + + const answerChoice = (value: string): void => { + if (!choice) return + // Keep the exchange in the transcript, then reply and clear the inline prompt. + appendChoiceExchange(choice.question, value) + window.rokdock.ai.respondUi({ requestId: choice.requestId, kind: 'choice', value }) + setAiChatChoice(null) + } const submit = (): void => { const text = draft @@ -69,19 +111,22 @@ export default function AiChatPanel({ flow = false }: { flow?: boolean } = {}): const ACTIONS = ( <> - - + + + setSettingsDialogOpen('ai')}> + + ) + // flow mode fills its flex parent (the right panel below the other sections); dock mode + // fills its own container (the middle drawer / left panel). Everything else (section, body, + // and growing list) is identical, so only the root differs. const rootStyle = flow ? ROOT_STYLE_FLOW : ROOT_STYLE_DOCK - const sectionStyle = flow ? undefined : SECTION_STYLE_DOCK - const sectionBodyStyle = flow ? SECTION_BODY_BASE : SECTION_BODY_STYLE_DOCK - const listStyle = flow ? LIST_STYLE_FLOW : LIST_STYLE return (
+ {/* 1px optical nudge up: the icon is bottom-heavy, so geometric center reads low. */} + + {`${AI_CHAT_TITLE} `} + {AI_BETA_SUFFIX} + + } + style={SECTION_STYLE} + bodyStyle={SECTION_BODY_STYLE} actions={ACTIONS} > -
+
{messages.length === 0 && !streaming && ( -
Ask about Roku, BrightScript, or SceneGraph, or paste output to explain.
+
Ask roBot about Roku, BrightScript, or SceneGraph, or paste output to explain.
)} {messages.map((message, i) => (
- {message.role === 'assistant' ? : message.content} + {/* Assistant replies are markdown. User messages are shown verbatim, + except when they carry a fenced code block (e.g. an "Ask roBot" + terminal selection), which we render as markdown so the fence + becomes a real code block instead of literal backticks. */} + {message.role === 'assistant' || message.content.includes('```') + ? + : message.content} + {message.image && } {message.role === 'assistant' && message.sources && message.sources.length > 0 && }
))} @@ -118,7 +177,26 @@ export default function AiChatPanel({ flow = false }: { flow?: boolean } = {}): ? : streaming.activity ?
{streaming.activity}
- : } + : } +
+ )} + {choice && ( +
+
{choice.question}
+
+ {choice.options.map((option, index) => ( + + ))} +
)}
@@ -129,7 +207,7 @@ export default function AiChatPanel({ flow = false }: { flow?: boolean } = {}): className="rokdock-input" style={INPUT_STYLE} value={draft} - placeholder="Ask anything..." + placeholder="Ask roBot anything..." rows={2} onChange={e => setDraft(e.target.value)} onKeyDown={onKeyDown} @@ -144,20 +222,36 @@ export default function AiChatPanel({ flow = false }: { flow?: boolean } = {}): ) } -// root -const ROOT_STYLE_FLOW: React.CSSProperties = { height: 'auto' } +// root: flow grows to fill the right panel's remaining space (a flex child, with a floor so +// it never collapses when the sections above are tall); dock fills its own container. +const ROOT_STYLE_FLOW: React.CSSProperties = { flex: 1, minHeight: 180, display: 'flex', flexDirection: 'column', overflow: 'hidden' } const ROOT_STYLE_DOCK: React.CSSProperties = { height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } -// section wrapper -const SECTION_STYLE_DOCK: React.CSSProperties = { flex: 1, minHeight: 0 } +// The section, its body, and the message list are the same in both modes (only the +// root differs); the list grows to fill and scrolls internally. +const SECTION_STYLE: React.CSSProperties = { flex: 1, minHeight: 0 } +const SECTION_BODY_STYLE: React.CSSProperties = { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--rokdock-bg-panel)' } +const LIST_STYLE: React.CSSProperties = { flex: 1, minHeight: 0, overflowY: 'auto', padding: 8, display: 'flex', flexDirection: 'column', gap: 8 } +// Header title: the wordmark logo, an off-screen "roBot" name, and the visible "(Beta)". +const TITLE_ROW_STYLE: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 3, verticalAlign: 'middle', lineHeight: 0 } +const WORDMARK_NUDGE_STYLE: React.CSSProperties = { transform: 'translateY(-1px)' } +const BETA_SUFFIX_STYLE: React.CSSProperties = { fontSize: 'var(--rokdock-font-xxs)', color: 'var(--rokdock-text-muted)', fontWeight: 400 } +// Off-screen text equivalent: the wordmark SVG has no text nodes, so the header carries +// the "roBot" name here for screen readers and tests while staying visually the logo. +const VISUALLY_HIDDEN: React.CSSProperties = { position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', border: 0 } +// The streaming "thinking" row: the glyph beside the animated typing dots. Block flex (not +// inline-flex) so it centers vertically in the bubble instead of baseline-aligning high. +const THINKING_ROW_STYLE: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8 } -// section body - shared base fields -const SECTION_BODY_BASE: React.CSSProperties = { display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--rokdock-bg-panel)' } -const SECTION_BODY_STYLE_DOCK: React.CSSProperties = { flex: 1, minHeight: 0, ...SECTION_BODY_BASE } +// Inline screenshot thumbnail roBot captured. A click opens the full Screenshot viewer. +const SCREENSHOT_THUMB_STYLE: React.CSSProperties = { display: 'block', marginTop: 6, maxWidth: '100%', borderRadius: 6, cursor: 'pointer', border: '1px solid var(--rokdock-border)' } +// Shown in place of the thumbnail when the inline preview cannot be rendered. +const SCREENSHOT_FALLBACK_STYLE: React.CSSProperties = { marginTop: 6, justifyContent: 'flex-start', textAlign: 'left', fontSize: 'var(--rokdock-font-sm)' } -const LIST_STYLE_BASE: React.CSSProperties = { overflowY: 'auto', padding: 8, display: 'flex', flexDirection: 'column', gap: 8 } -const LIST_STYLE: React.CSSProperties = { ...LIST_STYLE_BASE, flex: 1, minHeight: 0 } -const LIST_STYLE_FLOW: React.CSSProperties = { ...LIST_STYLE_BASE, maxHeight: 320 } +// roBot's inline "pick one" question: the prompt text above a column of clickable options. +const CHOICE_QUESTION_STYLE: React.CSSProperties = { fontSize: 'var(--rokdock-font-sm)', marginBottom: 8 } +const CHOICE_OPTIONS_STYLE: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 6 } +const CHOICE_OPTION_STYLE: React.CSSProperties = { justifyContent: 'flex-start', textAlign: 'left' } const EMPTY_STYLE: React.CSSProperties = { color: 'var(--rokdock-text-dim)', diff --git a/src/renderer/components/ai/aiUiPrompt.tsx b/src/renderer/components/ai/aiUiPrompt.tsx new file mode 100644 index 0000000..81eb52c --- /dev/null +++ b/src/renderer/components/ai/aiUiPrompt.tsx @@ -0,0 +1,66 @@ +/** + * The single subscriber to roBot's UI prompts. A confirm prompt (approve a state-changing + * device action) shows here as the app's own dialog (not a native OS box). A choice prompt + * (pick from options) is routed into the store so the chat panel can render it inline and it + * survives a panel remount. Mounted once at the app root. When the turn ends (done or Stop), + * any still-open prompt is dismissed since main has already settled the awaited reply. + */ +import React, { useEffect, useState } from 'react' +import type { CSSProperties } from 'react' +import type { AiUiRequest } from '../../../shared/ai/types' +import { useAppStore } from '../../store/appStore' +import ConfirmDialog from '../common/confirmDialog' + +const GRANT_LABEL: CSSProperties = { + display: 'flex', alignItems: 'center', gap: 8, fontSize: 'var(--rokdock-font-sm)', + color: 'var(--rokdock-text-dim)', cursor: 'pointer', +} + +export default function AiUiPrompt(): React.JSX.Element | null { + const streaming = useAppStore(state => state.aiChatStreaming) + const setChoice = useAppStore(state => state.setAiChatChoice) + const [request, setRequest] = useState<{ requestId: string; summary: string } | null>(null) + const [grantChat, setGrantChat] = useState(false) + + useEffect(() => window.rokdock.ai.onUiRequest((next: AiUiRequest) => { + if (next.kind === 'confirm') { + setGrantChat(false) + setRequest({ requestId: next.requestId, summary: next.summary }) + } else if (next.kind === 'choice') { + setChoice(next) + } + }), [setChoice]) + + // The turn ended (finished or the user pressed Stop): drop any open prompt. Main has already + // resolved the awaited reply (to a decline on abort), so leaving one up would be stale. + useEffect(() => { + if (!streaming) { + setRequest(null) + setChoice(null) + } + }, [streaming, setChoice]) + + if (!request) return null + + const respond = (choice: 'deny' | 'once' | 'chat'): void => { + window.rokdock.ai.respondUi({ requestId: request.requestId, kind: 'confirm', choice }) + setRequest(null) + } + + return ( + respond(grantChat ? 'chat' : 'once')} + onCancel={() => respond('deny')} + > + + + ) +} diff --git a/src/renderer/components/ai/assets/roBotGlyph.svg b/src/renderer/components/ai/assets/roBotGlyph.svg new file mode 100644 index 0000000..de6e6b5 --- /dev/null +++ b/src/renderer/components/ai/assets/roBotGlyph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/components/ai/assets/roBotGlyphMono.svg b/src/renderer/components/ai/assets/roBotGlyphMono.svg new file mode 100644 index 0000000..4c84e6b --- /dev/null +++ b/src/renderer/components/ai/assets/roBotGlyphMono.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/components/ai/assets/roBotLogotype.svg b/src/renderer/components/ai/assets/roBotLogotype.svg new file mode 100644 index 0000000..2576a3d --- /dev/null +++ b/src/renderer/components/ai/assets/roBotLogotype.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/components/ai/assets/roBotWordmark.svg b/src/renderer/components/ai/assets/roBotWordmark.svg new file mode 100644 index 0000000..d059aac --- /dev/null +++ b/src/renderer/components/ai/assets/roBotWordmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/components/ai/chatMarkdown.tsx b/src/renderer/components/ai/chatMarkdown.tsx index 9d32448..27b0f0a 100644 --- a/src/renderer/components/ai/chatMarkdown.tsx +++ b/src/renderer/components/ai/chatMarkdown.tsx @@ -12,15 +12,23 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { hasRokuSymbolShape } from '../../../shared/docs/docSymbols' import { highlightToHtml } from '../../docs/highlight/staticHighlight' +import { highlightConsoleToHtml } from './terminalConsoleHighlight' +import { resolveSyntaxTheme } from '../../styles/terminalSyntaxThemes' +import { resolveThemeMode } from '../../styles/theme' +import { useAppStore } from '../../store/appStore' const IDENTIFIER_RE = /[A-Za-z][A-Za-z0-9]*/g // Stable identity so streaming re-renders do not reinstall the remark pipeline. const REMARK_PLUGINS = [remarkGfm] /** - * A fenced code block with syntax highlighting (shared staticHighlight pipeline, - * inline-styled with the live theme vars) and a hover Copy button. The button + * A fenced code block with syntax highlighting and a hover Copy button. The button * sits on a non-scrolling wrapper so it stays pinned while wide code scrolls. + * + * A 'roku-console' fence (emitted by the terminal "Ask roBot" action) is + * highlighted with the terminal's own tokenizer and active syntax theme so the echo + * matches the terminal exactly. Every other language uses the shared staticHighlight + * pipeline (correct for real source, e.g. the assistant's BrightScript blocks). */ function ChatCodeBlock({ code, language }: { code: string; language: string }): React.JSX.Element { const [copied, setCopied] = React.useState(false) @@ -33,8 +41,20 @@ function ChatCodeBlock({ code, language }: { code: string; language: string }): }).catch(() => { /* clipboard unavailable or denied: stay idle */ }) } React.useEffect(() => () => { if (resetTimer.current !== null) window.clearTimeout(resetTimer.current) }, []) - // Re-highlight only when the code or language changes, not on unrelated re-renders. - const html = React.useMemo(() => highlightToHtml(code, language), [code, language]) + const syntaxPreset = useAppStore(state => state.terminalSyntaxThemePreset) + const syntaxCustom = useAppStore(state => state.terminalSyntaxThemeCustomColors) + const themeMode = resolveThemeMode(useAppStore(state => state.themeMode)) + const syntaxTheme = React.useMemo( + () => resolveSyntaxTheme(syntaxPreset, themeMode, syntaxCustom), + [syntaxPreset, themeMode, syntaxCustom], + ) + // Re-highlight only when the inputs change, not on unrelated re-renders. + const html = React.useMemo( + () => language === 'roku-console' + ? highlightConsoleToHtml(code, syntaxTheme) + : highlightToHtml(code, language), + [code, language, syntaxTheme], + ) return (
diff --git a/src/renderer/components/common/iconButton.tsx b/src/renderer/components/common/iconButton.tsx index b87d435..4a5287a 100644 --- a/src/renderer/components/common/iconButton.tsx +++ b/src/renderer/components/common/iconButton.tsx @@ -19,7 +19,9 @@ type IconButtonProps = { 'data-testid'?: string } -const sizeMap = { sm: 20, md: 26 } +/** Outer button footprint (px) per size variant. Exported so layouts can reserve space. */ +export const ICON_BUTTON_SIZE = { sm: 20, md: 26 } as const +const sizeMap = ICON_BUTTON_SIZE const iconSizeMap = { sm: 11, md: 12 } /** diff --git a/src/renderer/components/customTerminalView.tsx b/src/renderer/components/customTerminalView.tsx index 30f9010..1f675d6 100644 --- a/src/renderer/components/customTerminalView.tsx +++ b/src/renderer/components/customTerminalView.tsx @@ -41,9 +41,13 @@ import { } from '../../shared/terminal' import { resolveSyntaxTheme, type TerminalSyntaxTheme } from '../styles/terminalSyntaxThemes' import { escapeRegExp } from '@shared/escapeRegExp' +import { createRegexMatchClient } from './terminal/regexMatchClient' +import type { RegexMatchClient } from './terminal/regexMatchClient' import { selectionQualifiesForLookup, qualifyingLookupTerm } from './terminalDocsLookup' +import { wrapInCodeFence } from '../codeFence' import TerminalSelectionToolbar from './terminalSelectionToolbar' import ConfirmDialog from './common/confirmDialog' +import RegexFilterDialog from './terminal/regexFilterDialog' import { buildSegments, groupSegmentsForLine, @@ -91,6 +95,11 @@ export function clearTerminalCache(tabId: string): void { terminalLinesCache.delete(tabId) } +/** Read a tab's cached line buffer (write-through). Used by the terminal-output responder. */ +export function readTerminalCache(tabId: string): TerminalLineChunk[] | undefined { + return terminalLinesCache.get(tabId) +} + /** @@ -112,8 +121,8 @@ const JSON_LINK_ACTIVE_CLASS = 'rokdock-terminal-json-link-active' /** Remove the hover-active CSS class from every JSON link element inside `root`. */ function clearJsonLinkActiveInRoot(root: HTMLElement | null): void { if (!root) return - root.querySelectorAll(`.${JSON_LINK_ACTIVE_CLASS}`).forEach((el) => { - el.classList.remove(JSON_LINK_ACTIVE_CLASS) + root.querySelectorAll(`.${JSON_LINK_ACTIVE_CLASS}`).forEach((element) => { + element.classList.remove(JSON_LINK_ACTIVE_CLASS) }) } @@ -125,9 +134,9 @@ function clearJsonLinkActiveInRoot(root: HTMLElement | null): void { function setJsonLinkActiveGroupInRoot(root: HTMLElement | null, ig: string): void { if (!root) return clearJsonLinkActiveInRoot(root) - const sel = `[data-json-ig="${CSS.escape(ig)}"]` - root.querySelectorAll(sel).forEach((el) => { - el.classList.add(JSON_LINK_ACTIVE_CLASS) + const selector = `[data-json-ig="${CSS.escape(ig)}"]` + root.querySelectorAll(selector).forEach((element) => { + element.classList.add(JSON_LINK_ACTIVE_CLASS) }) } @@ -138,9 +147,9 @@ function setJsonLinkActiveGroupInRoot(root: HTMLElement | null, ig: string): voi */ function clearJsonLinkActiveGroupInRoot(root: HTMLElement | null, ig: string): void { if (!root) return - const sel = `[data-json-ig="${CSS.escape(ig)}"]` - root.querySelectorAll(sel).forEach((el) => { - el.classList.remove(JSON_LINK_ACTIVE_CLASS) + const selector = `[data-json-ig="${CSS.escape(ig)}"]` + root.querySelectorAll(selector).forEach((element) => { + element.classList.remove(JSON_LINK_ACTIVE_CLASS) }) } @@ -520,7 +529,10 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean const aiConfigured = useAppStore((state) => state.aiConfigured) const openChatWith = useAppStore((state) => state.openChatWith) - const [lines, setLines] = useState([]) + // Seed from the write-through cache so a remounted view (pane move, or the left panel being + // collapsed then reopened) restores its last-known buffer. The initializer runs once on mount, + // before the write-through effect below, so it cannot be clobbered by that effect's first run. + const [lines, setLines] = useState(() => terminalLinesCache.get(tab.id) ?? []) const [input, setInput] = useState('') const [historyIndex, setHistoryIndex] = useState(null) const [historyDraft, setHistoryDraft] = useState('') @@ -536,14 +548,20 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean const [searchRegex, setSearchRegex] = useState(false) const [historyMenuOpen, setHistoryMenuOpen] = useState(false) const [streamFilePath, setStreamFilePath] = useState(null) + // Optional line filter for Save-output / Stream-to-file: the prompt mode plus a + // snapshot of the buffer texts taken when it opens (for the live match count), null + // when closed; and the compiled regex applied to streamed lines (null = every line). + const [filterPrompt, setFilterPrompt] = useState<{ mode: 'save' | 'stream'; sampleLines: string[] } | null>(null) + const streamFilterRef = useRef(null) const markActivityRafRef = useRef(null) const bufferCountRafRef = useRef(null) const pendingBufferLineCountRef = useRef(null) - const linesRef = useRef(lines) + // Write through to the module cache so an always-mounted responder can read the focused tab's + // buffer even while this component is unmounted (e.g. the left panel is collapsed). useLayoutEffect(() => { - linesRef.current = lines - }, [lines]) + terminalLinesCache.set(tab.id, lines) + }, [lines, tab.id]) useLayoutEffect(() => { pendingBufferLineCountRef.current = lines.length @@ -875,39 +893,52 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean } }, [isActive, lines, jsonHoverDetectEnabled, tab.wordWrap]) - const searchState = useMemo(() => { - if (!isActive) return { matches: [], regexError: null } + // Search matching runs in a Web Worker (regexMatchClient) so a catastrophic-backtracking + // user pattern cannot freeze the renderer: the client hard-terminates a stuck worker on a + // watchdog timeout and surfaces "Pattern too slow" instead of hanging. The run is debounced, + // and the previous matches stay on screen until the new result arrives (no per-keystroke flicker). + const searchClientRef = useRef(null) + const filterClientRef = useRef(null) + const streamClientRef = useRef(null) + useEffect(() => () => { + searchClientRef.current?.dispose() + searchClientRef.current = null + filterClientRef.current?.dispose() + filterClientRef.current = null + streamClientRef.current?.dispose() + streamClientRef.current = null + }, []) + const ensureFilterClient = (): RegexMatchClient => { + if (!filterClientRef.current) filterClientRef.current = createRegexMatchClient() + return filterClientRef.current + } + // Set once the stream filter times out on a streamed line: from then on the stream is written + // unfiltered (see the stream effect), so a catastrophic pattern on a future line cannot freeze + // or repeatedly stall the write loop. Reset when a new stream starts or streaming stops. + const streamFilterTimedOutRef = useRef(false) + + const [searchState, setSearchState] = useState({ matches: [], regexError: null }) + useEffect(() => { + if (!isActive) { setSearchState({ matches: [], regexError: null }); return } const query = searchQuery.trim() - if (!query) return { matches: [], regexError: null } + if (!query) { setSearchState({ matches: [], regexError: null }); return } let source = searchRegex ? query : escapeRegExp(query) if (searchWholeWord) source = `\\b${source}\\b` + const flags = searchMatchCase ? 'g' : 'gi' + const lines = linesWithJsonFallback.map((line) => line.text) - let pattern: RegExp - try { - pattern = new RegExp(source, searchMatchCase ? 'g' : 'gi') - } catch { - return { matches: [], regexError: 'Invalid regex' } - } - - const matches: SearchMatch[] = [] - for (let lineIndex = 0; lineIndex < linesWithJsonFallback.length; lineIndex++) { - const haystack = linesWithJsonFallback[lineIndex].text - if (!haystack) continue - pattern.lastIndex = 0 - while (true) { - const found = pattern.exec(haystack) - if (!found) break - const token = found[0] - const start = found.index - const end = start + token.length - if (token.length === 0) { - pattern.lastIndex += 1 - continue - } - matches.push({ lineIndex, start, end }) - } - } - return { matches, regexError: null } + let cancelled = false + const debounce = setTimeout(() => { + if (!searchClientRef.current) searchClientRef.current = createRegexMatchClient() + void searchClientRef.current.search(source, flags, lines).then((outcome) => { + if (cancelled) return + if (outcome.status === 'ok') setSearchState({ matches: outcome.value, regexError: null }) + else if (outcome.status === 'invalid') setSearchState({ matches: [], regexError: 'Invalid regex' }) + else if (outcome.status === 'timeout') setSearchState({ matches: [], regexError: 'Pattern too slow' }) + // 'superseded': a newer keystroke is already in flight; let it set the state. + }) + }, 120) + return () => { cancelled = true; clearTimeout(debounce) } }, [isActive, linesWithJsonFallback, searchMatchCase, searchQuery, searchRegex, searchWholeWord]) const searchMatches = searchState.matches @@ -984,13 +1015,13 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean // selection rather than over it. Captures both the full selection (for Explain) // and the qualifying short term (for docs lookup, null when not applicable). const onOutputMouseUp = useCallback(() => { - const sel = window.getSelection() - const text = sel?.toString() ?? '' - if (!text.trim() || !sel || sel.rangeCount === 0) { + const selection = window.getSelection() + const text = selection?.toString() ?? '' + if (!text.trim() || !selection || selection.rangeCount === 0) { setSelectionAnchor(null) return } - const rect = sel.getRangeAt(0).getBoundingClientRect() + const rect = selection.getRangeAt(0).getBoundingClientRect() setSelectionAnchor({ x: rect.left, y: rect.top, selection: text, term: qualifyingLookupTerm() }) }, []) @@ -1007,6 +1038,20 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean return () => document.removeEventListener('selectionchange', onSelectionChange) }, [toolbarVisible]) + // The toolbar is pinned to a fixed viewport point captured when the selection + // was made. Resizing the layout (e.g. dragging the AI chat panel divider) moves + // the output without firing a scroll, so the toolbar would otherwise strand over + // now-hidden content. Clear it on any viewport resize, mirroring onViewportScroll. + // Attached at mount, where the anchor is null, so the observer's initial callback + // is a no-op rather than dismissing a fresh selection. + useEffect(() => { + const viewport = viewportRef.current + if (!viewport) return + const observer = new ResizeObserver(() => setSelectionAnchor(null)) + observer.observe(viewport) + return () => observer.disconnect() + }, []) + /** * Return the LineSegmentGroups for a terminal line, using a per-line identity * cache keyed by line.id. The cache is invalidated whenever the line object @@ -1024,7 +1069,13 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean return groups }, []) - const shouldVirtualize = !tab.wordWrap && !searchVisible + // Fixed-height virtualization stays ON during search: rendering the whole buffer (up to + // TERMINAL_MAX_BUFFER_LINES) when the find bar opened froze the terminal. The scroll-to-match + // effect brings an off-screen active match into the virtual window (set scrollTop, then + // scrollIntoView on the next frame), so jump-to-match still works without mounting every line. + // Word-wrap rows have variable height and cannot use fixed-height virtualization, so wrapped + // search still renders the full buffer (its match jump relies on the line already being in the DOM). + const shouldVirtualize = !tab.wordWrap const shouldWindowWrapRows = tab.wordWrap && tab.autoScroll && !searchVisible const totalVisibleLineCount = linesWithJsonFallback.length const virtualStartIndex = shouldVirtualize @@ -1164,22 +1215,6 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean [flushPendingTerminalLines] ) - // Save lines to cache on unmount using useLayoutEffect so the cleanup runs before - // the newly-mounted pane B component's useLayoutEffect setup in the same commit. - useLayoutEffect(() => { - return () => { - terminalLinesCache.set(tab.id, linesRef.current) - } - // tab.id is stable for a given instance; [] would also work but this is explicit - }, [tab.id]) - - // Restore from cache on mount. Runs after the unmounting component's layout effect - // cleanup (which saved to cache), so the cache entry is guaranteed to be there. - useLayoutEffect(() => { - const cached = terminalLinesCache.get(tab.id) - if (cached && cached.length > 0) setLines(cached) - }, [tab.id]) - useEffect( () => () => { if (flushRafRef.current !== null) { @@ -1234,21 +1269,55 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean setHistoryMenuOpen(false) }, [addTerminalCommandHistory, input, tab.id]) + /** Appends a one-line warning message to the terminal. */ + const appendWarning = useCallback((message: string) => { + appendLine({ text: message, tokens: [{ start: 0, end: message.length, kind: 'warning' }], overlays: [] }) + }, [appendLine]) + useEffect(() => { if (!streamFilePath || linesWithJsonFallback.length === 0) return if (streamWriteInFlightRef.current) return const from = Math.max(0, Math.min(streamCursorRef.current, linesWithJsonFallback.length)) if (from >= linesWithJsonFallback.length) return - const delta = linesWithJsonFallback.slice(from).map((line) => line.text).join('\n') + '\n' + // Advance only past the lines examined THIS pass (this snapshot's length), not the + // live ref length. Lines that arrive during the async filter/append are then picked up by + // a later pass instead of being skipped over. + const examinedEnd = linesWithJsonFallback.length + const newTexts = linesWithJsonFallback.slice(from).map((line) => line.text) + const filter = streamFilterRef.current + // Single-in-flight: hold the gate across the async filter AND the append so batches stay + // strictly ordered (the worker preserves input order, so writes remain FIFO). streamWriteInFlightRef.current = true - void window.rokdock.dialog.appendFile(streamFilePath, delta) - .then((ok: boolean) => { - if (ok) streamCursorRef.current = linesWithJsonFallbackRef.current.length - }) - .finally(() => { + void (async () => { + try { + // Filter this batch in the regex worker so a catastrophic pattern on a future line + // cannot freeze the renderer. On timeout the worker is terminated and streaming + // continues UNFILTERED from then on (one-time warning); line order is never disturbed. + let kept: string[] + if (!filter || streamFilterTimedOutRef.current) { + kept = newTexts + } else { + if (!streamClientRef.current) streamClientRef.current = createRegexMatchClient() + const outcome = await streamClientRef.current.filter(filter.source, filter.flags, newTexts) + if (outcome.status === 'ok') { + kept = outcome.value.map((index) => newTexts[index]!) + } else { + streamFilterTimedOutRef.current = true + appendWarning('Filter pattern too slow on a streamed line; writing subsequent lines unfiltered.') + kept = newTexts + } + } + if (kept.length === 0) { + streamCursorRef.current = examinedEnd + return + } + const ok = await window.rokdock.dialog.appendFile(streamFilePath, kept.join('\n') + '\n') + if (ok) streamCursorRef.current = examinedEnd + } finally { streamWriteInFlightRef.current = false - }) - }, [linesWithJsonFallback, streamFilePath]) + } + })() + }, [linesWithJsonFallback, streamFilePath, appendWarning]) useEffect(() => { const unsubData = window.rokdock.terminal.onData((id: string, chunk: TerminalLineChunk) => { @@ -1274,6 +1343,8 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean if (id !== tab.id) return updateTabStatus(id, 'disconnected') streamCursorRef.current = linesWithJsonFallbackRef.current.length + streamFilterRef.current = null + streamFilterTimedOutRef.current = false setStreamFilePath(null) appendLine({ text: `Process exited (code ${exitCode})`, @@ -1315,29 +1386,15 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean .reconnect(tab.id, tab.deviceIp, tab.deviceName, tab.port) .catch(() => updateTabStatus(tab.id, 'error')) } else if (action === 'save-output') { - const content = linesWithJsonFallbackRef.current.map((line) => line.text).join('\n') - void window.rokdock.dialog.saveFile(buildLogFilename(tab.deviceIp, tab.port), content).then((ok: boolean) => { - if (!ok) { - appendLine({ - text: 'Save output canceled or failed.', - tokens: [{ start: 0, end: 'Save output canceled or failed.'.length, kind: 'warning' }], - overlays: [] - }) - } - }) + // Prompt for an optional line filter before saving (empty = every line). + setFilterPrompt({ mode: 'save', sampleLines: linesWithJsonFallbackRef.current.map((line) => line.text) }) } else if (action === 'start-stream-output') { - void window.rokdock.dialog.pickSavePath(buildLogFilename(tab.deviceIp, tab.port, 'stream')).then((filePath: string | null) => { - if (!filePath) return - streamCursorRef.current = linesWithJsonFallbackRef.current.length - setStreamFilePath(filePath) - appendLine({ - text: `Streaming terminal output to: ${filePath}`, - tokens: [{ start: 0, end: `Streaming terminal output to: ${filePath}`.length, kind: 'info' }], - overlays: [] - }) - }) + // Prompt for an optional line filter before choosing the stream file. + setFilterPrompt({ mode: 'stream', sampleLines: linesWithJsonFallbackRef.current.map((line) => line.text) }) } else if (action === 'stop-stream-output') { streamCursorRef.current = linesWithJsonFallbackRef.current.length + streamFilterRef.current = null + streamFilterTimedOutRef.current = false setStreamFilePath(null) appendLine({ text: 'Stopped streaming terminal output.', @@ -1349,7 +1406,7 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean if (term) void window.rokdock.docs.lookUp(term) } else if (action === 'explain') { const selection = window.getSelection()?.toString() ?? '' - if (selection.trim()) void openChatWith(selection) + if (selection.trim()) void openChatWith(wrapInCodeFence(selection, 'roku-console')) } }) return () => { @@ -1380,6 +1437,40 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean updateTabStatus ]) + // Save the current buffer, keeping only the lines matching the chosen filter. Filtering runs in + // the regex worker: the buffer can grow between opening the dialog and confirming, so a pattern + // the dialog validated may still meet an unseen line here. A too-slow pattern aborts the save + // (the worker is terminated) instead of freezing the app. + const handleFilteredSave = async (regex: RegExp | null) => { + setFilterPrompt(null) + const texts = linesWithJsonFallbackRef.current.map((line) => line.text) + let content: string + if (!regex) { + content = texts.join('\n') + } else { + const outcome = await ensureFilterClient().filter(regex.source, regex.flags, texts) + if (outcome.status === 'timeout') { appendWarning('Filter pattern too slow; save aborted.'); return } + if (outcome.status !== 'ok') return // invalid: the dialog gates this, so treat as a no-op + content = outcome.value.map((index) => texts[index]!).join('\n') + } + const ok = await window.rokdock.dialog.saveFile(buildLogFilename(tab.deviceIp, tab.port), content) + if (!ok) appendWarning('Save output canceled or failed.') + } + + // Begin streaming to a chosen file, writing only lines matching the chosen filter. + const handleFilteredStream = (regex: RegExp | null) => { + setFilterPrompt(null) + void window.rokdock.dialog.pickSavePath(buildLogFilename(tab.deviceIp, tab.port, 'stream')).then((filePath: string | null) => { + if (!filePath) return + streamFilterRef.current = regex + streamFilterTimedOutRef.current = false + streamCursorRef.current = linesWithJsonFallbackRef.current.length + setStreamFilePath(filePath) + const message = `Streaming terminal output to: ${filePath}${regex ? ` (filter: ${regex.source})` : ''}` + appendLine({ text: message, tokens: [{ start: 0, end: message.length, kind: 'info' }], overlays: [] }) + }) + } + useEffect(() => { if (!tab.autoScroll || !viewportRef.current) return viewportRef.current.scrollTop = viewportRef.current.scrollHeight @@ -1821,18 +1912,18 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean {historyMenuOpen && terminalCommandHistory.length > 0 && (
- {[...terminalCommandHistory].slice(-20).reverse().map((cmd) => ( + {[...terminalCommandHistory].slice(-20).reverse().map((command) => ( ))}
@@ -1849,13 +1940,28 @@ function CustomTerminalView({ tab, isActive }: { tab: TabInfo; isActive: boolean setSelectionAnchor(null) }} onExplain={() => { - void openChatWith(selectionAnchor.selection) + void openChatWith(wrapInCodeFence(selectionAnchor.selection, 'roku-console')) setSelectionAnchor(null) }} onClose={() => setSelectionAnchor(null)} /> )}
+ ensureFilterClient().filter(source, flags, lines)} + onCancel={() => setFilterPrompt(null)} + onConfirm={(regex) => { + if (filterPrompt?.mode === 'stream') void handleFilteredStream(regex) + else void handleFilteredSave(regex) + }} + /> { searchCloseBtn: smallBtn, searchIconGroup: { display: 'flex', alignItems: 'center', gap: 2 }, viewport: { flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 10px' }, - output: { lineHeight: 1.45, userSelect: 'text' }, + // Render debug output literally: a ligature-capable mono font (e.g. JetBrains + // Mono) otherwise fuses sequences like -> or != into a single glyph, which + // misrepresents what the device actually emitted. + output: { lineHeight: 1.45, userSelect: 'text', fontVariantLigatures: 'none', fontFeatureSettings: '"liga" 0, "calt" 0' }, line: { minHeight: 18 }, lineSearchMatch: { background: 'var(--rokdock-search-line-bg)', borderRadius: 3 }, lineSearchActive: { background: 'var(--rokdock-search-line-active-bg)', outline: '1px solid var(--rokdock-brand-primary-light)', borderRadius: 3 }, diff --git a/src/renderer/components/devicePanel.tsx b/src/renderer/components/devicePanel.tsx index de65f36..83a94b8 100644 --- a/src/renderer/components/devicePanel.tsx +++ b/src/renderer/components/devicePanel.tsx @@ -143,9 +143,9 @@ export default function DevicePanel() { const cardEls = [...(listRef.current?.querySelectorAll('[data-device-ip]') ?? [])] as HTMLElement[] const candidates = cardEls - .map((el) => ({ - ip: el.dataset.deviceIp || '', - centerY: el.getBoundingClientRect().top + (el.getBoundingClientRect().height / 2) + .map((element) => ({ + ip: element.dataset.deviceIp || '', + centerY: element.getBoundingClientRect().top + (element.getBoundingClientRect().height / 2) })) .filter(({ ip }) => !!ip && ip !== drag.ip) diff --git a/src/renderer/components/devicePanel/deviceCard.tsx b/src/renderer/components/devicePanel/deviceCard.tsx index 1a1b984..e6e6d86 100644 --- a/src/renderer/components/devicePanel/deviceCard.tsx +++ b/src/renderer/components/devicePanel/deviceCard.tsx @@ -20,7 +20,7 @@ * since the /plugin_install endpoint requires developer mode. */ -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faChevronRight, faLock, faUnlock } from '@fortawesome/free-solid-svg-icons' import { useAppStore, createTabInfo, type Device } from '../../store/appStore' @@ -128,6 +128,12 @@ export default function DeviceCard({ const [hovered, setHovered] = useState(false) const [showRemoveConfirm, setShowRemoveConfirm] = useState(false) const [sideloadOpen, setSideloadOpen] = useState(false) + // Drag-and-drop sideload: dropping a single .zip/.pkg onto the card opens the + // SideloadDialog pre-loaded with that package (reusing the whole install + progress flow). + const [dragActive, setDragActive] = useState(false) + const [dropError, setDropError] = useState(null) + const [droppedFile, setDroppedFile] = useState<{ filePath: string; fileName: string } | null>(null) + const dropErrorTimer = useRef(null) const addTab = useAppStore((state) => state.addTab) const remoteTargetIp = useAppStore((state) => state.remoteTargetIp) const setRemoteTargetIp = useAppStore((state) => state.setRemoteTargetIp) @@ -154,6 +160,70 @@ export default function DeviceCard({ const isRemoteTarget = remoteTargetIp === device.ip const devHasAuth = deviceHasAuth[device.ip] const canSideload = device.developerEnabled !== false && !!deviceHasAuth[device.ip] + // Accent for the drag-drop affordance: red on a rejection, brand on a droppable card, + // muted when the card can't be sideloaded. Drives both the dashed border and its tint. + const dropAccent = dropError + ? 'var(--rokdock-state-error)' + : canSideload ? 'var(--rokdock-brand-primary)' : 'var(--rokdock-text-muted)' + + // Clear the transient drop-error timer on unmount. + useEffect(() => () => { if (dropErrorTimer.current !== null) window.clearTimeout(dropErrorTimer.current) }, []) + + /** True when an OS file (not an internal element drag) is dragged over the card. */ + const isFileDrag = (event: React.DragEvent): boolean => event.dataTransfer.types.includes('Files') + + /** Shows a transient rejection message on the card, auto-clearing after a few seconds. */ + const flashDropError = (message: string) => { + setDropError(message) + if (dropErrorTimer.current !== null) window.clearTimeout(dropErrorTimer.current) + dropErrorTimer.current = window.setTimeout(() => setDropError(null), 2800) + } + + const handleFileDragOver = (event: React.DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + event.dataTransfer.dropEffect = canSideload ? 'copy' : 'none' + setDragActive(true) + } + + const handleFileDragLeave = (event: React.DragEvent) => { + // Ignore leaving into a child. Only clear when the pointer exits the whole card. + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return + setDragActive(false) + } + + /** + * Validates a package dropped on the card and, if it passes the same gating as the + * menu action, opens the SideloadDialog pre-loaded with it. Rejections surface as a + * transient message rather than proceeding. + */ + const handleFileDrop = (event: React.DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + setDragActive(false) + const files = Array.from(event.dataTransfer.files) + if (files.length !== 1) { + flashDropError('Drop a single .zip or .pkg package') + return + } + const file = files[0]! + const name = file.name.toLowerCase() + if (!name.endsWith('.zip') && !name.endsWith('.pkg')) { + flashDropError('Only .zip or .pkg packages') + return + } + if (!canSideload) { + flashDropError(!deviceHasAuth[device.ip] ? 'No credentials set (Device Properties)' : 'Developer mode not detected') + return + } + const filePath = window.rokdock.sideload.getDroppedFilePath(file) + if (!filePath) { + flashDropError('Could not read the dropped file') + return + } + setDroppedFile({ filePath, fileName: file.name }) + setSideloadOpen(true) + } /** Opens a new terminal tab connected to this device on the given port. */ const handleConnect = async (port: number) => { @@ -204,6 +274,7 @@ export default function DeviceCard({ data-device-ip={device.ip} style={{ ...styles.card, + position: 'relative', ...(dragged ? styles.cardDragging : {}), ...(expanded ? styles.cardExpanded : {}), ...(hovered && !expanded ? styles.cardHover : {}), @@ -213,7 +284,47 @@ export default function DeviceCard({ }} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} + onDragOver={handleFileDragOver} + onDragLeave={handleFileDragLeave} + onDrop={handleFileDrop} > + {(dragActive || dropError) && ( +
+ + {dropError ?? (canSideload + ? 'Drop .zip / .pkg to sideload' + : !deviceHasAuth[device.ip] ? 'No credentials set' : 'Developer mode not detected')} + +
+ )}
{ @@ -306,7 +417,7 @@ export default function DeviceCard({ onCancel={() => setShowRemoveConfirm(false)} onConfirm={() => { void handleRemoveManualDevice() }} /> - setSideloadOpen(false)} /> + { setSideloadOpen(false); setDroppedFile(null) }} /> ) } diff --git a/src/renderer/components/docs/docsNote.tsx b/src/renderer/components/docs/docsNote.tsx index 1b821d6..4161cbe 100644 --- a/src/renderer/components/docs/docsNote.tsx +++ b/src/renderer/components/docs/docsNote.tsx @@ -11,6 +11,7 @@ export function DocsNote({ }): React.JSX.Element { return (
+ @@ -678,7 +704,7 @@ export default function SettingsDialog() {
{localPorts.map((port, idx) => ( -
+
setScreenshotFolder(e.target.value)} />
+ + Leave blank to save screenshots in the default folder shown above. +
diff --git a/src/renderer/components/sideloadDialog.tsx b/src/renderer/components/sideloadDialog.tsx index f34e770..b6daf9b 100644 --- a/src/renderer/components/sideloadDialog.tsx +++ b/src/renderer/components/sideloadDialog.tsx @@ -46,6 +46,8 @@ const DIALOG_CLOSE_BTN: CSSProperties = { interface SideloadDialogProps { device: Device | null onClose: () => void + /** When opened via a drag-drop onto the device card, the dropped package to pre-select. */ + initialFile?: { filePath: string; fileName: string } | null } type Phase = 'idle' | 'installing' | 'done' @@ -57,9 +59,9 @@ const ANIM_STYLE_ID = 'sideload-anim' */ function ensureAnimStyles() { if (document.getElementById(ANIM_STYLE_ID)) return - const el = document.createElement('style') - el.id = ANIM_STYLE_ID - el.textContent = ` + const styleElement = document.createElement('style') + styleElement.id = ANIM_STYLE_ID + styleElement.textContent = ` @keyframes sideload-shimmer { 0% { transform: translateX(-200%) skewX(-20deg); opacity: 0; } 20% { opacity: 1; } @@ -70,7 +72,7 @@ function ensureAnimStyles() { to { transform: rotate(360deg); } } ` - document.head.appendChild(el) + document.head.appendChild(styleElement) } /** @@ -78,7 +80,7 @@ function ensureAnimStyles() { * Manages the idle -> installing -> done phase state machine and surfaces * progress events from the main process as a progress bar. */ -export default function SideloadDialog({ device, onClose }: SideloadDialogProps) { +export default function SideloadDialog({ device, onClose, initialFile }: SideloadDialogProps) { const deviceNicknames = useAppStore(state => state.deviceNicknames) const setDevicePropertiesDevice = useAppStore(state => state.setDevicePropertiesDevice) @@ -91,17 +93,27 @@ export default function SideloadDialog({ device, onClose }: SideloadDialogProps) useEffect(() => { ensureAnimStyles() }, []) + // Reset everything when the dialog closes (device cleared). useEffect(() => { - if (!device) { - setPhase('idle') - setFilePath(null) - setFileName(null) - setProgress(0) - setStatus('') - setResult(null) - } + if (device) return + setPhase('idle') + setFilePath(null) + setFileName(null) + setProgress(0) + setStatus('') + setResult(null) }, [device]) + // Pre-select the dropped package when opened via a drag-drop onto the device card. + // Keyed on initialFile alone (not device) so a background discovery refresh mid-dialog + // does not re-seed the file or wipe a completed install result. + useEffect(() => { + if (!initialFile) return + setFilePath(initialFile.filePath) + setFileName(initialFile.fileName) + setResult(null) + }, [initialFile]) + useEffect(() => { if (phase !== 'installing') return const unsub = window.rokdock.sideload.onProgress(({ percent, status: nextStatus }: { percent: number; status: string }) => { diff --git a/src/renderer/components/splitTerminalContainer.tsx b/src/renderer/components/splitTerminalContainer.tsx index fac0151..3445e68 100644 --- a/src/renderer/components/splitTerminalContainer.tsx +++ b/src/renderer/components/splitTerminalContainer.tsx @@ -17,15 +17,22 @@ import React, { useRef, useState } from 'react' import { useAppStore } from '../store/appStore' import TerminalPane from './terminalPane' import PaneDivider from './paneDivider' -import IconButton from './common/iconButton' +import IconButton, { ICON_BUTTON_SIZE } from './common/iconButton' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faGear, faHexagon, faTableColumns } from '@fortawesome/free-solid-svg-icons' +import { faGear, faTableColumns } from '@fortawesome/free-solid-svg-icons' const CONTAINER_STYLE: React.CSSProperties = { display: 'flex', height: '100%', overflow: 'hidden', position: 'relative' } +const TOOLBAR_GAP = 2 +const TOOLBAR_PADDING_RIGHT = 6 const TOOLBAR_WRAP_STYLE: React.CSSProperties = { position: 'absolute', top: 0, right: 0, zIndex: 4, - display: 'flex', alignItems: 'center', height: 30, paddingRight: 6, gap: 2 + display: 'flex', alignItems: 'center', height: 30, paddingRight: TOOLBAR_PADDING_RIGHT, gap: TOOLBAR_GAP } +// Width the tab bar must reserve so tabs and the right scroll caret clear the absolute +// top-right toolbar. Derived from the real button footprint (so it can't drift): the +// right padding plus one Gear button, plus a second button and the gap when Split shows. +const TOOLBAR_RESERVE_GEAR = TOOLBAR_PADDING_RIGHT + ICON_BUTTON_SIZE.md +const TOOLBAR_RESERVE_SPLIT_GEAR = TOOLBAR_RESERVE_GEAR + TOOLBAR_GAP + ICON_BUTTON_SIZE.md /** * Renders the terminal work area: one or two TerminalPane instances separated @@ -57,8 +64,20 @@ export default function SplitTerminalContainer() { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5, gap: 16 }}> -
- + {/* Ghost mark echoing the app icon: its pointy-top hexagon with the 2x2 rounded-square + grid knocked out (no ">_" prompt). Geometry matches resources/icons/icon.svg. */} +
+ {/* The hexagon has generously rounded points (a rounded-corner path, so it keeps + the app icon's bounds without an outward-growing stroke). The 2x2 grid uses the + icon's exact square geometry, drawn as faint white panels that lighten the + purple rather than punching hard holes. */} +
No Active Connections @@ -71,6 +90,10 @@ export default function SplitTerminalContainer() { } const isSplit = paneB !== null + // The Split button shows only when there is a second tab to split and the view is not + // already split. It drives both the toolbar render and paneA's reserved width, so the two + // never drift apart. + const showSplitButton = !isSplit && tabs.length >= 2 return (
setFocusedPane('a')} + toolbarReserve={isSplit ? 0 : (showSplitButton ? TOOLBAR_RESERVE_SPLIT_GEAR : TOOLBAR_RESERVE_GEAR)} />
{isSplit && ( @@ -110,13 +134,14 @@ export default function SplitTerminalContainer() { paneId="b" isFocused={focusedPaneId === 'b'} onFocus={() => setFocusedPane('b')} + toolbarReserve={TOOLBAR_RESERVE_GEAR} />
)} {tabs.length > 0 && (
- {!isSplit && tabs.length >= 2 && ( + {showSplitButton && ( { if (focusedActiveTabId) splitTab(focusedActiveTabId) }} diff --git a/src/renderer/components/terminal/overlayCompiler.ts b/src/renderer/components/terminal/overlayCompiler.ts index 26c5b35..7c8a685 100644 --- a/src/renderer/components/terminal/overlayCompiler.ts +++ b/src/renderer/components/terminal/overlayCompiler.ts @@ -8,7 +8,8 @@ * Keeping this logic in a separate module makes it independently testable and * removes it from the React component's surface area. */ -import { findMatchingBracket } from '../../../shared/jsonUtils' +import { findMatchingBracket, keepOutermostSpans } from '../../../shared/jsonUtils' +import { JSON_INDENT_WIDTH } from '../../../shared/jsonIndent' import type { TerminalLineChunk, TerminalOverlaySpan, TerminalTokenSpan } from '../../../shared/terminal' import type { TerminalSyntaxTheme } from '../../styles/terminalSyntaxThemes' @@ -88,10 +89,11 @@ export function detectJsonOverlaysForLine(lines: TerminalLineChunk[], lineIndex: start: overlapStart - targetStart, end: overlapEnd - targetStart, kind: 'json', - // 4-space indent to match the main-process tokenizer (terminalTokenizer.ts), - // so JSON opened from the viewer formats identically regardless of which - // detection path (intra-line tokenizer vs multiline fallback) produced it. - value: JSON.stringify(parsed, null, 4) + // Shared JSON_INDENT_WIDTH so this multiline fallback, the main-process + // tokenizer (terminalTokenizer.ts), and the JSON editor all format + // identically. A payload opened from the terminal then matches the + // editor's own indent instead of being reindented on first view. + value: JSON.stringify(parsed, null, JSON_INDENT_WIDTH) }) } catch { // Not valid JSON. @@ -126,18 +128,12 @@ export function tokenizerCoversTrimmedLineAsSingleJson(line: TerminalLineChunk): } /** - * Drop JSON overlay spans that are entirely contained within a wider sibling span. - * Sorted by descending length so the largest span wins when two share the same range. + * Drop JSON overlay spans entirely contained within a wider sibling, keeping only the + * outermost. Delegates to the shared keepOutermostSpans sweep (also used by the terminal + * tokenizer's JSON-candidate filter) so the containment logic lives in one place. */ export function mergeJsonOverlaysForLine(jsonOverlays: TerminalOverlaySpan[]): TerminalOverlaySpan[] { - if (jsonOverlays.length <= 1) return jsonOverlays - const sorted = [...jsonOverlays].sort((first, second) => second.end - second.start - (first.end - first.start)) - const kept: TerminalOverlaySpan[] = [] - for (const candidate of sorted) { - if (kept.some((wider) => wider.start <= candidate.start && wider.end >= candidate.end)) continue - kept.push(candidate) - } - return kept + return keepOutermostSpans(jsonOverlays) } /** diff --git a/src/renderer/components/terminal/regexFilterDialog.tsx b/src/renderer/components/terminal/regexFilterDialog.tsx new file mode 100644 index 0000000..eaa3ce3 --- /dev/null +++ b/src/renderer/components/terminal/regexFilterDialog.tsx @@ -0,0 +1,123 @@ +/** + * Prompt shown before the terminal Save-output and Stream-to-file actions. + * + * Lets the user enter an optional regular expression that filters which lines get + * written. An empty pattern writes every line (the prior behavior). The pattern is + * validated live: an invalid regex shows an error and disables the confirm button. + * When a `countMatches` runner and sample lines are provided, a running + * "N of M lines match" count is computed IN THE REGEX WORKER, so a + * catastrophic-backtracking pattern cannot freeze the dialog: it surfaces + * "pattern too slow" and disables confirm instead of hanging. + * + * Built on ConfirmDialog so it inherits the shared dialog chrome, close button, and + * backdrop/escape handling; this component only adds the input and its feedback row. + */ + +import React, { useEffect, useMemo, useState } from 'react' +import ConfirmDialog from '../common/confirmDialog' +import { compileLineFilter } from '../../../shared/lineFilter' +import type { MatchOutcome } from './regexMatchClient' + +interface RegexFilterDialogProps { + open: boolean + title: string + description: string + confirmLabel: string + /** Current buffer line texts, used to preview a live match count. */ + sampleLines?: string[] + /** Worker-backed matcher for the live count. Omitted in contexts without a client. */ + countMatches?: (source: string, flags: string, lines: string[]) => Promise> + onCancel: () => void + /** Called with the compiled filter (null for an empty pattern = every line). */ + onConfirm: (regex: RegExp | null) => void +} + +type CountState = + | { status: 'idle' } + | { status: 'computing' } + | { status: 'done'; count: number } + | { status: 'tooSlow' } + +/** + * Renders the optional-regex prompt. Confirm is disabled while the pattern is an + * invalid regex or the preview timed out; pressing Enter confirms when allowed. + */ +export default function RegexFilterDialog({ + open, + title, + description, + confirmLabel, + sampleLines, + countMatches, + onCancel, + onConfirm +}: RegexFilterDialogProps) { + const [pattern, setPattern] = useState('') + + // Reset the pattern each time the prompt opens. + useEffect(() => { + if (open) setPattern('') + }, [open]) + + const { regex, error } = useMemo(() => compileLineFilter(pattern), [pattern]) + + // The live match count runs in the regex worker (debounced). The previous count stays + // visible while a new one computes; a timed-out pattern reports tooSlow and blocks confirm. + const [countState, setCountState] = useState({ status: 'idle' }) + useEffect(() => { + if (error || !regex || !sampleLines || !countMatches) { setCountState({ status: 'idle' }); return } + let cancelled = false + setCountState({ status: 'computing' }) + const debounce = setTimeout(() => { + void countMatches(regex.source, regex.flags, sampleLines).then((outcome) => { + if (cancelled) return + if (outcome.status === 'ok') setCountState({ status: 'done', count: outcome.value.length }) + else if (outcome.status === 'timeout') setCountState({ status: 'tooSlow' }) + // 'invalid' is already covered by `error`; 'superseded' means a newer run will set state. + }) + }, 150) + return () => { cancelled = true; clearTimeout(debounce) } + }, [error, regex, sampleLines, countMatches]) + + const confirmDisabled = !!error || countState.status === 'tooSlow' + const confirm = () => { + if (!confirmDisabled) onConfirm(regex) + } + + return ( + + setPattern(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { event.preventDefault(); confirm() } + }} + /> +
+ {error + ? Invalid regex: {error} + : countState.status === 'tooSlow' + ? Pattern too slow to preview. Try a simpler filter. + : countState.status === 'done' + ? {countState.count} of {sampleLines?.length ?? 0} current lines match + : countState.status === 'computing' + ? Counting matches... + : Leave empty to include every line.} +
+
+ ) +} diff --git a/src/renderer/components/terminal/regexMatchClient.ts b/src/renderer/components/terminal/regexMatchClient.ts new file mode 100644 index 0000000..c4efcbe --- /dev/null +++ b/src/renderer/components/terminal/regexMatchClient.ts @@ -0,0 +1,165 @@ +/** + * Renderer-side client for the regex-match Web Worker. + * + * Owns one worker and makes it freeze-proof: every request runs under a watchdog, + * and if the worker does not answer within timeoutMs (the signature of a + * catastrophic-backtracking pattern) it is hard-terminated and respawned, and the + * request resolves with { status: 'timeout' }. terminate() on a dedicated worker is + * an OS-thread kill, so it stops a mid-backtrack RegExp.exec unconditionally. + * + * Concurrency is single-in-flight with a one-slot latest-wins queue: a request that + * arrives while the worker is busy waits in `pending` (overwriting and superseding + * any earlier waiter), so a stuck job never blocks the newest query beyond one + * terminate cycle. The worker factory is injected so the client is unit-tested + * without a real Worker. + */ +import type { RegexLineMatch } from '@shared/regexMatch' +import type { RegexMatchRequest, RegexMatchResponse } from '../../workers/regexMatchProtocol' + +/** The subset of the Worker interface the client depends on (injectable for tests). */ +export interface RegexWorkerLike { + postMessage(message: RegexMatchRequest): void + terminate(): void + onmessage: ((event: MessageEvent) => void) | null + onerror: ((event: unknown) => void) | null +} + +/** Outcome of a match request. `superseded` means a newer request replaced this one while queued. */ +export type MatchOutcome = + | { status: 'ok'; value: T } + | { status: 'invalid' } + | { status: 'timeout' } + | { status: 'superseded' } + +const DEFAULT_TIMEOUT_MS = 250 + +type PendingJob = { + request: RegexMatchRequest + resolve: (outcome: MatchOutcome) => void +} + +export class RegexMatchClient { + private worker: RegexWorkerLike | null = null + private nextRequestId = 1 + private inflight: { job: PendingJob; timer: ReturnType } | null = null + private pending: PendingJob | null = null + + constructor( + private readonly createWorker: () => RegexWorkerLike, + private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS + ) {} + + /** Find every match of source/flags across lines. flags must include 'g'. */ + search(source: string, flags: string, lines: string[]): Promise> { + return this.enqueue('search', source, flags, lines) as Promise> + } + + /** Return the indices of lines matching source/flags. */ + filter(source: string, flags: string, lines: string[]): Promise> { + return this.enqueue('filter', source, flags, lines) as Promise> + } + + /** Tear down the worker and settle any outstanding jobs. Call on unmount. */ + dispose(): void { + if (this.inflight) { + clearTimeout(this.inflight.timer) + this.inflight.job.resolve({ status: 'timeout' }) + this.inflight = null + } + if (this.pending) { + this.pending.resolve({ status: 'superseded' }) + this.pending = null + } + this.killWorker() + } + + private enqueue(kind: RegexMatchRequest['kind'], source: string, flags: string, lines: string[]): Promise> { + return new Promise>((resolve) => { + const request = { requestId: this.nextRequestId++, kind, source, flags, lines } as RegexMatchRequest + const job: PendingJob = { request, resolve } + if (this.inflight) { + // Latest-wins: a queued-but-not-yet-run waiter is superseded by this newer one. + if (this.pending) this.pending.resolve({ status: 'superseded' }) + this.pending = job + } else { + this.dispatch(job) + } + }) + } + + private ensureWorker(): RegexWorkerLike { + if (this.worker) return this.worker + const worker = this.createWorker() + worker.onmessage = (event) => this.onMessage(event.data) + worker.onerror = () => this.onWorkerFailure() + this.worker = worker + return worker + } + + private dispatch(job: PendingJob): void { + const worker = this.ensureWorker() + const timer = setTimeout(() => this.onTimeout(), this.timeoutMs) + this.inflight = { job, timer } + worker.postMessage(job.request) + } + + private onMessage(response: RegexMatchResponse): void { + // Ignore a response that does not match the in-flight request (e.g. a late reply). + if (!this.inflight || response.requestId !== this.inflight.job.request.requestId) return + clearTimeout(this.inflight.timer) + const { job } = this.inflight + this.inflight = null + job.resolve(toOutcome(response)) + this.promotePending() + } + + private onTimeout(): void { + // The worker is stuck (or its reply was lost). Hard-kill it so the runaway + // regex stops, then respawn lazily on the next dispatch. + const job = this.inflight?.job + this.killWorker() + this.inflight = null + job?.resolve({ status: 'timeout' }) + this.promotePending() + } + + private onWorkerFailure(): void { + const job = this.inflight?.job + if (this.inflight) clearTimeout(this.inflight.timer) + this.killWorker() + this.inflight = null + job?.resolve({ status: 'timeout' }) + this.promotePending() + } + + private promotePending(): void { + if (!this.pending) return + const job = this.pending + this.pending = null + this.dispatch(job) + } + + private killWorker(): void { + if (!this.worker) return + try { + this.worker.terminate() + } catch { + // The worker may already be gone; nothing to reclaim. + } + this.worker = null + } +} + +function toOutcome(response: RegexMatchResponse): MatchOutcome { + if (response.status === 'invalid') return { status: 'invalid' } + if (response.kind === 'search') return { status: 'ok', value: response.matches as never } + return { status: 'ok', value: response.keptIndices as never } +} + +/** Builds a client backed by the real regex-match Web Worker (Vite same-origin module worker). */ +export function createRegexMatchClient(timeoutMs?: number): RegexMatchClient { + return new RegexMatchClient( + () => new Worker(new URL('../../workers/regexMatch.worker.ts', import.meta.url), { type: 'module' }) as unknown as RegexWorkerLike, + timeoutMs + ) +} diff --git a/src/renderer/components/terminalPane.tsx b/src/renderer/components/terminalPane.tsx index e19a500..3d46bdb 100644 --- a/src/renderer/components/terminalPane.tsx +++ b/src/renderer/components/terminalPane.tsx @@ -22,7 +22,8 @@ import { TERMINAL_MAX_BUFFER_LINES } from '../../shared/terminal' import CustomTerminalView, { clearTerminalCache } from './customTerminalView' import TabContextMenu from './tabContextMenu' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faXmark } from '@fortawesome/free-solid-svg-icons' +import { faXmark, faCaretLeft, faCaretRight } from '@fortawesome/free-solid-svg-icons' +import { createTabStripScroller, type TabStripScroller } from '../../shared/tabStripScroll' const STATUS_COLORS: Record = { connecting: 'var(--rokdock-state-connecting)', @@ -35,6 +36,9 @@ interface TerminalPaneProps { paneId: PaneId isFocused: boolean onFocus: () => void + /** Right padding (px) reserved on the tab bar for the container's absolute Split/Settings + * toolbar, which floats over this pane when it is the right-most one. 0 when not covered. */ + toolbarReserve?: number } /** @@ -42,7 +46,7 @@ interface TerminalPaneProps { * and the CustomTerminalView for the currently active tab. Inactive tab views * are hidden (display:none) but kept mounted to preserve their buffer state. */ -export default function TerminalPane({ paneId, isFocused, onFocus }: TerminalPaneProps) { +export default function TerminalPane({ paneId, isFocused, onFocus, toolbarReserve = 0 }: TerminalPaneProps) { const allTabs = useAppStore(state => state.tabs) const paneState = useAppStore(state => paneId === 'a' ? state.paneA : state.paneB) const tabLabelMode = useAppStore(state => state.tabLabelMode) @@ -52,6 +56,9 @@ export default function TerminalPane({ paneId, isFocused, onFocus }: TerminalPan const moveTabToPane = useAppStore(state => state.moveTabToPane) const reorderTab = useAppStore(state => state.reorderTab) const tabListRef = useRef(null) + const leftCaretRef = useRef(null) + const rightCaretRef = useRef(null) + const scrollerRef = useRef(null) const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; tabId: string } | null>(null) const [dragOverIdx, setDragOverIdx] = useState(null) const [crossPaneDragOver, setCrossPaneDragOver] = useState(false) @@ -93,6 +100,26 @@ export default function TerminalPane({ paneId, isFocused, onFocus }: TerminalPan const styles = useMemo(() => buildPaneStyles(isFocused), [isFocused]) + // Wire caret + wheel scrolling for the tab strip once the elements exist. + useEffect(() => { + if (!tabListRef.current || !leftCaretRef.current || !rightCaretRef.current) return + const scroller = createTabStripScroller(tabListRef.current, leftCaretRef.current, rightCaretRef.current) + scrollerRef.current = scroller + return () => { + scroller.dispose() + scrollerRef.current = null + } + }, []) + + // Recompute caret state on tab changes and keep the active tab in view when selected. + // tabLabelMode is a dep because switching name/IP labels changes every tab's width, + // which can move the strip in or out of overflow without any count change. + useEffect(() => { + scrollerRef.current?.refresh() + const activeEl = tabListRef.current?.querySelector(`[data-tab-id="${activeTabId}"]`) + activeEl?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }, [activeTabId, tabs.length, tabLabelMode]) + return (
+
{ - if (tabListRef.current && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { - e.preventDefault() - tabListRef.current.scrollLeft += e.deltaY - } - }} onDrop={(e) => { e.preventDefault() const tabId = e.dataTransfer.getData('text/plain') @@ -162,6 +196,16 @@ export default function TerminalPane({ paneId, isFocused, onFocus }: TerminalPan
)}
+
{tabs.map(tab => ( @@ -271,6 +315,7 @@ function PaneTab({ tab, labelMode, isActive, onSelect, onClose, onContextMenu, o return (
{ e.dataTransfer.setData('text/plain', tab.id) diff --git a/src/renderer/components/terminalSelectionToolbar.tsx b/src/renderer/components/terminalSelectionToolbar.tsx index 167dcf5..d732644 100644 --- a/src/renderer/components/terminalSelectionToolbar.tsx +++ b/src/renderer/components/terminalSelectionToolbar.tsx @@ -4,7 +4,7 @@ * actions are eligible for the current selection. * * - "Look up in Docs" appears for a short (1-3 word) term (non-null `term`). - * - "Explain this" appears for any non-empty selection when `aiAvailable` is true. + * - "Ask roBot" appears for any non-empty selection when `aiAvailable` is true. * * Renders nothing when no action is eligible. * @@ -16,7 +16,8 @@ */ import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faMagnifyingGlass, faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons' +import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons' +import { roBot } from './ai/roBotMark' import { AI_EXPLAIN_ACTION, withBeta } from '../../shared/ai/labels' interface Props { @@ -77,7 +78,7 @@ export default function TerminalSelectionToolbar({ anchor, selection, term, aiAv aria-label={withBeta(AI_EXPLAIN_ACTION)} onClick={onExplain} > - + )}
diff --git a/src/renderer/docs.css b/src/renderer/docs.css index 71f5bd1..2d535ff 100644 --- a/src/renderer/docs.css +++ b/src/renderer/docs.css @@ -1398,15 +1398,16 @@ font-weight: 700; } -/* Matched text highlighted on the opened page (CSS Custom Highlight API). All hits - get the base tint; the one the find bar is parked on gets a stronger tint. */ +/* Matched text highlighted on the opened page (CSS Custom Highlight API). Shares the + app-wide subtle amber search tokens (terminal + docs) so highlighting is consistent. + All hits get the base tint; the one the find bar is parked on gets the stronger tint. */ ::highlight(docs-search-hit) { - background-color: color-mix(in srgb, var(--rokdock-link) 24%, transparent); + background-color: var(--rokdock-search-highlight-match); color: var(--rokdock-text-bright); } ::highlight(docs-search-hit-current) { - background-color: color-mix(in srgb, var(--rokdock-link) 55%, transparent); + background-color: var(--rokdock-search-highlight-active); color: var(--rokdock-text-bright); } @@ -1558,23 +1559,34 @@ padding: 8px 11px 9px; display: flex; flex-direction: column; + color: #3f3a23; +} + +/* The tilted "paper" is a backdrop layer rather than an ancestor of the textarea. + Chromium mispositions a textarea caret when it sits inside a transformed + (composited) element, so the rotation lives here on a sibling layer and the + content (close button, textarea) sits upright on top with a caret that tracks + every line. The note still reads as a tilted sticky note. */ +.docs-note-paper { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; /* Warm paper, lit from above: lighter yellow at the top, deepening toward the bottom. */ background: linear-gradient(168deg, #fdf3a3 0%, #fdf0a6 48%, #fbe78a 100%); - color: #3f3a23; border-radius: 2px 2px 3px 3px; transform: rotate(-1.4deg); - /* Pivot from the stuck-down top edge when it straightens on focus. */ transform-origin: 50% 0; box-shadow: 0 1px 1px rgba(60, 50, 10, 0.18), 0 10px 22px -8px rgba(40, 33, 5, 0.4); - transition: transform 0.18s ease, box-shadow 0.18s ease; + transition: box-shadow 0.18s ease; } /* Soft sheen near the top edge: light from above, plus a hint of the adhesive band that holds a real sticky note down. */ -.docs-note::before { +.docs-note-paper::before { content: ''; position: absolute; inset: 0 0 auto 0; @@ -1584,9 +1596,9 @@ pointer-events: none; } -/* Focusing the note lifts it with a larger shadow while keeping its tilt. The - lift is the focus cue. */ -.docs-note:focus-within { +/* Focusing the note lifts the paper with a larger shadow. The lift is the focus + cue. */ +.docs-note:focus-within .docs-note-paper { box-shadow: 0 2px 4px rgba(60, 50, 10, 0.2), 0 24px 44px -10px rgba(40, 33, 5, 0.5); @@ -1595,7 +1607,7 @@ /* Peeled bottom-right corner: a clipped triangle of the page's underside, gradient-shaded so the crease sits in shadow and the lifted tip catches the light, with a soft drop-shadow along the crease so it reads as raised. */ -.docs-note::after { +.docs-note-paper::after { content: ''; position: absolute; bottom: 0; @@ -1609,9 +1621,17 @@ .docs-note-close { position: absolute; - top: 6px; - right: 8px; - padding: 2px; + top: 3px; + right: 4px; + /* Above the textarea (z-index 1), which overlaps this corner and would otherwise sit on top and + steal the clicks. A comfortable 24px target with the small glyph centered inside it. */ + z-index: 2; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; background: none; border: none; cursor: pointer; @@ -1626,6 +1646,8 @@ .docs-note-text { flex: 1; + position: relative; + z-index: 1; background: transparent; border: none; outline: none; diff --git a/src/renderer/hooks/useCaptureStream.ts b/src/renderer/hooks/useCaptureStream.ts index cfb881b..bbe960f 100644 --- a/src/renderer/hooks/useCaptureStream.ts +++ b/src/renderer/hooks/useCaptureStream.ts @@ -20,6 +20,7 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { useAppStore } from '../store/appStore' import { enumerateVideoInputs, applyCaptureDeviceReconcile } from '../utils/mediaDevices' import { findMatchingAudioDevice, planCaptureDeviceReconcile } from '@shared/captureDeviceMatch' +import { videoFrameToPngDataUrl } from '../utils/videoFrame' export interface CaptureDevice { deviceId: string @@ -248,6 +249,16 @@ export function useCaptureStream(active: boolean) { setStreamActive(false) }, []) + // Answer on-demand frame-grab requests (roBot's screenshot fallback) while streaming, by + // sending back the current video frame as a PNG data URL (or '' if none is available). + useEffect(() => { + if (!streamActive) return + return window.rokdock.capture.onGrabFrame((requestId: string) => { + const video = videoRef.current + window.rokdock.capture.frameGrabbed(requestId, video ? videoFrameToPngDataUrl(video) : '') + }) + }, [streamActive]) + return { videoRef, devices, diff --git a/src/renderer/hooks/useTerminalOutputResponder.ts b/src/renderer/hooks/useTerminalOutputResponder.ts new file mode 100644 index 0000000..4cf0b4d --- /dev/null +++ b/src/renderer/hooks/useTerminalOutputResponder.ts @@ -0,0 +1,18 @@ +/** + * Registers the terminal-output responder for roBot's tools. Mounted once from the App root + * (outside the terminal panel), so it answers even when the terminal panel is collapsed and + * unmounted. Resolves the focused tab from the store and reads its write-through cache. + */ +import { useEffect } from 'react' +import { useAppStore } from '../store/appStore' +import { readTerminalCache } from '../components/customTerminalView' +import { resolveFocusedTerminalPayload } from '../terminalOutputResolver' + +export function useTerminalOutputResponder(): void { + useEffect(() => { + return window.rokdock.terminalOutput.onRequest((requestId: string) => { + const payload = resolveFocusedTerminalPayload(useAppStore.getState(), readTerminalCache) + window.rokdock.terminalOutput.respond(requestId, payload) + }) + }, []) +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 0808ed7..41bc1ce 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ + content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';" /> RokDock