Skip to content

question tool: free-form answer cannot accept multi-line input (silent truncation since #54822) #56895

Open

Description

@TiagoGF

Description

The question tool's free-form answer ("Type your own answer…") cannot hold multi-line text, and pasting silently truncates it.

What happens: the custom-answer field is a TextInput (packages/app/src/session/requests/session-question-dock.tsx:718), which renders <input> (packages/ui/src/forms/text-input/text-input.tsx:6,67). An <input> cannot contain newlines, and the key handler commits on any Enter with no Shift+Enter branch (:748-750), so there is no keyboard path to a newline either.

Expected: a free-form answer should preserve line breaks. v2.0.21 used a <textarea> with Enter to commit and Shift+Enter for a newline (session-question-dock.tsx:676,693). The tool contract still describes free-form answers (packages/core/src/tool/plugin/question.ts:19) and Question.Answer is an unconstrained Array(String) (packages/schema/src/question.ts), so multi-line values are legal answers.

Regression from: #54822 (3ec856b6c2, "feat(desktop): refresh question composer").

Suggested direction (not a design demand): keep Enter-to-commit and restore newline input for the custom answer only — a textarea or a multiline mode — with the pre-#54822 Shift+Enter behaviour.

Scope/status: still present on dev (501b15af26). I reproduced this in the web/desktop question dock (packages/app); I did not test the TUI.

Plugins

Custom tool.execute.after plugin for secret capture — it only rewrites the tool result and does not touch input handling; reproduces with plugins disabled.

OpenCode version

v2.0.32

Steps to reproduce

  1. Trigger any question tool call (any tool that returns options).
  2. Choose "Type your own answer…".
  3. Paste a value that contains a newline, e.g. alpha⏎beta copied from an editor.
  4. The field ends up as alphabeta (or only alpha) — the line break is silently lost.
  5. Press Shift+Enter: it commits the answer instead of inserting a newline, so there is no way to type one either.

Screenshot and/or share link

Image

Operating System

ubuntu linux

Terminal

using the web UI

Activity

  1. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can confirm this in the web UI on v2.0.32, and it still happens on the latest dev (501b15af26).

    What I did:

    • Had a simulated model make a question tool call.
    • Opened the session in a headless Chromium browser.
    • Used the Type your own answer… field.

    What I saw:

    • The field renders a single-line <input type="text">.
    • Pasting alpha⏎beta gives alpha beta. Chromium turns the newline into a space, so the line break is lost.
    • Pressing Shift+Enter after typing first line doesn't insert a newline; the value stays first line. I didn't check whether it also submits the answer.

    This matches the switch from the v2.0.21 <textarea>, which had Shift+Enter handling, to TextInput in #54822. The script below reproduces it, and the screenshot shows the pasted value.

    Related, but not the same problem: #45230 (no way to turn off the custom-answer option), #33727 / #21143 (earlier Shift+Enter/IME fixes in the custom-answer textarea), and the TUI custom-answer issues #43516, #51294 and #30393.

    Screenshot

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #56895 in the web UI: the question tool's "Type your own answer…" field drops newlines.
    # Needs: opencode (v2) on PATH, opencode-drive, and playwright + chromium installed in $PW_DIR.
    #   PW_DIR=/workspace/pw   (bun add playwright && bunx playwright install --with-deps chromium)
    # Set OC_DEV=/path/to/opencode/checkout to run against a source checkout instead.
    set -e
    export PATH=$HOME/.bun/bin:$PATH
    PW_DIR=${PW_DIR:-/workspace/pw}
    if [ -n "$OC_DEV" ]; then OC_CMD="bun --conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js $OC_DEV/packages/cli/src/index.ts"; else OC_CMD=opencode; fi
    mkdir -p /tmp/opencode
    cat > /workspace/repro/serve-question.ts <<'DRIVE'
    // Drive part: starts OpenCode with a simulated model, makes it call the `question` tool,
    // writes the server URL to /tmp/opencode/url, then keeps the question pending until /tmp/opencode/done exists.
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    import * as fs from "node:fs"
    export default OpenCodeDriver.use({ ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}) }, ({ ui, llm, opencode }) =>
      Effect.gen(function* () {
        yield* llm.queue(Llm.toolCall({ index: 0, id: "call_q", name: "question", input: { questions: [{ question: "Which value?", header: "Value", options: [{ label: "One", description: "first" }] }] } }), Llm.finish("tool-calls"))
        yield* ui.submit("ask me")
        yield* ui.waitFor("Which value?")
        const info = yield* opencode.server.info()
        yield* Effect.promise(async () => {
          try { fs.unlinkSync("/tmp/opencode/done") } catch {}
          fs.writeFileSync("/tmp/opencode/url", info.urls[0])
          for (let i = 0; i < 300 && !fs.existsSync("/tmp/opencode/done"); i++) await new Promise((r) => setTimeout(r, 500))
        })
      }))
    DRIVE
    cat > "$PW_DIR/web.mjs" <<'WEB'
    import { chromium } from "playwright"
    const link = process.argv[2]
    const b = await chromium.launch(); const p = await b.newPage({ viewport: { width: 1200, height: 900 } })
    await p.goto(link); await p.waitForTimeout(6000)
    console.log("TEXT0", (await p.innerText("body")).slice(0, 300)); await p.screenshot({ path: "/workspace/repro/web-home.png" })
    await p.getByText("OpenCode Drive").first().click(); await p.waitForTimeout(2000)
    console.log("TEXT1", (await p.innerText("body")).slice(0, 400))
    const s = p.getByText("ask me").first(); if (await s.count()) { await s.click(); await p.waitForTimeout(2000) }
    await p.getByText("Which value?").first().waitFor({ timeout: 15000 })
    await p.screenshot({ path: "/workspace/repro/web-question.png" })
    const field = p.locator(".question-custom-field").first()
    const tag = await field.evaluate((el) => el.tagName)
    const box = tag === "INPUT" || tag === "TEXTAREA" ? field : field.locator("input,textarea").first()
    console.log("FIELD element:", tag, "->", await box.evaluate((el) => el.outerHTML.slice(0, 200)))
    await box.click()
    await p.keyboard.insertText("alpha\nbeta") // what a clipboard paste inserts
    await p.waitForTimeout(300)
    console.log("VALUE after pasting 'alpha\\nbeta':", JSON.stringify(await box.inputValue()))
    await p.screenshot({ path: "/workspace/repro/web-pasted.png" })
    await box.fill("first line")
    await p.keyboard.press("Shift+Enter"); await p.waitForTimeout(800)
    const still = await box.inputValue({ timeout: 1000 }).catch(() => "<field gone>")
    console.log("VALUE after Shift+Enter:", JSON.stringify(still))
    await p.screenshot({ path: "/workspace/repro/web-shift-enter.png" })
    console.log("TEXT2", (await p.innerText("body")).slice(0, 600))
    await b.close()
    WEB
    rm -f /tmp/opencode/url /tmp/opencode/done
    (timeout 200 opencode-drive run /workspace/repro/serve-question.ts > /tmp/opencode/drive.log 2>&1 &)
    for i in $(seq 120); do [ -f /tmp/opencode/url ] && break; sleep 1; done
    PORT=$(sed -E 's#.*:([0-9]+).*#\1#' /tmp/opencode/url)
    P=$(pgrep -f -- "--port $PORT" | head -1)
    # pair a browser with the drive's server (run `opencode pair` in the server's environment)
    LINK=$(env $(tr '\0' '\n' < /proc/$P/environ | grep -E "^(OPENCODE_CONFIG_DIR|OPENCODE_TEST_HOME|XDG_[A-Z_]+|OPENCODE_DRIVE)=" | xargs) timeout 60 $OC_CMD pair 2>&1 | grep -oE "http://[^ ]+/auth/connect/[A-Za-z0-9_-]+" | head -1)
    echo "LINK $LINK"
    (cd "$PW_DIR" && node web.mjs "$LINK")
    touch /tmp/opencode/done

    Run it with opencode-drive: opencode-drive run ./repro.sh. 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

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions