Skip to content

tui: ctrl+b background hint shown but keypress does nothing #49216

Open

Description

@sghng

Description

The v2 TUI shows a ctrl+b background hint under a running foreground subagent task, but pressing ctrl+b does nothing: the subagent keeps blocking the parent session, nothing is detached, and there is no error or toast. The hint appears to be intentional (there is a session_background keybind, a hidden session.background command, and a POST /experimental/session/:sessionID/background endpoint), so the keypress itself seems broken rather than the hint being stale.

Related but distinct: #36940 (hint renders when child work is already backgrounded).

Plugins

None

OpenCode version

2.0.3

Steps to reproduce

  1. Run with experimental background subagents enabled (the hint only renders when the capability is on).
  2. Prompt the main session to spawn a foreground (synchronous) subagent via the task tool (no background: true).
  3. While it runs (task row shows e.g. ↳ Bash Running …), observe the hint under it: ctrl+g view subagents · ctrl+b background.
  4. Press ctrl+b — both in the main transcript and while inside the subagent's session view (ctrl+g).
  5. Nothing happens.

Screenshot and/or share link

—

Operating System

macOS (Darwin 27.0.0, arm64)

Terminal

Ghostty (TERM=xterm-ghostty, COLORTERM=truecolor)

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I reproduced this on the latest dev (950111f) with a simulated model, but only in the subagent's session view:

    1. The parent session starts a foreground subagent, which runs a long shell command. The hint "Press ctrl+b to move running work to the background" appears.
    2. Open the subagent's session view from the Subagents picker. The hint is still shown there.
    3. Press ctrl+b. Nothing happens: no toast, and the subagent keeps blocking the parent.

    Calling the background endpoint directly at that point does work right away: the subagent moves to the background and the parent continues. So the server side is fine and only the keypress in that view fails. The recording and screenshot below show it, and the script below reproduces it.

    On current dev, pressing ctrl+b in the parent transcript (with the prompt focused) did background the subagent. I couldn't run 2.0.3 itself in my test setup, so I couldn't confirm the parent-transcript part of your report on that version. If it still happens there on a newer release, please say so here.

    Related:

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    // Parent session starts a foreground subagent; the subagent runs a shell that
    // never finishes on its own. The TUI shows the "ctrl+b ... background" hint;
    // pressing ctrl+b should move the running work to the background.
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true, keypressOverlay: true },
        project: { git: true },
        tools: ["shell"],
      },
      ({ ui, llm, tools, opencode }) =>
        Effect.gen(function* () {
          const shells = yield* tools.control("shell")
          // Scripted model: 1st agent request (parent) calls the subagent tool in the
          // foreground; 2nd (the subagent) runs a long shell; later requests reply with text.
          let n = 0
          yield* llm.serve((req: any) => {
            if (JSON.stringify(req).includes("You name conversations")) return Stream.make(Llm.text("Long job"))
            n++
            if (n === 1)
              return Stream.make(
                Llm.toolCall({ index: 0, id: "call_task", name: "subagent", input: { description: "Run long job", prompt: "Run the long job", agent: "general" } }),
                Llm.finish("tool-calls"),
              )
            if (n === 2)
              return Stream.make(
                Llm.toolCall({ index: 0, id: "call_shell", name: "shell", input: { command: "sleep 600", description: "long job" } }),
                Llm.finish("tool-calls"),
              )
            return Stream.make(Llm.text("Parent continued after backgrounding."))
          })
          yield* ui.submit("Spawn a subagent to run the long job")
          const shell = yield* shells.take("call_shell")
          yield* shell.progress("working...\n")
          yield* Effect.sleep("4 seconds")
          yield* ui.screenshot("01-hint-visible")
          // Open the running subagent's session view first (down + enter)
          if (!process.env.MAIN_VIEW) { // MAIN_VIEW=1 presses ctrl+b in the parent transcript instead (works on dev)
            yield* ui.press("down")
            yield* Effect.sleep("1 second")
            yield* ui.press("return")
            yield* Effect.sleep("3 seconds")
            yield* ui.screenshot("01b-subagent-view")
          }
          // 1) press ctrl+b (subagent view by default)
          yield* ui.press("b", { ctrl: true })
          yield* Effect.sleep("5 seconds")
          yield* ui.screenshot("02-after-ctrl-b")
          const afterKey = JSON.stringify(yield* ui.capture()).includes("Parent continued")
          yield* Effect.log(`after ctrl+b, parent continued: ${afterKey}`)
          // 2) control: call the same backend endpoint the keybind should call
          const sessions: any = yield* ((opencode as any).session.list() as Effect.Effect<any, unknown, never>)
          const list: any[] = Array.isArray(sessions) ? sessions : (sessions.items ?? sessions.data ?? [])
          const root = list.find((x) => !x.parentID)!
          yield* Effect.log(`sessions: ${JSON.stringify(list.map((x) => [x.id, x.parentID, x.title]))}`)
          yield* ((opencode as any).session.background({ sessionID: root.id }) as Effect.Effect<void, unknown, never>)
          yield* Effect.sleep("5 seconds")
          yield* ui.screenshot("03-after-api-background")
          const afterApi = JSON.stringify(yield* ui.capture()).includes("Parent continued")
          yield* Effect.log(`after API background, parent continued: ${afterApi}`)
          const msgs: any = yield* ((opencode as any).message.list({ sessionID: root.id }) as Effect.Effect<any, unknown, never>)
          yield* Effect.log("PARENT MESSAGES: " + JSON.stringify(msgs).slice(0, 3000))
          yield* shell.succeed({ output: "done\n", exit: 0 })
          yield* Effect.sleep("3 seconds")
        }),
    )

    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