[BUG] Plugin Pane: a press fails with "no handler is held under handle N" when the pane redraws while a ui.press hook awaits
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
- Load the plugin below with
claude --plugin-dir <folder>. - Run
/rows mode sleep, then/rows tick 50, then/rows. The pane opens with the keys and redraws every 50 ms. - Press
x60 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).