SLOPSHOPPER

cc-mod-focus-ring-repro

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

panecommandtimer
v0.1.0MITupdated 2026-10-16bassic-nerd/cc-mod-focus-ring-repro
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-mod-focus-ring-repro
│ ┃ Focus ring ✕ › fix the failing auth test and add an audit log call │ ┃ Type a prefix. The list below shrinks with │ ┃ each key. ⏺ Read(src/auth.ts) │ ┃ filter ⎿ Read 6 lines │ ┃ 16 match(es) ⏺ Update(src/auth.ts) │ ┃ [ alpha ] ⎿ Added 2 lines, removed 1 line │ ┃ [ alpine ] ⏺ Bash(bun test) │ ┃ [ amber ] ⎿ 3 pass, 1 fail │ ┃ [ anchor ] │ ┃ [ apple ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ apricot ] │ ┃ [ arrow ] ✻ Worked for 42s · done 4:20 PM │ ┃ [ aspen ] │ ┃ [ atlas ] › /focus-ring │ ┃ [ aurora ] ⎿ cc-mod-focus-ring-repro: Focus ring pane opened. Type a letter, │ ┃ [ autumn ] │ ┃ [ avenue ] │ ┃ [ azure ] │ ┃ [ badger ] │ ┃ [ basil ] │ ┃ [ beacon ] │ ┃ [ log: ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Focus ring
Type a prefix. The list below shrinks with each key. filter 16 match(es) [ alpha ] [ alpine ] [ amber ] [ anchor ] [ apple ] [ apricot ] [ arrow ] [ aspen ] [ atlas ] [ aurora ] [ autumn ] [ avenue ] [ azure ] [ badger ] [ basil ] [ beacon ] [ log: ]
README

cc-mod-focus-ring-repro

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.

Environment

  • Claude Code 2.1.300 (still present in 2.1.302), terminal surface
  • macOS; seen in Zed's integrated terminal
  • Mod API: ui.render hook drawing a Pane with the Input element

Install and run

From 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.

Repro steps

  1. Run /focus-ring. The pane shows a filter Input and 16 buttons (alpha, alpine, amber, ...).
  2. Press the 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.
  3. Type a prefix one key at a time, at an ordinary pace: a, l, p, i, n, e. The list shrinks with each key.
  4. Keep typing. Watch whether a key stops appearing in the filter and appears in the main prompt instead.

The failure is intermittent. If it does not show, delete the field and type again, or hold a key down.

Expected

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.

Actual

  • After a redraw that removes buttons from the list, the focus ring is on nothing. The pane still reports isFocused: true.
  • The next printable key is not delivered to the Input: it goes to the main prompt.
  • Asking for the ring back with $.ui.focus({ requestId, key }) restores typing.

Reading the log

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.

Checks

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.

Source 1 files
hooks/register.tsx 96 lines
1import 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