Skip to content

fix(tui): persist plugin activation choices #42417

Open

Description

@kitlangton

Problem

In the V2 TUI, /plugins currently mutates only the running registration. Editing any plugin source triggers reconciliation from config, which can re-enable unrelated plugins that the user manually disabled. The choice also does not survive a TUI restart.

Desired Behavior

A plugin disabled through /plugins remains disabled when:

  • another plugin source reloads;
  • its own source reloads;
  • the TUI restarts.

Enabling it again should remove or replace that durable preference without disturbing its package declaration or options.

Design Question

Choose a durable config representation. The simplest candidate for declared external plugins is:

{
  "package": "/path/to/plugin.ts",
  "enabled": false,
  "options": {}
}

Built-ins and auto-discovered plugins still require an ID-based override or another explicit representation because they have no package declaration.

PR #42410 demonstrates the bug, regression coverage, and an initial ordered-directive implementation. Finalize the config model before merging.

Acceptance Criteria

  • Activation choice is durable and config-backed.
  • Unrelated hot reload does not reactivate disabled plugins.
  • Package paths and options remain intact.
  • Built-in and discovered plugin behavior is explicit.
  • Reload and restart behavior have regression coverage.

Activity

  1. self-assigned this
    on Aug 13, 2026
  2. ls-pavel-makhov commented on Oct 14, 2026

    @ls-pavel-makhov

    Another trigger for this: switching to a different session. On 2.0.29 (macOS), I disabled several plugins in the /plugins dialog, and they were all re-enabled after I switched to another session. cli.json was unchanged throughout (no -<id> entries written).

    Reading the binary, reconciliation reruns when JSON.stringify([serverTuiPlugins, config.plugins]) changes. The server plugin list is requested per location, so switching to a session in a different directory can change it and reactivate everything, not only an unrelated plugin source reload. Regression coverage for this fix could include a session or location switch.

    I filed this separately as #55506 and am closing it as a duplicate of this issue.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the report. I reproduced this on 2.2.0, and it still happens on the latest dev (a97dc23e).

    Steps: I added two discovered TUI plugins, .opencode/plugins/alpha/tui.ts (repro.alpha) and .opencode/plugins/beta/tui.ts (repro.beta).

    1. In /plugins, I disabled repro.alpha. It showed inactive, local.
    2. I edited only beta/tui.ts and reopened /plugins. repro.alpha was active again.
    3. I disabled it again and started a fresh TUI. It was active again.

    Nothing was written to cli.json. The toggle only changes the running registration, and the reconcile step in packages/tui/src/plugin/context.tsx takes enabled from config alone, so any reconcile turns the plugin back on.

    The recording and screenshots below show the whole sequence, and the script below reproduces it. I didn't test the session/location-switch trigger mentioned in the comments.

    recording.mp4

    Screenshot

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#42417: a TUI plugin disabled via /plugins is
    // re-enabled when an unrelated plugin source reloads, and after a TUI restart.
    import { Effect } from "effect"
    import { join } from "node:path"
    import { writeFileSync } from "node:fs"
    import { OpenCodeDriver } from "opencode-drive"
    
    const plugin = (id: string, gen: number) =>
      `export default { id: "${id}", setup() { /* generation ${gen} */ } }\n`
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true, keypressOverlay: true },
        project: {
          git: true,
          files: {
            ".opencode/plugins/alpha/tui.ts": plugin("repro.alpha", 1),
            ".opencode/plugins/beta/tui.ts": plugin("repro.beta", 1),
          },
        },
      },
      (oc) =>
        Effect.gen(function* () {
          const { ui } = oc
          const text = (u: typeof ui) =>
            u.capture().pipe(
              Effect.map((f) => f.lines.map((l) => l.spans.map((s) => s.text).join("")).join("\n")),
            )
          const until = (u: typeof ui, re: RegExp) =>
            Effect.gen(function* () {
              for (let i = 0; i < 120; i++) {
                if (re.test(yield* text(u))) return
                yield* Effect.sleep("250 millis")
              }
              return yield* Effect.die(new Error("timeout waiting for " + re + "\n" + (yield* text(u))))
            })
          const alphaInactive = (u: typeof ui) =>
            Effect.gen(function* () {
              for (let i = 0; i < 40; i++) {
                if (/repro\.alpha[^\n]*inactive/.test(yield* text(u))) return true
                yield* Effect.sleep("250 millis")
              }
              return false
            })
          const openPlugins = Effect.gen(function* () {
            yield* ui.type("/plugins")
            yield* Effect.sleep("1 second")
            yield* ui.enter()
            yield* Effect.sleep("2 seconds")
            yield* until(ui, /repro\.beta/)
            yield* Effect.sleep("500 millis")
          })
    
          yield* until(ui, /commands/)
          yield* openPlugins
          yield* ui.screenshot("1-both-active")
    
          // Disable repro.alpha (first TUI entry is focused) with Enter.
          yield* ui.enter()
          yield* alphaInactive(ui)
          yield* ui.screenshot("2-alpha-disabled")
          yield* ui.press("\u001b")
    
          // Edit the UNRELATED plugin (beta) to trigger hot reload.
          writeFileSync(join(oc.artifacts, "files/.opencode/plugins/beta/tui.ts"), plugin("repro.beta", 2))
          yield* Effect.sleep("4 seconds")
          yield* openPlugins
          yield* ui.screenshot("3-after-beta-reload")
          const now = yield* text(ui)
          yield* Effect.log(now)
          yield* Effect.log(
            /repro\.alpha[^\n]*inactive/.test(now)
              ? "alpha still inactive after beta reload (OK)"
              : "BUG: alpha re-enabled after unrelated beta reload",
          )
          yield* ui.press("\u001b")
    
          // Disable alpha again, then simulate a TUI restart with a fresh TUI.
          yield* openPlugins
          yield* ui.enter()
          yield* alphaInactive(ui)
          yield* ui.press("\u001b")
          const second = yield* oc.tuis.launch({ viewport: { cols: 120, rows: 40 } })
          yield* until(second.ui, /commands/)
          yield* second.ui.type("/plugins")
          yield* Effect.sleep("1 second")
          yield* second.ui.enter()
          yield* until(second.ui, /repro\.beta/)
          yield* Effect.sleep("500 millis")
          yield* second.ui.screenshot("4-after-restart")
          const restarted = yield* text(second.ui)
          yield* Effect.log(restarted)
          yield* Effect.log(
            /repro\.alpha[^\n]*inactive/.test(restarted)
              ? "alpha still inactive after restart (OK)"
              : "BUG: alpha re-enabled after TUI restart",
          )
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions