Skip to content

tui: animated session tabs cause high CPU/GPU use and slow other apps #57462

Open

Description

@MrVoicer

Description

On an Intel HD 520 laptop, OpenCode's TUI animations cause sustained CLI,
terminal and compositor CPU/GPU activity. Other apps take longer to open beside
the visible TUI. Disabling OpenCode animations reproducibly removes most of the
penalty. It happens in Foot too, but is much worse in Ghostty.

An idle selected session is also affected when another session is working and
the tab strip is visible. With that same idle session selected, setting
tabs.mode: "off" removes the penalty even with animations enabled. This
implicates animated tab/status rendering, but the exact implementation is not
isolated. The all-sessions-idle case has not been tested.

Median launch-to-window-map latency beside the same working OpenCode session
(two launches per cell, forward/reverse order):

App Foot animations on/off Ghostty animations on/off
Flea 1.13 / 0.66 s 2.29 / 0.77 s
Firefox 2.38 / 1.60 s 5.19 / 1.76 s
Chromium 1.80 / 1.20 s 4.07 / 1.32 s

In those tests, Ghostty itself used 72–79% of one CPU core and 52–53% GPU
render-engine time with animations on, versus 1.5–1.75% CPU and ~2.5% GPU with
animations off. The OpenCode CLI used 69–78% versus 2–2.5% CPU. Foot's CLI also
used ~63–64% CPU with animations on. These are short samples, not whole-device
GPU measurements.

Ten-second CLI CPU profiles captured with SIGPROF show the animation-on profile
dominated by rendering work: render, addToHitGrid, bufferDrawBox,
bufferDrawTextBufferView, and bufferFillRect. Profiles are available on request.

Expected: small status animations should not continuously redraw enough of the
UI to materially slow other desktop apps, especially while viewing an idle tab.

Plugins

No third-party plugins found; built-in plugins only.

OpenCode version

2.0.32, latest release channel installed through mise/npm; shared background service.

Steps to reproduce

  1. Open a TUI session with its tab strip visible and start agent work. Our test
    used a long existing conversation, not a minimal new transcript.
  2. While it is working, compare these client overrides on the same session:
    OPENCODE_CLI_CONFIG_CONTENT='{"animations":true}' opencode --session <id>
    versus OPENCODE_CLI_CONFIG_CONTENT='{"animations":false}' opencode --session <id>.
  3. Keep the TUI visible and measure CLI/terminal CPU usage; open another app on
    the same workspace. Repeat in Ghostty and Foot.
  4. Select an older idle session while the other session continues working.
    Compare animations enabled with tabs visible against
    {"animations":true,"tabs":{"mode":"off"}}.

Persistent workaround: "animations": false in ~/.config/opencode/cli.json.
Disabling Hyprland animations or switching scrolling to dwindle did not help.
Ordinary idle terminal windows did not reproduce the large slowdown.

Screenshot

None; measurements and paired CPU profiles were collected instead.

Operating System

Omarchy/Arch Linux x86_64, kernel 7.2.8-9-omarchy, Hyprland 0.56.2 Wayland,
Intel HD Graphics 520/i915, Mesa 26.2.5, 1920×1080 at 1.25 scale, ~8 GB RAM.
Custom Phantomat overview plugin loaded but inactive; not isolated by unloading.
Noticed after a system update, but update causation is not established.

Terminal

Ghostty 1.3.1+omarchy.3 and Foot 1.28.0. Bash; truecolor. Agent subprocess
environment reported TERM=xterm-256color and TERM_PROGRAM=ghostty.
Foot logged an unsupported OSC-66 s parameter warning, so protocol fidelity
is not identical. No Alacritty/Kitty comparison performed.

Activity

  1. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed measurements. I could reproduce this in kind on 2.0.32 and on the latest release, 2.1.1. The sandbox can't measure terminal or GPU load, so I measured only the OpenCode process.

    What I did: I opened a session with the tab strip on and a long transcript, then held a turn open for 25 s with a simulated model and sampled the TUI process's CPU and terminal output:

    • 2.1.1, animations on: about 9.4% CPU and a constant ~37.5 KiB/s of terminal output.
    • 2.1.1, animations off: about 0.9% CPU and no output.
    • 2.0.32: 6.6% with animations on, 0.8% with them off, and 4.6% with animations on but tabs off.

    The absolute numbers are much lower than yours because the sandbox has a fast CPU and no real terminal or GPU. The pattern matches what you describe, though: while a session is busy, the animations keep the whole UI re-rendering and writing frames to the terminal, which your terminal then has to redraw. The screenshot below shows the busy tab spinner during the measurement, and the script below reproduces it.

    I couldn't run the current dev branch from source here, so this was checked against the 2.1.1 release. I also didn't test your idle-selected-tab case. Until this is looked at, "animations": false in cli.json remains the workaround. Related: #39681 (an earlier tab pulse frame-rate fix) and #42306.

    Screenshot

    Reproduction script (drive.ts)
    // Measures CPU of the OpenCode TUI process while a session is working (simulated model holds
    // the response open), with animations on vs off, and tabs on vs off.
    // Variants run sequentially in the same script; each launches a fresh TUI.
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    import { readFileSync, readdirSync } from "node:fs"
    
    const ticks = () => {
      const out = new Map<number, { cmd: string; t: number; w: number }>()
      for (const d of readdirSync("/proc")) {
        if (!/^\d+$/.test(d)) continue
        try {
          const cmd = readFileSync(`/proc/${d}/cmdline`, "utf8").replace(/\0/g, " ")
          if (!/opencode|packages\/cli\/src\/index\.ts/.test(cmd) || /opencode-drive|drive\.ts/.test(cmd)) continue
          const st = readFileSync(`/proc/${d}/stat`, "utf8").split(") ")[1]!.split(" ")
          let w = 0
          try { w = +(readFileSync(`/proc/${d}/io`, "utf8").match(/wchar: (\d+)/)?.[1] ?? 0) } catch {}
          out.set(+d, { cmd: cmd.slice(0, 90), t: +st[11]! + +st[12]!, w })
        } catch {}
      }
      return out
    }
    const sample = (label: string, secs: number) =>
      Effect.gen(function* () {
        const a = ticks()
        yield* Effect.sleep(secs * 1000)
        const b = ticks()
        for (const [pid, v] of b) {
          const pct = ((v.t - (a.get(pid)?.t ?? v.t)) / 100 / secs) * 100
          const kbs = (v.w - (a.get(pid)?.w ?? v.w)) / 1024 / secs
          if (pct > 0.5) yield* Effect.log(`[${label}] pid ${pid} ${pct.toFixed(1)}% CPU ${kbs.toFixed(1)} KiB/s written  ${v.cmd}`)
        }
      })
    
    const long = Array.from({ length: 30 }, (_, i) => `Line ${i}: ` + "lorem ipsum dolor sit amet ".repeat(4)).join("\n\n")
    
    const variant = process.env.VARIANT ?? "animations-on"
    const cfgs: Record<string, any> = {
      "animations-on": { animations: true, tabs: { mode: "on" } },
      "animations-off": { animations: false, tabs: { mode: "on" } },
      "anim-on-tabs-off": { animations: true, tabs: { mode: "off" } },
    }
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tuiConfig: cfgs[variant],
        tui: { recording: true, viewport: { cols: 160, rows: 45 } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          // a long transcript first, then a turn held open for 25 s (session "working")
          yield* llm.queue(Llm.text(long))
          yield* ui.submit("Write a long answer")
          yield* Effect.sleep(8000)
          yield* Effect.sleep(1500)
          yield* sample(`${variant} after-first-turn-idle`, 5)
          yield* llm.queue(Llm.text("Starting..."), Llm.pause(25000), Llm.text("Done working."))
          yield* ui.submit("Keep working for a while")
          yield* Effect.sleep(3000)
          yield* ui.screenshot(`${variant}-working`)
          yield* sample(`${variant} working`, 10)
          yield* Effect.sleep(14000)
        }),
    )

    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. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    User opencode-agent[bot] does not have write permissions

    github run

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