Skip to content

TUI mouse copy drops blank lines between markdown paragraphs (2.0) #55294

Open

Description

@dzatona

Description

Still reproduces on OpenCode 2.0. Re-filing as asked in #44056, which was closed with the V1 issues.

Mouse-selecting assistant markdown collapses blank lines between paragraphs (\n\n → \n).

<leader>y (messages_copy) copies part.text correctly. Only the mouse path is wrong.

Not #16521 / opentui#1103: that was empty logical lines inside one TextBuffer.

Plugins

none that touch the TUI

OpenCode version

2.0.20

Steps to reproduce

  1. Ask for three short paragraphs separated by blank lines
  2. Mouse-drag across them
  3. Paste -- blank lines are gone

Operating System

macOS 26.3

Terminal

WezTerm via tmux

Related: #44056 #16521 #25623 #10098, anomalyco/opentui#1103

Activity

  1. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks for re-filing. I reproduced this on 2.0.20, and it still happens on the latest dev (52ba7d6).

    Steps:

    1. The simulated model replies with three one-line paragraphs separated by blank lines.
    2. I drag the mouse from the start of the first paragraph to the end of the third. The TUI shows "Copied to clipboard".
    3. I check what was actually copied by decoding the clipboard write (OSC 52) that the TUI sends to the terminal.

    Result: Alpha paragraph one.\nBravo paragraph two.\nCharlie paragraph three., so the \n\n between paragraphs became \n. The recording and screenshot below show the selection, and the script below reproduces it and prints the copied text next to the expected text.

    The cause appears to be in opentui's multi-block selection: it joins only screen rows that contain text, so the empty row between two paragraph blocks is dropped. This matches your note that it's different from #16521 / opentui#1103, which fixed empty lines inside a single text buffer. Related: #44056 (the same report on v1), #16521, #25623.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const answer = "Alpha paragraph one.\n\nBravo paragraph two.\n\nCharlie paragraph three."
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true, pointerOverlay: true, viewport: { cols: 120, rows: 40 } },
      },
      ({ ui, llm, tui }) =>
        Effect.gen(function* () {
          yield* llm.queue(Llm.text(answer))
          yield* ui.submit("Write three short paragraphs separated by blank lines")
          yield* ui.waitFor("Charlie paragraph three.")
          yield* Effect.sleep(800)
          const frame = yield* ui.capture()
          const lines: string[] = (frame as any).lines.map((l: any) => l.spans.map((s: any) => s.text).join(""))
          const ya = lines.findIndex((l) => l.includes("Alpha paragraph one."))
          const yc = lines.findIndex((l) => l.includes("Charlie paragraph three."))
          const xa = lines[ya].indexOf("Alpha")
          const xc = lines[yc].indexOf("three.") + "three.".length
          yield* Effect.log(`alpha at ${xa},${ya}; charlie end at ${xc},${yc}`)
          yield* ui.screenshot("before-select")
          yield* ui.mouse({ action: "move", x: xa, y: ya })
          yield* ui.mouse({ action: "down", x: xa, y: ya, button: "left" })
          for (let y = ya; y <= yc; y++) yield* ui.mouse({ action: "move", x: y === yc ? xc : xa + 10, y })
          yield* ui.mouse({ action: "up", x: xc, y: yc, button: "left" })
          yield* Effect.sleep(500)
          yield* ui.screenshot("after-select")
          yield* Effect.sleep(1000)
          // Decode the OSC 52 clipboard write that the TUI sent to the terminal.
          const timeline = tui.recording!.timeline
          const raw = yield* Effect.promise(() => Bun.file(timeline).text())
          const copied: string[] = []
          for (const line of raw.split("\n")) {
            if (!line.includes('"output"')) continue
            const data = Buffer.from(JSON.parse(line).data, "base64").toString("latin1")
            for (const m of data.matchAll(/\x1b\]52;[^;]*;([^\x07\x1b]*)/g)) copied.push(Buffer.from(m[1], "base64").toString("utf8"))
          }
          const clipboard = copied.at(-1) ?? "(no OSC 52 write found)"
          yield* Effect.log(`copied text: ${JSON.stringify(clipboard)}`)
          yield* Effect.log(`expected:    ${JSON.stringify(answer)}`)
          yield* Effect.log(clipboard === answer ? "OK: blank lines preserved" : "BUG: copied text differs (blank lines dropped)")
        }),
    )

    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.

  2. Finn763 commented on Oct 19, 2026

    @Finn763
    Contributor

    I'd like to work on this — the mouse-selection copy path collapses blank lines, while messages_copy copies part.text verbatim. I'll fix the selection extraction and cover it with a test.

  3. Finn763 commented on Oct 19, 2026

    @Finn763
    Contributor

    Looked closer and the blank lines are lost in opentui's getSelectedText(): paragraph gaps are layout margins, not characters, so the selection text never contains them. That needs a fix in opentui (or a source-text mapping for transcript selections), not in the TUI copy handler here. Unclaiming so someone can take it upstream.

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