SLOPSHOPPER

smooth-stream

Reveal streaming assistant replies with a smooth typewriter effect

newrowstimer
★ 1v0.2.0MITupdated 2026-10-05KyongSik-Yoon/claude-mods/smooth-stream
A shopper browsing a rack in a slop shop
README

smooth-stream

A Claude Code mod that reveals streaming assistant replies with a smooth typewriter effect, instead of dropping each chunk on screen at once.

  • ~30 frames per second, at least a couple of characters per frame
  • Catches up on large bursts within a fraction of a second, so it never falls far behind the model
  • Only replies that arrive while the model is streaming are animated; old messages redrawn on resize or scroll show whole
  • Works in the terminal and the desktop app's Code tab

Install

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

Options

Defaults to normal; no setup needed. To change it, run /plugin configure smooth-stream@kyongsik-mods (or open /config) and set Reveal speed:

ValueFeel
slow1+ char per frame, drains a burst in ~0.8 s
normal (default)2+ chars per frame, drains a burst in ~0.4 s
fast4+ chars per frame, drains a burst in ~0.2 s

How it works

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.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 87 lines
1import 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}
87
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'smooth-stream': { shown: StateFamily<number> }
4  }
5}
6