[BUG] Plugin Pane: a press fails with "no handler is held under handle N" when the pane redraws while a ui.press hook awaits

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

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

A plugin's ui.press hook awaits something, such as a state read, before it calls next(e). If the plugin's Pane is drawn again during that wait, next(e) throws, and the Button's onPress never runs:

keyrepro: ui.press hook skipped: threw ui.press/ui.input/ui.select: no handler is held under handle 500

The handle belongs to the drawing the person pressed. The newer drawing seems to release it before the press reaches the bottom of the chain.

What Should Happen?

A press that started on a drawing reaches that drawing's onPress, even when a newer drawing lands while a hook is still running. For example, the engine could hold a drawing's handles until the presses in flight on it settle.

If releasing them is intended, the ui.press docs could say that a hook must not await before next(e).

Error Messages/Logs

keyrepro: ui.press hook skipped: threw ui.press/ui.input/ui.select: no handler is held under handle 500

Steps to Reproduce

  1. Load the plugin below with claude --plugin-dir <folder>.
  2. Run /rows mode sleep, then /rows tick 50, then /rows. The pane opens with the keys and redraws every 50 ms.
  3. Press x 60 times, about 70 ms apart. The first line of the pane counts the presses that ran and the ones that threw.

Each mode ran once, in a fresh session:

| Mode | What the hook awaits before next(e) | Presses that ran |
|---|---|---|
| bare | nothing | 60 of 60 |
| read | read($, TICK) | 58 of 60 |
| sleep | $.clock.sleep(50) | 2 of 60 |

Every press that did not run threw the error above.

.claude-plugin/plugin.json:

{ "name": "keyrepro", "version": "0.1.0", "description": "A pane hotkey pressed while the pane redraws" }

hooks/register.tsx:

import { atom, read, update } from 'claude-code'
import type { Register } from 'claude-code'

const PANE = 'rows'
const PRESSES = atom({ plugin: 'keyrepro', key: 'presses' } as const, 0)
const TICK = atom({ plugin: 'keyrepro', key: 'tick' } as const, 0)
let mode = 'bare'
let errors = 0

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    const r = await next(e)
    await $.command.register({ name: 'rows', description: 'Open the rows pane; "tick N", "mode bare|read|sleep"' })

    return r
  })

  on('command.run', { command: 'rows' }, async ($, e) => {
    const [how, arg] = e.args.trim().split(/\s+/)
    if (how === 'tick') {
      const ms = Number(arg) || 100
      void (async () => {
        for (let n = 0; n < 30_000 / ms; n += 1) {
          await $.clock.sleep(ms)
          await update($, TICK, t => t + 1)
        }
      })().catch(() => undefined)
      return { text: `Ticking every ${ms} ms for 30 seconds.` }
    }
    if (how === 'mode') {
      mode = arg ?? 'bare'
      await update($, PRESSES, () => 0)
      errors = 0
      return { text: `Press hook mode: ${mode}. Count reset.` }
    }
    await $.ui.open({ id: PANE, title: 'Rows', focus: true })

    return { text: 'Opened the rows pane.' }
  })

  on('ui.press', { plugin: 'keyrepro', requestId: PANE }, async ($, e, next) => {
    if (mode === 'read') await read($, TICK)
    if (mode === 'sleep') await $.clock.sleep(50)
    try {
      return await next(e)
    } catch (error) {
      errors += 1
      throw error
    }
  })

  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
    const { Box, Button, Text } = $.ui.resolve(e)
    const [presses, tick] = await Promise.all([read($, PRESSES), read($, TICK)])

    return (
      <Box flexDirection="column">
        <Text>
          mode={mode} presses={presses} errors={errors} tick={tick}
        </Text>
        <Box display="none">
          <Button key="count" plain hotkey="x" label="Count" onPress={() => void update($, PRESSES, p => p + 1)} />
        </Box>
      </Box>
    )
  })
}

Claude Model

Not model-related.

Is this a regression?

Don't know.

Claude Code Version

2.1.295 (Claude Code)

Platform

Anthropic API

Operating System

macOS 15.7.3

Terminal/Shell

tmux 3.7c

Additional Information

Where it came up. The inbox plugin's ui.press hook read one state value before calling next(e). Its pane redraws often, so a hotkey press failed now and then.

Workaround. Make the decision while drawing instead. Give each Button the onPress it needs when the pane is drawn, so no ui.press hook has to await before next(e).

View original on GitHub ↗