Reveal streaming assistant replies with a smooth typewriter effect

A Claude Code mod that reveals streaming assistant replies with a smooth typewriter effect, instead of dropping each chunk on screen at once.
claude plugin marketplace add KyongSik-Yoon/claude-mods
claude plugin install smooth-stream@kyongsik-mods
Then run /reload-plugins or start a new session. Requires a Claude Code build with mods support (tested on 2.1.289).
Defaults to normal; no setup needed. To change it, run /plugin configure smooth-stream@kyongsik-mods (or open /config) and set Reveal speed:
| Value | Feel |
|---|---|
slow | 1+ char per frame, drains a burst in ~0.8 s |
normal (default) | 2+ chars per frame, drains a burst in ~0.4 s |
fast | 4+ chars per frame, drains a burst in ~0.2 s |
A ui.render hook on AssistantMessage hands the engine a prefix of the reply text, and a 33 ms $.clock timer grows that prefix through $.state, so each frame redraws only the message being typed. A turn.step hook tracks whether the main loop is streaming, which is how already-finished messages are left alone.
claude plugin validate .
claude plugin test .hooks/register.tsx 87 lines1import type { Register } from 'claude-code'
2
3const SHOWN = { plugin: 'smooth-stream', key: 'shown' } as const
4
5// ~30 frames/s. Each frame reveals at least minStep chars, more when the
6// backlog is large, so a burst of lines drains within catchUpFrames.
7const FRAME_MS = 33
8
9type Pace = { minStep: number; catchUpFrames: number }
10
11export const PACES = {
12 slow: { minStep: 1, catchUpFrames: 24 },
13 normal: { minStep: 2, catchUpFrames: 12 },
14 fast: { minStep: 4, catchUpFrames: 6 },
15} satisfies Record<string, Pace>
16
17type Track = { target: number; shown: number }
18
19// Never cut between the halves of a surrogate pair.
20export const cut = (text: string, n: number): string => {
21 const code = text.charCodeAt(n - 1)
22 return text.slice(0, code >= 0xd800 && code <= 0xdbff ? n - 1 : n)
23}
24
25export const step = (t: Track, pace: Pace = PACES.normal): number =>
26 Math.min(t.target, t.shown + Math.max(pace.minStep, Math.ceil((t.target - t.shown) / pace.catchUpFrames)))
27
28export const register: Register = (on, options) => {
29 const pace: Pace = Object.hasOwn(PACES, String(options.speed)) ? PACES[options.speed as keyof typeof PACES] : PACES.normal
30 // Messages still being revealed, by message id.
31 const tracks = new Map<string, Track>()
32 // Messages drawn whole; a redraw (resize, scroll) must not replay them.
33 const done = new Set<string>()
34 // Main-loop model requests in flight: a message first drawn then is streaming.
35 let live = 0
36
37 on('session.start', ($, e, next) => {
38 $.clock.every(FRAME_MS, () => {
39 for (const [id, t] of tracks) {
40 if (t.shown >= t.target) {
41 if (live === 0) {
42 tracks.delete(id)
43 done.add(id)
44 }
45 continue
46 }
47 t.shown = step(t, pace)
48 void $.state.set({ ...SHOWN, id }, t.shown)
49 }
50 })
51
52 return next(e)
53 })
54
55 on('turn.step', async function* ($, e, next) {
56 if (e.agentId) return yield* next(e)
57 live += 1
58 try {
59 return yield* next(e)
60 } finally {
61 live -= 1
62 }
63 })
64
65 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
66 const id = e.requestId
67 const full = e.props.text
68 let t = tracks.get(id)
69
70 if (!t) {
71 if (live === 0 || done.has(id)) {
72 done.add(id)
73 return next(e)
74 }
75 t = { target: full.length, shown: 0 }
76 tracks.set(id, t)
77 }
78 t.target = full.length
79
80 // Subscribes this message to its own counter: each frame redraws it alone.
81 const { value } = await $.state.get({ ...SHOWN, id })
82 const n = Math.min(value ?? t.shown, full.length)
83
84 return n >= full.length ? next(e) : next({ ...e, props: { ...e.props, text: cut(full, n) } })
85 })
86}
87types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'smooth-stream': { shown: StateFamily<number> }
4 }
5}
6