SLOPSHOPPER

tiktok-break

Plays TikTok in a side pane while Claude works

newpanecommandtoastprocessnetwork
v0.1.1MITupdated 2026-10-05Chinteyley/tiktok-break
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tiktok-break
│ ┃ tiktok ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▣ TikTok │ tiktok-break │ │ ┃ k: Prev j: Next p: Pause m: Mute l: Like ⏺ Read(src/auth.ts) │ tiktok-break: set a Chromium-based browser │ │ ⎿ Read 6 lines │ (Chrome, Chromium, Brave, Edge, Helium, │ │ ⏺ Update(src/auth.ts) │ Vivaldi…) as your default; Firefox and │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ 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 │ │ › /tiktok │ ⎿ tiktok-break: No Chromium browser is your default browser to pla │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tiktok
▣ TikTok k: Prev j: Next p: Pause m: Mute l: Like r: Repost
README

tiktok-break

Watch TikTok inside Claude Code while Claude works, in a pane beside the transcript.

The TikTok pane docked beside a Claude Code session

Unofficial. Not affiliated with TikTok or Anthropic.

Commands

CommandWhat it does
/tiktokPlays your For You feed in a pane; run it again to close.
/tiktok loginOpens a real browser window on the mod's profile so you can log in. Close it, then run /tiktok.

The pane docks at the side, full height, when Claude Code is in its fullscreen layout and the terminal is at least 110 columns wide. In a smaller terminal it is a block above the prompt. Docked, a video is framed with its creator, likes, comments, saves and shares; above the prompt, the video alone.

Pane controls

Click a control under the picture, or press ctrl+x tab to give the pane the keyboard and use its key. Esc hands the keyboard back to the prompt.

KeyControl
kPrevious video
jNext video
pPause or play
mMute or unmute
lLike
rRepost (again takes it back)

A video that finishes moves on to the next by itself. Like and Repost are real actions on the account you logged in with.

Requirements

  • macOS or Linux
  • A Chromium-based browser (Chrome, Chromium, Brave, Edge, Vivaldi, Helium…) set as your default browser. Firefox and Safari can't be driven the way the pane needs. A default browser launched through env or flatpak isn't detected. A snap-packaged Chromium can't write the profile in ~/.config and won't work.
  • Bun on your PATH
  • For the pane, a terminal that draws the kitty graphics protocol. Built and tested in Ghostty; kitty should work but is untested.
  • Claude Code with function-hook mods. Built against 2.1.289; that API is early access and may change between releases.

Install

claude plugin marketplace add Chinteyley/tiktok-break
claude plugin install tiktok-break@tiktok-break

Then start Claude Code, or run /reload-plugins in a session that is already open.

To work on the mod instead, clone the repository and load the folder directly; edits to it reload live:

git clone https://github.com/Chinteyley/tiktok-break
claude --plugin-dir ./tiktok-break

How it works

viewer/viewer.ts runs a headless Chrome on a profile of its own (~/Library/Application Support/tiktok-break on macOS, ~/.config/tiktok-break on Linux), opens the For You feed and screencasts it over the DevTools protocol. Each frame is written to one PNG file, and the mod (hooks/register.tsx) repaints the pane's picture from that file through the terminal's graphics protocol. The controls go the other way over a Unix socket: the viewer presses TikTok's own keyboard shortcuts in the page. Sound comes from that Chrome.

/tiktok login opens a plain app window of the same browser on the same profile, which is how the pane comes to be logged in.

Good to know

  • Automation and TikTok's terms. TikTok drops likes from a browser that says it is automated, so the viewer's Chrome is started to report a regular Chrome name with the automation flag off. That gets around TikTok's automation check on your account, and TikTok could restrict the account for it. Use it at your own risk.
  • Disk writes. Frames are PNG files of roughly half a megabyte, written about 30 times a second while the pane plays.
  • Photo posts are shown with their whole item and do not move on by themselves; press Next.
  • Clicks and typing inside the picture are not possible. Use the /tiktok login window for comments and search.
  • Logged out, TikTok covers the feed with a login dialog after a few videos. The pane shows the dialog; run /tiktok login.

Development

claude plugin validate .
claude plugin test .
tsc -p .

tsc needs the type declarations Claude Code lays into .claude-plugin/types/ the first time it loads the mod; that folder is not checked in.

License

MIT

Source 1 files
hooks/register.tsx 345 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const LOGIN = 'https://www.tiktok.com/login'
4const PANE = 'tiktok'
5// From this width the fullscreen layout docks a pane beside the transcript.
6const DOCK_COLUMNS = 110
7// An Image's box is at most this many cells each way.
8const MAX_CELLS = 255
9// The pane's controls: the key that presses each while the pane has the
10// keyboard, the viewer's name for what it does, and its label.
11const CONTROLS = [
12  ['k', 'prev', 'Prev'],
13  ['j', 'next', 'Next'],
14  ['p', 'pause', 'Pause'],
15  ['m', 'mute', 'Mute'],
16  ['l', 'like', 'Like'],
17  ['r', 'repost', 'Repost'],
18] as const
19const CONTROL_GAP = 2
20
21// The viewer feeding the pane, while it runs.
22let viewer: ReturnType<EngineInterface['process']['spawn']> | undefined
23let generation = 0
24
25const FIND = `
26case $(uname) in
27  Darwin)
28    echo "$HOME/Library/Application Support/tiktok-break"
29    app=$(osascript -l JavaScript -e 'ObjC.import("AppKit"); const u = $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString("https://www.tiktok.com")); u.isNil() ? "" : u.path.js' 2>/dev/null)
30    ls "$app/Contents/Frameworks" 2>/dev/null | grep -q ' Framework\\.framework$' &&
31      b="$app/Contents/MacOS/$(defaults read "$app/Contents/Info" CFBundleExecutable)" ;;
32  *)
33    echo "\${XDG_CONFIG_HOME:-$HOME/.config}/tiktok-break"
34    id=$(xdg-settings get default-web-browser 2>/dev/null)
35    for d in "\${XDG_DATA_HOME:-$HOME/.local/share}" $(echo "\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" | tr : ' '); do
36      [ -n "$id" ] && [ -f "$d/applications/$id" ] && b=$(sed -n 's/^Exec=\\([^ ]*\\).*/\\1/p' "$d/applications/$id" | head -n 1) && break
37    done
38    "$b" --version 2>/dev/null | grep -Eq '[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+' || b= ;;
39esac
40command -v "$b" || echo
41`
42
43let located: Promise<{ profile: string; browser: string }> | undefined
44
45function locate($: EngineInterface) {
46  located ??= $.process.run(['sh', '-c', FIND]).then(({ stdout }) => {
47    const [dir = '', binary = ''] = stdout.split('\n')
48
49    return { profile: dir, browser: binary }
50  })
51
52  return located
53}
54
55async function profile($: EngineInterface) {
56  return (await locate($)).profile
57}
58
59async function findBrowser($: EngineInterface) {
60  const found = (await locate($)).browser
61
62  if (!found) {
63    $.ui.toast('tiktok-break: set a Chromium-based browser (Chrome, Chromium, Brave, Edge, Helium, Vivaldi…) as your default; Firefox and Safari cannot drive the pane')
64
65    return undefined
66  }
67
68  return found
69}
70
71async function frame($: EngineInterface) {
72  return `${await profile($)}/pane-frame.png`
73}
74
75async function socket($: EngineInterface) {
76  return `${await profile($)}/pane.sock`
77}
78
79// The browser runs on a profile of its own, so the window is a process this
80// mod can end without touching the person's own browser. The shell returns
81// before the browser does, so it waits a second to catch one that dies at
82// startup, and keeps the browser's output in the profile to show then. On a
83// first run the profile does not exist yet, so the log needs it made first.
84const LAUNCH = `
85log=$1; shift
86mkdir -p "\${log%/*}" || exit
87"$@" >"$log" 2>&1 &
88sleep 1
89kill -0 $! 2>/dev/null || { wait $!; s=$?; cat "$log" >&2; exit $s; }
90`
91
92// Resolves whether the window came up.
93async function open($: EngineInterface, url: string) {
94  const binary = (await locate($)).browser
95  const dir = await profile($)
96  const { exitCode, stderr } = await $.process.run([
97    'sh',
98    '-c',
99    LAUNCH,
100    'sh',
101    `${dir}/launch.log`,
102    binary,
103    `--app=${url}`,
104    `--user-data-dir=${dir}`,
105    '--window-size=430,900',
106    '--no-first-run',
107    '--no-default-browser-check',
108  ])
109
110  if (exitCode !== 0) {
111    $.ui.toast(`tiktok-break: could not open ${binary}: ${stderr.trim().split('\n').at(-1) ?? ''}`)
112  }
113
114  return exitCode === 0
115}
116
117// Whether any browser runs on the mod's profile: a window, or the pane's.
118async function isBusy($: EngineInterface) {
119  const { exitCode } = await $.process.run([
120    'pgrep',
121    '-f',
122    '--',
123    `--user-data-dir=${await profile($)}`,
124  ])
125
126  return exitCode === 0
127}
128
129// Ends a window on the mod's profile; resolves true when one was up.
130async function quit($: EngineInterface) {
131  const { exitCode } = await $.process.run([
132    'pkill',
133    '-f',
134    '--',
135    `--app=[^ ]* --user-data-dir=${await profile($)}`,
136  ])
137
138  return exitCode === 0
139}
140
141// Chrome takes a moment to leave the profile, and one started before then
142// is handed to the one leaving and lost.
143async function settle($: EngineInterface) {
144  for (let i = 0; i < 20 && (await isBusy($)); i++) {
145    await $.clock.sleep(150)
146  }
147}
148
149// Runs the viewer and repaints the pane's picture at each frame it writes;
150// the loop is the viewer's life, so ending the stream ends the playback.
151// Docked there is room to frame a video with its likes and creator; the
152// small block above the prompt frames the video alone.
153async function watch($: EngineInterface, binary: string, isDocked: boolean) {
154  const file = await frame($)
155  const frames = $.process.spawn({
156    argv: [
157      'bun',
158      `${$.plugin.root}/viewer/viewer.ts`,
159      await profile($),
160      file,
161      await socket($),
162      isDocked ? 'item' : 'video',
163      binary,
164    ],
165  })
166  viewer = frames
167  let complaint = ''
168
169  try {
170    for await (const piece of frames) {
171      if (piece.stream === 'stderr') {
172        complaint = piece.text
173        continue
174      }
175
176      generation += 1
177      void $.ui.blit({
178        requestId: PANE,
179        key: 'view',
180        source: { file, format: 'png', generation },
181      })
182    }
183  } catch {
184    complaint = 'bun could not be started'
185  }
186
187  // Still ours: the viewer ended by itself, not by the pane closing.
188  if (viewer === frames) {
189    viewer = undefined
190    $.ui.toast(`tiktok-break: the viewer stopped. ${complaint.slice(0, 120)}`)
191  }
192}
193
194async function unwatch() {
195  const frames = viewer
196  viewer = undefined
197  await frames?.return({ code: null, signal: 'SIGTERM' })
198}
199
200// How many rows the controls take once they wrap at this width; each is
201// drawn as its key, a colon, a space and its label.
202function controlRows(columns: number) {
203  let rows = 1
204  let used = 0
205
206  for (const [, , label] of CONTROLS) {
207    const width = label.length + 3
208
209    if (used > 0 && used + CONTROL_GAP + width > columns) {
210      rows += 1
211      used = width
212    } else {
213      used += (used > 0 ? CONTROL_GAP : 0) + width
214    }
215  }
216
217  return rows
218}
219
220// Hands one of the pane's controls to the viewer, which presses TikTok's own
221// key for it.
222async function send($: EngineInterface, action: string) {
223  try {
224    await $.http.fetch('http://viewer/', {
225      method: 'POST',
226      body: action,
227      socketPath: await socket($),
228    })
229  } catch (error) {
230    $.ui.toast(`tiktok-break: ${String(error).slice(0, 120)}`)
231  }
232}
233
234export const register: Register = on => {
235  on('session.start', async ($, e, next) => {
236    await $.command.register({
237      name: 'tiktok',
238      description: 'Play TikTok in a side pane; "login" opens a window to log in',
239      argumentHint: '[login]',
240      immediate: true,
241    })
242
243    return next(e)
244  })
245
246  on('command.run', { command: 'tiktok' }, async ($, e) => {
247    // Logging in takes a real window: a QR code, a password manager, a
248    // captcha. The pane's Chrome shares the profile, so it is logged in after.
249    if (e.args.trim() === 'login') {
250      if (viewer !== undefined) {
251        await $.ui.close({ id: PANE })
252      }
253
254      if (!(await findBrowser($))) {
255        return { text: 'No Chromium browser is your default browser to log in with.' }
256      }
257
258      await quit($)
259      await settle($)
260      if (!(await open($, LOGIN))) {
261        return { text: 'The login window did not open.' }
262      }
263
264      return {
265        text: 'Log in to TikTok in the window that opened, then close it and run /tiktok.',
266      }
267    }
268
269    if (viewer !== undefined) {
270      await $.ui.close({ id: PANE })
271
272      return { text: 'TikTok pane closed.' }
273    }
274
275    const binary = await findBrowser($)
276
277    if (!binary) {
278      return { text: 'No Chromium browser is your default browser to play TikTok in.' }
279    }
280
281    const { isFullscreen, columns } = e.presentation
282    const isDocked = isFullscreen && columns >= DOCK_COLUMNS
283
284    await quit($)
285    await settle($)
286    void watch($, binary, isDocked)
287    await $.ui.open({ id: PANE, title: 'TikTok', columns: 46, rows: 60 })
288
289    const where = isDocked
290      ? 'at the side'
291      : `above the prompt; from ${DOCK_COLUMNS} columns (now ${columns}) it docks at the side, full height`
292
293    return {
294      text: `TikTok pane opened ${where}. Click a control under the picture, or press ctrl+x tab to give the pane the keys. /tiktok again closes it; /tiktok login logs in.`,
295    }
296  })
297
298  on('ui.close', { id: PANE }, async ($, e, next) => {
299    await unwatch()
300
301    return next(e)
302  })
303
304  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305    if (e.surface !== 'terminal') {
306      const { Text } = $.ui.resolve(e)
307
308      return <Text>The TikTok pane draws in the terminal only.</Text>
309    }
310
311    const { Box, Button, Image } = $.ui.resolve(e)
312    // The picture is fitted inside its box, so the box is all the room there
313    // is. A docked pane's body is that room, less the rows of controls; an
314    // inline block is only as tall as what is drawn in it, so there the
315    // picture asks for half the terminal.
316    const rows =
317      e.props.placement === 'dock'
318        ? e.props.scroll.bodyRows - controlRows(e.props.bodyColumns)
319        : Math.max(8, Math.floor((e.viewport?.rows ?? 32) / 2))
320
321    return (
322      <Box flexDirection="column">
323        <Image
324          key="view"
325          source={{ file: await frame($), format: 'png', generation }}
326          columns={Math.max(1, Math.min(MAX_CELLS, e.props.bodyColumns))}
327          rows={Math.max(1, Math.min(MAX_CELLS, rows))}
328          alt="TikTok"
329        />
330        <Box columnGap={CONTROL_GAP} flexWrap="wrap">
331          {CONTROLS.map(([hotkey, action, label]) => (
332            <Button
333              key={action}
334              plain
335              hotkey={hotkey}
336              label={label}
337              onPress={() => void send($, action)}
338            />
339          ))}
340        </Box>
341      </Box>
342    )
343  })
344}
345