SLOPSHOPPER

Doomscroll

A TikTok-style video feed in a pane beside Claude Code. It plays while Claude works, scrolls with your mouse wheel, and pauses when Claude is done.

newpanespinnercommandtoastprocess
v1.0.2MITupdated 2026-10-02teatimedev/doomscroll
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · doomscroll
│ ┃ For You ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ Doomscroll needs Ghostty or kitty │ ┃ ♥ ⏺ Read(src/auth.ts) │ ┃ ❞ ⎿ Read 6 lines │ ┃ ⚑ ⏺ Update(src/auth.ts) │ ┃ ➦ ⎿ Added 2 lines, removed 1 line │ ┃ ──────────────────────────────── ⏺ Bash(bun test) │ ┃ The Doomscroll player keeps stopping. ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /doomscroll │ ┃ ⎿ doomscroll: Doomscroll is open. Scroll over it for the next vide │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · For You
▣ Doomscroll needs Ghostty or kitty ♥ - ❞ - ⚑ - ➦ - ──────────────────────────────── The Doomscroll player keeps stopping. It…
README

Doomscroll

A TikTok-style video feed in a pane beside Claude Code. It opens when Claude starts working, plays while Claude works, swipes to the next video with your mouse wheel, and pauses the moment Claude is done: Claude's done. Back to work.

It is a Claude Code mod: a plugin of function hooks that draws its own pane. The video is real video, decoded with ffmpeg and drawn with the kitty graphics protocol, at 30 frames a second, with sound.

What you get

  • A For You feed. About 70 creators across comedy, memes, slop and oddly satisfying videos, animals, sports, unhinged brand accounts, TV clips and streamers. Each session samples a fresh mix, and you can add your own creators.
  • TikTok's swipe. The wheel or trackpad over the pane slides to the next video, the way the app does. Upcoming videos download ahead, so the next one is already there.
  • It knows when to stop. The pane plays while Claude works and pauses when the turn ends. The spinner keeps count: Sautéing · 4 TikToks deep…
  • A familiar layout. The video sits centred with likes, comments, saves and shares beside it, the creator and caption underneath, and a progress bar.

Requirements

Claude Codev2.1.287 or later (mods were introduced in that version)
TerminalGhostty or kitty. The video uses kitty graphics with Unicode placeholders. Other terminals show a note instead of the picture.
OSmacOS or Linux. Windows isn't supported.
Tools on your PATHpython3 (3.8 or later), ffmpeg and ffprobe, and yt-dlp

Install the tools:

# macOS
brew install ffmpeg yt-dlp

# Debian / Ubuntu (other distros: your package manager)
sudo apt install ffmpeg python3 pipx && pipx install yt-dlp

Keep yt-dlp up to date (brew upgrade yt-dlp or pipx upgrade yt-dlp), because TikTok changes often.

For the full effect, use Claude Code's fullscreen layout, where the pane docks beside the transcript. Set "tui": "fullscreen" in ~/.claude/settings.json. A dark Claude Code theme (/theme) in a dark terminal looks best. The pane takes your terminal's background colour, read from your Ghostty or kitty config.

Install

claude plugin marketplace add teatimedev/doomscroll
claude plugin install doomscroll@doomscroll

Or from inside a session: /plugin install doomscroll --marketplace teatimedev/doomscroll. Run /reload-plugins in a session that is already open.

To try it for one session without installing, clone the repository and run claude --plugin-dir ./doomscroll.

Use it

Ask Claude for something. The pane opens on the right as soon as Claude starts working. On terminals narrower than 144 columns it waits, and /doomscroll opens it at any width.

Do thisTo
Scroll the wheel or trackpad over the paneGo to the next or previous video
Click the pane, then j / kNext / previous
pPause or play, including after Claude is done if you want to keep watching
mMute or unmute (remembered)
lLike: saves the video to /doomscroll likes
oOpen the video in your browser
EscGive the keyboard back to the prompt

Commands:

  • /doomscroll opens the pane and keeps playing even while Claude is idle
  • /doomscroll off closes it for the session (closing the pane yourself does the same)
  • /doomscroll mute / unmute
  • /doomscroll add @handle / remove @handle / creators manage your own creators
  • /doomscroll likes lists what you liked, with links
  • /doomscroll doctor prints what the player found: tools, audio, terminal, feed and recent errors

Settings

claude plugin configure doomscroll (or the /config menu) sets:

  • For You mix (on): the built-in creator mix. Turn it off to see only your own creators.
  • Your creators: comma-separated handles that are always in the feed
  • Sound on (on): whether a new session starts with sound
  • Open while Claude works (on): off means only /doomscroll opens the pane

Environment variables: DOOMSCROLL_MUTED=1 forces silence, DOOMSCROLL_BACKGROUND=#rrggbb sets the pane's background when Doomscroll can't read your terminal's, and DOOMSCROLL_TRANSPORT=file hands frames to the terminal as files instead of shared memory. Try the last if the picture stays blank.

What it runs, fetches and stores

A mod runs with your permissions, so here is everything this one does. claude plugin validate . lists the same hooks and calls from the source.

  • Runs python3 bin/doomscrolld.py once per interactive terminal session. That player runs yt-dlp, ffmpeg and ffprobe, plus ps to find your terminal's size. It never runs in claude -p, scheduled tasks, the VS Code panel or the Desktop app.
  • Fetches public creator pages from tiktok.com through yt-dlp, and the videos and cover images from TikTok's CDN. Nothing is sent anywhere else. There is no account, no login, no analytics and no telemetry.
  • Stores up to 40 videos, 300 cover images, the feed index and a list of watched video IDs in ~/.cache/doomscroll ($XDG_CACHE_HOME/doomscroll if set). Frames pass through POSIX shared memory and a temporary folder, and a Unix socket in /tmp carries commands between the hooks and the player. The player removes its socket, frames and temporary folder when the session ends.
  • Reads your Ghostty or kitty config, only for the background colour.
  • Plays sound through your default output (CoreAudio on macOS, PulseAudio/PipeWire or ALSA on Linux).
  • Does not read, store or send your prompts, Claude's answers or your files. The hooks use turns only to know when Claude starts and stops.

Troubleshooting

  • No pane: the terminal is under 144 columns (run /doomscroll), or Claude Code is older than v2.1.287.
  • "Doomscroll needs ffmpeg, yt-dlp": install them as above, then run /doomscroll, which checks again.
  • "This terminal can't draw video": use Ghostty or kitty, outside tmux or other multiplexers, which usually block the graphics.
  • Blank picture in Ghostty or kitty: try DOOMSCROLL_TRANSPORT=file claude.
  • "Couldn't reach TikTok": update yt-dlp. TikTok may also be unavailable where you are.
  • Anything else: /doomscroll doctor, then open an issue with its output.

Develop

claude --plugin-dir .                        # load it for one session
claude plugin validate --strict .claude-plugin/plugin.json
claude plugin test .                         # the mod's tests, with a fake player
python3 -m unittest discover -s tests -p 'test_*.py'   # the player's tests

hooks/register.tsx draws the pane and drives the player. bin/doomscrolld.py is the player: feed, downloads, decoding, the swipe animation and audio. types/index.d.ts declares the state the pane draws from.

Disclaimer

Doomscroll is an unofficial fan project. It is not affiliated with, endorsed by or sponsored by TikTok, ByteDance or Anthropic. The videos belong to their creators. It plays public videos for personal viewing, the way a browser would. Use it in line with TikTok's terms where you live.

License

MIT

Source 2 files
hooks/register.tsx 660 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ImageSource, Register } from 'claude-code'
3
4import type { DoomscrollItem, DoomscrollView } from '../types'
5
6// Doomscroll: a TikTok-style "For You" pane that plays while Claude works.
7//
8// bin/doomscrolld.py does the heavy lifting (feed, downloads, ffmpeg): it writes
9// each decoded frame to shared memory and prints its name, and this module
10// blits that name into a keyed Image, so no pixel crosses `$`. Commands go
11// back to it over HTTP on a Unix socket.
12
13const PANE = 'doomscroll'
14const TITLE = 'For You'
15const BELOW = 3 // progress bar, caption line, controls line
16const RAIL = 8 // "♥ 292K" and its kin, tight to the video's right edge
17const GAP = 1
18const BLACK_BG = '#000000' // until the daemon says what the terminal's background is
19const FG = '#f1f1f2'
20const DIM = '#8a8b91'
21const FALLBACK_CELL = { cw: 8, ch: 17 }
22const ACCENT = '#fe2c55'
23
24const EMPTY: DoomscrollView = {
25  status: 'starting',
26  index: -1,
27  total: 0,
28  item: null,
29  muted: false,
30  watched: 0,
31  message: null,
32  poster: null,
33  ahead: 0,
34  cell: null,
35  term: null,
36}
37
38const view = atom({ plugin: 'doomscroll', key: 'view' } as const, EMPTY)
39const isWorking = atom({ plugin: 'doomscroll', key: 'isWorking' } as const, false)
40const isSnoozed = atom({ plugin: 'doomscroll', key: 'isSnoozed' } as const, false)
41const isHeld = atom({ plugin: 'doomscroll', key: 'isHeld' } as const, false)
42const isUserPaused = atom({ plugin: 'doomscroll', key: 'isUserPaused' } as const, false)
43const isDone = atom({ plugin: 'doomscroll', key: 'isDone' } as const, false) // a turn ended since the last swipe
44const liked = atom({ plugin: 'doomscroll', key: 'liked' } as const, [])
45
46type Frame = { source: ImageSource; pos: number; dur: number }
47
48// The daemon's socket and the frame pump: module state, rebuilt on a reload
49// along with the daemon itself.
50let sock: string | null = null
51// Set when the terminal draws the Image's alt instead of pixels: no decoding.
52let isBlind = false
53// Set when playback paused because the pane went out of view (a dialog over
54// it, another tab): the pane's next draw resumes it.
55let isHiddenPause = false
56// Only an interactive terminal session runs the player: never a `claude -p`
57// run, a scheduled task or the desktop app, which would play sound to nobody.
58let isActive = false
59let frame: Frame | null = null
60let isBlitting = false
61let framesSinceBar = 0
62let deniedFrames = 0
63let mounted = { cols: 0, rows: 0 } // the Image's size, as the pane last drew it
64// The Image and the bar are keyed by their size: the engine keeps an image's
65// placement grid (c, r) from its first draw under a key, so a resize under the
66// same key draws the picture at the old size inside the new box.
67let videoKey = 'video'
68let background = BLACK_BG
69let barKey = 'bar'
70let sized = { cols: 0, rows: 0 } // the size the daemon decodes at
71let lastTickAt = 0
72let lastSwipeAt = 0
73
74// ------------------------------------------------------------------ helpers
75
76const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
77
78function toBase64(bytes: Uint8Array): string {
79  let out = ''
80  for (let i = 0; i < bytes.length; i += 3) {
81    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
82    out += BASE64.charAt((n >> 18) & 63) + BASE64.charAt((n >> 12) & 63)
83    out += i + 1 < bytes.length ? BASE64.charAt((n >> 6) & 63) : '='
84    out += i + 2 < bytes.length ? BASE64.charAt(n & 63) : '='
85  }
86  return out
87}
88
89/** One pixel of the background colour: what the Image shows before any frame. */
90function blank(background: string): ImageSource {
91  const n = Number.parseInt(background.slice(1), 16) || 0
92  return { rgba: toBase64(new Uint8Array([(n >> 16) & 255, (n >> 8) & 255, n & 255, 255])), width: 1, height: 1 }
93}
94
95/** The progress bar as Raster cells: played `━` white, the rest a dim `─`. */
96function barCells(columns: number, fraction: number, background: string): string {
97  const bg = Number.parseInt(background.slice(1), 16) || 0
98  const words = new Uint32Array(columns * 3)
99  const filled = Math.round(columns * Math.min(1, Math.max(0, fraction)))
100  for (let i = 0; i < columns; i++) {
101    words[i * 3] = i < filled ? 0x2501 : 0x2500
102    words[i * 3 + 1] = i < filled ? 0xf1f1f2 : 0x3c3c40
103    words[i * 3 + 2] = bg
104  }
105  return toBase64(new Uint8Array(words.buffer))
106}
107
108function count(n: number | null | undefined): string {
109  if (n === null || n === undefined) return '-'
110  // Short enough for the rail: 4923, 19.7K, 292K, 1.2M
111  if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, '')}M`
112  if (n >= 1e5) return `${Math.round(n / 1e3)}K`
113  if (n >= 1e4) return `${(n / 1e3).toFixed(1).replace(/\.0$/, '')}K`
114  return String(n)
115}
116
117function clip(text: string, max: number): string {
118  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`
119}
120
121function feedLine(yours: readonly string[], isMix: boolean): string {
122  const list = yours.length > 0 ? yours.map(h => `@${h}`).join(', ') : 'none yet'
123  return `Your creators: ${list}${isMix ? ', plus the For You mix (about 70 creators: comedy, memes, slop, animals, sports and more).' : '. The For You mix is off.'}`
124}
125
126function handles(words: readonly string[]): string[] {
127  return words.map(w => w.trim().replace(/^@/, '').toLowerCase()).filter(w => /^[a-z0-9._]{2,24}$/.test(w))
128}
129
130/**
131 * The video box in cells (9:16 at the terminal's real cell shape), the rail
132 * beside it, and a left spacer as wide as the rail when there is room for
133 * one, so the video itself sits in the middle of the pane.
134 */
135function layout(bodyCols: number, bodyRows: number, cell: DoomscrollView['cell']) {
136  const { cw, ch } = cell ?? FALLBACK_CELL
137  const aspect = (9 / 16) * (ch / cw) // columns per row of a 9:16 picture
138  const side = RAIL + GAP
139  let rows = Math.max(4, bodyRows - BELOW)
140  let cols = Math.round(rows * aspect)
141  let rail = side
142  if (cols + side + 2 > bodyCols) {
143    cols = bodyCols - side - 2
144    if (cols < 16) {
145      rail = 0
146      cols = bodyCols - 2
147    }
148    rows = Math.max(4, Math.min(bodyRows - BELOW, Math.round(cols / aspect)))
149  }
150  cols = Math.max(1, Math.min(255, cols))
151  rows = Math.max(1, Math.min(255, rows))
152  const spacer = rail > 0 && cols + 2 * side + 2 <= bodyCols ? side : 0
153  return { cols, rows, rail, spacer }
154}
155
156/** The dock width that seats the video centered with the rail beside it. */
157function wantedColumns(v: DoomscrollView): number {
158  const rows = v.term ? v.term.rows - 7 : 36
159  const box = layout(999, rows, v.cell)
160  return box.cols + 2 * (RAIL + GAP) + 4
161}
162
163// ------------------------------------------------------------------- daemon
164
165/** A command whose answer matters (doctor): the daemon's JSON reply, or null. */
166async function ask($: EngineInterface, body: Record<string, unknown>): Promise<unknown> {
167  if (sock === null) return null
168  try {
169    const res = await $.http.fetch('http://doomscrolld/cmd', {
170      method: 'POST',
171      headers: { 'content-type': 'application/json' },
172      body: JSON.stringify(body),
173      socketPath: sock,
174    })
175    return res.ok ? JSON.parse(res.text) : null
176  } catch {
177    return null
178  }
179}
180
181async function send($: EngineInterface, body: Record<string, unknown>): Promise<void> {
182  if (sock === null) return
183  try {
184    await $.http.fetch('http://doomscrolld/cmd', {
185      method: 'POST',
186      headers: { 'content-type': 'application/json' },
187      body: JSON.stringify(body),
188      socketPath: sock,
189    })
190  } catch (error) {
191    $.ui.log(`doomscroll: ${String(error)}`, { to: 'debug' })
192  }
193}
194
195/** Tells the daemon the box the pane drew, so frames come at its shape. */
196async function syncSize($: EngineInterface): Promise<void> {
197  if (mounted.cols === 0 || (mounted.cols === sized.cols && mounted.rows === sized.rows)) return
198  sized = mounted
199  await send($, { op: 'size', cols: mounted.cols, rows: mounted.rows })
200}
201
202/** Play only while the pane is on screen and Claude is busy (or they asked). */
203async function syncPlayback($: EngineInterface): Promise<void> {
204  if (sock === null) return
205  await syncSize($)
206  const pane = (await $.ui.panes()).find(p => p.id === PANE)
207  const isShown = pane !== undefined && pane.isShown && pane.isPlaced
208  const [working, snoozed, held, paused] = await Promise.all([
209    read($, isWorking),
210    read($, isSnoozed),
211    read($, isHeld),
212    read($, isUserPaused),
213  ])
214  const shouldPlay = isShown && !isBlind && !snoozed && !paused && (working || held)
215  await send($, { op: shouldPlay ? 'play' : 'pause' })
216}
217
218function onFrame($: EngineInterface, line: string): void {
219  const [, kind, name = '', gen, w, h, pos, dur] = line.split(' ')
220  const width = Number(w)
221  const height = Number(h)
222  frame = {
223    source:
224      kind === 'shm'
225        ? { shm: name, format: 'rgb', width, height }
226        : { file: name, format: 'rgb', width, height, generation: Number(gen) },
227    pos: Number(pos),
228    dur: Number(dur),
229  }
230  if (!isBlitting) void pump($)
231  if (mounted.cols !== sized.cols || mounted.rows !== sized.rows) void syncSize($)
232}
233
234/** Blits the newest frame; frames that land mid-blit fold into the next one. */
235async function pump($: EngineInterface): Promise<void> {
236  isBlitting = true
237  try {
238    let sent: Frame | null = null
239    while (frame !== null && frame !== sent) {
240      sent = frame
241      const shown = await $.ui.blit({ requestId: PANE, key: videoKey, source: sent.source })
242      if (shown.deny !== undefined) {
243        // Not on screen (closed, another tab), or a terminal without kitty
244        // graphics drawing the alt: pause rather than decode for nobody.
245        deniedFrames += 1
246        if (deniedFrames === 15) void onBlocked($, shown.deny)
247        break
248      }
249      deniedFrames = 0
250      framesSinceBar += 1
251      if (framesSinceBar >= 6 && mounted.cols > 0 && sent.dur > 0) {
252        framesSinceBar = 0
253        await $.ui.blit({
254          requestId: PANE,
255          key: barKey,
256          cells: barCells(mounted.cols, sent.pos / sent.dur, background),
257        })
258      }
259    }
260  } finally {
261    isBlitting = false
262  }
263}
264
265async function onBlocked($: EngineInterface, reason: string): Promise<void> {
266  const pane = (await $.ui.panes()).find(p => p.id === PANE)
267  if (pane !== undefined && pane.isShown && pane.isPlaced) {
268    isBlind = true
269    $.ui.log(`doomscroll: frames refused while the pane is up (${reason}); stopping playback`, { to: 'debug' })
270    await update($, view, (v): DoomscrollView => ({
271      ...v,
272      status: 'empty',
273      message: "This terminal can't draw video. Doomscroll needs Ghostty or kitty.",
274    }))
275  } else {
276    isHiddenPause = true
277  }
278  await syncPlayback($)
279}
280
281async function onLine($: EngineInterface, line: string): Promise<void> {
282  if (line.startsWith('R ')) {
283    sock = line.slice(2).trim()
284    sized = { cols: 0, rows: 0 }
285    await syncPlayback($)
286  } else if (line.startsWith('S ')) {
287    if (isBlind) return
288    const next = JSON.parse(line.slice(2)) as DoomscrollView
289    await update($, view, () => next)
290  } else if (line.startsWith('E ')) {
291    $.ui.log(`doomscroll: ${line.slice(2)}`, { to: 'debug' })
292  }
293}
294
295async function runDaemon($: EngineInterface, creatorsOption: string, isMix: boolean, soundDefault: boolean) {
296  const stored = await $.store.get('creators')
297  const creators = Array.isArray(stored) ? handles(stored.map(String)) : handles(creatorsOption.split(','))
298  const storedMute = await $.store.get('muted')
299  const isMuted = typeof storedMute === 'boolean' ? storedMute : !soundDefault
300
301  for (let attempt = 0; attempt < 3; attempt++) {
302    const socketPath = `/tmp/doomscroll-${Math.random().toString(36).slice(2, 10)}.sock`
303    const argv = ['python3', `${$.plugin.root}/bin/doomscrolld.py`, '--sock', socketPath, '--creators', creators.join(',')]
304    if (!isMix) argv.push('--no-mix')
305    if (isMuted) argv.push('--muted')
306    let buffer = ''
307    try {
308      for await (const chunk of $.process.spawn({ argv })) {
309        if (chunk.stream === 'stderr') {
310          $.ui.log(`doomscrolld: ${chunk.text.trim()}`, { to: 'debug' })
311          continue
312        }
313        buffer += chunk.text
314        const lines = buffer.split('\n')
315        buffer = lines.pop() ?? ''
316        let latest: string | null = null
317        for (const line of lines) {
318          if (line.startsWith('F ')) latest = line
319          else await onLine($, line)
320        }
321        if (latest !== null) onFrame($, latest)
322      }
323    } catch (error) {
324      $.ui.log(`doomscroll: the player stopped: ${String(error)}`, { to: 'debug' })
325    }
326    sock = null
327    await $.clock.sleep(2000)
328  }
329  await update($, view, (v): DoomscrollView => ({
330    ...v,
331    status: 'empty',
332    message: 'The Doomscroll player keeps stopping. It needs python3 (3.8+); /doomscroll doctor says more.',
333  }))
334}
335
336// -------------------------------------------------------------------- actions
337
338async function openPane($: EngineInterface, isAsked: boolean) {
339  const v = await read($, view)
340  const rows = v.term ? Math.max(12, v.term.rows - 10) : 30
341  return $.ui.open({
342    id: PANE,
343    title: TITLE,
344    columns: wantedColumns(v),
345    rows,
346    ...(isAsked ? { focus: true as const } : {}),
347  })
348}
349
350async function swipe($: EngineInterface, op: 'next' | 'prev') {
351  await update($, isDone, () => false)
352  await update($, isUserPaused, () => false)
353  if (!(await read($, isWorking))) await update($, isHeld, () => true)
354  await send($, { op })
355}
356
357async function togglePause($: EngineInterface) {
358  const v = await read($, view)
359  if (v.status === 'playing' || v.status === 'loading') {
360    await update($, isUserPaused, () => true)
361  } else {
362    await update($, isDone, () => false)
363    await update($, isUserPaused, () => false)
364    if (!(await read($, isWorking))) await update($, isHeld, () => true)
365  }
366  await syncPlayback($)
367}
368
369async function toggleMute($: EngineInterface, muted?: boolean) {
370  const next = muted ?? !(await read($, view)).muted
371  await $.store.set('muted', next)
372  await send($, { op: 'mute', muted: next })
373  return next
374}
375
376async function toggleLike($: EngineInterface, item: DoomscrollItem) {
377  const ids = await read($, liked)
378  const isLiked = ids.includes(item.id)
379  await update($, liked, list => (isLiked ? list.filter(id => id !== item.id) : [...list, item.id]))
380  const saved = await $.store.get('likes')
381  const list = Array.isArray(saved) ? (saved as { id: string }[]) : []
382  await $.store.set(
383    'likes',
384    isLiked
385      ? list.filter(one => one.id !== item.id)
386      : [...list, { id: item.id, url: item.url, author: item.author, desc: clip(item.desc, 80) }].slice(-200),
387  )
388}
389
390// ------------------------------------------------------------------- register
391
392export const register: Register = (on, options) => {
393  const creatorsOption = typeof options.creators === 'string' ? options.creators : ''
394  const isMix = options.mix !== false
395  const soundDefault = options.sound !== false
396  const autoOpen = options.autoOpen !== false
397
398  on('session.start', async ($, e, next) => {
399    const started = await next(e)
400    isActive = e.isInteractive && e.surface === 'terminal'
401    if (!isActive) return started
402    await $.command.register({
403      name: 'doomscroll',
404      description: 'Scroll TikToks in a side pane while Claude works',
405      argumentHint: '[off | mute | unmute | add @user | remove @user | creators | likes | doctor]',
406      immediate: true,
407    })
408    const saved = await $.store.get('likes')
409    if (Array.isArray(saved)) {
410      const ids = (saved as { id: string }[]).map(one => one.id)
411      await update($, liked, () => ids)
412    }
413    void runDaemon($, creatorsOption, isMix, soundDefault)
414    return started
415  })
416
417  on('turn.start', async ($, e, next) => {
418    const started = await next(e)
419    if (!isActive) return started
420    isBlind = false // the terminal gets another chance each turn
421    deniedFrames = 0
422    await update($, isWorking, () => true)
423    await update($, isDone, () => false)
424    await update($, isUserPaused, () => false)
425    if (autoOpen && !(await read($, isSnoozed))) await openPane($, false)
426    await syncPlayback($)
427    return started
428  })
429
430  on('turn.complete', async ($, e, next) => {
431    const done = await next(e)
432    if (!isActive || e.agentId !== undefined) return done // a subagent's turn; the main one runs on
433    const { status } = await read($, view)
434    const wasPlaying = status === 'playing' || status === 'loading'
435    await update($, isDone, () => wasPlaying)
436    await update($, isWorking, () => false)
437    await update($, isHeld, () => false)
438    await syncPlayback($)
439    if (wasPlaying && !(await read($, isSnoozed))) $.ui.toast("Claude's done. Back to work.")
440    return done
441  })
442
443  on('ui.close', { id: PANE }, async ($, e, next) => {
444    const closed = await next(e)
445    if (e.origin.kind === 'person') {
446      await update($, isSnoozed, () => true)
447      $.ui.toast('Doomscroll snoozed. /doomscroll brings it back.')
448    }
449    await send($, { op: 'pause' })
450    return closed
451  })
452
453  // The wheel over the pane is the swipe: one per gesture, however many ticks.
454  on('ui.scroll', { requestId: PANE }, async ($, e) => {
455    if (e.by === 0) return {}
456    const now = await $.clock.now()
457    const gap = now - lastTickAt
458    lastTickAt = now
459    if (gap < 220 && now - lastSwipeAt < 1100) return {}
460    lastSwipeAt = now
461    await swipe($, e.by > 0 ? 'next' : 'prev')
462    return {}
463  })
464
465  on('command.run', { command: 'doomscroll' }, async ($, e) => {
466    const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
467    switch (verb.toLowerCase()) {
468      case '':
469      case 'on': {
470        isBlind = false
471        deniedFrames = 0
472        await send($, { op: 'recheck' }) // picks up ffmpeg / yt-dlp installed since the session began
473        await update($, isSnoozed, () => false)
474        await update($, isUserPaused, () => false)
475        await update($, isHeld, () => true)
476        const opened = await openPane($, true)
477        await syncPlayback($)
478        return {
479          text: opened.isPlaced
480            ? 'Doomscroll is open. Scroll over it for the next video.'
481            : `Doomscroll is waiting for room: ${opened.reason}`,
482        }
483      }
484      case 'off': {
485        await update($, isSnoozed, () => true)
486        await $.ui.close({ id: PANE })
487        await send($, { op: 'pause' })
488        return { text: 'Doomscroll is off for this session. /doomscroll brings it back.' }
489      }
490      case 'mute':
491      case 'unmute': {
492        const muted = await toggleMute($, verb.toLowerCase() === 'mute')
493        return { text: muted ? 'Doomscroll muted.' : 'Doomscroll sound on.' }
494      }
495      case 'add':
496      case 'remove': {
497        const stored = await $.store.get('creators')
498        const current = Array.isArray(stored) ? handles(stored.map(String)) : handles(creatorsOption.split(','))
499        const named = handles(rest)
500        if (named.length === 0) return { text: `Usage: /doomscroll ${verb} @handle` }
501        const next =
502          verb.toLowerCase() === 'add'
503            ? [...current, ...named.filter(h => !current.includes(h))]
504            : current.filter(h => !named.includes(h))
505        await $.store.set('creators', next)
506        await send($, { op: 'creators', creators: next })
507        return { text: feedLine(next, isMix) }
508      }
509      case 'creators':
510      case 'list': {
511        const stored = await $.store.get('creators')
512        const current = Array.isArray(stored) ? handles(stored.map(String)) : handles(creatorsOption.split(','))
513        return { text: feedLine(current, isMix) }
514      }
515      case 'likes': {
516        const saved = await $.store.get('likes')
517        const list = Array.isArray(saved) ? (saved as { url: string; author: string; desc: string }[]) : []
518        if (list.length === 0) return { text: 'No likes yet. Press l on a video you like.' }
519        return { text: list.map(one => `@${one.author}: ${one.desc}\n  ${one.url}`).join('\n') }
520      }
521      case 'doctor': {
522        const reply = (await ask($, { op: 'doctor' })) as { doctor?: unknown } | null
523        if (reply === null || reply.doctor === undefined) {
524          return { text: 'The Doomscroll player is not running. It needs python3 (3.8+) on PATH; see the README.' }
525        }
526        return { text: `Doomscroll doctor:\n${JSON.stringify(reply.doctor, null, 2)}` }
527      }
528      default:
529        return { text: 'Usage: /doomscroll [off | mute | unmute | add @user | remove @user | creators | likes | doctor]' }
530    }
531  })
532
533  // "Sautéing… · 4 TikToks deep"
534  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
535    const v = await read($, view)
536    if (v.watched === 0 || v.status !== 'playing') return next(e)
537    const base = e.props.message ?? e.props.word
538    const deep = `${v.watched} TikTok${v.watched === 1 ? '' : 's'} deep`
539    return next({ ...e, props: { ...e.props, message: `${base} · ${deep}` } })
540  })
541
542  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
543    const v = await read($, view)
544    const [held, done, likedIds] = await Promise.all([read($, isHeld), read($, isDone), read($, liked)])
545
546    if (e.surface !== 'terminal') {
547      const { Box, Text } = $.ui.resolve(e)
548      return (
549        <Box flexDirection="column" padding={1}>
550          <Text bold>For You</Text>
551          <Text dimColor>The video plays in the terminal (Ghostty or kitty).</Text>
552        </Box>
553      )
554    }
555
556    const { Box, Text, Button, Image, Raster } = $.ui.resolve(e)
557    const bodyCols = e.props.bodyColumns
558    const bodyRows = e.props.scroll.bodyRows
559    const box = layout(bodyCols, bodyRows, v.cell)
560    // Recorded only: the daemon's loop sends it (a send from here dies with
561    // this render when a newer one supersedes it).
562    if (box.cols !== mounted.cols || box.rows !== mounted.rows) mounted = { cols: box.cols, rows: box.rows }
563    if (isHiddenPause) {
564      // Drawn again after being out of view: play on if Claude still works.
565      isHiddenPause = false
566      deniedFrames = 0
567      void syncPlayback($)
568    }
569    videoKey = `video-${box.cols}x${box.rows}`
570    barKey = `bar-${box.cols}`
571
572    const item = v.item
573    const isPlaying = v.status === 'playing'
574    const BG = v.bg ?? BLACK_BG
575    background = BG
576    const source: ImageSource =
577      isPlaying && frame !== null
578        ? frame.source
579        : v.poster !== null
580          ? { file: v.poster.file, format: 'rgb', width: v.poster.width, height: v.poster.height, generation: v.poster.generation }
581          : blank(BG)
582    const fraction = frame !== null && frame.dur > 0 ? frame.pos / frame.dur : 0
583    const isLiked = item !== null && likedIds.includes(item.id)
584    const isBackToWork = done && !held && v.status === 'paused'
585    const under = box.cols + box.rail // the caption runs under the video and the rail
586
587    // One line under the video: the post, or what is going on instead.
588    const caption = (() => {
589      if (v.status === 'starting' || v.status === 'warming')
590        return <Text color={DIM} wrap="truncate">Loading your For You page…</Text>
591      if (v.status === 'empty')
592        return <Text color={DIM} wrap="truncate">{v.message ?? 'Nothing to watch. /doomscroll add @someone'}</Text>
593      if (isBackToWork)
594        return <Text color={ACCENT} bold wrap="truncate">Claude's done. Back to work.</Text>
595      if (item === null) return <Text color={DIM}> </Text>
596      return (
597        <Text color={DIM} wrap="truncate">
598          {v.status === 'paused' ? <Text color={FG}>❚❚ </Text> : null}
599          {v.status === 'loading' ? <Text color={FG}>… </Text> : null}
600          <Text color={FG} bold>{`@${item.author}`}</Text>
601          {` ${item.desc}`}
602        </Text>
603      )
604    })()
605
606    const rail = box.rail > 0 && (
607      <Box flexDirection="column" width={RAIL} marginLeft={GAP} alignItems="flex-start">
608        {item !== null && (
609          <Text>
610            <Text color={BG} backgroundColor={FG} bold>{` ${item.author.slice(0, 1).toUpperCase()} `}</Text>
611            <Text color={ACCENT} bold>+</Text>
612          </Text>
613        )}
614        <Text color={FG}> </Text>
615        <Text color={isLiked ? ACCENT : FG}>
616          {'♥ '}
617          <Text color={FG}>{count(item === null ? null : (item.likes ?? 0) + (isLiked ? 1 : 0))}</Text>
618        </Text>
619        <Text color={FG}>{`❞ ${count(item?.comments)}`}</Text>
620        <Text color={FG}>{`⚑ ${count(item?.saves)}`}</Text>
621        <Text color={FG}>{`➦ ${count(item?.shares)}`}</Text>
622        {v.muted && <Text color={DIM}>♪ off</Text>}
623      </Box>
624    )
625
626    // Controls only while the pane holds the keys (a click, ctrl+x tab): the
627    // hotkeys need their Buttons drawn. Otherwise a hint for the first few.
628    const controls = e.props.isFocused ? (
629      <Box flexDirection="row" columnGap={2}>
630        <Button key="next" plain hotkey="j" label="↓" onPress={() => swipe($, 'next')} />
631        <Button key="prev" plain hotkey="k" label="↑" onPress={() => swipe($, 'prev')} />
632        <Button key="pause" plain hotkey="p" label={isPlaying ? '❚❚' : '▶'} onPress={() => togglePause($)} />
633        <Button key="mute" plain hotkey="m" label={v.muted ? '♪ on' : '♪ off'} onPress={() => toggleMute($)} />
634        {item !== null && <Button key="like" plain hotkey="l" label={isLiked ? '♥ liked' : '♥'} onPress={() => toggleLike($, item)} />}
635        {item !== null && <Button key="open" plain hotkey="o" label="↗" onPress={() => send($, { op: 'open' })} />}
636      </Box>
637    ) : (
638      <Text color={DIM} wrap="truncate">{v.watched <= 2 && isPlaying ? 'scroll for the next one · click for keys' : ' '}</Text>
639    )
640
641    return (
642      <Box flexDirection="column" width={bodyCols} height={bodyRows} backgroundColor={BG} alignItems="center" justifyContent="center">
643        <Box flexDirection="row" alignItems="flex-end">
644          {box.spacer > 0 && <Box width={box.spacer} />}
645          <Image key={videoKey} source={source} columns={box.cols} rows={box.rows} alt="Doomscroll needs Ghostty or kitty" />
646          {rail}
647        </Box>
648        <Box flexDirection="row">
649          {box.spacer > 0 && <Box width={box.spacer} />}
650          <Box flexDirection="column" width={under}>
651            <Raster key={barKey} columns={box.cols} rows={1} cells={barCells(box.cols, fraction, BG)} />
652            {caption}
653            {controls}
654          </Box>
655        </Box>
656      </Box>
657    )
658  })
659}
660
types/index.d.ts 49 lines
1export type DoomscrollItem = {
2  id: string
3  url: string
4  author: string
5  name: string
6  desc: string
7  likes: number | null
8  comments: number | null
9  shares: number | null
10  saves: number | null
11  views: number | null
12  music: string
13  duration: number | null
14}
15
16export type DoomscrollPoster = { file: string; width: number; height: number; generation: number }
17
18export type DoomscrollStatus = 'starting' | 'warming' | 'loading' | 'playing' | 'paused' | 'empty'
19
20export type DoomscrollView = {
21  status: DoomscrollStatus
22  index: number
23  total: number
24  item: DoomscrollItem | null
25  muted: boolean
26  watched: number
27  message: string | null
28  poster: DoomscrollPoster | null
29  ahead: number
30  cell: { cw: number; ch: number } | null
31  /** The terminal's background colour (#rrggbb), when the daemon could read it. */
32  bg?: string | null
33  term: { rows: number; cols: number } | null
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    doomscroll: {
39      view: DoomscrollView
40      isWorking: boolean
41      isSnoozed: boolean
42      isHeld: boolean
43      isUserPaused: boolean
44      isDone: boolean
45      liked: string[]
46    }
47  }
48}
49