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..ffeb2b6 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 `>=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` | ## 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 --global --force ``` -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 --global --force ``` -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 `>=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 | +| 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..681390e 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 `>=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` | ## Скриншоты @@ -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 --global --force ``` -Этот релиз является 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 --global --force ``` -Это рекомендуемый путь для пользователей: 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 `>=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` | +| 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 100644 index 0000000..f379640 Binary files /dev/null and b/bun.lockb differ 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";