SLOPSHOPPER

ghost-typist

Types out the code the agent writes, live, in a side pane with keyboard sounds

newpanecommandstatustimeraudio
v0.2.1MITupdated 2026-10-08gaupoit/ghost-typist
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ghost-typist
│ ┃ Ghost Typist ✕ › fix the failing auth test and add an audit log call │ ┃ Ghost Typist │ ┃ Waiting for the agent to write some code… ⏺ 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 │ │ › /typist │ ⎿ ghost-typist: Ghost Typist pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ghost Typist
Ghost Typist Waiting for the agent to write some code…
README

Ghost Typist

A Claude Code mod that makes it feel like you are typing while the agent codes.

When the agent calls Write, Edit, MultiEdit, NotebookEdit or Bash, Ghost Typist catches the code in the model's stream as it is generated and types it out in a side pane at a human pace, with a cursor and keyboard sounds.

It only shows things: the agent never waits for the typing.

Ghost Typist guide: watch mode, then hacker mode

Full-resolution video: docs/guide.mp4. The recording is silent; the keyboard sounds play in your terminal.

Install

/plugin install ghost-typist --marketplace gaupoit/ghost-typist

The pane opens on its own when the agent starts writing, on a terminal at least 144 columns wide. On a narrower one, type /typist to open it.

Commands

CommandWhat it does
/typistOpen the pane
/typist on / offTurn typing on or off for this session
/typist mode hackerHacker Typer mode: you type it (see below)
/typist mode watchBack to typing by itself
/typist speed 140Base speed in words per minute
/typist sound clickyclicky, thock or off
/typist statusShow the current settings

Defaults are in /config (mode, speed, sound, which tools to type, auto-open).

Hacker Typer mode

/typist mode hacker opens the pane with the keyboard in it. Every letter or digit key you press types the next 3 to 5 characters of the agent's code (indentation comes for free). Stop pressing and the code waits for you. When the agent starts its next call, the one you were on finishes by itself, so you never fall behind. Esc returns to the prompt; click the pane or press ctrl+x tab to come back.

How it works

  • A turn.step hook passes every chunk of the model's stream on unchanged, and copies the tool call's argument pieces (incomplete JSON) into a queue.
  • A small partial-JSON reader pulls out content, new_string, new_source or command as it arrives, escapes and all.
  • In watch mode, a 50 ms ticker types it at the chosen speed with human timing: pauses at newlines and brackets, near-free indentation, random variation. It speeds up when it falls more than ~600 characters behind, and once the model finishes the call it types the rest within 8 seconds, so it never lags far behind the agent.
  • Sound is one looped clip, started and stopped with the typing (macOS afplay; Linux terminals stay silent).

Subagent calls are not typed. For Edit, only the new text is typed.

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .

The clips in sounds/ are synthesised (scripts/gen_sounds.py). The guide video is recorded with VHS: vhs docs/guide.tape.

License

MIT

Source 4 files
hooks/register.tsx 378 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { GhostJob, GhostSettings } from '../types'
5import { readToolText, TOOL_FIELDS } from './extract'
6import { advance, DEFAULT_PACE, measuredWpm, revealForKeys } from './typist'
7import type { Progress } from './typist'
8
9const PANE = 'ghost-typist'
10// Hacker mode listens through hidden Buttons, one per letter and digit: a
11// text field would keep every key typed into it on screen.
12const KEY_PREFIX = 'key-'
13const HOTKEYS = 'abcdefghijklmnopqrstuvwxyz0123456789'.split('')
14const TICK_MS = 50
15const SOUNDS = ['clicky', 'thock', 'off'] as const
16type Sound = (typeof SOUNDS)[number]
17const MODES = ['watch', 'hacker'] as const
18type Mode = (typeof MODES)[number]
19// In hacker mode the clicks stop this long after the last key.
20const KEY_SOUND_MS = 250
21
22const jobAtom = atom({ plugin: 'ghost-typist', key: 'job' } as const, null)
23const queuedAtom = atom({ plugin: 'ghost-typist', key: 'queued' } as const, 0)
24const settingsAtom = atom({ plugin: 'ghost-typist', key: 'settings' } as const, {
25  isEnabled: true,
26})
27
28// A tool call being typed out. `raw` is its arguments' JSON as it streams in.
29type Pending = {
30  id: string
31  tool: string
32  raw: string
33  parsedLength: number
34  text: string
35  path?: string
36  isStreamDone: boolean
37  progress: Progress
38  typingMs: number
39}
40
41const basename = (path: string) => path.split('/').at(-1) ?? path
42
43type Defaults = { wpm: number; sound: Sound; mode: Mode; tools: Set<string>; autoOpen: boolean }
44
45let defaults: Defaults = {
46  wpm: DEFAULT_PACE.wpm,
47  sound: 'clicky',
48  mode: 'watch',
49  tools: new Set(Object.keys(TOOL_FIELDS)),
50  autoOpen: true,
51}
52const queue: Pending[] = []
53let ticker: Timer | undefined
54let isTicking = false
55let sound: AbortController | undefined
56let soundOff: Timer | undefined
57let hasOpened = false
58
59const settingsOf = (s: GhostSettings) => ({
60  isEnabled: s.isEnabled,
61  wpm: s.wpm ?? defaults.wpm,
62  sound: s.sound ?? defaults.sound,
63  mode: s.mode ?? defaults.mode,
64})
65
66function setSound($: EngineInterface, isTyping: boolean, kind: Sound) {
67  if (isTyping && sound === undefined && kind !== 'off') {
68    sound = new AbortController()
69    $.audio
70      .play({ asset: `sounds/${kind}.wav` }, { shouldLoop: true, gain: 0.5, signal: sound.signal })
71      .catch(() => undefined)
72  } else if (!isTyping && sound !== undefined) {
73    sound.abort()
74    sound = undefined
75  }
76}
77
78function stop($: EngineInterface) {
79  ticker?.cancel()
80  ticker = undefined
81  setSound($, false, 'off')
82  $.ui.status(undefined)
83}
84
85function parse(job: Pending) {
86  if (job.raw.length === job.parsedLength) return
87  const read = readToolText(job.tool, job.raw)
88  job.text = job.tool === 'Bash' ? `$ ${read.text}` : read.text
89  job.path = read.path ?? job.path
90  job.parsedLength = job.raw.length
91}
92
93// Shows the job's progress, and moves on once it is fully typed.
94async function publish($: EngineInterface, job: Pending, mode: Mode) {
95  const isFinished = job.isStreamDone && job.progress.shown >= job.text.length
96  const name = job.path ? basename(job.path) : job.tool
97  $.ui.status(
98    isFinished ? undefined : mode === 'hacker' ? `⌨ hacker mode · type to write ${name}` : `⌨ typing ${name}`,
99  )
100
101  const snapshot: GhostJob = {
102    id: job.id,
103    tool: job.tool,
104    path: job.path,
105    text: job.text.slice(0, job.progress.shown),
106    total: job.text.length,
107    isStreamDone: job.isStreamDone,
108    wpm: measuredWpm(job.progress.shown, job.typingMs),
109  }
110  await update($, jobAtom, () => snapshot)
111  await update($, queuedAtom, () => Math.max(0, queue.length - 1))
112
113  // The last finished job stays on screen until the next one starts.
114  if (isFinished && queue[0] === job) {
115    queue.shift()
116    if (queue.length === 0) stop($)
117  }
118}
119
120async function tick($: EngineInterface) {
121  const job = queue[0]
122  if (job === undefined) return stop($)
123  parse(job)
124
125  const settings = settingsOf(await read($, settingsAtom))
126  // Hacker mode waits for keys, unless another call is waiting behind this one.
127  const isAuto = settings.mode === 'watch' || queue.length > 1
128  if (isAuto) {
129    const pace = {
130      ...DEFAULT_PACE,
131      wpm: settings.wpm,
132      finishWithinMs: queue.length > 1 ? 500 : DEFAULT_PACE.finishWithinMs,
133    }
134    const before = job.progress.shown
135    job.progress = advance(job.text, job.progress, TICK_MS, job.isStreamDone, pace, Math.random)
136    if (job.progress.shown > before) job.typingMs += TICK_MS
137  }
138  if (settings.mode === 'watch') {
139    setSound($, job.progress.shown < job.text.length, settings.sound)
140  }
141
142  await publish($, job, settings.mode)
143}
144
145// Hacker mode: the person pressed `presses` keys in the pane.
146async function pressKeys($: EngineInterface, presses: number) {
147  const job = queue[0]
148  if (job === undefined) return
149  parse(job)
150  const settings = settingsOf(await read($, settingsAtom))
151
152  const before = job.progress.shown
153  job.progress = { ...job.progress, shown: revealForKeys(job.text, before, presses, Math.random) }
154  // Count about one keystroke's time per press, so the footer shows a real WPM.
155  job.typingMs += presses * 120
156
157  setSound($, job.progress.shown > before, settings.sound)
158  soundOff?.cancel()
159  soundOff = $.clock.after(KEY_SOUND_MS, () => setSound($, false, 'off'))
160
161  await publish($, job, settings.mode)
162}
163
164function ensureTicker($: EngineInterface) {
165  if (ticker !== undefined) return
166  ticker = $.clock.every(TICK_MS, () => {
167    if (isTicking) return
168    isTicking = true
169    tick($)
170      .catch(() => undefined)
171      .finally(() => {
172        isTicking = false
173      })
174  })
175}
176
177
178export const register: Register = (on, options) => {
179  defaults = {
180    wpm: Number(options.wpm ?? DEFAULT_PACE.wpm),
181    sound: (SOUNDS.includes(options.sound as Sound) ? options.sound : 'clicky') as Sound,
182    tools: new Set(
183      String(options.tools ?? Object.keys(TOOL_FIELDS).join(','))
184        .split(',')
185        .map(t => t.trim())
186        .filter(t => t in TOOL_FIELDS),
187    ),
188    mode: (MODES.includes(options.mode as Mode) ? options.mode : 'watch') as Mode,
189    autoOpen: options.autoOpen !== false,
190  }
191
192  on('session.start', async ($, e, next) => {
193    await $.command.register({
194      name: 'typist',
195      description: 'Ghost Typist: open the pane, or on | off | mode watch|hacker | speed <wpm> | sound clicky|thock|off',
196    })
197
198    return next(e)
199  })
200
201  on('command.run', { command: 'typist' }, async ($, e) => {
202    const [word = '', value = ''] = e.args.trim().split(/\s+/)
203    const current = settingsOf(await read($, settingsAtom))
204
205    if (word === '' || word === 'open') {
206      await $.ui.open({ id: PANE, title: 'Ghost Typist' })
207      return { text: 'Ghost Typist pane opened.' }
208    }
209    if (word === 'on' || word === 'off') {
210      await update($, settingsAtom, s => ({ ...s, isEnabled: word === 'on' }))
211      if (word === 'off') stop($)
212      return { text: `Ghost Typist is ${word}.` }
213    }
214    if (word === 'speed') {
215      const wpm = Number(value)
216      if (!Number.isFinite(wpm) || wpm < 10 || wpm > 2000) {
217        return { text: `Speed must be a number of WPM from 10 to 2000 (now ${current.wpm}).` }
218      }
219      await update($, settingsAtom, s => ({ ...s, wpm }))
220      return { text: `Ghost Typist types at ${wpm} WPM.` }
221    }
222    if (word === 'mode') {
223      if (!MODES.includes(value as Mode)) {
224        return { text: `Mode is watch or hacker (now ${current.mode}).` }
225      }
226      await update($, settingsAtom, s => ({ ...s, mode: value as Mode }))
227      if (value === 'hacker') {
228        // Asked for, so the pane may take the keyboard.
229        await $.ui.open({ id: PANE, title: 'Ghost Typist', focus: true })
230        return { text: 'Hacker mode: each letter key you press in the pane types the agent\'s code. Esc returns to the prompt.' }
231      }
232      return { text: 'Watch mode: Ghost Typist types by itself.' }
233    }
234    if (word === 'sound') {
235      if (!SOUNDS.includes(value as Sound)) {
236        return { text: `Sound is one of ${SOUNDS.join(', ')} (now ${current.sound}).` }
237      }
238      await update($, settingsAtom, s => ({ ...s, sound: value as Sound }))
239      if (value === 'off') setSound($, false, 'off')
240      return { text: `Ghost Typist sound: ${value}.` }
241    }
242
243    return {
244      text: `Ghost Typist is ${current.isEnabled ? 'on' : 'off'} · ${current.mode} mode · ${current.wpm} WPM · sound ${current.sound}. Usage: /typist [open | on | off | mode watch|hacker | speed <wpm> | sound clicky|thock|off]`,
245    }
246  })
247
248  // Watches the model's stream and copies tool arguments into the queue.
249  // Every chunk is passed on unchanged.
250  on('turn.step', async function* ($, e, next) {
251    const stream = next(e)
252    const settings = await read($, settingsAtom)
253    if (!settings.isEnabled || e.agentId !== undefined) return yield* stream
254
255    const byIndex = new Map<number, Pending>()
256    const finishAll = () => {
257      for (const job of byIndex.values()) job.isStreamDone = true
258    }
259    try {
260      for await (const chunk of stream) {
261        if (chunk.kind === 'tool') {
262          finishAll()
263          if (defaults.tools.has(chunk.name)) {
264            const job: Pending = {
265              id: chunk.id,
266              tool: chunk.name,
267              raw: '',
268              parsedLength: -1,
269              text: '',
270              isStreamDone: false,
271              progress: { shown: 0, carry: 0 },
272              typingMs: 0,
273            }
274            byIndex.set(chunk.index, job)
275            queue.push(job)
276            if (defaults.autoOpen && !hasOpened) {
277              hasOpened = true
278              void $.ui.open({ id: PANE, title: 'Ghost Typist' })
279            }
280            ensureTicker($)
281          }
282        } else if (chunk.kind === 'input') {
283          const job = byIndex.get(chunk.index)
284          if (job !== undefined) job.raw += chunk.json
285        }
286        yield chunk
287      }
288    } finally {
289      finishAll()
290    }
291
292    return await stream.result
293  })
294
295  // Hacker mode: every edit of the pane's key field is a key press.
296  // Answered here: each key redraws the pane, so by the time the press
297  // would reach its Button the handler it was drawn with is gone.
298  on('ui.press', async ($, e, next) => {
299    if (e.plugin !== 'ghost-typist' || e.requestId !== PANE || !e.element.startsWith(KEY_PREFIX)) {
300      return next(e)
301    }
302    await pressKeys($, 1)
303
304    return { element: e.element }
305  })
306
307  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
308    const elements = $.ui.resolve(e)
309    const { Box, Text, Code } = elements
310    const job = await read($, jobAtom)
311    const queued = await read($, queuedAtom)
312    const { mode } = settingsOf(await read($, settingsAtom))
313    // Surfaces without buttons stay in watch mode.
314    const Button = mode === 'hacker' && 'Button' in elements ? elements.Button : undefined
315    const keyField =
316      Button === undefined ? null : (
317        <Box flexDirection="column">
318          <Text dimColor>
319            {e.props.isFocused ? 'mash any letter key…' : 'focus the pane (click, or ctrl+x tab) and mash letters'}
320          </Text>
321          <Box display="none">
322            {HOTKEYS.map(ch => (
323              <Button key={`${KEY_PREFIX}${ch}`} hotkey={ch} label={ch} onPress={() => undefined} />
324            ))}
325          </Box>
326        </Box>
327      )
328
329    if (job === null) {
330      return (
331        <Box flexDirection="column">
332          <Text bold>Ghost Typist</Text>
333          <Text dimColor>Waiting for the agent to write some code…</Text>
334          {keyField}
335        </Box>
336      )
337    }
338
339    const columns = Math.max(20, e.props.bodyColumns - 6)
340    const room = Math.max(3, e.props.scroll.bodyRows - (keyField === null ? 4 : 6))
341    const isTyping = !job.isStreamDone || job.text.length < job.total
342    // Keep the cursor in view: the tail of the text that fits, counting wrapped rows.
343    const lines = job.text.split('\n')
344    let used = 0
345    let first = lines.length
346    while (first > 0) {
347      const rows = Math.max(1, Math.ceil(lines[first - 1]!.length / columns))
348      if (used + rows > room) break
349      used += rows
350      first -= 1
351    }
352    const source = lines.slice(first).join('\n') + (isTyping ? '▌' : '')
353    const pct = job.total === 0 ? 0 : Math.round((job.text.length / job.total) * 100)
354
355    return (
356      <Box flexDirection="column">
357        <Box flexDirection="row" gap={1}>
358          <Text bold color="claude">
359            {job.tool}
360          </Text>
361          <Text>{job.path ?? ''}</Text>
362        </Box>
363        <Code
364          source={source}
365          path={job.path}
366          language={job.tool === 'Bash' ? 'bash' : undefined}
367          startLine={job.path !== undefined && job.tool === 'Write' ? first + 1 : undefined}
368        />
369        <Text dimColor>
370          {isTyping ? (Button === undefined ? 'typing' : 'hacker mode') : 'done'} · {job.wpm} WPM ·{' '}
371          {pct}%{queued > 0 ? ` · ${queued} queued` : ''}
372        </Text>
373        {keyField}
374      </Box>
375    )
376  })
377}
378
hooks/extract.ts 113 lines
1// Reads string fields out of a tool call's arguments while they are still
2// streaming, so the JSON is usually incomplete: `{"file_path":"a.ts","content":"imp`.
3
4export type Field = { value: string; isComplete: boolean }
5
6const ESCAPES: Record<string, string> = {
7  '"': '"',
8  '\\': '\\',
9  '/': '/',
10  b: '\b',
11  f: '\f',
12  n: '\n',
13  r: '\r',
14  t: '\t',
15}
16
17// Decodes the JSON string whose opening quote is at `start`. Stops cleanly
18// at the end of the input, dropping an escape that is cut in half.
19const readString = (raw: string, start: number): Field & { end: number } => {
20  let value = ''
21  let i = start + 1
22  while (i < raw.length) {
23    const ch = raw[i]!
24    if (ch === '"') return { value, isComplete: true, end: i + 1 }
25    if (ch !== '\\') {
26      value += ch
27      i += 1
28      continue
29    }
30    const esc = raw[i + 1]
31    if (esc === undefined) break
32    if (esc === 'u') {
33      const hex = raw.slice(i + 2, i + 6)
34      if (hex.length < 4) break
35      value += String.fromCharCode(parseInt(hex, 16))
36      i += 6
37      continue
38    }
39    value += ESCAPES[esc] ?? esc
40    i += 2
41  }
42
43  return { value, isComplete: false, end: raw.length }
44}
45
46// Every string value of `key`, at any depth, in order (MultiEdit's
47// `edits[].new_string` gives several). The last may be incomplete.
48export const extractFields = (raw: string, key: string): Field[] => {
49  const found: Field[] = []
50  const stack: Array<'{' | '['> = []
51  let isKeyNext = false
52  let lastKey: string | undefined
53  let i = 0
54  while (i < raw.length) {
55    const ch = raw[i]!
56    if (ch === '"') {
57      const str = readString(raw, i)
58      if (isKeyNext && stack.at(-1) === '{') {
59        lastKey = str.value
60        isKeyNext = false
61      } else if (lastKey === key) {
62        found.push({ value: str.value, isComplete: str.isComplete })
63      }
64      i = str.end
65      continue
66    }
67    if (ch === '{' || ch === '[') {
68      stack.push(ch)
69      isKeyNext = ch === '{'
70    } else if (ch === '}' || ch === ']') {
71      stack.pop()
72      isKeyNext = false
73    } else if (ch === ',') {
74      isKeyNext = stack.at(-1) === '{'
75    } else if (ch === ':') {
76      isKeyNext = false
77    }
78    i += 1
79  }
80
81  return found
82}
83
84export const extractField = (raw: string, key: string): Field | undefined =>
85  extractFields(raw, key)[0]
86
87// What each tool types, and where its target path lives.
88export const TOOL_FIELDS: Record<string, { text: string; path?: string }> = {
89  Write: { text: 'content', path: 'file_path' },
90  Edit: { text: 'new_string', path: 'file_path' },
91  MultiEdit: { text: 'new_string', path: 'file_path' },
92  NotebookEdit: { text: 'new_source', path: 'notebook_path' },
93  Bash: { text: 'command' },
94}
95
96export const EDIT_SEPARATOR = '\n\n⋯\n\n'
97
98// The text a tool call has typed so far, and whether its arguments are done.
99export const readToolText = (
100  tool: string,
101  raw: string,
102): { text: string; path?: string } => {
103  const spec = TOOL_FIELDS[tool]
104  if (spec === undefined) return { text: '' }
105  const parts = extractFields(raw, spec.text).map(f => f.value)
106  const path = spec.path === undefined ? undefined : extractField(raw, spec.path)
107
108  return {
109    text: parts.join(EDIT_SEPARATOR),
110    path: path?.isComplete ? path.value : undefined,
111  }
112}
113
hooks/typist.ts 92 lines
1// Pacing: how many characters a human-ish typist gets through in `dtMs`.
2// Pure, so tests drive it with a fixed random source.
3
4export type Pace = {
5  wpm: number
6  // How far behind the model the typist may fall before speeding up.
7  maxLagChars: number
8  // Once the model has finished the call, the rest is typed within this.
9  finishWithinMs: number
10}
11
12export const DEFAULT_PACE: Pace = { wpm: 110, maxLagChars: 600, finishWithinMs: 8000 }
13
14// Cost of one character, in units of a plain keystroke.
15export const charCost = (ch: string, prev: string | undefined, rand: number): number => {
16  const jitter = 0.6 + rand * 0.8
17  // Auto-indent: leading whitespace after a newline costs almost nothing.
18  if ((ch === ' ' || ch === '\t') && (prev === '\n' || prev === ' ' || prev === '\t')) {
19    return 0.08
20  }
21  if (ch === '\n') return 3.5 * jitter
22  if (';{}()'.includes(ch)) return 1.8 * jitter
23  if (ch === ' ') return 1.3 * jitter
24
25  return jitter
26}
27
28export type Progress = {
29  shown: number
30  // Keystroke budget carried over between ticks.
31  carry: number
32  // Time spent typing since the model finished the call.
33  sinceDoneMs?: number
34}
35
36export const advance = (
37  text: string,
38  progress: Progress,
39  dtMs: number,
40  isStreamDone: boolean,
41  pace: Pace,
42  random: () => number,
43): Progress => {
44  const left = text.length - progress.shown
45  if (left <= 0) return { shown: text.length, carry: 0 }
46
47  const keysPerMs = (pace.wpm * 5) / 60_000
48  const speedUp = Math.max(1, left / pace.maxLagChars)
49  let budget = progress.carry + dtMs * keysPerMs * speedUp
50  let shown = progress.shown
51  while (shown < text.length) {
52    const cost = charCost(text[shown]!, text[shown - 1], random())
53    if (cost > budget) break
54    budget -= cost
55    shown += 1
56  }
57
58  // Once the call is complete, spread what is left over the time remaining
59  // in the window, so it ends on time whatever the speed.
60  const sinceDoneMs = isStreamDone ? (progress.sinceDoneMs ?? 0) + dtMs : undefined
61  if (sinceDoneMs !== undefined) {
62    const remainingMs = Math.max(dtMs, pace.finishWithinMs - sinceDoneMs + dtMs)
63    shown = Math.max(shown, progress.shown + Math.ceil((left * dtMs) / remainingMs))
64  }
65  shown = Math.min(shown, text.length)
66
67  return { shown, carry: shown === text.length ? 0 : budget, sinceDoneMs }
68}
69
70// Words per minute actually reached, for the footer.
71export const measuredWpm = (chars: number, ms: number): number =>
72  ms <= 0 ? 0 : Math.round(chars / 5 / (ms / 60_000))
73
74// Hacker Typer mode: each key the person presses reveals a few characters,
75// and indentation after a newline comes along for free.
76export const revealForKeys = (
77  text: string,
78  shown: number,
79  presses: number,
80  random: () => number,
81): number => {
82  let next = shown
83  for (let i = 0; i < presses && next < text.length; i += 1) {
84    next += 3 + Math.floor(random() * 3)
85    if (text[next - 1] === '\n') {
86      while (next < text.length && /[ \t]/.test(text[next]!)) next += 1
87    }
88  }
89
90  return Math.min(next, text.length)
91}
92
types/index.d.ts 29 lines
1export type GhostJob = {
2  id: string
3  tool: string
4  path?: string
5  // The part typed out so far.
6  text: string
7  // Characters received from the model so far.
8  total: number
9  isStreamDone: boolean
10  wpm: number
11}
12
13export type GhostSettings = {
14  isEnabled: boolean
15  wpm?: number
16  sound?: 'clicky' | 'thock' | 'off'
17  mode?: 'watch' | 'hacker'
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'ghost-typist': {
23      job: GhostJob | null
24      queued: number
25      settings: GhostSettings
26    }
27  }
28}
29