Repro: a Claude Code mod Input drops the newest typed character when a redraw draws a stale value

A Pane with an Input drawn with value={state.text}, updated in onInput, plus a 200 ms timer that invalidates ui.render (as a spinner or a live poll would). A redraw that lands between a keystroke and the mod hearing about it draws the older value, and the host appears to discard its edit buffer, so the newest character vanishes.
ui.render hook drawing a Pane with the Input elementFrom a terminal session, at the prompt:
/plugin install cc-mod-input-reset-repro --marketplace bassic-nerd/cc-mod-input-reset-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-input-reset-repro
claude --plugin-dir ./cc-mod-input-reset-repro
Then run the slash command /input-reset. It opens a pane and gives it the keyboard.
The whole mod is one file, hooks/register.tsx.
/input-reset. The pane shows a target sentence, an Input, a status line and a workaround toggle (off).the quick brown fox jumps over the lazy dog. Do it a few times; clear the field between attempts by pressing the toggle twice.ok while what you typed is still a prefix of the target, and MISMATCH at N once a character is missing.Every typed character lands in the field, in order, whether or not the mod redraws meanwhile.
MISMATCH: the newest character was dropped. It does not appear in the main prompt; it just vanishes.Input is drawn with value={state.text}, the value the mod last heard. Between a keystroke and onInput running, a timer redraw draws the previous value.Input is drawn with a constant value="", a seed that only changes programmatically (the toggle also gives the Input a fresh key). A redraw never carries a different value, and the typed text survives. The status line is still driven by onInput.So value is not only a seed: drawing a changed value appears to replace what the person has typed. The types describe value as "the text the field holds when drawn; the person's typing replaces it until the hook draws another", which allows this reading, but it makes a lagging value a data-loss hazard.
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 70 lines1import type { Register } from 'claude-code'
2
3const PANE = 'input-reset'
4const TARGET = 'the quick brown fox jumps over the lazy dog'
5
6// Module state is enough for a repro; a hot reload resets it.
7const state = { text: '', workaround: false, epoch: 0, timer: false }
8
9// First index where `text` stops being a prefix of TARGET, or -1 while it still is one.
10export const mismatchAt = (text: string): number => {
11 for (let i = 0; i < text.length; i++) if (text[i] !== TARGET[i]) return i
12 return -1
13}
14
15export const register: Register = on => {
16 on('session.start', async ($, e, next) => {
17 await $.command.register({ name: 'input-reset', description: 'Open the Input reset repro pane' })
18 return next(e)
19 })
20
21 on('command.run', { command: 'input-reset' }, async $ => {
22 await $.ui.open({ id: PANE, title: 'Input reset', focus: true })
23 if (!state.timer) {
24 state.timer = true
25 // A redraw every 200 ms, as a spinner or a live-refresh poll would cause.
26 $.clock.every(200, () => $.ui.invalidate('ui.render'))
27 }
28 return { text: 'Input reset pane opened. Type the target sentence at normal speed.' }
29 })
30
31 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e, next) => {
32 if (e.surface !== 'terminal') return next(e)
33 const { Box, Text, Input, Button } = $.ui.resolve(e)
34 const bad = mismatchAt(state.text)
35 // Bug mode draws the value the mod last heard. Workaround mode draws a seed that only
36 // changes programmatically (the toggle), so a redraw never carries a different value.
37 const value = state.workaround ? '' : state.text
38 return (
39 <Box flexDirection="column">
40 <Text dimColor>Type, at normal speed: {TARGET}</Text>
41 <Input
42 key={`text:${state.epoch}`}
43 autoFocus
44 placeholder="type here"
45 value={value}
46 onInput={v => {
47 state.text = v
48 $.ui.invalidate('ui.render')
49 }}
50 onSubmit={() => {}}
51 />
52 <Text>
53 typed {state.text.length} chars: {bad < 0 ? 'ok, still a prefix of the target' : `MISMATCH at ${bad} (a character was dropped)`}
54 </Text>
55 <Button
56 key="toggle"
57 onPress={() => {
58 state.workaround = !state.workaround
59 state.text = ''
60 state.epoch++
61 $.ui.invalidate('ui.render')
62 }}
63 >
64 workaround (constant seed): {state.workaround ? 'on' : 'off'}
65 </Button>
66 </Box>
67 )
68 })
69}
70