Skip to content

Web: rapidly toggling the terminal open creates duplicate terminals and leaves them unfocused #55992

Open

Description

@cinvymoe

Description

Pressing the toggle-terminal shortcut repeatedly (open, close, open again in quick succession, while the first pty create request is still in flight) creates two terminal tabs instead of one. When the two create replies resolve out of order, the pending focus request ends up naming a terminal that is no longer active, so the panel opens with focus nowhere - typing does nothing until you click into the terminal input.

Plugins

None (built-in terminal extension).

OpenCode version

2.0.26 (v2 branch)

Steps to reproduce

  1. Open a session and focus the composer.
  2. Press the toggle terminal shortcut three times in quick succession (open / close / open) before the first terminal finishes creating.
  3. Two "Terminal 1" tabs appear; once things settle, focus is not in the terminal and typing goes nowhere until you click the terminal input.

Activity

  1. opencode-agent commented on Oct 16, 2026

    @opencode-agent
    Contributor

    Thanks for the clear report! I was able to reproduce this on 2.0.26, and it still happens on the latest dev (37e3912d8).

    I didn't have a browser available, so I drove the terminal code directly, with a fake server so I could control when each create request replies. Opening, closing and opening the terminal again before the first terminal is created sends two create requests, both titled "Terminal 1". When the second reply arrives before the first, you get two "Terminal 1" tabs. The active tab is the one from the first request, but the pending focus request names the other one, so neither terminal gets focus. This matches what you described.

    The script below reproduces it as a bun test inside packages/gui-extensions. It passes while the bug is present.

    No related issues cover this exact problem. The closest are #54559 (Ctrl+backtick not toggling the terminal on web) and #55016 (Mod+J terminal toggle).

    Reproduction script (repro-55992.test.ts)
    // Run: copy into packages/gui-extensions/src/terminal/ of a dev checkout, then from packages/gui-extensions:
    //   bun install && bun test --conditions=browser src/terminal/repro-55992.test.ts
    // The test passes while the bug is present (asserts 2 tabs and no focus on the active terminal).
    // Reproduction for anomalyco/opencode#55992: open / close / open the terminal dock while the first
    // pty.create is in flight. Uses the real terminal model (model.ts) and the panel's auto-create and
    // focus-gating logic (copied from panel.tsx), with a fake server whose pty.create replies we resolve by hand.
    import { expect, test } from "bun:test"
    import { createRoot, createSignal } from "solid-js"
    import { createStore, produce } from "solid-js/store"
    import { createKeyed } from "../sdk"
    import { createTerminalModel } from "./model"
    
    test("#55992 rapid toggle creates duplicate terminals and leaves focus on a non-active one", async () => {
      const pending: Array<{ title: string; resolve: (v: { data: { id: string; title: string } }) => void }> = []
      const client = {
        pty: {
          create: (input: { title: string }) =>
            new Promise<{ data: { id: string; title: string } }>((resolve) => pending.push({ title: input.title, resolve })),
          update: async () => ({}),
          remove: async () => ({}),
        },
      }
      const server = { id: "srv", client, data: { on: () => () => {} } }
    
      const storage = {
        memory: (_: string, opts: { initial: unknown }) => [opts.initial],
        store: (_: string, opts: { initial: { all: unknown[]; active?: string } }) => {
          const [state, setState] = createStore({ value: structuredClone(opts.initial) })
          return {
            get value() {
              return state.value
            },
            ready: () => true,
            update: (fn: (draft: any) => void) => setState("value", produce(fn as any)),
            set: (v: any) => setState("value", v),
          }
        },
        remove: () => {},
      }
    
      await createRoot(async (dispose) => {
        const model = createTerminalModel({ storage: storage as any, sessions: { list: () => [] } as any })
        const session = { server: server as any, directory: "/proj" }
        const terminal = () => model.load(session)
        const [opened, setOpened] = createSignal(false)
    
        // panel.tsx: while the dock shows, a workspace first observed empty gets its first terminal.
        createKeyed(opened, () => {
          const seen = { workspace: "" }
          createKeyed(
            () => (terminal().ready() ? terminal() : undefined),
            (workspace) => {
              if (seen.workspace === workspace.key) return
              seen.workspace = workspace.key
              if (workspace.all().length === 0) workspace.new()
            },
          )
        })
    
        // Let setup effects run, then toggle: open, close, open, each as its own key press,
        // all before the first create replies.
        await Bun.sleep(0)
        setOpened(true)
        await Bun.sleep(0)
        setOpened(false)
        await Bun.sleep(0)
        setOpened(true)
        await Bun.sleep(0)
    
        console.log("pty.create calls in flight:", pending.length, pending.map((p) => p.title))
    
        // Replies arrive out of order: the second create resolves first.
        pending[1]?.resolve({ data: { id: "pty-B", title: pending[1].title } })
        await Bun.sleep(0)
        pending[0]?.resolve({ data: { id: "pty-A", title: pending[0].title } })
        await Bun.sleep(0)
    
        const ws = terminal()
        const tabs = ws.all().map((p) => `${p.id}:${p.title}`)
        const active = ws.active()!
        // panel.tsx focus gate: focus only when opened && focusRequested(active)
        const willFocus = opened() && ws.focusRequested(active)
        const focusNamesOther = ws.all().some((p) => p.id !== active && ws.focusRequested(p.id))
        console.log("tabs:", tabs)
        console.log("active:", active, "| panel focuses active terminal:", willFocus, "| focus request names a non-active terminal:", focusNamesOther)
    
        expect(tabs.length).toBe(2) // bug: should be 1
        expect(willFocus).toBe(false) // bug: should be true
        dispose()
      })
    })

    Run it: bun repro-55992.test.ts

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions