From f2e93b14fe7c6eee133acae70c4709fb5ed14014 Mon Sep 17 00:00:00 2001 From: Mark1708 Date: Wed, 21 Oct 2026 00:07:29 +0300 Subject: [PATCH 1/4] feat: migrate agents sidebar to OpenCode v2 --- .github/workflows/release.yml | 61 ++++++++ .gitignore | 8 +- .gitlab-ci.yml | 49 ------ AGENTS.md | 87 ++++++----- CHANGELOG.md | 20 +++ CONTRIBUTING.md | 23 ++- README.md | 108 +++++++------ README.ru.md | 110 +++++++------ bun.lockb | Bin 0 -> 161976 bytes package.json | 38 +++-- scripts/prune-declarations.mjs | 43 +++++ scripts/verify-package.mjs | 163 +++++++++++++++++++ src/config-watcher.test.ts | 135 ++++++++++++++++ src/config-watcher.ts | 134 ++++++++++++++++ src/config.ts | 15 -- src/defaults.ts | 1 - src/index.ts | 10 +- src/providers/omo.ts | 19 ++- src/render.ts | 276 +++++++++++++-------------------- src/runtime.test.ts | 142 +++++++++++++++++ src/runtime.ts | 179 +++++++++++++++++++++ src/solid-runtime.d.ts | 3 + src/tui-v2.test.ts | 234 ++++++++++++++++++++++++++++ src/tui.test.ts | 12 +- src/tui.ts | 119 ++++++++++++++ src/types.ts | 2 +- tsconfig.build.json | 21 +++ tsconfig.json | 9 +- tui.ts | 1 - 29 files changed, 1583 insertions(+), 439 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 .gitlab-ci.yml create mode 100755 bun.lockb create mode 100644 scripts/prune-declarations.mjs create mode 100644 scripts/verify-package.mjs create mode 100644 src/config-watcher.test.ts create mode 100644 src/config-watcher.ts create mode 100644 src/runtime.test.ts create mode 100644 src/runtime.ts create mode 100644 src/solid-runtime.d.ts create mode 100644 src/tui-v2.test.ts create mode 100644 src/tui.ts create mode 100644 tsconfig.build.json delete mode 100644 tui.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f35dcc9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + BUN_VERSION: 1.3.4 + +jobs: + release: + name: Publish exact artifact + if: github.repository == 'Mark1708/opencode-agents-sidebar' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + - name: Validate tag + run: test "$GITHUB_REF_NAME" = "v$(node -p "require('./package.json').version")" + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun test + - run: bun run build:all + - run: bun run verify:package + - name: Record artifact digest + run: sha256sum .artifacts/package.tgz | tee .artifacts/package.tgz.sha256 + - name: Publish to npm with provenance + run: | + package=$(node -p "require('./package.json').name") + version=$(node -p "require('./package.json').version") + local_sha1=$(sha1sum .artifacts/package.tgz | cut -d' ' -f1) + registry_sha1=$(npm view "$package@$version" dist.shasum 2>/dev/null || true) + if [ -n "$registry_sha1" ]; then + test "$registry_sha1" = "$local_sha1" + echo "$package@$version already matches the verified artifact" + else + npm publish .artifacts/package.tgz --access public --provenance + fi + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" .artifacts/package.tgz .artifacts/package.tgz.sha256 --clobber + else + gh release create "$GITHUB_REF_NAME" .artifacts/package.tgz .artifacts/package.tgz.sha256 --verify-tag --generate-notes --title "$GITHUB_REF_NAME" + fi diff --git a/.gitignore b/.gitignore index 1a3073e..47fdba6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,8 @@ node_modules/ -tui.js -index.js -*.d.ts -*.d.ts.map -*.js.map dist/ +.artifacts/ .DS_Store *.log -bun.lockb -src/tui.ts .env .env.* *.pem diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index a6eb679..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,49 +0,0 @@ -stages: - - validate - - build - - publish - -variables: - BUN_VERSION: "1.2" - -.default: - tags: [k8s] - image: oven/bun:${BUN_VERSION} - before_script: - - bun install --frozen-lockfile - -typecheck: - extends: .default - stage: validate - script: - - bun run typecheck - -test: - extends: .default - stage: validate - script: - - bun test - -build: - extends: .default - stage: build - script: - - bun run build - - bun run build:index - artifacts: - paths: - - dist/ - expire_in: 1 day - -publish-npm: - stage: publish - tags: [k8s] - image: node:22-alpine - needs: [typecheck, test, build] - rules: - - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ - script: - - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc - - npm publish --access public - after_script: - - rm -f .npmrc diff --git a/AGENTS.md b/AGENTS.md index 2b186c4..516521c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,54 +1,65 @@ # AGENTS SIDEBAR PLUGIN -**Version:** 1.0.7 | **npm:** `opencode-agents-sidebar` | **Plugin ID:** `agents-sidebar:tui` +**Version:** 2.0.0 | **npm:** `opencode-agents-sidebar` | **Plugin IDs:** `agents-sidebar`, `agents-sidebar.tui` ## OVERVIEW -Displays OhMyOpenAgent agent lifecycle categories, collapsible sections, model info, and team status in sidebar. Reads agent config from `oh-my-openagent.json`. +OpenCode V2-only sidebar for OhMyOpenAgent lifecycle categories, collapsible groups, selection, model details, and config diagnostics. Current discovery reads only `oh-my-openagent.json`; the provider seam permits a future native OpenCode source but does not imply universal discovery. ## STRUCTURE +```text +src/ +├── index.ts # V2 server entry (`@opencode/plugin`) +├── tui.ts # V2 setup, signals, reload ownership, teardown +├── render.ts # sole OpenTUI/Solid boundary and tracked mount +├── runtime.ts # runtime state and serialized reload controller +├── config-watcher.ts # directory watches with polling recovery +├── providers/ # agent provider seam and OMO source +├── agents.ts # categorization and group construction +├── config.ts # sidebar config parsing +├── defaults.ts # immutable defaults +├── format.ts # single-line render formatting +└── types.ts # domain and render types +scripts/verify-package.mjs +.github/workflows/ +├── ci.yml # Required PR/push checks and package artifact +└── release.yml # Tag validation, npm OIDC publish, GitHub Release ``` -agents-sidebar/ -├── src/ -│ ├── index.ts # Plugin API stub (definePlugin) -│ ├── tui.ts # Re-exports from all modules + render.ts default -│ ├── render.ts # TUI entry -- element/render/poll loop (uses @opentui/solid) -│ ├── agents.ts # Agent discovery, category mapping, group building -│ ├── config.ts # Reads ~/.config/opencode/oh-my-openagent.json -│ ├── defaults.ts # DEFAULTS object with all config fallbacks -│ ├── format.ts # Agent name/model/category formatting -│ └── types.ts # Core types: AgentEntry, CategoryGroup, OmoConfig, SidebarConfig -├── assets/ # Screenshots for README -├── .gitlab-ci.yml # CI: validate -> build -> publish -└── package.json -``` -## WHERE TO LOOK +## CONTRACTS + +- Server dependency: `@opencode/plugin@2.0.32`. +- Host/TUI peers: `@opencode/theme`, `@opentui/core`, `@opentui/solid`, and `solid-js`. +- Package exports are `.` and `./tui`; local-directory host smoke uses disposable root wrappers, never repository root shims. +- `@opentui/solid` is imported only in `src/render.ts`. +- A slot callback creates one stable container; `insert(container, () => ...)` owns reactive rendering. +- Runtime state is `loaded | missing | invalid | error`, with deterministic fingerprint and watch specification. +- Reload work is serialized/coalesced and generation-gated. Cleanup invalidates pending work before closing the slot, controller, and watcher. +- User collapse and selection state survives reload after first valid initialization. +- All display output is single-line and truncate-safe. -| Task | File | -|------|------| -| Add agent fields | `src/types.ts` (`AgentEntry`) + `src/agents.ts` (mapping logic) | -| Change category logic | `src/agents.ts` -- `categorizeAgents()`, `DEFAULT_CATEGORY` | -| Display formatting | `src/format.ts` -- agent row rendering, name truncation | -| Config options | `src/types.ts` (`SidebarConfig`) + `src/defaults.ts` (`DEFAULTS`) | -| Config parsing | `src/config.ts` -- `mergeConfig()`, type-safe value extractors | -| TUI rendering | `src/render.ts` -- sidebar layout, sections, keybind handling | +## COMMANDS + +```bash +bun run typecheck +bun test +bun run build:all +bun run verify:package +``` -## CONVENTIONS (THIS REPO) +## AUTOMATION -- **Config source**: Single file `~/.config/opencode/oh-my-openagent.json` with `tui` sub-key for sidebar config -- **Type-safe parsing**: `isRecord()`, `stringValue()`, `numberValue()`, `booleanValue()`, `enumValue()` -- no `as` casts -- **Agent categories**: Defined in `defaults.ts` (`DEFAULT_CATEGORY_ORDER`), users override via `tui.category_order` -- **Alias maps**: `provider_aliases`, `model_aliases`, `variant_aliases` for display name overrides -- **Model display modes**: `"inline"` (default), `"details-only"`, `"hidden"` -- **Show disabled modes**: `"hidden"`, `"dimmed"`, `"grouped"` -- **Config fingerprint**: `configFingerprint()` for change detection in poll loop +- GitHub is the source of truth. `CI / Typecheck`, `CI / Tests`, and `CI / Package` are required before merge. +- CI uploads the exact `.artifacts/package.tgz` produced by `verify:package`. +- A `vX.Y.Z` tag must equal `package.json.version`; the release workflow repeats every gate, publishes the verified tarball through npm Trusted Publishing/OIDC, and attaches it with its SHA-256 digest to the GitHub Release. +- GitLab is a read-only mirror and has no release pipeline. ## ANTI-PATTERNS -- NEVER add runtime dependencies beyond peer deps (`@opencode-ai/plugin`, `@opentui/solid`, `solid-js`) -- NEVER import `@opentui/solid` outside `render.ts` -- NEVER mutate `DEFAULTS` object -- always spread into new objects -- NEVER use `text()` for interactive elements -- use `box()` with `onMouseDown` -- All format output MUST be single-line, truncate-safe +- NEVER add `@opencode-ai/plugin`, `@opencode-ai/sdk`, V1 plugin lifecycle, dual runtime shapes, or root entry shims. +- NEVER import `@opentui/solid` outside `src/render.ts`. +- NEVER mutate defaults or accepted runtime state; create new values. +- NEVER publish an unchanged fingerprint or let an older reload generation commit. +- NEVER use `text()` for interactive elements; use `box()` with `onMouseDown`. +- NEVER restore removed `slot_order`; V2 contribution order follows package order in host config. diff --git a/CHANGELOG.md b/CHANGELOG.md index 334cda9..079b6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2026-10-21 + +### Breaking + +- Requires OpenCode v2 and the `@opencode/plugin` v2 server/TUI contracts. OpenCode v1 users must remain on `opencode-agents-sidebar@1.0.8`. +- Removes the V1 lifecycle, repository root entrypoint shim, and deprecated `slot_order`; v2 contribution order follows package order in host configuration. + +### Added + +- Adds deterministic `loaded`, `missing`, `invalid`, and `error` runtime states with full fingerprints and recovery watch specifications. +- Adds serialized, coalesced, generation-safe reloads that preserve user collapse and selection state. +- Adds directory-based config watching with delete/recreate, atomic-replacement, and polling recovery support. +- Adds strict declaration-closure, packed-artifact, clean-import, and consumer type checks. + +### Changed + +- Uses a stable tracked Solid render root so runtime, click, selection, and live theme changes render without host snapshot requests. +- Documents OhMyOpenAgent configuration as the only current agent source; the provider seam permits future native sources without claiming universal discovery. +- Moves required checks and exact-artifact npm releases from GitLab CI to GitHub Actions with Trusted Publishing/OIDC. + ## [1.0.8] - 2026-05-30 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3bf185..9ddb189 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for your interest in contributing. This document covers the developmen ## Prerequisites -- **Bun** (latest stable) -- +- **Bun** >= 1.3.0 -- - **TypeScript** >= 5.5 (installed via `bun install`) ## Development Setup @@ -12,10 +12,11 @@ Thank you for your interest in contributing. This document covers the developmen ```bash git clone https://github.com/Mark1708/opencode-agents-sidebar.git cd agents-sidebar -bun install -bun run build:all -bun test +bun install --frozen-lockfile bun run typecheck +bun test +bun run build:all +bun run verify:package ``` ## Build Commands @@ -43,10 +44,18 @@ bun run typecheck ## Pull Request Process 1. Fork the repository and create a branch from `main`. -2. Run `bun run build:all && bun run typecheck && bun test` before pushing. +2. Run `bun install --frozen-lockfile && bun run typecheck && bun test && bun run build:all && bun run verify:package`. 3. Write clear, descriptive commit messages (lowercase, imperative mood). 4. Open a pull request against `main` with a description of the change. -5. All CI checks must pass before merge. +5. Required GitHub Actions `Typecheck`, `Tests`, and `Package` checks must pass before merge. + +## Release process + +GitHub is the source of truth. Maintainers release by pushing a `vX.Y.Z` tag +that matches `package.json.version`. The release workflow repeats every gate, +publishes the exact verified `.artifacts/package.tgz` through npm Trusted +Publishing/OIDC, and attaches the tarball plus its SHA-256 digest to the GitHub +Release. GitLab is maintained only as a read-only mirror. ## Language @@ -54,4 +63,4 @@ Use English for all code comments, commit messages, issue descriptions, and pull ## Project Context -This is an OpenCode TUI sidebar plugin built with `@opentui/solid` and `@opencode-ai/plugin`. It displays normalized provider agents, collapsible sections, and model information. In the Phase 0/1 foundation, [OhMyOpenAgent](https://github.com/oh-my-openagent) metadata remains only a temporary bootstrap source until the OpenCode-native provider is added. +This is an OpenCode v2 TUI sidebar plugin built with `@opentui/solid` and `@opencode/plugin`. Its current data source is `oh-my-openagent.json`; the provider seam permits a future native OpenCode source but does not claim universal discovery. diff --git a/README.md b/README.md index e8814a0..5c99c68 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [Русская версия](./README.ru.md) -> Universal OpenCode TUI sidebar plugin for browsing agents and subagents. OhMyOpenAgent metadata is treated as an optional bootstrap/enrichment source, not the core product model. +> OpenCode v2 TUI sidebar for browsing agents and subagents from the current OhMyOpenAgent configuration source. [![Package](https://img.shields.io/badge/npm-opencode--agents--sidebar-111827?style=for-the-badge&labelColor=111827&color=5b5ef4)](https://www.npmjs.com/package/opencode-agents-sidebar) ![Runtime](https://img.shields.io/badge/runtime-Bun-111827?style=for-the-badge&logo=bun&logoColor=5b5ef4) @@ -13,10 +13,10 @@ |---|---| | Status | Actively maintained personal OpenCode tool/plugin | | Type | OpenCode TUI plugin / host extension | -| Host app | OpenCode `>= v1.14.49` documented; `@opencode-ai/plugin >=1.4.0` peer dependency | -| Package | `opencode-agents-sidebar` `1.0.8`, published on npm | -| Runtime | Bun `>=1.1.0` | -| Maintainer checks | `bun install && bun run build:all && bun test && bun run typecheck` | +| Host app | OpenCode v2; `2.0.32` is the release baseline | +| Package | `opencode-agents-sidebar` `2.0.0` | +| Runtime | Bun `>=1.3.0` | +| Maintainer checks | `bun install --frozen-lockfile && bun run typecheck && bun test && bun run build:all && bun run verify:package` | ## Screenshots @@ -31,80 +31,84 @@ The plugin renders agent categories and details inside the OpenCode terminal UI ## Summary - Displays OpenCode agents in the OpenCode sidebar. -- Uses a normalized provider boundary so OpenCode-native, custom, and optional OhMyOpenAgent sources can feed the same UI. +- Reads agents from `oh-my-openagent.json` through a normalized provider boundary; the seam permits a future native OpenCode source without claiming universal discovery today. - Groups agents into neutral categories such as primary, subagents, built-in, project, global, custom, disabled, hidden, and other. - Supports collapsible category sections and a collapsible main sidebar panel. - Shows provider, model, variant, fallback, and disabled-agent information according to display settings. - Supports compact and split row layouts for different terminal widths. - Supports provider, model, and variant aliases for shorter terminal output. -- Reloads provider data by polling and fingerprint comparison. +- Reloads configuration through directory watches with polling recovery and deterministic fingerprint comparison. - Handles missing or invalid configuration files with explicit sidebar states. ## Quick start ```sh -# install the published OpenCode plugin globally -opencode plugin opencode-agents-sidebar@latest --global --force - -# optional: build from a local checkout for development -git clone https://github.com/Mark1708/opencode-agents-sidebar.git -cd opencode-agents-sidebar -bun install -bun run build:all +opencode plugin add opencode-agents-sidebar@2.0.0 ``` -This release is a provider-boundary foundation: the UI is OpenCode-first, while native OpenCode agent discovery is planned for the next implementation phase. - ## Installation -### OpenCode plugin install +### OpenCode v2 + +Install the release with the v2 plugin manager: ```sh -opencode plugin opencode-agents-sidebar@latest --global --force +opencode plugin add opencode-agents-sidebar@2.0.0 ``` -This is the recommended path for users because the plugin is published on npm and OpenCode can install it directly. +The equivalent explicit configuration is: + +```jsonc +{ + "$schema": "https://opencode.ai/v2/cli.json", + "plugins": ["opencode-agents-sidebar@2.0.0"] +} +``` + +Add the package once, either to `~/.config/opencode/cli.json` or to the v2 +`plugins` field in `opencode.json(c)`. Package order controls v2 sidebar +contribution order. + +This major does not support OpenCode v1. V1 users must pin +`opencode-agents-sidebar@1.0.8`; there is no dual-runtime compatibility shim. ### Local checkout for development ```sh git clone https://github.com/Mark1708/opencode-agents-sidebar.git cd opencode-agents-sidebar -bun install +bun install --frozen-lockfile bun run build:all +bun run verify:package ``` -The build emits the plugin entry and TUI bundle into `dist/`. - -For local OpenCode testing, install the checkout directly: - -```sh -opencode plugin "file:/absolute/path/to/opencode-agents-sidebar" --global --force -``` +The build emits separate v2 server and TUI definitions into `dist/`; the +package verifier writes `.artifacts/package.tgz`. Repository root shims are +intentionally absent. ## Compatibility | Component | Supported version | Source | |---|---|---| -| Host app | OpenCode `>= v1.14.49` | README compatibility note | -| Plugin API | `@opencode-ai/plugin >=1.4.0` | `package.json` peer dependency | -| TUI runtime | `@opentui/solid >=0.1.0`, `solid-js >=1.8.0` | `package.json` peer dependencies | -| Local runtime | Bun `>=1.1.0` | `package.json` engines and Bun-based scripts | +| Host app | OpenCode v2 (`2.0.32` release baseline) | Release smoke contract | +| Plugin API | `@opencode/plugin 2.0.32` | `package.json` dependency | +| Theme API | `@opencode/theme 2.0.32` | `package.json` peer dependency | +| TUI runtime | `@opentui/core >=0.5.17`, `@opentui/solid >=0.5.17`, `solid-js 1.9.12` | `package.json` peer dependencies | +| Local runtime | Bun `>=1.3.0` | `package.json` engine | | TypeScript | `^5.5.0` | `package.json` dev dependency | ## Configuration -The sidebar is being redesigned around normalized agent providers. In this foundation release, the temporary bootstrap provider still reads: +The current release reads one source: ```text ~/.config/opencode/oh-my-openagent.json ``` -That file is no longer the core product model. It is only an optional/bootstrap source until native OpenCode agent discovery is implemented. - -Top-level sections such as `agents`, `agent_order`, `disabled_agents`, `team_mode`, and `tmux` are shared OhMyOpenAgent data. The sidebar renders that data when present, but those sections are not separate sidebar plugin configuration. - -The provider seam is already in place: `src/providers/omo.ts` adapts the temporary bootstrap data, and `src/providers/custom.ts` is the minimal placeholder for explicit custom agents. +Top-level `agents`, `agent_order`, `disabled_agents`, `team_mode`, and `tmux` +sections are OhMyOpenAgent data. `src/providers/omo.ts` normalizes that data for +the sidebar. The provider seam permits a future native OpenCode source, but +native or universal agent discovery is not part of this release. Smallest useful configuration: @@ -126,7 +130,6 @@ Full documented shape: "sidebar_width": 34, "name_width": 18, "poll_interval_ms": 2000, - "slot_order": 850, "title": "Agents", "category_order": ["Primary", "Subagents", "Built-in", "Project", "Global", "Custom", "Disabled", "Hidden", "Other"], "model_display": "details-only", @@ -173,6 +176,9 @@ Supported values: - `agent_row_mode`: `"compact"` or `"split"`. - `symbols`: `"unicode"` or `"ascii"`. +OpenCode v2 sidebar contribution order follows package order in the host +`plugins` list. + ## Usage - Start OpenCode with the plugin installed. @@ -232,14 +238,16 @@ team 2/4 ├── dist/ # Built package output ├── src/ │ ├── agents/ # Normalized agent types, grouping, merge logic -│ ├── providers/ # AgentProvider boundary and source implementations +│ ├── providers/ # AgentProvider boundary and OhMyOpenAgent source │ ├── agents.ts # Agent list/view-state helpers │ ├── config.ts # Raw JSON reading and sidebar config merge helpers +│ ├── config-watcher.ts # Directory watches with polling recovery +│ ├── runtime.ts # Runtime states, fingerprints, serialized reloads │ ├── defaults.ts # Neutral sidebar defaults │ ├── format.ts # Text formatting and display utilities -│ ├── index.ts # OpenCode plugin entry -│ ├── render.ts # TUI plugin render entry -│ ├── tui.ts # TUI component implementation +│ ├── index.ts # OpenCode v2 server definition +│ ├── render.ts # Sole OpenTUI/Solid render boundary +│ ├── tui.ts # OpenCode v2 lifecycle and state ownership │ └── types.ts # TypeScript type definitions ├── package.json # Package metadata, scripts, peer dependencies ├── tsconfig.json # TypeScript configuration @@ -248,18 +256,18 @@ team 2/4 ## Troubleshooting -- If the published plugin does not load, run `opencode plugin opencode-agents-sidebar@latest --global --force` again and restart OpenCode. -- If a local checkout does not load, rebuild with `bun run build:all`, then reinstall with `opencode plugin "file:/absolute/path/to/opencode-agents-sidebar" --global --force`. -- If agents do not appear in this foundation release, verify the temporary bootstrap provider has agent metadata available. -- If provider data is invalid, the sidebar shows neutral provider diagnostics. +- If the package does not load, run `opencode plugin check` and verify that `opencode-agents-sidebar@2.0.0` appears once in the v2 `plugins` list. +- If a local build does not load, run `bun run build:all && bun run verify:package` and test `.artifacts/package.tgz` through a disposable v2 wrapper. +- If agents do not appear, verify `~/.config/opencode/oh-my-openagent.json` contains valid agent metadata. +- Missing and malformed configuration produce explicit sidebar states and recover after create, replace, or edit. - If content is cut off, adjust `sidebar_width`, `name_width`, or `agent_row_mode`. - If model names are too long, use `model_aliases`, `provider_aliases`, or `variant_aliases`. -- If config changes do not appear immediately, wait for the polling interval or restart OpenCode. +- If config changes do not appear, verify the configured path is readable; native directory watching falls back to polling after watch errors. ## Limitations / Security -- The foundation release reads local provider metadata, not provider credentials. The current built-in data source is the temporary OhMyOpenAgent bootstrap provider, plus a minimal custom-provider seam. -- Config reload uses provider file watchers with polling fallback and fingerprint comparison. +- The plugin reads local OhMyOpenAgent metadata, not provider credentials. Native OpenCode agent discovery is a future provider implementation. +- Config reload uses directory watchers with polling recovery, deterministic fingerprints, and generation-gated serialized reads. - Sidebar width behavior is constrained by terminal and OpenCode TUI rendering. - Styling depends on the host OpenCode and OpenTUI theme capabilities. - Example configuration values are placeholders and can be adjusted per local setup. diff --git a/README.ru.md b/README.ru.md index e994e28..00e1bbd 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,6 +1,6 @@ # opencode-agents-sidebar -> Universal OpenCode TUI sidebar plugin для просмотра agents и subagents. Metadata OhMyOpenAgent используется как optional bootstrap/enrichment source, а не как core product model. +> OpenCode v2 TUI sidebar для просмотра agents и subagents из текущего configuration source OhMyOpenAgent. [![Package](https://img.shields.io/badge/npm-opencode--agents--sidebar-111827?style=for-the-badge&labelColor=111827&color=5b5ef4)](https://www.npmjs.com/package/opencode-agents-sidebar) ![Runtime](https://img.shields.io/badge/runtime-Bun-111827?style=for-the-badge&logo=bun&logoColor=5b5ef4) @@ -13,10 +13,10 @@ |---|---| | Статус | Активно поддерживаемый персональный OpenCode tool/plugin | | Тип | OpenCode TUI plugin / host extension | -| Host app | Документирован OpenCode `>= v1.14.49`; peer dependency `@opencode-ai/plugin >=1.4.0` | -| Package | `opencode-agents-sidebar` `1.0.8`, опубликован в npm | -| Runtime | Bun `>=1.1.0` | -| Проверки maintainer | `bun install && bun run build:all && bun test && bun run typecheck` | +| Host app | OpenCode v2; release baseline — `2.0.32` | +| Package | `opencode-agents-sidebar` `2.0.0` | +| Runtime | Bun `>=1.3.0` | +| Проверки maintainer | `bun install --frozen-lockfile && bun run typecheck && bun test && bun run build:all && bun run verify:package` | ## Скриншоты @@ -31,80 +31,84 @@ Plugin отображает agent categories и details внутри sidebar т ## Сводка - Показывает OpenCode agents в OpenCode sidebar. -- Использует normalized provider boundary, чтобы OpenCode-native, custom и optional OhMyOpenAgent sources могли питать один UI. +- Читает agents из `oh-my-openagent.json` через normalized provider boundary; seam допускает будущий native OpenCode source, но текущий release не заявляет universal discovery. - Группирует agents в нейтральные категории: primary, subagents, built-in, project, global, custom, disabled, hidden и other. - Поддерживает сворачиваемые category sections и сворачиваемый главный sidebar panel. - Показывает provider, model, variant, fallback и disabled-agent информацию согласно display settings. - Поддерживает compact и split row layouts для разных terminal widths. - Поддерживает provider, model и variant aliases для более короткого terminal output. -- Перезагружает provider data через polling и fingerprint comparison. +- Перезагружает configuration через directory watches с polling recovery и deterministic fingerprint comparison. - Обрабатывает отсутствующие или некорректные configuration files явными состояниями sidebar. ## Быстрый старт ```sh -# установить опубликованный OpenCode plugin глобально -opencode plugin opencode-agents-sidebar@latest --global --force - -# опционально: собрать из локального checkout для разработки -git clone https://github.com/Mark1708/opencode-agents-sidebar.git -cd opencode-agents-sidebar -bun install -bun run build:all +opencode plugin add opencode-agents-sidebar@2.0.0 ``` -Этот релиз является foundation для provider boundary: UI уже OpenCode-first, а native OpenCode agent discovery запланирован на следующий implementation phase. - ## Установка -### Установка OpenCode plugin +### OpenCode v2 + +Установите release через v2 plugin manager: ```sh -opencode plugin opencode-agents-sidebar@latest --global --force +opencode plugin add opencode-agents-sidebar@2.0.0 ``` -Это рекомендуемый путь для пользователей: plugin опубликован в npm, и OpenCode может установить его напрямую. +Эквивалентная явная конфигурация: + +```jsonc +{ + "$schema": "https://opencode.ai/v2/cli.json", + "plugins": ["opencode-agents-sidebar@2.0.0"] +} +``` + +Добавьте package один раз: в `~/.config/opencode/cli.json` либо в v2-поле +`plugins` файла `opencode.json(c)`. Порядок packages определяет порядок v2 +sidebar contributions. + +Этот major не поддерживает OpenCode v1. Пользователям v1 нужно закрепить +`opencode-agents-sidebar@1.0.8`; dual-runtime compatibility shim отсутствует. ### Локальный checkout для разработки ```sh git clone https://github.com/Mark1708/opencode-agents-sidebar.git cd opencode-agents-sidebar -bun install +bun install --frozen-lockfile bun run build:all +bun run verify:package ``` -Build создаёт plugin entry и TUI bundle в `dist/`. - -Для локальной проверки в OpenCode установите checkout напрямую: - -```sh -opencode plugin "file:/absolute/path/to/opencode-agents-sidebar" --global --force -``` +Build создаёт отдельные v2 server и TUI definitions в `dist/`; package verifier +записывает `.artifacts/package.tgz`. Root shims в репозитории намеренно +отсутствуют. ## Совместимость | Компонент | Поддерживаемая версия | Источник | |---|---|---| -| Host app | OpenCode `>= v1.14.49` | README compatibility note | -| Plugin API | `@opencode-ai/plugin >=1.4.0` | `package.json` peer dependency | -| TUI runtime | `@opentui/solid >=0.1.0`, `solid-js >=1.8.0` | `package.json` peer dependencies | -| Local runtime | Bun `>=1.1.0` | `package.json` engines и Bun-based scripts | -| TypeScript | `^5.5.0` | `package.json` dev dependency | +| Host app | OpenCode v2 (`2.0.32` — release baseline) | Release smoke contract | +| Plugin API | `@opencode/plugin 2.0.32` | dependency в `package.json` | +| Theme API | `@opencode/theme 2.0.32` | peer dependency в `package.json` | +| TUI runtime | `@opentui/core >=0.5.17`, `@opentui/solid >=0.5.17`, `solid-js 1.9.12` | peer dependencies в `package.json` | +| Local runtime | Bun `>=1.3.0` | engine в `package.json` | +| TypeScript | `^5.5.0` | dev dependency в `package.json` | ## Конфигурация -Sidebar redesign построен вокруг normalized agent providers. В foundation release temporary bootstrap provider всё ещё читает: +Текущий release читает один source: ```text ~/.config/opencode/oh-my-openagent.json ``` -Этот файл больше не является core product model. Он используется только как optional/bootstrap source до реализации native OpenCode agent discovery. - -Top-level sections вроде `agents`, `agent_order`, `disabled_agents`, `team_mode` и `tmux` — это общие данные OhMyOpenAgent. Sidebar отображает эти данные при наличии, но эти sections не являются отдельной configuration sidebar plugin. - -Provider seam уже есть: `src/providers/omo.ts` адаптирует temporary bootstrap data, а `src/providers/custom.ts` является минимальной заготовкой для explicit custom agents. +Top-level sections `agents`, `agent_order`, `disabled_agents`, `team_mode` и +`tmux` — данные OhMyOpenAgent. `src/providers/omo.ts` нормализует их для +sidebar. Provider seam допускает будущий native OpenCode source, но native или +universal agent discovery не входят в этот release. Минимальная полезная конфигурация: @@ -137,7 +141,6 @@ Provider seam уже есть: `src/providers/omo.ts` адаптирует tempo "sidebar_width": 34, "name_width": 18, "poll_interval_ms": 2000, - "slot_order": 850, "title": "Agents", "category_order": ["Primary", "Subagents", "Built-in", "Project", "Global", "Custom", "Disabled", "Hidden", "Other"], "model_display": "details-only", @@ -173,6 +176,9 @@ Provider seam уже есть: `src/providers/omo.ts` адаптирует tempo - `agent_row_mode`: `"compact"` или `"split"`. - `symbols`: `"unicode"` или `"ascii"`. +В OpenCode v2 порядок sidebar contributions определяется порядком packages в +host-списке `plugins`. + ## Использование - Запустите OpenCode с установленным plugin. @@ -232,14 +238,16 @@ team 2/4 ├── dist/ # Собранный package output ├── src/ │ ├── agents/ # Normalized agent types, grouping, merge logic -│ ├── providers/ # AgentProvider boundary и source implementations +│ ├── providers/ # AgentProvider boundary и source OhMyOpenAgent │ ├── agents.ts # Agent list/view-state helpers │ ├── config.ts # Raw JSON reading и sidebar config merge helpers +│ ├── config-watcher.ts # Directory watches с polling recovery +│ ├── runtime.ts # Runtime states, fingerprints, serialized reloads │ ├── defaults.ts # Neutral sidebar defaults │ ├── format.ts # Text formatting и display utilities -│ ├── index.ts # OpenCode plugin entry -│ ├── render.ts # TUI plugin render entry -│ ├── tui.ts # Реализация TUI component +│ ├── index.ts # OpenCode v2 server definition +│ ├── render.ts # Единственная OpenTUI/Solid render boundary +│ ├── tui.ts # OpenCode v2 lifecycle и state ownership │ └── types.ts # TypeScript type definitions ├── package.json # Package metadata, scripts, peer dependencies ├── tsconfig.json # TypeScript configuration @@ -248,18 +256,18 @@ team 2/4 ## Устранение неполадок -- Если published plugin не загружается, снова выполните `opencode plugin opencode-agents-sidebar@latest --global --force` и перезапустите OpenCode. -- Если local checkout не загружается, пересоберите его через `bun run build:all`, затем переустановите: `opencode plugin "file:/absolute/path/to/opencode-agents-sidebar" --global --force`. -- Если agents не отображаются в foundation release, проверьте, что temporary bootstrap provider имеет agent metadata. -- Если provider data некорректна, sidebar показывает neutral provider diagnostics. +- Если package не загружается, выполните `opencode plugin check` и убедитесь, что `opencode-agents-sidebar@2.0.0` один раз указан в v2-списке `plugins`. +- Если local build не загружается, выполните `bun run build:all && bun run verify:package` и проверьте `.artifacts/package.tgz` через disposable v2 wrapper. +- Если agents не отображаются, проверьте valid agent metadata в `~/.config/opencode/oh-my-openagent.json`. +- Missing и malformed configuration дают явные sidebar states и восстанавливаются после create, replace или edit. - Если content обрезается, настройте `sidebar_width`, `name_width` или `agent_row_mode`. - Если model names слишком длинные, используйте `model_aliases`, `provider_aliases` или `variant_aliases`. -- Если config changes не появляются сразу, дождитесь polling interval или перезапустите OpenCode. +- Если config changes не появляются, проверьте доступность configured path; после watch error native directory watching переходит на polling. ## Ограничения / безопасность -- Foundation release читает local provider metadata, а не provider credentials. Текущий built-in data source — temporary OhMyOpenAgent bootstrap provider плюс минимальный custom-provider seam. -- Config reload использует provider file watchers с polling fallback и fingerprint comparison. +- Plugin читает local metadata OhMyOpenAgent, а не provider credentials. Native OpenCode agent discovery остаётся будущей provider implementation. +- Config reload использует directory watchers с polling recovery, deterministic fingerprints и generation-gated serialized reads. - Поведение sidebar width ограничено terminal и OpenCode TUI rendering. - Styling зависит от возможностей host OpenCode и OpenTUI theme. - Example configuration values являются placeholders и могут настраиваться под локальную среду. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..f379640d5b51bf6e63c4ff9726383b8a7721b3f7 GIT binary patch literal 161976 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 Z(PfB_%Hx0D(PgL38Xq35-T$Zk{vX6SA*lcW literal 0 HcmV?d00001 diff --git a/package.json b/package.json index fe92494..c859383 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-agents-sidebar", - "version": "1.0.8", - "description": "Universal OpenCode TUI sidebar for browsing provider agents and subagents", + "version": "2.0.0", + "description": "OpenCode v2 TUI sidebar for browsing OhMyOpenAgent agents and subagents", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -26,42 +26,48 @@ "subagents", "terminal" ], - "oc-plugin": [ - "tui" - ], "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }, "./tui": { - "import": "./dist/tui.js" + "import": "./dist/tui.js", + "types": "./dist/tui.d.ts" } }, "files": [ "dist" ], "scripts": { - "clean": "rm -rf dist", - "build": "bun build src/render.ts --outfile dist/tui.js --target bun --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk --external @opentui/core --external @opentui/solid --external solid-js", - "build:index": "bun build src/index.ts --outfile dist/index.js --target bun --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk", - "build:types": "bun x tsc --emitDeclarationOnly", - "build:all": "bun run clean && bun run build:types && bun run build:index && bun run build", + "clean": "rm -rf dist .artifacts", + "build:index": "bun build src/index.ts --outfile dist/index.js --target bun --format esm --external @opencode/plugin --external @opencode/plugin/tui --external @opencode/theme --external @opentui/core --external @opentui/solid --external solid-js", + "build:tui": "bun build src/tui.ts --outfile dist/tui.js --target bun --format esm --external @opencode/plugin --external @opencode/plugin/tui --external @opencode/theme --external @opentui/core --external @opentui/solid --external solid-js", + "build:types": "bun x tsc --project tsconfig.build.json && node scripts/prune-declarations.mjs", + "build:all": "bun run clean && bun run build:types && bun run build:index && bun run build:tui", "test": "bun test", "typecheck": "bun x tsc --noEmit", - "dev:install": "cp dist/tui.js tui.js", + "verify:package": "node scripts/verify-package.mjs", "prepublishOnly": "echo 'Skipping build -- CI already built dist/'" }, + "dependencies": { + "@opencode/plugin": "2.0.32" + }, "peerDependencies": { - "@opencode-ai/plugin": ">=1.4.0", - "@opentui/solid": ">=0.1.0", - "solid-js": ">=1.8.0" + "@opencode/theme": "2.0.32", + "@opentui/core": ">=0.5.17", + "@opentui/solid": ">=0.5.17", + "solid-js": "1.9.12" }, "devDependencies": { + "@opencode/theme": "2.0.32", + "@opentui/core": "0.5.17", + "@opentui/solid": "0.5.17", "@types/bun": "latest", + "solid-js": "1.9.12", "typescript": "^5.5.0" }, "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" } } diff --git a/scripts/prune-declarations.mjs b/scripts/prune-declarations.mjs new file mode 100644 index 0000000..d95cae0 --- /dev/null +++ b/scripts/prune-declarations.mjs @@ -0,0 +1,43 @@ +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { dirname, join, posix, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const declarationRoots = ["dist/index.d.ts", "dist/tui.d.ts"]; +const closure = new Set(); +const pending = [...declarationRoots]; + +while (pending.length > 0) { + const declaration = pending.pop(); + if (!declaration || closure.has(declaration)) continue; + if (!existsSync(join(projectRoot, declaration))) { + throw new Error(`Missing declaration entrypoint: ${declaration}`); + } + closure.add(declaration); + const source = readFileSync(join(projectRoot, declaration), "utf8"); + for (const match of source.matchAll(/["'](\.{1,2}\/[^"']+)["']/g)) { + const unresolved = posix.normalize(posix.join(posix.dirname(declaration), match[1])); + const candidates = match[1].endsWith(".js") + ? [`${unresolved.slice(0, -3)}.d.ts`] + : [unresolved, `${unresolved}.d.ts`, posix.join(unresolved, "index.d.ts")]; + const target = candidates.find((candidate) => existsSync(join(projectRoot, candidate))); + if (target && !closure.has(target)) pending.push(target); + } +} + +function declarationsUnder(directory) { + return readdirSync(join(projectRoot, directory), { withFileTypes: true }).flatMap((entry) => { + const path = posix.join(directory, entry.name); + return entry.isDirectory() + ? declarationsUnder(path) + : path.endsWith(".d.ts") + ? [path] + : []; + }); +} + +for (const declaration of declarationsUnder("dist")) { + if (closure.has(declaration)) continue; + rmSync(join(projectRoot, declaration)); + rmSync(join(projectRoot, `${declaration}.map`), { force: true }); +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..7a7b9f3 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,163 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, posix, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")); +const expectedIds = { + "opencode-usage-monitor": ["usage-monitor", "usage-monitor.tui"], + "opencode-agents-sidebar": ["agents-sidebar", "agents-sidebar.tui"], +}; +const ids = expectedIds[packageJson.name]; +if (!ids) throw new Error(`Unsupported package verifier target: ${packageJson.name}`); + +const artifactsDir = join(projectRoot, ".artifacts"); +const fixedTarball = join(artifactsDir, "package.tgz"); +rmSync(artifactsDir, { recursive: true, force: true }); +mkdirSync(artifactsDir, { recursive: true }); + +const packOutput = execFileSync("npm", ["pack", "--json", "--pack-destination", artifactsDir], { + cwd: projectRoot, + encoding: "utf8", +}); +const packResult = JSON.parse(packOutput); +const tarballs = readdirSync(artifactsDir).filter((name) => name.endsWith(".tgz")); +if (!Array.isArray(packResult) || packResult.length !== 1 || tarballs.length !== 1) { + throw new Error(`Expected exactly one packed tarball, got ${tarballs.length}`); +} +renameSync(join(artifactsDir, tarballs[0]), fixedTarball); + +const archiveEntries = execFileSync("tar", ["-tzf", fixedTarball], { encoding: "utf8" }) + .split("\n") + .filter(Boolean); +const entrySet = new Set(archiveEntries); +for (const required of [ + "package/dist/index.js", + "package/dist/index.d.ts", + "package/dist/tui.js", + "package/dist/tui.d.ts", +]) { + if (!entrySet.has(required)) throw new Error(`Package is missing ${required}`); +} + +const forbidden = archiveEntries.filter((entry) => + entry.startsWith("package/src/") || + entry.startsWith("package/dist/src/") || + entry.includes("/__tests__/") || + /\.test\.d\.ts$/.test(entry) || + /^package\/(?:index|tui)\.(?:js|ts|d\.ts)$/.test(entry) || + entry.endsWith(".tgz") || + /(?:^|\/)\.npmrc$/.test(entry) || + /(?:^|\/)\.env(?:\.|$)/.test(entry) +); +if (forbidden.length > 0) throw new Error(`Forbidden package entries:\n${forbidden.join("\n")}`); + +function declarationTarget(fromFile, specifier) { + const unresolved = posix.normalize(posix.join(posix.dirname(fromFile), specifier)); + const candidates = specifier.endsWith(".js") + ? [`${unresolved.slice(0, -3)}.d.ts`] + : specifier.endsWith(".d.ts") + ? [unresolved] + : [unresolved, `${unresolved}.d.ts`, posix.join(unresolved, "index.d.ts")]; + return candidates.find((candidate) => existsSync(join(projectRoot, candidate))); +} + +const declarationClosure = new Set(); +const pendingDeclarations = ["dist/index.d.ts", "dist/tui.d.ts"]; +while (pendingDeclarations.length > 0) { + const declaration = pendingDeclarations.pop(); + if (!declaration || declarationClosure.has(declaration)) continue; + declarationClosure.add(declaration); + const source = readFileSync(join(projectRoot, declaration), "utf8"); + for (const match of source.matchAll(/["'](\.{1,2}\/[^"']+)["']/g)) { + const target = declarationTarget(declaration, match[1]); + if (target && !declarationClosure.has(target)) pendingDeclarations.push(target); + } +} +const unexpectedDeclarations = archiveEntries + .filter((entry) => entry.endsWith(".d.ts")) + .map((entry) => entry.slice("package/".length)) + .filter((entry) => !declarationClosure.has(entry)); +if (unexpectedDeclarations.length > 0) { + throw new Error(`Declarations outside exported closure:\n${unexpectedDeclarations.join("\n")}`); +} + +const consumerDir = mkdtempSync(join(tmpdir(), `${packageJson.name}-consumer-`)); +try { + const packageSpecifier = packageJson.name; + writeFileSync(join(consumerDir, "package.json"), JSON.stringify({ + private: true, + type: "module", + dependencies: { + [packageSpecifier]: pathToFileURL(fixedTarball).href, + "@opencode/theme": "2.0.32", + "@opentui/core": "0.5.17", + "@opentui/solid": "0.5.17", + "solid-js": "1.9.12", + }, + devDependencies: { + "@types/json-schema": "7.0.15", + "@types/node": "24.12.0", + typescript: "5.9.3", + }, + overrides: { + "@aws-sdk/client-cognito-identity": "3.1057.0", + "@aws-sdk/core": "3.978.3", + "@aws-sdk/credential-provider-cognito-identity": "3.972.73", + "@aws-sdk/credential-provider-env": "3.972.74", + "@aws-sdk/credential-provider-http": "3.972.76", + "@aws-sdk/credential-provider-ini": "3.973.19", + "@aws-sdk/credential-provider-login": "3.972.81", + "@aws-sdk/credential-provider-node": "3.972.86", + "@aws-sdk/credential-provider-process": "3.972.74", + "@aws-sdk/credential-provider-sso": "3.973.18", + "@aws-sdk/credential-provider-web-identity": "3.972.80", + "@aws-sdk/credential-providers": "3.1057.0", + "@aws-sdk/nested-clients": "3.997.48", + "@aws-sdk/signature-v4-multi-region": "3.996.48", + "@aws-sdk/token-providers": "3.1150.0", + "@aws-sdk/types": "3.974.6", + "@aws-sdk/util-locate-window": "3.965.10", + "@aws-sdk/xml-builder": "3.972.41", + }, + }, null, 2)); + writeFileSync(join(consumerDir, "import-check.mjs"), ` +import server from ${JSON.stringify(packageSpecifier)}; +import tui from ${JSON.stringify(`${packageSpecifier}/tui`)}; +for (const [module, expectedId] of [[server, ${JSON.stringify(ids[0])}], [tui, ${JSON.stringify(ids[1])}]]) { + if (module.id !== expectedId || typeof module.setup !== "function") throw new Error("Invalid V2 plugin export"); + if (Object.hasOwn(module, "server") || Object.hasOwn(module, "tui")) throw new Error("V1 plugin field leaked"); +} +`); + writeFileSync(join(consumerDir, "consumer.ts"), ` +import server from ${JSON.stringify(packageSpecifier)}; +import tui from ${JSON.stringify(`${packageSpecifier}/tui`)}; +const serverId: string = server.id; +const tuiId: string = tui.id; +const serverSetup: Function = server.setup; +const tuiSetup: Function = tui.setup; +void [serverId, tuiId, serverSetup, tuiSetup]; +`); + writeFileSync(join(consumerDir, "tsconfig.json"), JSON.stringify({ + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "Bundler", + lib: ["ESNext", "DOM"], + strict: true, + skipLibCheck: false, + noEmit: true, + types: ["node"], + }, + files: ["consumer.ts"], + }, null, 2)); + execFileSync("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: consumerDir, stdio: "inherit" }); + execFileSync("bun", ["run", "import-check.mjs"], { cwd: consumerDir, stdio: "inherit" }); + execFileSync(join(consumerDir, "node_modules", ".bin", "tsc"), ["--project", "tsconfig.json"], { cwd: consumerDir, stdio: "inherit" }); +} finally { + rmSync(consumerDir, { recursive: true, force: true }); +} + +console.log(`${packageJson.name}@${packageJson.version}: package contract verified`); diff --git a/src/config-watcher.test.ts b/src/config-watcher.test.ts new file mode 100644 index 0000000..5bc005b --- /dev/null +++ b/src/config-watcher.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { + createConfigWatcher, + type ConfigWatchDependencies, + type DirectoryWatcher, +} from "./config-watcher.js"; + +async function settle(): Promise { + for (let turn = 0; turn < 8; turn += 1) await Promise.resolve(); +} + +type FakeWatch = DirectoryWatcher & { + directory: string; + emit(filename: string | null): void; + fail(): void; + closed: number; +}; + +function fakeDependencies(): { + dependencies: ConfigWatchDependencies; + watches: FakeWatch[]; + polls: Array<() => void>; + cleared: unknown[]; + setWatchFailure(value: boolean): void; +} { + const watches: FakeWatch[] = []; + const polls: Array<() => void> = []; + const cleared: unknown[] = []; + let watchFailure = false; + const dependencies: ConfigWatchDependencies = { + watchDirectory: (directory, listener) => { + if (watchFailure) throw new Error("watch failed"); + let errorListener = (): void => undefined; + const watcher: FakeWatch = { + directory, + closed: 0, + close() { this.closed += 1; }, + onError(callback) { errorListener = callback; }, + emit: listener, + fail: () => errorListener(), + }; + watches.push(watcher); + return watcher; + }, + setInterval: (callback) => { + polls.push(callback); + return { poll: polls.length }; + }, + clearInterval: (handle) => { cleared.push(handle); }, + }; + return { + dependencies, + watches, + polls, + cleared, + setWatchFailure: (value) => { watchFailure = value; }, + }; +} + +describe("directory config watcher", () => { + test("deduplicates directories and filters unrelated filenames while accepting null", async () => { + const fake = fakeDependencies(); + let reloads = 0; + const watcher = createConfigWatcher(async () => { reloads += 1; }, fake.dependencies); + watcher.update(["/tmp/config/a.json", "/tmp/config/b.json", "/tmp/config/a.json"], 2000); + + expect(fake.watches.map((entry) => entry.directory)).toEqual(["/tmp/config"]); + fake.watches[0]?.emit("unrelated.json"); + fake.watches[0]?.emit("a.json"); + fake.watches[0]?.emit(null); + await settle(); + + expect(reloads).toBe(2); + }); + + test("keeps polling after rejected reload and restores native watches after success", async () => { + const fake = fakeDependencies(); + fake.setWatchFailure(true); + let rejectReload = true; + let reloads = 0; + const watcher = createConfigWatcher(async () => { + reloads += 1; + if (rejectReload) throw new Error("still unavailable"); + }, fake.dependencies); + watcher.update(["/tmp/config.json"], 5000); + + expect(fake.polls).toHaveLength(1); + fake.polls[0]?.(); + await settle(); + expect(reloads).toBe(1); + expect(fake.cleared).toHaveLength(0); + + rejectReload = false; + fake.setWatchFailure(false); + fake.polls[0]?.(); + await settle(); + expect(reloads).toBe(2); + expect(fake.watches.map((entry) => entry.directory)).toEqual(["/tmp"]); + expect(fake.cleared).toHaveLength(1); + }); + + test("reconfigures path and interval, recovers from native errors, and disposes idempotently", async () => { + const fake = fakeDependencies(); + const watcher = createConfigWatcher(async () => undefined, fake.dependencies); + watcher.update(["/tmp/a.json"], 2000); + watcher.update(["/var/config/b.json"], 9000); + + expect(fake.watches[0]?.closed).toBe(1); + expect(fake.watches[1]?.directory).toBe("/var/config"); + fake.setWatchFailure(true); + fake.watches[1]?.fail(); + await settle(); + expect(fake.watches[1]?.closed).toBe(1); + expect(fake.polls).toHaveLength(1); + + watcher.dispose(); + watcher.dispose(); + watcher.update(["/ignored.json"], 1000); + expect(fake.cleared).toHaveLength(1); + expect(fake.watches).toHaveLength(2); + }); + + test("observes replacement rename and the following in-place change", async () => { + const fake = fakeDependencies(); + let reloads = 0; + const watcher = createConfigWatcher(async () => { reloads += 1; }, fake.dependencies); + watcher.update(["/tmp/config.json"], 2000); + + fake.watches[0]?.emit("config.json"); + fake.watches[0]?.emit("config.json"); + await settle(); + + expect(reloads).toBe(2); + }); +}); diff --git a/src/config-watcher.ts b/src/config-watcher.ts new file mode 100644 index 0000000..bf06f2c --- /dev/null +++ b/src/config-watcher.ts @@ -0,0 +1,134 @@ +import { watch } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; + +export interface DirectoryWatcher { + close(): void; + onError(callback: () => void): void; +} + +export interface ConfigWatchDependencies { + watchDirectory(directory: string, listener: (filename: string | null) => void): DirectoryWatcher; + setInterval(callback: () => void, intervalMs: number): unknown; + clearInterval(handle: unknown): void; +} + +export interface ConfigWatcher { + update(paths: readonly string[], pollIntervalMs: number): void; + dispose(): void; +} + +const DEFAULT_DEPENDENCIES: ConfigWatchDependencies = { + watchDirectory: (directory, listener) => { + const nativeWatcher = watch(directory, { persistent: false }, (_event, filename) => { + listener(filename === null ? null : filename.toString()); + }); + return { + close: () => nativeWatcher.close(), + onError: (callback) => { nativeWatcher.on("error", callback); }, + }; + }, + setInterval: (callback, intervalMs) => setInterval(callback, intervalMs), + clearInterval: (handle) => clearInterval(handle as Parameters[0]), +}; + +export function createConfigWatcher( + reload: () => Promise, + dependencies: ConfigWatchDependencies = DEFAULT_DEPENDENCIES, +): ConfigWatcher { + let disposed = false; + let paths: string[] = []; + let pollIntervalMs = 2000; + let nativeWatchers: DirectoryWatcher[] = []; + let pollHandle: unknown; + let pollRunning = false; + + const closeNativeWatchers = (): void => { + for (const watcher of nativeWatchers) watcher.close(); + nativeWatchers = []; + }; + const stopPolling = (): void => { + if (pollHandle === undefined) return; + dependencies.clearInterval(pollHandle); + pollHandle = undefined; + }; + + const startPolling = (): void => { + closeNativeWatchers(); + if (disposed || paths.length === 0 || pollHandle !== undefined) return; + pollHandle = dependencies.setInterval(() => { void poll(); }, pollIntervalMs); + }; + + const openNativeWatchers = (): boolean => { + if (disposed || paths.length === 0) return true; + const pathsByDirectory = new Map>(); + for (const path of paths) { + const directory = dirname(path); + const names = pathsByDirectory.get(directory) ?? new Set(); + names.add(basename(path)); + pathsByDirectory.set(directory, names); + } + + const opened: DirectoryWatcher[] = []; + try { + for (const [directory, names] of pathsByDirectory) { + const watcher = dependencies.watchDirectory(directory, (filename) => { + if (disposed || (filename !== null && !names.has(filename))) return; + void reload().catch(() => undefined); + }); + watcher.onError(startPolling); + opened.push(watcher); + } + } catch { + for (const watcher of opened) watcher.close(); + return false; + } + nativeWatchers = opened; + return true; + }; + + const recoverNativeWatchers = (): void => { + closeNativeWatchers(); + if (!openNativeWatchers()) return; + stopPolling(); + }; + + const poll = async (): Promise => { + if (disposed || pollRunning) return; + pollRunning = true; + try { + await reload(); + if (!disposed) recoverNativeWatchers(); + } catch { + // Keep polling until both reload and native watch setup recover. + } finally { + pollRunning = false; + } + }; + + return { + update(nextPaths, nextPollIntervalMs) { + if (disposed) return; + const normalizedPaths = [...new Set(nextPaths.map((path) => resolve(path)))].sort(); + const normalizedInterval = Number.isFinite(nextPollIntervalMs) && nextPollIntervalMs > 0 + ? nextPollIntervalMs + : 2000; + if ( + normalizedInterval === pollIntervalMs + && normalizedPaths.length === paths.length + && normalizedPaths.every((path, index) => path === paths[index]) + ) return; + + closeNativeWatchers(); + stopPolling(); + paths = normalizedPaths; + pollIntervalMs = normalizedInterval; + if (!openNativeWatchers()) startPolling(); + }, + dispose() { + if (disposed) return; + disposed = true; + closeNativeWatchers(); + stopPolling(); + }, + }; +} diff --git a/src/config.ts b/src/config.ts index 1ebab05..1b06979 100644 --- a/src/config.ts +++ b/src/config.ts @@ -43,7 +43,6 @@ export function mergeConfig(userConfig: unknown): SidebarConfig { sidebar_width: numberValue(config.sidebar_width, DEFAULTS.sidebar_width), name_width: numberValue(config.name_width, DEFAULTS.name_width), poll_interval_ms: numberValue(config.poll_interval_ms, DEFAULTS.poll_interval_ms), - slot_order: numberValue(config.slot_order, DEFAULTS.slot_order), title: stringValue(config.title, DEFAULTS.title), category_order: stringArrayValue(config.category_order, DEFAULTS.category_order), model_display: enumValue(config.model_display, ["inline", "details-only", "hidden"], DEFAULTS.model_display), @@ -58,20 +57,6 @@ export function mergeConfig(userConfig: unknown): SidebarConfig { }; } -export function configFingerprint(state: ConfigState): string { - if (state.kind !== "loaded") { - return state.kind === "invalid" ? `${state.kind}:${state.path}:${state.error}` : `${state.kind}:${state.path}`; - } - const config = state.config; - return JSON.stringify({ - agents: config.agents ?? {}, - agent_order: config.agent_order ?? [], - disabled_agents: config.disabled_agents ?? [], - tui: config.tui ?? {}, - team_mode: config.team_mode ?? {}, - tmux: config.tmux ?? {}, - }); -} export async function readConfigAsync(path: string = CONFIG_PATH): Promise { const file = Bun.file(path); diff --git a/src/defaults.ts b/src/defaults.ts index ef64108..f42e8c4 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -8,7 +8,6 @@ export const DEFAULTS: SidebarConfig & { default_collapsed: string[] } = { sidebar_width: 34, name_width: 18, poll_interval_ms: 2000, - slot_order: 850, title: "Agents", category_order: ["Primary", "Subagents", "Built-in", "Project", "Global", "Custom", "Disabled", "Hidden", "Other"], default_collapsed: DEFAULT_COLLAPSED_CATEGORIES, diff --git a/src/index.ts b/src/index.ts index 2052cbb..8439b89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,6 @@ -import type { Plugin } from "@opencode-ai/plugin"; +import { Plugin } from "@opencode/plugin"; -const plugin: { id: string; server: Plugin } = { +export default Plugin.define({ id: "agents-sidebar", - server: async (_input) => ({}), -}; - -export default plugin; + setup() {}, +}); diff --git a/src/providers/omo.ts b/src/providers/omo.ts index ba17953..c22b7c1 100644 --- a/src/providers/omo.ts +++ b/src/providers/omo.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; import { DEFAULTS, DISABLED_CATEGORY } from "../defaults.js"; -import { readConfigAsync } from "../config.js"; +import { isRecord, readConfigAsync } from "../config.js"; import type { AgentEntry, AgentMode, ModelSource } from "../agents/types.js"; import type { AgentProvider, AgentProviderContext, AgentSourceState } from "./types.js"; import type { AgentRowMode, AliasMap, ModelDisplay, ShowDisabledMode, SymbolMode } from "../types.js"; @@ -58,7 +58,6 @@ export interface OmoTuiConfig { sidebar_width?: number; name_width?: number; poll_interval_ms?: number; - slot_order?: number; title?: string; provider_aliases?: AliasMap; model_aliases?: AliasMap; @@ -111,10 +110,12 @@ export const omoProvider: AgentProvider = { export async function loadOmoSourceState(path = OMO_CONFIG_PATH): Promise { const state = await readConfigAsync(path); - if (state.kind === "missing") return emptyOmoState(path); + if (state.kind === "missing") return emptyOmoState(path, "missing"); if (state.kind === "invalid") { + const empty = emptyOmoState(path, "invalid"); return { - ...emptyOmoState(path), + ...empty, + metadata: { ...empty.metadata, configError: state.error }, errors: [{ code: "omo-config-invalid", severity: "error", message: state.error, source: "oh-my-openagent", path }], }; } @@ -129,6 +130,8 @@ export function normalizeOmoConfig(config: OmoConfig, path = OMO_CONFIG_PATH): A source: "oh-my-openagent", agents, metadata: { + configState: "loaded", + configPath: path, configuredAgentCount: Object.keys(config.agents ?? {}).length, team_mode: config.team_mode ?? {}, tmux: config.tmux ?? {}, @@ -203,11 +206,11 @@ function normalizeMode(mode: string | undefined): AgentMode { return "unknown"; } -function emptyOmoState(path: string): AgentSourceState { +function emptyOmoState(path: string, configState: "missing" | "invalid"): AgentSourceState { return { source: "oh-my-openagent", agents: [], - metadata: {}, + metadata: { configState, configPath: path }, warnings: [], errors: [], watchedPaths: [path], @@ -293,7 +296,6 @@ function readTui(value: unknown): OmoTuiConfig | undefined { sidebar_width: numberValue(value.sidebar_width), name_width: numberValue(value.name_width), poll_interval_ms: numberValue(value.poll_interval_ms), - slot_order: numberValue(value.slot_order), title: stringValue(value.title), provider_aliases: readStringRecord(value.provider_aliases), model_aliases: readStringRecord(value.model_aliases), @@ -301,9 +303,6 @@ function readTui(value: unknown): OmoTuiConfig | undefined { }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; diff --git a/src/render.ts b/src/render.ts index 72bc83e..e57ce43 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,20 +1,12 @@ import { createElement, insert, setProp } from "@opentui/solid"; import type { JSX } from "@opentui/solid"; -import { watch } from "node:fs"; -import { createSignal } from "solid-js"; -import type { TuiPluginModule } from "@opencode-ai/plugin/tui"; import { DEFAULTS } from "./defaults.js"; -import { mergeConfig } from "./config.js"; -import { buildSidebarViewState, filterAgents, groupByCategory } from "./agents.js"; +import { sanitizeLine, shortError } from "./config.js"; +import { filterAgents, groupByCategory } from "./agents.js"; import { agentFallbacks, agentVariant, compactAgentLine, formatDetailLine, formatFullModelRef, formatSplitAgentLines, truncateTo } from "./format.js"; -import { loadAgentSources } from "./providers/index.js"; +import type { SidebarRuntimeState } from "./runtime.js"; import type { AgentEntry, AgentRowMode, CategoryGroup, Child, DiagnosticWarning, HeaderStats, ModelDisplay, ProviderStatusState, SidebarConfig, SidebarViewState, SymbolMode, TextTheme } from "./types.js"; -import type { AgentSourceState } from "./providers/types.js"; -const WATCH_DEBOUNCE_MS = 100; - -type Watcher = ReturnType; -type Timer = ReturnType; function element(tag: string, props: Record, children: Child[] = []): JSX.Element { const node = createElement(tag); @@ -24,7 +16,7 @@ function element(tag: string, props: Record, children: Child[] for (const child of children) { if (child !== null && child !== undefined && child !== false) insert(node, child); } - return node as JSX.Element; + return node as unknown as JSX.Element; } function text(props: Record, children: Child[]): JSX.Element { @@ -35,12 +27,6 @@ function box(props: Record, children: Child[] = []): JSX.Elemen return element("box", props, children); } -interface PluginApiLike { - renderer: { requestRender: () => void }; - lifecycle: { onDispose: (callback: () => void) => void }; - slots: { register: (options: { order: number; slots: { sidebar_content: () => JSX.Element } }) => void }; - theme: { current: TextTheme }; -} export function renderText(value: string, color: unknown): JSX.Element { return text({ fg: color }, [value]); @@ -158,6 +144,105 @@ export function renderPanel(children: Child[], _theme: TextTheme): JSX.Element { return box({ width: "100%", flexDirection: "column", paddingTop: 0, paddingBottom: 0, paddingLeft: 0, paddingRight: 0 }, children); } +export type ResolvedThemeLike = Readonly<{ + text: Readonly<{ + base: unknown; + muted: unknown; + feedback: Readonly<{ + warning: Readonly<{ base: unknown }>; + error: Readonly<{ base: unknown }>; + }>; + }>; + background: Readonly<{ base: unknown }>; + border: Readonly<{ base: unknown }>; +}>; + +export type SidebarAccessors = Readonly<{ + getState: () => SidebarRuntimeState; + getCollapsedState: () => Record; + getSelectedAgent: () => string | null; + getSectionCollapsed: () => boolean; + onToggleCategory: (category: string) => void; + onToggleAgent: (name: string) => void; + onToggleSection: () => void; + getTheme: () => ResolvedThemeLike; +}>; + +export type SidebarRenderInput = Readonly<{ + state: SidebarRuntimeState; + collapsedState: Record; + selectedAgent: string | null; + isSectionCollapsed: boolean; + toggleCategory: (category: string) => void; + toggleAgent: (name: string) => void; + toggleSection: () => void; + theme: TextTheme; +}>; + +export type SidebarMountNode = JSX.Element; + +export interface SidebarMountAdapter { + createContainer(): Node; + insert(container: Node, accessor: () => unknown): void; + render(input: SidebarRenderInput): unknown; + renderError(message: string, theme: TextTheme): unknown; +} + +const FALLBACK_THEME: TextTheme = { + accent: undefined, + background: undefined, + borderActive: undefined, + text: undefined, + textMuted: undefined, + error: undefined, +}; + +const DEFAULT_MOUNT_ADAPTER: SidebarMountAdapter = { + createContainer: () => box({ width: "100%" }), + insert: (container, accessor) => { insert(container, accessor); }, + render: (input) => renderSidebar(input), + renderError: (message, theme) => box({ width: "100%" }, [ + text({ fg: theme.error ?? theme.textMuted }, [`agents-sidebar render error: ${message}`]), + ]), +}; + +export function mountSidebar( + accessors: SidebarAccessors, + adapter: SidebarMountAdapter = DEFAULT_MOUNT_ADAPTER as unknown as SidebarMountAdapter, +): Node { + const container = adapter.createContainer(); + adapter.insert(container, () => { + let theme = FALLBACK_THEME; + try { + theme = textThemeFrom(accessors.getTheme()); + return adapter.render({ + state: accessors.getState(), + collapsedState: accessors.getCollapsedState(), + selectedAgent: accessors.getSelectedAgent(), + isSectionCollapsed: accessors.getSectionCollapsed(), + toggleCategory: accessors.onToggleCategory, + toggleAgent: accessors.onToggleAgent, + toggleSection: accessors.onToggleSection, + theme, + }); + } catch (error: unknown) { + return adapter.renderError(sanitizeLine(shortError(error)).slice(0, 80), theme); + } + }); + return container; +} + +function textThemeFrom(theme: ResolvedThemeLike): TextTheme { + return { + accent: theme.text.feedback.warning.base, + background: theme.background.base, + borderActive: theme.border.base, + error: theme.text.feedback.error.base, + text: theme.text.base, + textMuted: theme.text.muted, + }; +} + export function renderSidebar(props: RenderSidebarProps): JSX.Element { return renderSidebarView({ ...props, state: props.state }); } @@ -172,11 +257,8 @@ export function renderSidebarView(props: RenderSidebarViewProps): JSX.Element { return renderLoadedSidebar(props, agents); } -type SidebarRuntimeState = - | (Extract & { watchedPaths: string[]; defaultCollapsed: string[]; sectionCollapsed: boolean }) - | { kind: "error"; error: string; config: SidebarConfig }; -interface RenderSidebarProps { +export interface RenderSidebarProps { state: SidebarRuntimeState; collapsedState: Record; selectedAgent: string | null; @@ -188,7 +270,7 @@ interface RenderSidebarProps { } interface RenderSidebarViewProps extends Omit { - state: SidebarViewState | { kind: "error"; error: string; config: SidebarConfig }; + state: SidebarRuntimeState | SidebarViewState; } function renderLoadedSidebar(props: RenderSidebarViewProps, agents: AgentEntry[]): JSX.Element { @@ -244,151 +326,3 @@ function errorStateMessage(state: Exclude { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function debounce(callback: () => void, delayMs: number): () => void { - let timer: ReturnType | undefined; - return () => { - if (timer !== undefined) clearTimeout(timer); - timer = setTimeout(callback, delayMs); - }; -} - -function createReload(api: PluginApiLike, _state: () => SidebarRuntimeState, setState: (next: SidebarRuntimeState) => void, fingerprint: () => string, setFingerprint: (next: string) => void): () => void { - return () => void (async () => { - try { - const nextState = await loadSidebarRuntimeState(); - const nextFingerprint = sidebarFingerprint(nextState); - if (nextFingerprint === fingerprint()) return; - setState(nextState); - setFingerprint(nextFingerprint); - api.renderer.requestRender(); - } catch (_err: unknown) { - return; - } - })(); -} - -function registerConfigWatcher(paths: string[], reload: () => void, fallbackMs: number): { watchers: Watcher[]; fallbackTimer?: Timer } { - const debouncedReload = debounce(reload, WATCH_DEBOUNCE_MS); - const watchers: Watcher[] = []; - try { - for (const path of paths) { - watchers.push(watch(path, { persistent: false }, (event: string) => { - if (event === "change" || event === "rename") debouncedReload(); - })); - } - return { watchers }; - } catch (_err: unknown) { - for (const watcher of watchers) watcher.close(); - return { watchers: [], fallbackTimer: setInterval(reload, fallbackMs) }; - } -} - -async function loadSidebarRuntimeState(): Promise { - try { - const loaded = await loadAgentSources(); - const sidebarConfig = configFromSources(loaded.sources); - const viewState = buildSidebarViewState(loaded.sources, sidebarConfig); - if (viewState.kind !== "loaded") return { kind: "error", error: "Agent providers did not load", config: sidebarConfig }; - return { - ...viewState, - watchedPaths: loaded.sources.flatMap((source) => source.watchedPaths), - defaultCollapsed: defaultCollapsedFromSources(loaded.sources), - sectionCollapsed: sectionCollapsedFromSources(loaded.sources), - }; - } catch (error: unknown) { - return { kind: "error", error: error instanceof Error ? error.message : String(error), config: DEFAULTS }; - } -} - -function configFromSources(sources: AgentSourceState[]): SidebarConfig { - const tui = sources.find((source) => isRecord(source.metadata.tui))?.metadata.tui; - return mergeConfig(tui); -} - -function defaultCollapsedFromSources(sources: AgentSourceState[]): string[] { - const tui = sources.find((source) => isRecord(source.metadata.tui))?.metadata.tui; - if (!isRecord(tui) || !Array.isArray(tui.default_collapsed)) return DEFAULTS.default_collapsed; - return tui.default_collapsed.every((item) => typeof item === "string") ? [...tui.default_collapsed] : DEFAULTS.default_collapsed; -} - -function sectionCollapsedFromSources(sources: AgentSourceState[]): boolean { - const tui = sources.find((source) => isRecord(source.metadata.tui))?.metadata.tui; - return isRecord(tui) && typeof tui.default_agents_collapsed === "boolean" ? tui.default_agents_collapsed : false; -} - -function sidebarFingerprint(state: SidebarRuntimeState): string { - if (state.kind !== "loaded") return `error:${state.error}`; - return JSON.stringify({ agents: state.agents, providerStatus: state.providerStatus, watchedPaths: state.watchedPaths }); -} - -const plugin: TuiPluginModule & { id: string } = { - id: "agents-sidebar:tui", - tui: async (api, _options, _meta) => { - let currentState = await loadSidebarRuntimeState(); - let lastFingerprint = sidebarFingerprint(currentState); - let collapsedInitialized = false; - const initialConfig = currentState.kind === "loaded" ? currentState.sidebarConfig : currentState.config; - const [getCollapsedMap, setCollapsedMap] = createSignal>({}); - const [getSelectedAgent, setSelectedAgent] = createSignal(null); - const [isSectionCollapsed, setSectionCollapsed] = createSignal(false); - - const initializeCollapsed = (state: SidebarRuntimeState): void => { - if (collapsedInitialized || state.kind !== "loaded") return; - collapsedInitialized = true; - setCollapsedMap(Object.fromEntries(state.defaultCollapsed.map((name) => [name, true]))); - setSectionCollapsed(state.sectionCollapsed); - }; - initializeCollapsed(currentState); - const setState = (next: SidebarRuntimeState): void => { currentState = next; initializeCollapsed(next); }; - const setFingerprint = (next: string): void => { lastFingerprint = next; }; - const requestRender = (): void => api.renderer.requestRender(); - const watchedPaths = currentState.kind === "loaded" ? currentState.watchedPaths : []; - const watcherState = registerConfigWatcher(watchedPaths, createReload(api, () => currentState, setState, () => lastFingerprint, setFingerprint), initialConfig.poll_interval_ms); - const toggleCategory = (category: string): void => { - setCollapsedMap({ ...getCollapsedMap(), [category]: !(getCollapsedMap()[category] ?? false) }); - requestRender(); - }; - const toggleAgent = (name: string): void => { - setSelectedAgent(getSelectedAgent() === name ? null : name); - requestRender(); - }; - const toggleSectionCollapsed = (): void => { - setSectionCollapsed(!isSectionCollapsed()); - requestRender(); - }; - api.lifecycle.onDispose(() => { - for (const watcher of watcherState.watchers) watcher.close(); - if (watcherState.fallbackTimer !== undefined) clearInterval(watcherState.fallbackTimer); - }); - api.slots.register({ - order: initialConfig.slot_order, - slots: { - sidebar_content: () => { - try { - const theme = api.theme.current as TextTheme & { error: unknown }; - const result = renderSidebar({ - state: currentState, - collapsedState: getCollapsedMap(), - selectedAgent: getSelectedAgent(), - isSectionCollapsed: isSectionCollapsed(), - toggleCategory, - toggleAgent, - toggleSection: toggleSectionCollapsed, - theme, - }); - return result; - } catch (renderErr: unknown) { - const theme = api.theme.current as TextTheme & { error: unknown }; - return box({ width: "100%" }, [text({ fg: theme.error }, [`agents-sidebar render error: ${String(renderErr).slice(0, 60)}`])]); - } - }, - }, - }); - }, -}; - -export default plugin; diff --git a/src/runtime.test.ts b/src/runtime.test.ts new file mode 100644 index 0000000..082eeb5 --- /dev/null +++ b/src/runtime.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULTS } from "./defaults.js"; +import { + createReloadController, + loadSidebarRuntimeState, + type SidebarRuntimeState, +} from "./runtime.js"; +import type { LoadedAgentSources } from "./providers/index.js"; +import type { AgentSourceState } from "./providers/types.js"; + +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolvePromise: ((value: T) => void) | undefined; + return { + promise: new Promise((resolve) => { resolvePromise = resolve; }), + resolve: (value) => resolvePromise?.(value), + }; +} + +function source(overrides: Partial = {}): AgentSourceState { + return { + source: "oh-my-openagent", + agents: [], + metadata: { + configState: "loaded", + configuredAgentCount: 0, + tui: {}, + team_mode: {}, + tmux: {}, + }, + warnings: [], + errors: [], + watchedPaths: ["/tmp/oh-my-openagent.json"], + ...overrides, + }; +} + +function loadedSources(omo: AgentSourceState): LoadedAgentSources { + return { sources: [omo], agents: omo.agents }; +} + +function state(fingerprint: string): SidebarRuntimeState { + return { + kind: "missing", + path: `/tmp/${fingerprint}.json`, + renderConfig: DEFAULTS, + fingerprint, + watchSpec: { paths: [`/tmp/${fingerprint}.json`], pollIntervalMs: 2000 }, + }; +} + +describe("Agents runtime state", () => { + test("distinguishes valid-empty, missing, invalid, and read errors", async () => { + const validEmpty = await loadSidebarRuntimeState({ + loadSources: async () => loadedSources(source()), + }); + const missing = await loadSidebarRuntimeState({ + loadSources: async () => loadedSources(source({ metadata: { configState: "missing" } })), + }); + const invalid = await loadSidebarRuntimeState({ + loadSources: async () => loadedSources(source({ + metadata: { configState: "invalid", configError: "bad json" }, + errors: [{ code: "omo-config-invalid", severity: "error", message: "bad json", source: "oh-my-openagent" }], + })), + }); + const failed = await loadSidebarRuntimeState({ + loadSources: async () => { throw new Error("disk unavailable\nsecret detail"); }, + fallbackWatchSpec: { paths: ["/tmp/recover.json"], pollIntervalMs: 9000 }, + }); + + expect(validEmpty.kind).toBe("loaded"); + if (validEmpty.kind === "loaded") expect(validEmpty.hasConfiguredAgents).toBe(false); + expect(missing.kind).toBe("missing"); + expect(invalid.kind).toBe("invalid"); + if (invalid.kind === "invalid") expect(invalid.error).toBe("bad json"); + expect(failed.kind).toBe("error"); + expect(failed.watchSpec).toEqual({ paths: ["/tmp/recover.json"], pollIntervalMs: 9000 }); + if (failed.kind === "error") expect(failed.error).toBe("disk unavailable"); + }); + + test("fingerprint changes for config and diagnostics but is stable for identical state", async () => { + const base = source(); + const titled = source({ metadata: { ...base.metadata, tui: { title: "Reviewers" } } }); + const diagnosed = source({ + warnings: [{ code: "provider-warning", severity: "warning", message: "degraded", source: "oh-my-openagent" }], + }); + const first = await loadSidebarRuntimeState({ loadSources: async () => loadedSources(base) }); + const repeated = await loadSidebarRuntimeState({ loadSources: async () => loadedSources(base) }); + const configChanged = await loadSidebarRuntimeState({ loadSources: async () => loadedSources(titled) }); + const diagnosticsChanged = await loadSidebarRuntimeState({ loadSources: async () => loadedSources(diagnosed) }); + + expect(repeated.fingerprint).toBe(first.fingerprint); + expect(configChanged.fingerprint).not.toBe(first.fingerprint); + expect(diagnosticsChanged.fingerprint).not.toBe(first.fingerprint); + }); +}); + +describe("serialized reload controller", () => { + test("coalesces overlap and never publishes the stale completion", async () => { + const first = deferred(); + const second = deferred(); + let reads = 0; + const accepted: string[] = []; + const controller = createReloadController({ + initialState: state("initial"), + load: async () => (++reads === 1 ? first.promise : second.promise), + accept: (next) => { accepted.push(next.fingerprint); }, + }); + + const firstRequest = controller.request(); + const secondRequest = controller.request(); + first.resolve(state("stale")); + await Promise.resolve(); + second.resolve(state("fresh")); + await Promise.all([firstRequest, secondRequest]); + + expect(reads).toBe(2); + expect(accepted).toEqual(["fresh"]); + }); + + test("suppresses identical states and late completion after dispose", async () => { + const late = deferred(); + const accepted: string[] = []; + const initial = state("same"); + let reads = 0; + const controller = createReloadController({ + initialState: initial, + load: async () => { + reads += 1; + return reads === 1 ? initial : late.promise; + }, + accept: (next) => { accepted.push(next.fingerprint); }, + }); + + await controller.request(); + const pending = controller.request(); + controller.dispose(); + late.resolve(state("late")); + await pending; + + expect(accepted).toEqual([]); + }); +}); diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..43832b5 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,179 @@ +import { buildSidebarViewState } from "./agents.js"; +import { isRecord, mergeConfig, sanitizeLine, shortError } from "./config.js"; +import { OMO_CONFIG_PATH } from "./providers/omo.js"; +import { loadAgentSources, type LoadedAgentSources } from "./providers/index.js"; +import type { AgentSourceState } from "./providers/types.js"; +import type { SidebarConfig, SidebarViewState } from "./types.js"; + +export type WatchSpec = Readonly<{ + paths: readonly string[]; + pollIntervalMs: number; +}>; + +type RuntimeBase = Readonly<{ + renderConfig: SidebarConfig; + fingerprint: string; + watchSpec: WatchSpec; +}>; + +type LoadedViewState = Extract; + +export type SidebarRuntimeState = + | (LoadedViewState & RuntimeBase & Readonly<{ + defaultCollapsed: readonly string[]; + defaultSectionCollapsed: boolean; + }>) + | (Extract & RuntimeBase) + | (Extract & RuntimeBase) + | (Readonly<{ kind: "error"; error: string }> & RuntimeBase); + +export type RuntimeLoadOptions = Readonly<{ + loadSources?: () => Promise; + fallbackWatchSpec?: WatchSpec; +}>; + +export async function loadSidebarRuntimeState( + options: RuntimeLoadOptions = {}, +): Promise { + const fallbackWatchSpec = copyWatchSpec(options.fallbackWatchSpec ?? { + paths: [OMO_CONFIG_PATH], + pollIntervalMs: 2000, + }); + try { + const loaded = await (options.loadSources ?? loadAgentSources)(); + const omoSource = loaded.sources.find((source) => source.source === "oh-my-openagent"); + const renderConfig = configFromSources(loaded.sources); + const watchSpec = watchSpecFromSources(loaded.sources, renderConfig, fallbackWatchSpec); + const configState = omoSource?.metadata.configState; + const path = omoSource?.watchedPaths[0] ?? watchSpec.paths[0] ?? OMO_CONFIG_PATH; + + if (configState === "missing") { + return fingerprinted({ kind: "missing", path, renderConfig, watchSpec }); + } + if (configState === "invalid") { + return fingerprinted({ + kind: "invalid", + path, + error: configError(omoSource), + renderConfig, + watchSpec, + }); + } + + const viewState = buildSidebarViewState(loaded.sources, renderConfig); + return fingerprinted({ + ...viewState, + renderConfig, + watchSpec, + defaultCollapsed: defaultCollapsedFromSources(loaded.sources), + defaultSectionCollapsed: defaultSectionCollapsedFromSources(loaded.sources), + }); + } catch (error: unknown) { + return fingerprinted({ + kind: "error", + error: sanitizeLine(shortError(error)).slice(0, 160), + renderConfig: mergeConfig(undefined), + watchSpec: fallbackWatchSpec, + }); + } +} + +function fingerprinted(state: T): T & Readonly<{ fingerprint: string }> { + return { ...state, fingerprint: JSON.stringify(state) }; +} + +function copyWatchSpec(spec: WatchSpec): WatchSpec { + return { paths: [...spec.paths], pollIntervalMs: spec.pollIntervalMs }; +} + +function configFromSources(sources: readonly AgentSourceState[]): SidebarConfig { + const tui = sources.find((source) => isRecord(source.metadata.tui))?.metadata.tui; + return mergeConfig(tui); +} + +function watchSpecFromSources( + sources: readonly AgentSourceState[], + config: SidebarConfig, + fallback: WatchSpec, +): WatchSpec { + const watchedPaths = [...new Set(sources.flatMap((source) => source.watchedPaths))].sort(); + return { + paths: watchedPaths.length > 0 ? watchedPaths : [...fallback.paths], + pollIntervalMs: config.poll_interval_ms, + }; +} + +function defaultCollapsedFromSources(sources: readonly AgentSourceState[]): readonly string[] { + const tui = sources.find((source) => isRecord(source.metadata.tui))?.metadata.tui; + if (!isRecord(tui) || !Array.isArray(tui.default_collapsed)) return []; + return tui.default_collapsed.every((item) => typeof item === "string") + ? [...tui.default_collapsed] + : []; +} + +function defaultSectionCollapsedFromSources(sources: readonly AgentSourceState[]): boolean { + const tui = sources.find((source) => isRecord(source.metadata.tui))?.metadata.tui; + return isRecord(tui) && typeof tui.default_agents_collapsed === "boolean" + ? tui.default_agents_collapsed + : false; +} + +function configError(source: AgentSourceState | undefined): string { + const metadataError = source?.metadata.configError; + if (typeof metadataError === "string") return metadataError; + return source?.errors[0]?.message ?? "Invalid OhMyOpenAgent configuration"; +} + +export type ReloadController = Readonly<{ + request(): Promise; + dispose(): void; +}>; + +export type ReloadControllerOptions = Readonly<{ + initialState: SidebarRuntimeState; + load(fallbackWatchSpec: WatchSpec): Promise; + accept(state: SidebarRuntimeState): void; +}>; + +export function createReloadController(options: ReloadControllerOptions): ReloadController { + let disposed = false; + let generation = 0; + let queued = false; + let active: Promise | undefined; + let current = options.initialState; + + const drain = async (): Promise => { + while (queued && !disposed) { + queued = false; + const requestGeneration = generation; + let next: SidebarRuntimeState; + try { + next = await options.load(current.watchSpec); + } catch { + continue; + } + if (disposed || requestGeneration !== generation) continue; + if (next.fingerprint === current.fingerprint) continue; + current = next; + options.accept(next); + } + }; + + return { + request() { + if (disposed) return Promise.resolve(); + generation += 1; + queued = true; + if (!active) { + active = drain().finally(() => { active = undefined; }); + } + return active; + }, + dispose() { + if (disposed) return; + disposed = true; + generation += 1; + queued = false; + }, + }; +} diff --git a/src/solid-runtime.d.ts b/src/solid-runtime.d.ts new file mode 100644 index 0000000..618a99a --- /dev/null +++ b/src/solid-runtime.d.ts @@ -0,0 +1,3 @@ +declare module "solid-js/dist/solid.js" { + export * from "solid-js"; +} diff --git a/src/tui-v2.test.ts b/src/tui-v2.test.ts new file mode 100644 index 0000000..b395d99 --- /dev/null +++ b/src/tui-v2.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from "bun:test"; +import { createRenderEffect, createRoot, createSignal } from "solid-js/dist/solid.js"; +import serverPlugin from "./index.js"; +import agentsTuiPlugin, { + createAgentsTuiDefinition, + type SidebarAccessors, +} from "./tui.js"; +import { mountSidebar } from "./render.js"; +import { DEFAULTS } from "./defaults.js"; +import type { SidebarRuntimeState } from "./runtime.js"; + +type TestTheme = Readonly<{ + text: Readonly<{ + base: string; + muted: string; + feedback: Readonly<{ + warning: Readonly<{ base: string }>; + error: Readonly<{ base: string }>; + }>; + }>; + background: Readonly<{ base: string }>; + border: Readonly<{ base: string }>; +}>; + +function theme(base: string): TestTheme { + return { + text: { + base, + muted: `${base}-muted`, + feedback: { + warning: { base: `${base}-warning` }, + error: { base: `${base}-error` }, + }, + }, + background: { base: `${base}-background` }, + border: { base: `${base}-border` }, + }; +} + +function loadedState(fingerprint: string, defaults: string[] = []): SidebarRuntimeState { + return { + kind: "loaded", + agents: [], + hasConfiguredAgents: false, + sidebarConfig: { ...DEFAULTS }, + providerStatus: { + showTeam: true, + teamEnabled: false, + showTmux: true, + tmuxEnabled: false, + }, + diagnostics: [], + defaultCollapsed: defaults, + defaultSectionCollapsed: true, + renderConfig: { ...DEFAULTS }, + fingerprint, + watchSpec: { paths: ["/tmp/omo.json"], pollIntervalMs: 2000 }, + }; +} + +function missingState(fingerprint: string): SidebarRuntimeState { + return { + kind: "missing", + path: "/tmp/omo.json", + renderConfig: { ...DEFAULTS }, + fingerprint, + watchSpec: { paths: ["/tmp/omo.json"], pollIntervalMs: 2000 }, + }; +} + +async function settle(): Promise { + for (let turn = 0; turn < 8; turn += 1) await Promise.resolve(); +} + +describe("Agents V2 plugin", () => { + test("exports server and TUI definitions with only id and setup", () => { + expect(Object.keys(serverPlugin).sort()).toEqual(["id", "setup"]); + expect(Object.keys(agentsTuiPlugin).sort()).toEqual(["id", "setup"]); + expect(serverPlugin.id).toBe("agents-sidebar"); + expect(agentsTuiPlugin.id).toBe("agents-sidebar.tui"); + }); + + test("mounts one tracked subtree whose state, click, selection, and theme reads stay live", () => { + let disposeRoot = (): void => undefined; + let inserts = 0; + let containers = 0; + let rendered = ""; + let toggleCategory = (): void => undefined; + let toggleAgent = (): void => undefined; + let publishState = (_state: SidebarRuntimeState): void => undefined; + let publishTheme = (_theme: TestTheme): void => undefined; + + createRoot((dispose) => { + disposeRoot = dispose; + const [getState, setState] = createSignal(loadedState("one")); + const [getCollapsed, setCollapsed] = createSignal>({}); + const [getSelected, setSelected] = createSignal(null); + const [getSectionCollapsed] = createSignal(false); + const [getTheme, setTheme] = createSignal(theme("light")); + publishState = setState; + publishTheme = setTheme; + mountSidebar({ + getState, + getCollapsedState: getCollapsed, + getSelectedAgent: getSelected, + getSectionCollapsed, + onToggleCategory: (category) => setCollapsed((current) => ({ ...current, [category]: !current[category] })), + onToggleAgent: (name) => setSelected((current) => current === name ? null : name), + onToggleSection: () => undefined, + getTheme, + }, { + createContainer: () => { containers += 1; return { kind: "container" }; }, + insert: (_container, accessor) => { + inserts += 1; + createRenderEffect(() => { rendered = String(accessor()); }); + }, + render: (input) => { + toggleCategory = () => input.toggleCategory("Research"); + toggleAgent = () => input.toggleAgent("oracle"); + return `${input.state.fingerprint}|${String(input.collapsedState.Research)}|${input.selectedAgent ?? ""}|${String(input.theme.text)}`; + }, + renderError: (message) => `error:${message}`, + }); + }); + + expect(rendered).toBe("one|undefined||light"); + publishState(loadedState("two")); + expect(rendered).toBe("two|undefined||light"); + toggleCategory(); + expect(rendered).toBe("two|true||light"); + toggleAgent(); + expect(rendered).toBe("two|true|oracle|light"); + publishTheme(theme("dark")); + expect(rendered).toBe("two|true|oracle|dark"); + expect(containers).toBe(1); + expect(inserts).toBe(1); + disposeRoot(); + }); + + test("initializes collapse only on first loaded state and preserves user state across reloads", async () => { + const states = [missingState("missing"), loadedState("loaded", ["Research"]), loadedState("changed", ["Engineering"])]; + let reads = 0; + let triggerReload: (() => Promise) | undefined; + let accessors: SidebarAccessors | undefined; + let slotClaims = 0; + let slotRender: (() => unknown) | undefined; + let watcherDisposals = 0; + const updates: string[][] = []; + const definition = createAgentsTuiDefinition({ + loadRuntimeState: async () => states[Math.min(reads++, states.length - 1)] ?? missingState("fallback"), + createWatcher: (reload) => { + triggerReload = reload; + return { + update: (paths) => { updates.push([...paths]); }, + dispose: () => { watcherDisposals += 1; }, + }; + }, + mountPanel: (nextAccessors) => { + accessors = nextAccessors; + return {} as never; + }, + }); + const context = { + theme: theme("light"), + ui: { + slot: (claim: { render: () => unknown }) => { + slotClaims += 1; + slotRender = claim.render; + return () => undefined; + }, + }, + }; + + const cleanup = await Reflect.apply(definition.setup, undefined, [context]); + if (typeof cleanup !== "function" || !triggerReload || !slotRender) throw new Error("Expected active setup"); + slotRender(); + if (!accessors) throw new Error("Expected mounted accessors"); + expect(accessors.getState().kind).toBe("missing"); + expect(accessors.getCollapsedState()).toEqual({}); + + await triggerReload(); + expect(accessors.getCollapsedState()).toEqual({ Research: true }); + expect(accessors.getSectionCollapsed()).toBe(true); + accessors.onToggleCategory("Research"); + accessors.onToggleAgent("oracle"); + await triggerReload(); + + expect(accessors.getCollapsedState()).toEqual({ Research: false }); + expect(accessors.getSelectedAgent()).toBe("oracle"); + expect(slotClaims).toBe(1); + expect(updates).toHaveLength(3); + await cleanup(); + expect(watcherDisposals).toBe(1); + }); + + test("cleanup blocks a delayed reload from publishing state or updating watches", async () => { + let resolveLate: ((state: SidebarRuntimeState) => void) | undefined; + const late = new Promise((resolve) => { resolveLate = resolve; }); + let reads = 0; + let triggerReload: (() => Promise) | undefined; + let accessors: SidebarAccessors | undefined; + let slotDisposals = 0; + const updates: string[][] = []; + const definition = createAgentsTuiDefinition({ + loadRuntimeState: async () => ++reads === 1 ? missingState("initial") : late, + createWatcher: (reload) => { + triggerReload = reload; + return { + update: (paths) => { updates.push([...paths]); }, + dispose: () => undefined, + }; + }, + mountPanel: (nextAccessors) => { + accessors = nextAccessors; + return {} as never; + }, + }); + const cleanup = await Reflect.apply(definition.setup, undefined, [{ + theme: theme("light"), + ui: { slot: (claim: { render: () => unknown }) => { claim.render(); return () => { slotDisposals += 1; }; } }, + }]); + if (typeof cleanup !== "function" || !triggerReload || !accessors) throw new Error("Expected active setup"); + + const pending = triggerReload(); + await settle(); + await cleanup(); + resolveLate?.(loadedState("late", ["Research"])); + await pending; + + expect(accessors.getState().fingerprint).toBe("initial"); + expect(updates).toHaveLength(1); + expect(slotDisposals).toBe(1); + }); +}); diff --git a/src/tui.test.ts b/src/tui.test.ts index 9a6f550..f2b484e 100644 --- a/src/tui.test.ts +++ b/src/tui.test.ts @@ -3,7 +3,7 @@ import { unlink } from "node:fs/promises"; import { buildAgentList, buildSidebarViewState, filterAgents, formatModelName, groupByCategory } from "./agents.js"; import { mergeAgentSources } from "./agents/merge.js"; import { resolveGenericCategory } from "./agents/categories.js"; -import { configFingerprint, mergeConfig, readConfigAsync } from "./config.js"; +import { mergeConfig, readConfigAsync } from "./config.js"; import { DEFAULTS } from "./defaults.js"; import { agentFallbackCount, @@ -82,11 +82,6 @@ describe("agents sidebar provider foundation", () => { expect(DEFAULTS.title).toBe("Agents"); }); - test("render core layer does not import OmoConfig or use old OmO missing copy", async () => { - const renderSource = await Bun.file("src/render.ts").text(); - expect(renderSource).not.toContain("OmoConfig"); - expect(renderSource).not.toContain("OmO not configured"); - }); test("OmO bootstrap provider normalizes agents into AgentEntry", () => { const state = normalizeOmoConfig(SAMPLE_OMO_CONFIG, "/tmp/omo.json"); @@ -201,9 +196,8 @@ describe("agents sidebar pure functions", () => { }); }); - test("configFingerprint includes missing and invalid states", () => { - expect(configFingerprint({ kind: "missing", path: "/tmp/missing.json" })).toBe("missing:/tmp/missing.json"); - expect(configFingerprint({ kind: "invalid", path: "/tmp/bad.json", error: "bad json" })).toBe("invalid:/tmp/bad.json:bad json"); + test("mergeConfig ignores the removed slot_order field", () => { + expect(Object.hasOwn(mergeConfig({ slot_order: 850 }), "slot_order")).toBe(false); }); test("mergeConfig user aliases extend defaults", () => { diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..85cec7e --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,119 @@ +import { Plugin } from "@opencode/plugin/tui"; +import { createSignal } from "solid-js"; +import { + createConfigWatcher, + type ConfigWatcher, +} from "./config-watcher.js"; +import { + mountSidebar, + type ResolvedThemeLike, + type SidebarAccessors, + type SidebarMountNode, +} from "./render.js"; +import { + createReloadController, + loadSidebarRuntimeState, + type ReloadController, + type SidebarRuntimeState, + type WatchSpec, +} from "./runtime.js"; + +export type { SidebarAccessors } from "./render.js"; + +type AgentsTuiDependencies = Readonly<{ + loadRuntimeState(fallbackWatchSpec?: WatchSpec): Promise; + createWatcher(reload: () => Promise): ConfigWatcher; + mountPanel(accessors: SidebarAccessors): SidebarMountNode; +}>; + +const DEFAULT_DEPENDENCIES: AgentsTuiDependencies = { + loadRuntimeState: (fallbackWatchSpec) => loadSidebarRuntimeState({ fallbackWatchSpec }), + createWatcher: (reload) => createConfigWatcher(reload), + mountPanel: (accessors) => mountSidebar(accessors), +}; + +export function createAgentsTuiDefinition( + overrides: Partial = {}, +): Plugin.Definition { + const dependencies: AgentsTuiDependencies = { + ...DEFAULT_DEPENDENCIES, + ...overrides, + }; + return Plugin.define({ + id: "agents-sidebar.tui", + async setup(context) { + let disposed = false; + const initialState = await dependencies.loadRuntimeState(); + const [getState, setState] = createSignal(initialState); + const [getCollapsedState, setCollapsedState] = createSignal>({}); + const [getSelectedAgent, setSelectedAgent] = createSignal(null); + const [getSectionCollapsed, setSectionCollapsed] = createSignal(false); + let collapseInitialized = false; + + const initializeCollapse = (state: SidebarRuntimeState): void => { + if (collapseInitialized || state.kind !== "loaded") return; + collapseInitialized = true; + setCollapsedState(Object.fromEntries( + state.defaultCollapsed.map((category) => [category, true]), + )); + setSectionCollapsed(state.defaultSectionCollapsed); + }; + initializeCollapse(initialState); + + let controller: ReloadController | undefined; + const watcher = dependencies.createWatcher( + () => controller?.request() ?? Promise.resolve(), + ); + controller = createReloadController({ + initialState, + load: (fallbackWatchSpec) => dependencies.loadRuntimeState(fallbackWatchSpec), + accept: (state) => { + if (disposed) return; + setState(state); + initializeCollapse(state); + watcher.update(state.watchSpec.paths, state.watchSpec.pollIntervalMs); + }, + }); + watcher.update(initialState.watchSpec.paths, initialState.watchSpec.pollIntervalMs); + + const toggleCategory = (category: string): void => { + setCollapsedState((current) => ({ + ...current, + [category]: !(current[category] ?? false), + })); + }; + const toggleAgent = (name: string): void => { + setSelectedAgent((current) => current === name ? null : name); + }; + const toggleSection = (): void => { + setSectionCollapsed((current) => !current); + }; + + const unregisterSlot = context.ui.slot({ + append: "sidebar.content", + render: () => dependencies.mountPanel({ + getState, + getCollapsedState, + getSelectedAgent, + getSectionCollapsed, + onToggleCategory: toggleCategory, + onToggleAgent: toggleAgent, + onToggleSection: toggleSection, + getTheme: () => context.theme as unknown as ResolvedThemeLike, + }), + }); + + return () => { + if (disposed) return; + disposed = true; + controller?.dispose(); + unregisterSlot(); + watcher.dispose(); + }; + }, + }); +} + +const plugin = createAgentsTuiDefinition(); + +export default plugin; diff --git a/src/types.ts b/src/types.ts index 0488ae6..fe38606 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,6 @@ export interface SidebarConfig { sidebar_width: number; name_width: number; poll_interval_ms: number; - slot_order: number; title: string; category_order: string[]; model_display: ModelDisplay; @@ -31,6 +30,7 @@ export interface TextTheme { borderActive: unknown; text: unknown; textMuted: unknown; + error?: unknown; } export type ConfigState = diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..f87426f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "emitDeclarationOnly": true, + "noEmit": false + }, + "files": [ + "src/index.ts", + "src/tui.ts" + ], + "include": [], + "exclude": [ + "src/**/__tests__/**", + "src/**/*.test.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 3bd5128..b6b5d10 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,13 +8,8 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": ".", "types": ["bun"] }, - "include": ["src/**/*.ts", "*.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/tui.ts b/tui.ts deleted file mode 100644 index 9137873..0000000 --- a/tui.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./src/render.js"; From fcf208cfd69b78f2daf0600d6d4d6bab3322cc26 Mon Sep 17 00:00:00 2001 From: Mark1708 Date: Wed, 21 Oct 2026 00:08:25 +0300 Subject: [PATCH 2/4] chore: normalize lockfile permissions --- bun.lockb | Bin 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 bun.lockb diff --git a/bun.lockb b/bun.lockb old mode 100755 new mode 100644 From c57c989b20ae81f6ad9f4333aa55a908a76c6879 Mon Sep 17 00:00:00 2001 From: Mark1708 Date: Wed, 21 Oct 2026 00:12:43 +0300 Subject: [PATCH 3/4] docs: distinguish host and plugin API versions --- README.md | 8 ++++---- README.ru.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5c99c68..c54552a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ |---|---| | Status | Actively maintained personal OpenCode tool/plugin | | Type | OpenCode TUI plugin / host extension | -| Host app | OpenCode v2; `2.0.32` is the release baseline | +| Host app | OpenCode `1.19.3` with the v2 plugin API | | Package | `opencode-agents-sidebar` `2.0.0` | | Runtime | Bun `>=1.3.0` | | Maintainer checks | `bun install --frozen-lockfile && bun run typecheck && bun test && bun run build:all && bun run verify:package` | @@ -43,7 +43,7 @@ The plugin renders agent categories and details inside the OpenCode terminal UI ## Quick start ```sh -opencode plugin add opencode-agents-sidebar@2.0.0 +opencode plugin opencode-agents-sidebar@2.0.0 --global --force ``` ## Installation @@ -53,7 +53,7 @@ opencode plugin add opencode-agents-sidebar@2.0.0 Install the release with the v2 plugin manager: ```sh -opencode plugin add opencode-agents-sidebar@2.0.0 +opencode plugin opencode-agents-sidebar@2.0.0 --global --force ``` The equivalent explicit configuration is: @@ -90,7 +90,7 @@ intentionally absent. | Component | Supported version | Source | |---|---|---| -| Host app | OpenCode v2 (`2.0.32` release baseline) | Release smoke contract | +| Host app | OpenCode `1.19.3` (v2 plugin API release baseline) | Release smoke contract | | Plugin API | `@opencode/plugin 2.0.32` | `package.json` dependency | | Theme API | `@opencode/theme 2.0.32` | `package.json` peer dependency | | TUI runtime | `@opentui/core >=0.5.17`, `@opentui/solid >=0.5.17`, `solid-js 1.9.12` | `package.json` peer dependencies | diff --git a/README.ru.md b/README.ru.md index 00e1bbd..71d99af 100644 --- a/README.ru.md +++ b/README.ru.md @@ -13,7 +13,7 @@ |---|---| | Статус | Активно поддерживаемый персональный OpenCode tool/plugin | | Тип | OpenCode TUI plugin / host extension | -| Host app | OpenCode v2; release baseline — `2.0.32` | +| Host app | OpenCode `1.19.3` с v2 plugin API | | Package | `opencode-agents-sidebar` `2.0.0` | | Runtime | Bun `>=1.3.0` | | Проверки maintainer | `bun install --frozen-lockfile && bun run typecheck && bun test && bun run build:all && bun run verify:package` | @@ -43,7 +43,7 @@ Plugin отображает agent categories и details внутри sidebar т ## Быстрый старт ```sh -opencode plugin add opencode-agents-sidebar@2.0.0 +opencode plugin opencode-agents-sidebar@2.0.0 --global --force ``` ## Установка @@ -53,7 +53,7 @@ opencode plugin add opencode-agents-sidebar@2.0.0 Установите release через v2 plugin manager: ```sh -opencode plugin add opencode-agents-sidebar@2.0.0 +opencode plugin opencode-agents-sidebar@2.0.0 --global --force ``` Эквивалентная явная конфигурация: @@ -90,7 +90,7 @@ Build создаёт отдельные v2 server и TUI definitions в `dist/`; | Компонент | Поддерживаемая версия | Источник | |---|---|---| -| Host app | OpenCode v2 (`2.0.32` — release baseline) | Release smoke contract | +| Host app | OpenCode `1.19.3` (release baseline v2 plugin API) | Release smoke contract | | Plugin API | `@opencode/plugin 2.0.32` | dependency в `package.json` | | Theme API | `@opencode/theme 2.0.32` | peer dependency в `package.json` | | TUI runtime | `@opentui/core >=0.5.17`, `@opentui/solid >=0.5.17`, `solid-js 1.9.12` | peer dependencies в `package.json` | From 3c67231a7b9ea8a1b390f632a4e8d08a678e7e7f Mon Sep 17 00:00:00 2001 From: Mark1708 Date: Wed, 21 Oct 2026 08:33:47 +0300 Subject: [PATCH 4/4] docs: correct OpenCode v2 installation --- README.md | 8 ++++---- README.ru.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c54552a..ffeb2b6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ |---|---| | Status | Actively maintained personal OpenCode tool/plugin | | Type | OpenCode TUI plugin / host extension | -| Host app | OpenCode `1.19.3` with the v2 plugin API | +| Host app | OpenCode `>=2.0.32`; release smoke also covers current `2.1.2` | | Package | `opencode-agents-sidebar` `2.0.0` | | Runtime | Bun `>=1.3.0` | | Maintainer checks | `bun install --frozen-lockfile && bun run typecheck && bun test && bun run build:all && bun run verify:package` | @@ -43,7 +43,7 @@ The plugin renders agent categories and details inside the OpenCode terminal UI ## Quick start ```sh -opencode plugin opencode-agents-sidebar@2.0.0 --global --force +opencode plugin add opencode-agents-sidebar@2.0.0 --global --force ``` ## Installation @@ -53,7 +53,7 @@ opencode plugin opencode-agents-sidebar@2.0.0 --global --force Install the release with the v2 plugin manager: ```sh -opencode plugin opencode-agents-sidebar@2.0.0 --global --force +opencode plugin add opencode-agents-sidebar@2.0.0 --global --force ``` The equivalent explicit configuration is: @@ -90,7 +90,7 @@ intentionally absent. | Component | Supported version | Source | |---|---|---| -| Host app | OpenCode `1.19.3` (v2 plugin API release baseline) | Release smoke contract | +| Host app | OpenCode `>=2.0.32` (smoked on `2.0.32` and `2.1.2`) | Release smoke contract | | Plugin API | `@opencode/plugin 2.0.32` | `package.json` dependency | | Theme API | `@opencode/theme 2.0.32` | `package.json` peer dependency | | TUI runtime | `@opentui/core >=0.5.17`, `@opentui/solid >=0.5.17`, `solid-js 1.9.12` | `package.json` peer dependencies | diff --git a/README.ru.md b/README.ru.md index 71d99af..681390e 100644 --- a/README.ru.md +++ b/README.ru.md @@ -13,7 +13,7 @@ |---|---| | Статус | Активно поддерживаемый персональный OpenCode tool/plugin | | Тип | OpenCode TUI plugin / host extension | -| Host app | OpenCode `1.19.3` с v2 plugin API | +| Host app | OpenCode `>=2.0.32`; release smoke также проверен на текущей `2.1.2` | | Package | `opencode-agents-sidebar` `2.0.0` | | Runtime | Bun `>=1.3.0` | | Проверки maintainer | `bun install --frozen-lockfile && bun run typecheck && bun test && bun run build:all && bun run verify:package` | @@ -43,7 +43,7 @@ Plugin отображает agent categories и details внутри sidebar т ## Быстрый старт ```sh -opencode plugin opencode-agents-sidebar@2.0.0 --global --force +opencode plugin add opencode-agents-sidebar@2.0.0 --global --force ``` ## Установка @@ -53,7 +53,7 @@ opencode plugin opencode-agents-sidebar@2.0.0 --global --force Установите release через v2 plugin manager: ```sh -opencode plugin opencode-agents-sidebar@2.0.0 --global --force +opencode plugin add opencode-agents-sidebar@2.0.0 --global --force ``` Эквивалентная явная конфигурация: @@ -90,7 +90,7 @@ Build создаёт отдельные v2 server и TUI definitions в `dist/`; | Компонент | Поддерживаемая версия | Источник | |---|---|---| -| Host app | OpenCode `1.19.3` (release baseline v2 plugin API) | Release smoke contract | +| Host app | OpenCode `>=2.0.32` (smoke на `2.0.32` и `2.1.2`) | Release smoke contract | | Plugin API | `@opencode/plugin 2.0.32` | dependency в `package.json` | | Theme API | `@opencode/theme 2.0.32` | peer dependency в `package.json` | | TUI runtime | `@opentui/core >=0.5.17`, `@opentui/solid >=0.5.17`, `solid-js 1.9.12` | peer dependencies в `package.json` |