Repro: a Claude Code mod pane's focus ring lands on nothing after a keystroke-driven redraw shrinks the focusable list

A Pane with an Input followed by a list of Buttons. Each keystroke filters the list, so the number of focusable buttons changes while you type. After such a redraw the focus ring can be left on nothing while $.ui.panes() still reports the pane as isFocused, and the next printable key goes to the main prompt.
ui.render hook drawing a Pane with the Input elementFrom a terminal session, at the prompt:
/plugin install cc-mod-focus-ring-repro --marketplace bassic-nerd/cc-mod-focus-ring-repro
Answer y to add the marketplace, then pick a scope. Or load it straight from a clone, for one session:
git clone https://github.com/bassic-nerd/cc-mod-focus-ring-repro
claude --plugin-dir ./cc-mod-focus-ring-repro
Then run the slash command /focus-ring. It opens a pane and gives it the keyboard.
The whole mod is one file, hooks/register.tsx.
/focus-ring. The pane shows a filter Input and 16 buttons (alpha, alpine, amber, ...).log: off button, so it reads log: on. The log (below) lists isFocused changes and every ui.input. Then click or tab back into the filter field.a, l, p, i, n, e. The list shrinks with each key.The failure is intermittent. If it does not show, delete the field and type again, or hold a key down.
The Input keeps the focus ring across redraws while it is still drawn under the same key, or the pane reports not focused when the ring is on nothing.
isFocused: true.Input: it goes to the main prompt.$.ui.focus({ requestId, key }) restores typing.Turn the log on with the log: on button. It draws the last 8 lines in the pane and records:
isFocused=<bool>: the pane's isFocused from $.ui.panes(), polled every 100 ms, written when it changes.ui.input <element> change len=<n>: every edit the Input raises.A key that reaches the Input shows as a ui.input line with len up by one. A key that goes to the prompt shows as no ui.input line while isFocused=true is still the last isFocused line. The log itself redraws the pane, which the reporter should keep in mind; the bug does not need it.
claude plugin validate .
claude plugin test .
tsc --noEmit -p .
(the last needs the API types in .claude-plugin/types/, which Claude Code lays beside a mod it loads from a folder.)
License: MIT.
hooks/register.tsx 96 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const PANE = 'focus-ring'
4const ITEMS = [
5 'alpha', 'alpine', 'amber', 'anchor', 'apple', 'apricot', 'arrow', 'aspen',
6 'atlas', 'aurora', 'autumn', 'avenue', 'azure', 'badger', 'basil', 'beacon',
7]
8const LOG_ROWS = 8
9
10// Module state is enough for a repro; a hot reload resets it.
11const state = { text: '', log: false, lines: [] as string[], focused: null as boolean | null, polling: false }
12
13export const filtered = (text: string): string[] => ITEMS.filter(i => i.startsWith(text.toLowerCase()))
14
15function note($: Pick<EngineInterface, 'ui'>, line: string): void {
16 state.lines = [...state.lines, line].slice(-LOG_ROWS)
17 $.ui.invalidate('ui.render')
18}
19
20export const register: Register = on => {
21 on('session.start', async ($, e, next) => {
22 await $.command.register({ name: 'focus-ring', description: 'Open the focus ring repro pane' })
23 return next(e)
24 })
25
26 on('command.run', { command: 'focus-ring' }, async $ => {
27 await $.ui.open({ id: PANE, title: 'Focus ring', focus: true })
28 if (!state.polling) {
29 state.polling = true
30 // Opt-in log source 1: the pane's isFocused, polled.
31 $.clock.every(100, async () => {
32 if (!state.log) return
33 const pane = (await $.ui.panes()).find(p => p.id === PANE)
34 const focused = pane ? pane.isFocused : null
35 if (focused !== state.focused) {
36 state.focused = focused
37 note($, `isFocused=${focused}`)
38 }
39 })
40 }
41 return { text: 'Focus ring pane opened. Type a letter, e.g. "a", then "al", "alp".' }
42 })
43
44 // Opt-in log source 2: each ui.input the pane's Input raises.
45 on('ui.input', { requestId: PANE }, ($, e, next) => {
46 if (state.log) note($, `ui.input ${e.element} ${e.kind} len=${e.value.length}`)
47 return next(e)
48 })
49
50 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e, next) => {
51 if (e.surface !== 'terminal') return next(e)
52 const { Box, Text, Input, Button } = $.ui.resolve(e)
53 const rows = filtered(state.text)
54 return (
55 <Box flexDirection="column">
56 <Text dimColor>Type a prefix. The list below shrinks with each key.</Text>
57 <Input
58 key="filter"
59 autoFocus
60 placeholder="filter"
61 value=""
62 onInput={v => {
63 state.text = v
64 $.ui.invalidate('ui.render')
65 }}
66 onSubmit={() => {}}
67 />
68 <Text dimColor>{rows.length} match(es)</Text>
69 {rows.map(name => (
70 <Button key={`row:${name}`} onPress={() => $.ui.log(`pressed ${name}`)}>
71 {name}
72 </Button>
73 ))}
74 <Button
75 key="log"
76 onPress={() => {
77 state.log = !state.log
78 state.lines = []
79 state.focused = null
80 $.ui.invalidate('ui.render')
81 }}
82 >
83 log: {state.log ? 'on' : 'off'}
84 </Button>
85 {state.log && (
86 <Box flexDirection="column" height={LOG_ROWS}>
87 {state.lines.map((l, i) => (
88 <Text key={`l${i}`} dimColor>{l}</Text>
89 ))}
90 </Box>
91 )}
92 </Box>
93 )
94 })
95}
96