Skip to content

tui: vertical session tabs render the working pulse only 10 columns wide #50426

Closed

Description

@le-xot

Description

With vertical session tabs (tabs.layout: "vertical"), the animated "session is running" pulse is hardcoded to 10 columns instead of the width of the tab. On a ~40-column rail it looks like a stray short dark stripe that crosses only part of the tab — both the separators above/below the tab and the sweep inside its title/detail rows. Horizontal tabs are fine, which makes the vertical rail look broken/inconsistent.

Plugins

superpowers@git+https://github.com/obra/superpowers.git, @cardinal4/opencode-quota@latest (both unrelated to this rendering issue)

OpenCode version

2.0.12

Steps to reproduce

  1. In ~/.config/opencode/cli.json: "tabs": { "layout": "vertical" } (defaults: indicators: "status", animations: true).
  2. Open a session and send a prompt so the tab stays in the running/thinking state.
  3. Watch the active tab in the vertical rail: the pulse strip above/below it and the sweep inside its title/detail rows cover only the first 10 columns.

Expected behavior

The pulse/separator strips span the full tab width, as they do in the horizontal tab layout.

Actual behavior

They are clipped to 10 columns, so on a wide rail they read as a short black line.

Additional context

Source (v2.0.12):

Suggested fix: drop the hardcoded width in VerticalSessionTabs (use the rail width, e.g. width() or "100%"), matching the horizontal layout.

Workaround (for users hitting it): "animations": false in cli.json.

Environment: Arch Linux (kernel 6.18.52-1-lts, x86_64), Alacritty, truecolor, zsh.

Activity

  1. argszero commented on Sep 22, 2026

    @argszero
    Contributor

    I'd like to work on this.

    Confirmed against the current v2 sources: VerticalSessionTabs in packages/tui/src/component/session-tabs.tsx defines const indicatorWidth = 10 and passes it as width to all four of its TabPulse call sites (separator above, separator below, title row, detail row), while the horizontal tab layout passes no width and therefore falls back to props.width ?? "100%" in packages/tui/src/component/tab-pulse.tsx. That matches the reported symptom exactly — only the vertical rail is clipped.

    Plan: drop the hardcoded width at those four call sites so the strips span the rail the same way they do in the horizontal layout, and cover it with a render test alongside the existing packages/tui/test/component/session-tabs-*.tsx tests.

  2. argszero commented on Sep 22, 2026

    @argszero
    Contributor

    Update — the 10-cell strip looks deliberate, so this reads as a design decision rather than a regression

    I read the code and its history before starting. The facts in the report are exact on current v2:

    • const indicatorWidth = 10 — packages/tui/src/component/session-tabs.tsx:826
    • passed as width at all four TabPulse sites in VerticalSessionTabs: :964 (edge="above"), :991 (edge="below"), :1012 (title row), :1097 (detail row)
    • the horizontal TabPulse (:1660) passes no width, so it takes props.width ?? "100%" (packages/tui/src/component/tab-pulse.tsx:792)

    What changed my reading is the change that introduced the constant: c894f87771 — feat(tui): refine session tab activity indicators (#42144, merged 2026-08-12). Its description names this exact behaviour as the intent:

    vertical tabs use a compact 10-cell running sweep instead of washing the full row

    vertical flashes radiate from the shortcut with a feathered falloff

    and its scope section says:

    This does not change the horizontal tab background pulse geometry.

    That same commit added the flash/glow tails on these four sites (flashTail={8}, glowTail={8}, detail glowTail={10}), and TabPulseRenderable clamps every tail to width - 2 (tab-pulse.tsx:675-684) — 8 is the largest falloff a 10-cell strip can express. So the strip width and the tails were sized together, and the horizontal layout keeping "100%" is the difference that PR says it chose to keep.

    If the intent now is that the strip should follow the rail width, the change itself is small — drop width={indicatorWidth} so the four sites fall back to "100%" like the horizontal one, or pass the rail width explicitly — but I would expect the tails to need retuning in the same change: an 8-cell falloff near the shortcut does not reach across a 40-column rail, which is the shape those constants were picked for.

    So I am not going to open a PR that reverts #42144 on my own reading of it. I am happy to implement whichever direction is wanted, including a middle option (keep the compact sweep on the title/detail rows, let the above/below separators span the row) — just say which.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report! I reproduced this on 2.0.12 and on the latest dev (fa1260d).

    Setup: tabs: { enabled: true, layout: "vertical" } in cli.json, a 200-column terminal (so the rail is about 42 columns), and a session kept running. In every frame, the pulse in the active tab's title row, detail row and separator below stays within the first 10 columns; the rest of the tab never animates. The recording below shows the whole run. The attached contrast-boosted crop of the rail makes the faint pulse visible and shows where it stops, and the script below reproduces it.

    The cause looks like the one you pointed out: VerticalSessionTabs passes a fixed indicatorWidth = 10 to its four TabPulse calls, while horizontal tabs use the full width. Maintainers will need to confirm whether the short indicator was meant to look this way. Until then, "animations": false remains the workaround.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#50426: vertical session tabs clip the "running" pulse to 10 columns.
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tuiConfig: { tabs: { enabled: true, layout: "vertical" } },
        tui: { recording: true, viewport: { cols: 200, rows: 45 } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* ui.screenshot("vertical-idle")
          // Keep the session running for a while so the pulse animates.
          yield* llm.queue(Llm.pause(12000), Llm.text("Done thinking."))
          yield* ui.submit("Think about something for a while")
          for (let i = 0; i < 8; i++) {
            yield* Effect.sleep(700)
            yield* ui.screenshot(`vertical-running-${i}`)
          }
          // Columns 10+ of the ~42-column rail never get the pulse; compare screenshots.
          yield* ui.waitFor("Done thinking.", { timeout: 30000 }).pipe(Effect.catchTag("UiWaitTimeoutError", () => Effect.void))
        }),
    )

    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

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions