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.

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.
Sautéing · 4 TikToks deep…| Claude Code | v2.1.287 or later (mods were introduced in that version) |
| Terminal | Ghostty or kitty. The video uses kitty graphics with Unicode placeholders. Other terminals show a note instead of the picture. |
| OS | macOS or Linux. Windows isn't supported. |
Tools on your PATH | python3 (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.
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.
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 this | To |
|---|---|
| Scroll the wheel or trackpad over the pane | Go to the next or previous video |
Click the pane, then j / k | Next / previous |
p | Pause or play, including after Claude is done if you want to keep watching |
m | Mute or unmute (remembered) |
l | Like: saves the video to /doomscroll likes |
o | Open the video in your browser |
| Esc | Give 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 errorsclaude plugin configure doomscroll (or the /config menu) sets:
/doomscroll opens the paneEnvironment 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.
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.
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.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.~/.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./doomscroll), or Claude Code is older than v2.1.287./doomscroll, which checks again.DOOMSCROLL_TRANSPORT=file claude.yt-dlp. TikTok may also be unavailable where you are./doomscroll doctor, then open an issue with its output.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.
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.
hooks/register.tsx 660 lines1import { 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}
660types/index.d.ts 49 lines1export 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