SLOPSHOPPER

dev-reader

Browse and read the latest DEV posts in a pane while Claude works

panecommandnetwork
★ 1v0.1.0Apache-2.0updated 2026-10-18pinkpixel-dev/dev-reader
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-reader
│ ┃ DEV latest ✕ › fix the failing auth test and add an audit log call │ ┃ Loading latest posts... │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ 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 │ ┃ │ ┃ › /dev │ ┃ ⎿ dev-reader: DEV pane open. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · DEV latest
Loading latest posts...
README

dev-reader

dev-reader is a Claude Code mod that lets you browse and read DEV posts in a side pane while Claude is working.

I made it because I kept sitting there waiting on long turns with nothing to do. Now I type /dev and read something until Claude's done, and I don't have to leave the terminal.

What it does

  • /dev opens a pane with the 30 newest posts on DEV
  • /dev <tag> shows posts for one tag, like /dev rust or /dev #javascript
  • Pick a post and it opens as rendered markdown, with the author, date, reading time, reactions, and an "Open on DEV" link
  • The command runs right away, even in the middle of a turn, so it doesn't wait in the queue
  • If DEV can't be reached, the error shows up inside the pane, not in your conversation

It only reads public data from the DEV API. There's no API key, no login, and nothing gets posted anywhere.

Requirements

You need a Claude Code version that supports mods (plugin hooks modules). I built and tested this on Claude Code 2.1.303. The mod API is early access, so a future Claude Code update could break it.

Installation

The repo is its own plugin marketplace, so you can install it from inside Claude Code with one command:

/plugin install dev-reader --marketplace pinkpixel-dev/dev-reader

Claude Code asks whether to add the pinkpixel-dev/dev-reader marketplace. Press y, then pick a scope: just you, or the project you're in. After that, /dev is ready to use.

That one-step install needs Claude Code 2.1.275 or later. On an older version, add the marketplace first and then install from it:

/plugin marketplace add pinkpixel-dev/dev-reader
/plugin install dev-reader@dev-reader

Running from a local clone

If you want to hack on it, clone the repo and point Claude Code at the folder:

git clone https://github.com/pinkpixel-dev/dev-reader.git
claude --plugin-dir /path/to/dev-reader

Usage

/dev            newest posts
/dev rust       posts tagged #rust
/dev close      close the pane

When the pane opens, it has the keyboard:

KeyWhat it does
Tab / arrowsMove between posts
EnterOpen the selected post
bBack to the list (while reading a post)
rRefresh the list
qClose the pane
EscGive the keyboard back to the prompt (the pane stays open)

Scroll a long post with the mouse wheel or the arrow keys. In a fullscreen terminal, you can also click posts and buttons.

Things to know

  • With no tag, the list comes from DEV's /articles/latest endpoint, so it's newest first. With a tag, it uses /articles?tag=..., which I think sorts by DEV's own ranking, not strictly by date.
  • In fullscreen at 110 columns or wider, the pane opens beside the conversation. In a narrower terminal, it opens inline above the prompt.
  • The pane has its own dark background (#1e1f22) so it stays readable no matter what your terminal theme puts behind it. Article text uses Claude Code's normal markdown colors.

How it works

Everything lives in hooks/register.tsx. On session start it registers /dev as an immediate command. Running the command opens a pane and fetches posts through Claude Code's $.http.fetch. The current view (loading, list, article, or error) is kept in one $.state value, and the pane redraws whenever that value changes. Opening a post makes a second request to /api/articles/{id} to get the full body_markdown.

.claude-plugin/plugin.json   mod manifest
hooks/hooks.json             points Claude Code at the hooks module
hooks/register.tsx           command, DEV requests, and the pane
types/index.d.ts             type for the pane's saved state
tests/dev-reader.test.tsx    tests

Development

Check the manifest and module:

claude plugin validate .

Run the tests:

claude plugin test .

The tests fake DEV's responses, so they don't hit the network. They cover the tag list, opening a post, going back, the default feed, and the error state, on both the terminal and desktop surfaces.

License

Apache 2.0. See LICENSE.

Made with 💖 by Pink Pixel

Source 2 files
hooks/register.tsx 205 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Article, Post, View } from '../types'
5
6const PANE = 'dev-reader'
7const API = 'https://dev.to/api'
8const HEADERS = {
9  accept: 'application/vnd.forem.api-v1+json',
10  'user-agent': 'dev-reader-claude-mod/0.1.0',
11}
12
13const BG = '#1e1f22'
14
15const view = atom({ plugin: 'dev-reader', key: 'view' } as const, {
16  mode: 'loading',
17  tag: '',
18} as View)
19
20// Tab and newline are the only control characters a drawing accepts.
21const clean = (text: string) =>
22  text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
23
24const stripFrontMatter = (md: string) => md.replace(/^---\n[\s\S]*?\n---\n/, '')
25
26export const normalizeTag = (raw: string | undefined) => (raw ?? '').trim().replace(/^#/, '').toLowerCase()
27
28export const toPost = (raw: any): Post => ({
29  id: raw.id,
30  title: clean(String(raw.title ?? 'Untitled')),
31  author: clean(String(raw.user?.name ?? raw.user?.username ?? 'unknown')),
32  tags: Array.isArray(raw.tag_list)
33    ? raw.tag_list
34    : String(raw.tag_list ?? '').split(',').map((t: string) => t.trim()).filter(Boolean),
35  minutes: raw.reading_time_minutes ?? 1,
36  reactions: raw.public_reactions_count ?? 0,
37  comments: raw.comments_count ?? 0,
38  date: clean(String(raw.readable_publish_date ?? '')),
39  url: String(raw.url ?? ''),
40})
41
42const setView = ($: EngineInterface, next: View) => update($, view, () => next)
43
44const getJson = async ($: EngineInterface, url: string) => {
45  const res = await $.http.fetch(url, { headers: HEADERS })
46  if (!res.ok) throw new Error(`DEV answered ${res.status}`)
47
48  return JSON.parse(res.text)
49}
50
51const loadList = async ($: EngineInterface, tag: string) => {
52  await setView($, { mode: 'loading', tag })
53  try {
54    const url = tag
55      ? `${API}/articles?per_page=30&tag=${encodeURIComponent(tag)}`
56      : `${API}/articles/latest?per_page=30`
57    const posts = (await getJson($, url)).map(toPost)
58    await setView($, { mode: 'list', tag, posts })
59  } catch (err) {
60    const message = err instanceof Error ? err.message : String(err)
61    await setView($, { mode: 'error', tag, posts: [], message })
62  }
63}
64
65const loadArticle = async ($: EngineInterface, post: Post) => {
66  const current = await read($, view)
67  const posts = 'posts' in current ? current.posts : []
68  const { tag } = current
69  try {
70    const raw = await getJson($, `${API}/articles/${post.id}`)
71    const article: Article = {
72      ...toPost(raw),
73      body: clean(stripFrontMatter(String(raw.body_markdown ?? ''))),
74    }
75    await setView($, { mode: 'article', tag, posts, article })
76    await $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)
77  } catch (err) {
78    const message = err instanceof Error ? err.message : String(err)
79    await setView($, { mode: 'error', tag, posts, message })
80  }
81}
82
83const backToList = ($: EngineInterface) =>
84  update($, view, (v): View => ({ mode: 'list', tag: v.tag, posts: 'posts' in v ? v.posts : [] }))
85
86const openPane = ($: EngineInterface, tag: string) =>
87  $.ui.open({ id: PANE, title: tag ? `DEV #${tag}` : 'DEV latest', focus: true })
88
89export const register: Register = on => {
90  on('session.start', async ($, e, next) => {
91    await $.command.register({
92      name: 'dev',
93      description: 'Browse the latest DEV posts in a pane',
94      argumentHint: '[tag | close]',
95      immediate: true,
96    })
97
98    return next(e)
99  })
100
101  on('command.run', { command: 'dev' }, async ($, e) => {
102    const arg = normalizeTag(e.args)
103
104    if (arg === 'close') {
105      await $.ui.close({ id: PANE })
106
107      return { text: 'DEV pane closed.' }
108    }
109
110    const opened = await openPane($, arg)
111    void loadList($, arg)
112
113    return {
114      text: opened.isPlaced
115        ? `DEV pane open${arg ? ` for #${arg}` : ''}.`
116        : `DEV pane is waiting for room: ${opened.reason}`,
117    }
118  })
119
120  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
122    const v = await read($, view)
123    const width = Math.max(20, e.props.bodyColumns)
124    const label = v.tag ? `#${v.tag}` : 'latest'
125
126    // Paint the whole pane so it reads dark whatever the surface draws behind it.
127    const shell = (child: RenderChildren) => (
128      <Box
129        flexDirection="column"
130        backgroundColor={BG}
131        width="100%"
132        minHeight={e.props.scroll.bodyRows}
133        paddingX={1}
134      >
135        {child}
136      </Box>
137    )
138
139    const toolbar = (
140      <Box flexDirection="row" gap={1}>
141        {v.mode === 'article' && (
142          <Button key="back" hotkey="b" plain onPress={() => backToList($)} label="Back" />
143        )}
144        <Button key="refresh" hotkey="r" plain onPress={() => loadList($, v.tag)} label="Refresh" />
145        <Button key="close" hotkey="q" plain onPress={() => $.ui.close({ id: PANE })} label="Close" />
146      </Box>
147    )
148
149    if (v.mode === 'loading') {
150      return shell(
151        <Box flexDirection="column">
152          <Text dimColor>Loading {label} posts...</Text>
153        </Box>
154      )
155    }
156
157    if (v.mode === 'error') {
158      return shell(
159        <Box flexDirection="column" gap={1}>
160          <Text color="error">Couldn't reach DEV: {v.message}</Text>
161          {toolbar}
162        </Box>
163      )
164    }
165
166    if (v.mode === 'article') {
167      const a = v.article
168      const meta = `${a.author} · ${a.date} · ${a.minutes} min · ${a.reactions} reactions · ${a.comments} comments`
169      const header = `# ${a.title}\n\n${a.tags.map(t => `#${t}`).join(' ')}\n\n[Open on DEV](${a.url})\n\n---\n\n`
170
171      return shell(
172        <Box flexDirection="column" gap={1}>
173          {toolbar}
174          <Text dimColor wrap="truncate-end">{meta}</Text>
175          <Markdown text={(header + a.body).slice(0, 99000)} />
176        </Box>
177      )
178    }
179
180    return shell(
181      <Box flexDirection="column">
182        {toolbar}
183        <Text dimColor>{v.posts.length} {label} posts</Text>
184        {v.posts.length === 0 && <Text dimColor>Nothing here yet. Try another tag.</Text>}
185        {v.posts.map((post, i) => (
186          <Box key={`row-${post.id}`} flexDirection="column" marginTop={1} width={width}>
187            <Button
188              key={`post-${post.id}`}
189              plain
190              autoFocus={i === 0 ? true : undefined}
191              onPress={() => loadArticle($, post)}
192            >
193              {post.title}
194            </Button>
195            <Text dimColor wrap="truncate-end">
196              {post.author} · {post.minutes} min · {post.reactions} reactions
197              {post.tags.length > 0 ? ` · ${post.tags.slice(0, 3).map(t => `#${t}`).join(' ')}` : ''}
198            </Text>
199          </Box>
200        ))}
201      </Box>
202    )
203  })
204}
205
types/index.d.ts 26 lines
1export type Post = {
2  id: number
3  title: string
4  author: string
5  tags: string[]
6  minutes: number
7  reactions: number
8  comments: number
9  date: string
10  url: string
11}
12
13export type Article = Post & { body: string }
14
15export type View =
16  | { mode: 'loading'; tag: string }
17  | { mode: 'list'; tag: string; posts: Post[] }
18  | { mode: 'article'; tag: string; posts: Post[]; article: Article }
19  | { mode: 'error'; tag: string; posts: Post[]; message: string }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'dev-reader': { view: View }
24  }
25}
26