SLOPSHOPPER

keystroke

/keystroke: compose replies through type/backspace/send tool calls, a few characters per call; the reply still lands as a normal assistant message

bandrowsguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keystroke
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keystroke ⎿ keystroke: keystroke is off. /keystroke on (1 character per call) | <n> (n characters per call) | off | status ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

keystroke

A Claude Code mod (function hooks plugin) for one experiment: does Claude write differently when each reply is composed a few characters at a time?

With the mode on, the model does not write its reply as text. It calls three tools:

toolwhat it does
mcp__keystroke__typeappends text to the draft, at most N characters per call (N = 1 by default), one call per response
mcp__keystroke__backspacedeletes the last count characters
mcp__keystroke__senddelivers the draft as the reply and ends the turn

To the person the session looks almost unchanged: the keystroke tool rows are hidden, the draft is drawn live in the band above the prompt with a cursor, and the sent reply arrives as an ordinary assistant message. Under the hood that message was never produced by a model call: after send, the mod answers the next model step itself, yielding the draft as the step's text with end_turn, so the transcript holds a normal assistant text row and the next request carries a valid tool_use → tool_result → assistant text sequence.

Use

/keystroke on        # 1 character per type() call
/keystroke 5         # 5 characters per call
/keystroke off
/keystroke status

The setting is kept per session id in the plugin store, so claude -r keeps it. The status line shows keystroke N/call while it is on.

What the mod enforces

  • Chunk size. A type call longer than N characters is refused and nothing is typed.
  • One keystroke per response. Models batch: Sonnet 5.5 put a whole 56-character greeting into one response as 56 type calls, which is the message written in one go. Of several keystroke calls in one response only the first is typed; the rest are refused with a reason. After one refused batch the model goes one call per response.
  • No direct text. A classic.Stop hook blocks a turn that ends with direct text or an unsent draft, at most twice per turn, so a session where the tools are unavailable still gets an answer on the third try.
  • No narration. Text in a response that also makes a keystroke call ("still typing, two letters left") is held back and dropped from the record. Text in a response that makes no keystroke call is shown, and the stop hook decides what to do with it.
  • Main conversation only. The tools refuse calls from subagent loops, and the system prompt section says so.

How it is built

One hooks module, hooks/register.tsx:

  • session.start registers the tools (isDeferred follows the mode) and the command, and loads the mode from $.store.
  • prompt.compose appends a keystroke:mode section to the system prompt while on.
  • tool.call serves the three tools from $.state (keystroke.draft); tool.check allows them without a prompt.
  • turn.step (streaming) records the response's keystroke calls for the one-per-response rule, drops narration, and, on the step after send, answers without a model call.
  • ui.render draws an empty Box for keystroke ToolUse/ToolResult/ToolGroup rows and the draft in AbovePrompt.

Cost and speed

Every character is one model request against the session's cached prefix plus two transcript rows (about 60 tokens). Measured with Sonnet 5.5 at medium effort on 2.1.293, thinking between keystrokes: about 3 s per character (17 characters in 58 s, 41 in two minutes), so a 50-character reply takes two to three minutes and adds roughly 3k tokens to the context. That is the experiment, not a flaw, but it rules out chunk 1 for long replies; /keystroke 5 or more is the usable setting for anything beyond a sentence.

Tests

claude plugin test <this folder> runs hooks/register.test.ts (parsing, the draft view, type/backspace/send, the chunk rule, the stop hook, the synthesized step, the narration rule). The one-per-response rule depends on tool_use ids the test kit assigns itself, so it is covered by an interactive probe instead.

Source 2 files
hooks/register.tsx 305 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnStepChunk } from 'claude-code'
3
4import type { KeystrokeMode } from '../types'
5
6// Keystroke mode: the model composes its reply through three tools (type, backspace,
7// send) instead of writing text directly, at most `chunk` characters per `type` call.
8// The per-keystroke tool rows are hidden, the draft is drawn live in the band above the
9// prompt, and once `send` runs the next model step is answered by this module: the
10// draft is yielded as the step's text and the turn ends, so the transcript holds a
11// normal assistant message that no model call produced.
12
13const PLUGIN = 'keystroke'
14const COMMAND = 'keystroke'
15const TOOLS = ['type', 'backspace', 'send'] as const
16type ToolName = (typeof TOOLS)[number]
17const fullName = (t: ToolName) => `mcp__${PLUGIN}__${t}`
18const isOurs = (tool: unknown) => typeof tool === 'string' && tool.startsWith(`mcp__${PLUGIN}__`)
19
20const OFF: KeystrokeMode = { on: false, chunk: 1 }
21const MAX_BLOCKS_PER_TURN = 2
22const VIEW_CHARS = 60
23
24const mode = atom({ plugin: 'keystroke', key: 'mode' } as const, OFF)
25const draft = atom({ plugin: 'keystroke', key: 'draft' } as const, '')
26
27// Per-turn bookkeeping. Module variables are fine here: a hot reload mid-turn loses
28// them, and the worst case is one unblocked direct-text turn.
29let pendingSend: string | null = null
30let synthesizedThisTurn = false
31let blocksThisTurn = 0
32// The keystroke calls of the latest main-loop response, in order. Only the first is
33// typed: a response holding a whole message as 50 calls is the message written in one
34// go, which is what the mode exists to prevent.
35let stepKeystrokes: string[] = []
36const ONE_PER_RESPONSE = 'one keystroke per response: this call was not typed. Make one keystroke call, read its result, then the next.'
37
38const chars = (s: string) => [...s]
39const count = (s: string) => chars(s).length
40const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
41
42export function parseArgs(args: string): KeystrokeMode | 'status' | undefined {
43  const a = args.trim().toLowerCase()
44  if (a === '' || a === 'status') return 'status'
45  if (a === 'off') return OFF
46  if (a === 'on') return { on: true, chunk: 1 }
47  const m = /^(\d+)$/.exec(a)
48  if (!m) return undefined
49  const n = Number(m[1])
50  return n >= 1 && n <= 10000 ? { on: true, chunk: n } : undefined
51}
52
53// What `type` and `backspace` answer: the end of the current line with a cursor, the
54// glance a typist takes at the field. The full draft is already in the model's context
55// as its own calls.
56export function view(d: string): string {
57  const line = d.slice(d.lastIndexOf('\n') + 1)
58  const cs = chars(line)
59  const tail = cs.slice(-VIEW_CHARS).join('')
60  return `${cs.length > VIEW_CHARS ? '…' : ''}${tail}▍`
61}
62
63export function section(m: KeystrokeMode): string {
64  return [
65    '# Keystroke mode',
66    '',
67    'Your replies to the user are composed through three tools instead of written directly:',
68    `- \`${fullName('type')}\` appends text to your draft, at most ${plural(m.chunk, 'character')} per call.`,
69    `- \`${fullName('backspace')}\` deletes the last characters of the draft.`,
70    `- \`${fullName('send')}\` delivers the draft as your reply and ends your turn.`,
71    '',
72    'One keystroke per response: each call answers with the draft so far before you choose the next one; of several keystroke calls in one response only the first is typed. Text written outside these tools is not a reply: a turn that ends with direct text, or with an unsent draft, is handed back to you to finish through the tools. Other tools work as usual. Only the main conversation composes this way; subagents reply normally.',
73  ].join('\n')
74}
75
76const storeKey = async ($: EngineInterface) => `mode:${await $.session.id()}`
77
78async function loadMode($: EngineInterface): Promise<KeystrokeMode> {
79  const v = (await $.store.get(await storeKey($))) as Partial<KeystrokeMode> | undefined
80  return v && typeof v.on === 'boolean' && typeof v.chunk === 'number' ? { on: v.on, chunk: v.chunk } : OFF
81}
82
83async function setMode($: EngineInterface, m: KeystrokeMode): Promise<void> {
84  await $.store.set(await storeKey($), m)
85  await update($, mode, () => m)
86  $.ui.status(m.on ? `keystroke ${m.chunk}/call` : undefined)
87  // The system prompt section and the tools' deferral read the mode; both are cached.
88  $.ui.invalidate('tool.describe')
89  $.ui.invalidate('prompt.section')
90}
91
92function describe(t: ToolName, m: KeystrokeMode): string {
93  switch (t) {
94    case 'type':
95      return `Append text to your draft reply: at most ${plural(m.chunk, 'character')} per call, one call per response. Answers with the end of the current line and a cursor.`
96    case 'backspace':
97      return 'Delete the last `count` characters of your draft reply (default 1). Answers with the end of the current line and a cursor.'
98    case 'send':
99      return 'Deliver your draft as the reply to the user and end your turn.'
100  }
101}
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    const m = await loadMode($)
106    await update($, mode, () => m)
107    await $.tool.register({
108      name: 'type',
109      description: describe('type', m),
110      inputSchema: {
111        type: 'object',
112        properties: { text: { type: 'string', description: 'The characters to append.' } },
113        required: ['text'],
114      },
115      isDeferred: !m.on,
116    })
117    await $.tool.register({
118      name: 'backspace',
119      description: describe('backspace', m),
120      inputSchema: {
121        type: 'object',
122        properties: { count: { type: 'integer', minimum: 1, description: 'How many characters to delete (default 1).' } },
123      },
124      isDeferred: !m.on,
125    })
126    await $.tool.register({
127      name: 'send',
128      description: describe('send', m),
129      inputSchema: { type: 'object', properties: {} },
130      isDeferred: !m.on,
131    })
132    await $.command.register({
133      name: COMMAND,
134      description: 'Compose replies through keystroke tools, a few characters per call',
135      argumentHint: 'on | off | <characters per call> | status',
136    })
137    $.ui.status(m.on ? `keystroke ${m.chunk}/call` : undefined)
138    return next(e)
139  })
140
141  on('command.run', { command: COMMAND }, async ($, e) => {
142    const parsed = parseArgs(e.args)
143    const usage = `/${COMMAND} on (1 character per call) | <n> (n characters per call) | off | status`
144    if (parsed === undefined) return { text: `Could not read "${e.args.trim()}". ${usage}` }
145    if (parsed === 'status') {
146      const m = await read($, mode)
147      return { text: m.on ? `keystroke is on: ${plural(m.chunk, 'character')} per type() call. ${usage}` : `keystroke is off. ${usage}` }
148    }
149    await setMode($, parsed)
150    pendingSend = null
151    await update($, draft, () => '')
152    return {
153      text: parsed.on
154        ? `keystroke on: replies are now composed through type (${plural(parsed.chunk, 'character')} per call), backspace and send.`
155        : 'keystroke off: replies are written directly again.',
156    }
157  })
158
159  for (const t of TOOLS) {
160    on('tool.describe', { tool: fullName(t) }, async ($, e, next) => {
161      const r = await next(e)
162      const m = await read($, mode)
163      return { ...r, description: describe(t, m), isDeferred: !m.on }
164    })
165    // Our own tools never need the person's approval.
166    on('tool.check', { tool: fullName(t) }, () => ({ decision: 'allow' }))
167  }
168
169  on('prompt.compose', async ($, e, next) => {
170    const r = await next(e)
171    const m = await read($, mode)
172    if (!m.on || e.traits.includes('teammate')) return r
173    return { sections: [...r.sections, { id: `${PLUGIN}:mode`, text: section(m), scope: 'session' }] }
174  })
175
176  const notFirst = (id: string) => stepKeystrokes.length > 1 && stepKeystrokes[0] !== id
177
178  on('tool.call', { tool: fullName('type') }, async ($, e) => {
179    if (e.agentId !== undefined) return { deny: 'keystroke tools serve the main conversation only; reply normally.' }
180    const m = await read($, mode)
181    if (!m.on) return { deny: 'keystroke mode is off; write your reply directly.' }
182    if (notFirst(e.tool_use_id)) return { deny: ONE_PER_RESPONSE }
183    const text = String((e as { text?: unknown }).text ?? '')
184    const n = count(text)
185    if (n === 0) return { deny: 'nothing to type: pass text.' }
186    if (n > m.chunk) {
187      return { deny: `one type() call takes at most ${plural(m.chunk, 'character')}; this one had ${n}. Nothing was typed; resend in smaller pieces.` }
188    }
189    await update($, draft, d => d + text)
190    return { result: view(await read($, draft)) }
191  })
192
193  on('tool.call', { tool: fullName('backspace') }, async ($, e) => {
194    if (e.agentId !== undefined) return { deny: 'keystroke tools serve the main conversation only; reply normally.' }
195    const m = await read($, mode)
196    if (!m.on) return { deny: 'keystroke mode is off; write your reply directly.' }
197    if (notFirst(e.tool_use_id)) return { deny: ONE_PER_RESPONSE }
198    const raw = Number((e as { count?: unknown }).count ?? 1)
199    const k = Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1
200    await update($, draft, d => chars(d).slice(0, Math.max(0, count(d) - k)).join(''))
201    return { result: view(await read($, draft)) }
202  })
203
204  on('tool.call', { tool: fullName('send') }, async ($, e) => {
205    if (e.agentId !== undefined) return { deny: 'keystroke tools serve the main conversation only; reply normally.' }
206    const m = await read($, mode)
207    if (!m.on) return { deny: 'keystroke mode is off; write your reply directly.' }
208    if (notFirst(e.tool_use_id)) return { deny: ONE_PER_RESPONSE }
209    const d = await read($, draft)
210    if (d.trim() === '') return { deny: 'the draft is empty; type the reply first.' }
211    pendingSend = d
212    return { result: `sent (${plural(count(d), 'character')}). Your turn ends here.` }
213  })
214
215  on('turn.start', async ($, e, next) => {
216    pendingSend = null
217    synthesizedThisTurn = false
218    blocksThisTurn = 0
219    await update($, draft, () => '')
220    return next(e)
221  })
222
223  // The step after `send`: no model call; the draft becomes the step's text.
224  on('turn.step', async function* ($, e, next) {
225    if (e.agentId !== undefined) return yield* next(e)
226    if (pendingSend === null) {
227      stepKeystrokes = []
228      const m = await read($, mode)
229      if (!m.on) return yield* next(e)
230      // Text in a response that also makes a keystroke call is narration ("still
231      // typing..."), not a reply: it is held back and dropped. Text in a response with
232      // no keystroke call is shown, and the stop hook decides what to do about it.
233      const held: TurnStepChunk[] = []
234      for await (const c of next(e)) {
235        if (c.kind === 'tool' && isOurs(c.name)) stepKeystrokes.push(c.id)
236        if (c.kind === 'text') {
237          held.push(c)
238          continue
239        }
240        if (c.kind === 'stop' && stepKeystrokes.length === 0) yield* held
241        yield c
242      }
243      return
244    }
245    const text = pendingSend
246    pendingSend = null
247    synthesizedThisTurn = true
248    await update($, draft, () => '')
249    yield { kind: 'text', index: 0, text }
250    yield { kind: 'stop', stopReason: 'end_turn', usage: null }
251    return { turnId: e.turnId, index: e.index, answer: text, toolUses: [], stopReason: 'end_turn', usage: null }
252  })
253
254  on('classic.Stop', async ($, e, next) => {
255    const m = await read($, mode)
256    if (!m.on || synthesizedThisTurn || blocksThisTurn >= MAX_BLOCKS_PER_TURN) return next(e)
257    const d = await read($, draft)
258    const direct = (e.last_assistant_message ?? '').trim()
259    if (d.trim() !== '') {
260      blocksThisTurn += 1
261      return { block: `keystroke mode: your draft (${plural(count(d), 'character')}) was not sent. Keep typing with ${fullName('type')} or deliver it with ${fullName('send')}.` }
262    }
263    if (direct !== '') {
264      blocksThisTurn += 1
265      return {
266        block: `keystroke mode is on: the ${plural(count(direct), 'character')} you wrote directly are not a reply. Compose it with ${fullName('type')} (at most ${plural(m.chunk, 'character')} per call) and finish with ${fullName('send')}.`,
267      }
268    }
269    return next(e)
270  })
271
272  // Keystroke rows are noise in the transcript: the draft is drawn in the band instead,
273  // and the sent reply arrives as an assistant message.
274  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
275    if (!isOurs(e.props.tool)) return next(e)
276    const { Box } = $.ui.resolve(e)
277    return <Box />
278  })
279  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
280    if (!isOurs(e.props.tool)) return next(e)
281    const { Box } = $.ui.resolve(e)
282    return <Box />
283  })
284  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
285    if (!e.props.calls.every(c => isOurs(c.tool))) return next(e)
286    const { Box } = $.ui.resolve(e)
287    return <Box />
288  })
289
290  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
291    const m = await read($, mode)
292    const d = await read($, draft)
293    if (!m.on || d === '' || e.props.hasSurvey) return next(e)
294    const { Box, Text } = $.ui.resolve(e)
295    return (
296      <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
297        <Text dimColor>
298          draft · {plural(count(d), 'character')} · {m.chunk}/call
299        </Text>
300        <Text wrap="wrap">{d}▍</Text>
301      </Box>
302    )
303  })
304}
305
types/index.d.ts 8 lines
1export type KeystrokeMode = { on: boolean; chunk: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    keystroke: { mode: KeystrokeMode; draft: string }
6  }
7}
8