[BUG] Plugins: an empty absolute <Client> over a <Raster> on a transcript row stops $.ui.blit repaints from reaching the screen

Status Open
Reported on v2.1.292
Maintainer reply None cached
Activity 0 comments · opened Oct 8, 2026

Summary

A plugin draws a transcript row as a Raster and lays an empty, absolutely positioned Client over it, as a click
catcher. After that, $.ui.blit repaints of the Raster never reach the terminal. Every blit resolves {} (taken,
no deny), but the row keeps its first picture. Remove the Client, or put the Raster inside a Box that does not
clip, and the same blits show at once.

Environment

  • Claude Code 2.1.292 and 2.1.295 (both reproduce). The harness test of the same case also ran on 2.1.294.
  • Linux (x86-64), fullscreen TUI ("tui": "fullscreen"), inside tmux 3.7c.
  • Plugin loaded with CLAUDE_CODE_PLUGIN_DIRS; no other plugins; --setting-sources project,local.

Minimal repro

A plugin with three files. /rcr catcher draws its own output row as a 40×3 Raster with the empty Client over it,
then blits a new letter into every cell every 50 ms for 3 s, then blank cells for 3 s.

hooks/register.tsx:

import type { Register, Timer } from 'claude-code'

const COLS = 40
const ROWS = 3
type Mode = 'catcher' | 'plain' | 'wrapped'
let mode: Mode = 'catcher'
let site: string | null = null // the requestId of the newest /rcr output row
let timer: Timer | undefined

const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function base64(bytes: Uint8Array) {
  let out = ''
  for (let i = 0; i < bytes.length; i += 3) {
    const left = bytes.length - i
    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
    out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
    out += left > 1 ? B64.charAt((n >> 6) & 63) : '='
    out += left > 2 ? B64.charAt(n & 63) : '='
  }
  return out
}
// every cell the same glyph, white on dark blue
function cells(codePoint: number) {
  const words = new Uint32Array(COLS * ROWS * 3)
  for (let i = 0; i < COLS * ROWS; i++) words.set([codePoint, 0xffffff, 0x1a1b26], i * 3)
  return base64(new Uint8Array(words.buffer))
}

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    await $.command.register({ name: 'rcr', description: 'Raster + click catcher repro', argumentHint: 'catcher | plain | wrapped' })
    return next(e)
  })

  on('command.run', { command: 'rcr' }, async ($, e) => {
    const arg = e.args.trim()
    mode = arg === 'plain' || arg === 'wrapped' ? arg : 'catcher'
    timer?.cancel()
    let frame = 0
    const refused: string[] = []
    // once the row is drawn: letters A..Z for 3 s, then blank for 3 s
    $.clock.after(1000, () => {
      timer = $.clock.every(50, () => {
        frame++
        const glyph = frame <= 60 ? 0x41 + (frame % 26) : 0x20
        if (site) void $.ui.blit({ requestId: site, key: 'r', cells: cells(glyph) }).then(r => {
          if (r.deny !== undefined && refused.length < 3) refused.push(r.deny)
        })
        if (frame === 120) {
          timer?.cancel()
          $.ui.log(`rcr: done, mode=${mode}, refused: ${refused.join(' | ') || 'none'}`, { to: 'debug' })
        }
      })
    })
    return { text: `rcr: ${mode}` }
  })

  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
    if (e.surface !== 'terminal' || e.props.command !== 'rcr') return next(e)
    site = e.requestId
    const { Box, Client, Raster } = $.ui.resolve(e)
    const raster = <Raster key="r" columns={COLS} rows={ROWS} cells={cells(0x2e)} />
    return (
      <Box flexDirection="column">
        {mode === 'wrapped' ? <Box flexDirection="column">{raster}</Box> : raster}
        {mode !== 'plain' && (
          <Box position="absolute" top={0} left={0}>
            <Client key="c" module="./catcher.ts" width={COLS} height={ROWS} />
          </Box>
        )}
      </Box>
    )
  })
}

hooks/catcher.ts:

import type { ClientModule } from 'claude-code'

// an empty click catcher: paints no cell
const Catcher: ClientModule = (_props, surface) => {
  surface.onPointer(() => undefined)
  return surface.elements.Box({})
}

export default Catcher

hooks/hooks.json: { "modules": ["./register.tsx"] }. .claude-plugin/plugin.json:
{ "name": "raster-catcher", "version": "0.1.0", "description": "Raster + click catcher repro" }.

Steps:

  1. Start Claude Code in the fullscreen TUI with only this plugin loaded and --debug-file.
  2. Type /rcr catcher and watch the output row for 8 s.
  3. Start a fresh session and do the same with /rcr plain, then /rcr wrapped.

Expected

In every mode, the row shows a new letter about every 50 ms for 3 s, then turns blank.

Actual

Screen captures (tmux capture-pane) every 0.5 s after the command. The letter shown is what fills the 40-wide
block; . is the Raster's initial cells; _ means blank:

| Mode | 2.1.295 | 2.1.292 |
|---|---|---|
| catcher | . . . . . . . . . . . . . . . . . . (never changes) | same |
| plain | . . J T C M V F _ _ _ _ … | . . J T C M V F _ _ _ … |
| wrapped | . . J S C M V F _ _ _ _ … | . . I S C L V E _ _ _ … |

In catcher mode, all 120 blits resolve {} (the debug line says refused: none), yet nothing from them is drawn.

More evidence (from the plugin where we found it)

We hit this in a screensaver plugin that draws each visible transcript row as Raster bands, repainted by blit at
20 fps, with the same kind of click catcher over each row. Symptoms: rows near the top froze while the rest
animated; when the picture went blank, the top rows kept their last frame; at one point the whole screen froze for
~20 s. What we measured:

  • Counting blit results in the plugin: 140 blits/s sent, 140/s taken, 0 denied, 0 rejected.
  • Recording the terminal output with tmux pipe-pane: about 1.1 MB/s of frames, then no byte at all for 21 s

while blits kept being taken.

  • Sending SIGCONT to the process (which repaints the whole screen) mid-freeze showed the correct, current picture.

So the tree held the new cells; the frame-by-frame output did not write them.

  • The non-blocking stdout layer is not involved: CLAUDE_CODE_NONBLOCKING_STDOUT=0 froze the same way.
  • Removing the click catcher, or wrapping the Raster bands in a plain Box placed before it, fixed it fully.
  • An attempt to reproduce in a Pane (Raster plus the same catcher) did not freeze on 2.1.294. We saw it only on

transcript rows.

Likely cause (from reading the bundled 2.1.292 code; minified names)

When the renderer draws a Box's children in order (Tv), a child that changed and clips on both axes (every
Raster's own wrapper Box is overflow: hidden) sets only a weak flag (B), not the flag that stops later siblings
from reusing the previous frame (I):

if (J && !I) if (!Av(Q) || ie) I = true; else B = true

The absolute catcher Box after it gets skipSelfBlit, so it does not copy itself from the previous frame. But it
still passes prevScreen to its own children. Its child, the unchanged Client node, then takes the "unchanged node,
same layout" path in Is and does u.blit(prevScreen, rect) over its whole rect — the full row. That overwrites
the cells the Raster just drew with the previous frame's cells. The new frame equals the old one there, so the
diff writes nothing.

Two things make it stick:

  • A Raster skips a repaint whose text equals what it already holds. So once a band's picture stops changing, no

later blit marks it changed again, and one lost update stays on screen.

  • In the transcript list, only rows drawn before the first changed row get prevScreen, so the topmost changed

row is the one that loses its update.

A possible fix: treat a changed child that clips like any other changed child for absolute siblings, or never let
the children of a skipSelfBlit node blit from prevScreen.

Workaround

Wrap the Raster(s) in a Box that does not clip, placed before the catcher (the wrapped mode above). The catcher
stays last, so hit-testing still finds it.

View original on GitHub ↗