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

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.
/dev opens a pane with the 30 newest posts on DEV/dev <tag> shows posts for one tag, like /dev rust or /dev #javascriptIt only reads public data from the DEV API. There's no API key, no login, and nothing gets posted anywhere.
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.
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
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
/dev newest posts
/dev rust posts tagged #rust
/dev close close the pane
When the pane opens, it has the keyboard:
| Key | What it does |
|---|---|
| Tab / arrows | Move between posts |
| Enter | Open the selected post |
b | Back to the list (while reading a post) |
r | Refresh the list |
q | Close the pane |
| Esc | Give 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.
/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.#1e1f22) so it stays readable no matter what your terminal theme puts behind it. Article text uses Claude Code's normal markdown colors.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
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.
Apache 2.0. See LICENSE.
Made with 💖 by Pink Pixel
hooks/register.tsx 205 lines1import { 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}
205types/index.d.ts 26 lines1export 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