Shows the slonk card this session works on and the column it is in right now

Маркетплейс модов для Claude Code.
| Мод | Что делает |
|---|---|
slonk-card | Показывает карточку slonk, с которой работает сессия, и колонку, в которой она сейчас лежит |
Нужен Claude Code v2.1.287+ в терминале или приложение Claude Desktop v2.1.286+ (вкладка Code).
claude plugin marketplace add romandots/claude-mods
claude plugin install slonk-card@romandots-mods
То же можно сделать прямо в сессии Claude Code:
/plugin marketplace add romandots/claude-mods
/plugin install slonk-card@romandots-mods
Если сессия уже открыта, выполните в ней /reload-plugins. Проверить, что мод загрузился: /plugin. В терминале под вкладками будет строка 1 mod active · slonk-card.
Обновление: /plugin → Installed → slonk-card → Update, или claude plugin update slonk-card@romandots-mods.
Мод выполняется с вашими правами. Посмотреть, какие события он слушает и что вызывает, можно до установки:
claude plugin validate plugins/slonk-card
Следит за вызовами MCP-инструментов slonk, в том числе у субагентов, и показывает текущую карточку:
slonk TANSULTANT-2016 · Code Review (терминал и Desktop);TANSULTANT-2016: Development → Code Review.Как мод определяет карточку:
get_issue, claim_issue, transition_issue, create_issue и т.п.), делает её текущей;comment_issue, link_git_ref), мод сам запрашивает get_issue;slonk распознаётся по ответу: ключ вида PROJ-123 и состояние с группой Plane. Поэтому подходит любое имя MCP-сервера (slonk-developer, коннектор с UUID-именем и т.п.).
/slonk-card| Команда | Действие |
|---|---|
/slonk-card | Перечитать текущую карточку |
/slonk-card PROJ-123 | Следить за другой карточкой |
/slonk-card off | Перестать следить |
/slonk-card hide / show | Скрыть или показать полосу |
claude -p мод ничего не рисует.claude plugin validate plugins/slonk-card
claude plugin test plugins/slonk-card
Для проверки на лету: claude --plugin-dir plugins/slonk-card. Перед релизом поднимите version в plugins/slonk-card/.claude-plugin/plugin.json.
hooks/register.tsx 349 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Card } from '../types'
5
6const card = atom({ plugin: 'slonk-card', key: 'card' } as const, null)
7const lastServer = atom({ plugin: 'slonk-card', key: 'lastServer' } as const, null)
8const isHidden = atom({ plugin: 'slonk-card', key: 'isHidden' } as const, false)
9const pulse = atom({ plugin: 'slonk-card', key: 'pulse' } as const, false)
10
11const POLL_MS = 60_000
12const PULSE_MS = 700
13// slonk's main flow, left to right: one stepper cell per column.
14export const FLOW = [
15 'Backlog',
16 'To Do',
17 'Analysis',
18 'Development',
19 'Security Review',
20 'Code Review',
21 'Testing',
22 'Documenting',
23 'Merging',
24 'Done',
25] as const
26// Any MCP tool, mcp__<server>__<tool>: slonk may be connected as slonk-developer,
27// slonk-qa, ... or as a connector named by a UUID, so a server is told by its answers.
28const MCP_TOOL = /^mcp__(.+)__([a-z_]+)$/i
29const ISSUE_KEY = /^[A-Z][A-Z0-9_]*-\d+$/
30// Plane's state groups: an issue carrying one of them came from slonk.
31const PLANE_GROUPS = new Set(['backlog', 'unstarted', 'started', 'completed', 'cancelled'])
32// Servers that answered with a slonk issue in this module's life.
33const slonkServers = new Set<string>()
34const isSlonk = (server: string) => /slonk/i.test(server) || slonkServers.has(server)
35
36const GROUP_COLOR: Record<string, string> = {
37 backlog: 'gray',
38 unstarted: 'blue',
39 started: 'yellow',
40 completed: 'green',
41 cancelled: 'red',
42}
43
44type Issue = { key: string; title: string; column: string; group: string }
45
46const asIssue = (value: unknown): Issue | null => {
47 if (typeof value !== 'object' || value === null) return null
48 const v = value as Record<string, unknown>
49 const state = v.state as Record<string, unknown> | undefined
50 if (typeof v.key !== 'string' || !ISSUE_KEY.test(v.key)) return null
51 if (typeof state?.name !== 'string' || typeof state.group !== 'string') return null
52 if (!PLANE_GROUPS.has(state.group)) return null
53
54 return {
55 key: v.key,
56 title: typeof v.name === 'string' ? v.name : '',
57 column: state.name,
58 group: state.group,
59 }
60}
61
62/** Every slonk issue a tool answer carries: a single issue or a list of them. */
63export const issuesIn = (text: string | undefined): Issue[] => {
64 if (!text) return []
65 let data: unknown
66 try {
67 data = JSON.parse(text)
68 } catch {
69 return []
70 }
71 const one = asIssue(data)
72 if (one) return [one]
73 const d = data as Record<string, unknown> | null
74 for (const field of ['issue', 'result']) {
75 const nested = asIssue(d?.[field])
76 if (nested) return [nested]
77 }
78 const list = d?.issues
79 return Array.isArray(list) ? list.map(asIssue).filter((i): i is Issue => i !== null) : []
80}
81
82const describe = (c: Card) => `${c.key} · ${c.column}${c.title ? ` — ${c.title}` : ''}`
83
84let poll: { cancel: () => void } | null = null
85let flicker: { cancel: () => void } | null = null
86// False once the host refused a call the mod made on its own (e.g. under the auto
87// permission mode, whose classifier only judges actions the model asked for).
88let canPoll = true
89
90const store = async ($: EngineInterface, issue: Issue, server: string) => {
91 const now = await $.clock.now()
92 const before = await read($, card)
93 if (before && before.key === issue.key && before.column !== issue.column) {
94 $.ui.toast(`${issue.key}: ${before.column} → ${issue.column}`)
95 }
96 const at = FLOW.indexOf(issue.column as (typeof FLOW)[number])
97 const flowIndex = at >= 0 ? at : before?.key === issue.key ? before.flowIndex : undefined
98 await update($, card, () => ({ ...issue, server, checkedAt: now, flowIndex }))
99 $.ui.status(`slonk ${issue.key} · ${issue.column}`)
100}
101
102/** Re-reads the tracked card (or `key`) through slonk's get_issue. */
103const refresh = async ($: EngineInterface, key?: string): Promise<string> => {
104 const current = await read($, card)
105 const target = key ?? current?.key
106 const server = current?.server ?? (await read($, lastServer))
107 if (!target) return 'Карточка ещё не выбрана: работайте со slonk или укажите /slonk-card <KEY>.'
108 if (!server) return 'Не знаю, через какой slonk-сервер читать: сначала вызовите любой инструмент slonk.'
109
110 try {
111 const res = await $.mcp.call(server, 'get_issue', { issue_id: target }).catch((err: unknown) => {
112 // The host refused the call itself (not the server answering an error).
113 canPoll = false
114 throw err
115 })
116 const text = res.content.map(block => block.text ?? '').join('')
117 const issue = issuesIn(text)[0]
118 if (res.isError || !issue) throw new Error(text.slice(0, 200) || 'пустой ответ')
119 await store($, issue, server)
120 return describe({ ...issue, server, checkedAt: 0 })
121 } catch (err) {
122 const message = err instanceof Error ? err.message : String(err)
123 if (current && current.key === target) {
124 await update($, card, c => (c ? { ...c, error: message } : c))
125 }
126 return `Не удалось прочитать ${target}: ${message}`
127 }
128}
129
130/** A card still travelling the flow: its current step flickers. */
131const isMoving = (c: Card) => c.column !== 'Done' && FLOW.includes(c.column as (typeof FLOW)[number])
132
133type StepState = 'done' | 'current' | 'blocked' | 'todo'
134
135/** How a step is painted: filled when passed or current, outlined when ahead. */
136export const stepLook = (step: StepState, isLit: boolean) => {
137 // Every step carries the same square border so all of them have one shape;
138 // a filled step's border is the colour of its fill.
139 const fill = (color: string) => ({ backgroundColor: color, borderStyle: 'single', borderColor: color })
140 if (step === 'done') return fill('success')
141 if (step === 'blocked') return fill('error')
142 if (step === 'current') return fill(isLit ? 'claude' : 'warning')
143 return { backgroundColor: undefined, borderStyle: 'single', borderColor: 'inactive' }
144}
145
146const STEP_PX = 7
147// Plane's own state colours, so the stepper reads like the board.
148const SVG_COLOR = { done: '#46A758', blocked: '#EF4444', current: '#F59E0B', currentLit: '#D97706', todo: '#9AA4BC' }
149
150/**
151 * The desktop stepper: one rectangle per FLOW column across the band's width,
152 * passed and current ones filled, the ones ahead outlined; the current one
153 * flickers through an SVG animation, so no redraw is needed for it.
154 */
155export const stepperSvg = (states: StepState[]) => {
156 const span = 1000
157 const gap = 8
158 const w = (span - gap * (states.length - 1)) / states.length
159 const rects = states.map((step, i) => {
160 const x = (i * (w + gap)).toFixed(2)
161 const size = `x="${x}" y="0.5" width="${w.toFixed(2)}" height="${STEP_PX - 1}"`
162 if (step === 'todo') {
163 return `<rect ${size} fill="none" stroke="${SVG_COLOR.todo}" stroke-width="1" vector-effect="non-scaling-stroke"/>`
164 }
165 if (step === 'current') {
166 return (
167 `<rect ${size} fill="${SVG_COLOR.current}">` +
168 `<animate attributeName="fill" values="${SVG_COLOR.current};${SVG_COLOR.currentLit};${SVG_COLOR.current}" dur="1.4s" repeatCount="indefinite"/>` +
169 `</rect>`
170 )
171 }
172 return `<rect ${size} fill="${step === 'done' ? SVG_COLOR.done : SVG_COLOR.blocked}"/>`
173 })
174
175 return (
176 // Wider than any band: the desktop draws an SVG at its own width capped by
177 // the slot, so this makes it span the band; the height is pinned by the prop.
178 `<svg xmlns="http://www.w3.org/2000/svg" width="10000" height="${STEP_PX}" ` +
179 `viewBox="0 0 ${span} ${STEP_PX}" preserveAspectRatio="none">${rects.join('')}</svg>`
180 )
181}
182
183/** Terminal cell width so the nine steps and their gaps span the band. */
184const cellWidth = (columns: number) => Math.max(3, Math.floor((columns - (FLOW.length - 1)) / FLOW.length))
185
186/** What each FLOW cell shows for a card. */
187export const steps = (c: Pick<Card, 'column' | 'group' | 'flowIndex'>): StepState[] => {
188 const at = FLOW.indexOf(c.column as (typeof FLOW)[number])
189 const current = at >= 0 ? at : (c.flowIndex ?? -1)
190 const isDone = c.column === 'Done' || c.group === 'completed'
191 const isStuck = at < 0 && c.group !== 'completed'
192
193 return FLOW.map((_, i) => {
194 if (isDone || i < current) return 'done'
195 if (i === current) return isStuck ? 'blocked' : 'current'
196 return 'todo'
197 })
198}
199
200export const register: Register = on => {
201 on('session.start', async ($, e, next) => {
202 await $.command.register({
203 name: 'slonk-card',
204 description: 'Текущая карточка slonk: /slonk-card [KEY | off | hide | show]',
205 })
206 poll?.cancel()
207 flicker?.cancel()
208 flicker = $.clock.every(PULSE_MS, () => {
209 void read($, card).then(c => (c && isMoving(c) ? update($, pulse, p => !p) : undefined))
210 })
211 poll = $.clock.every(POLL_MS, () => {
212 if (canPoll) void read($, card).then(c => (c ? refresh($) : undefined))
213 })
214
215 return next(e)
216 })
217
218 on('session.end', async ($, e, next) => {
219 poll?.cancel()
220 poll = null
221 flicker?.cancel()
222 flicker = null
223
224 return next(e)
225 })
226
227 on('tool.call', { tool: MCP_TOOL }, async ($, e, next) => {
228 const ran = await next(e)
229 const [, server = '', name = ''] = MCP_TOOL.exec(e.tool) ?? []
230 if (ran.deny !== undefined || ran.isError) return ran
231
232 const args = e as unknown as Record<string, unknown>
233 const issueId = typeof args.issue_id === 'string' ? args.issue_id : undefined
234 const found = issuesIn(ran.text)
235 if (found.length > 0) slonkServers.add(server)
236 if (!isSlonk(server)) return ran
237 await update($, lastServer, () => server)
238
239 if (found.length === 1 && (issueId || name === 'create_issue' || name === 'get_issue')) {
240 // A single-card call: this card becomes the tracked one.
241 await store($, found[0]!, server)
242 } else if (found.length > 0) {
243 // A list: only refresh the tracked card if it is in there.
244 const current = await read($, card)
245 const same = current && found.find(i => i.key === current.key)
246 if (same) await store($, same, server)
247 } else if (issueId) {
248 // comment_issue, link_git_ref, ...: no state in the answer, ask for it.
249 const current = await read($, card)
250 if (current?.key !== issueId) {
251 await update($, card, () => ({ key: issueId, title: '', column: '…', group: '', server, checkedAt: 0 }))
252 }
253 await refresh($, issueId)
254 }
255
256 return ran
257 })
258
259 on('command.run', { command: 'slonk-card' }, async ($, e) => {
260 const arg = e.args.trim()
261
262 if (arg === 'off') {
263 await update($, card, () => null)
264 $.ui.status(undefined)
265 return { text: 'Слежение за карточкой выключено.' }
266 }
267 if (arg === 'hide' || arg === 'show') {
268 await update($, isHidden, () => arg === 'hide')
269 return { text: arg === 'hide' ? 'Полоса скрыта.' : 'Полоса показана.' }
270 }
271 if (arg && !ISSUE_KEY.test(arg.toUpperCase())) {
272 return { text: 'Использование: /slonk-card [KEY | off | hide | show]' }
273 }
274
275 return { text: await refresh($, arg ? arg.toUpperCase() : undefined) }
276 })
277
278 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
279 const c = await read($, card)
280 if (!c || e.props.hasSurvey || (await read($, isHidden))) return next(e)
281
282 const { Box, Button, Text } = $.ui.resolve(e)
283 const minutes = c.checkedAt ? Math.floor(((await $.clock.now()) - c.checkedAt) / 60_000) : null
284
285 // Only the terminal flickers by redraw; the desktop SVG animates itself.
286 const isLit = e.surface === 'desktop' ? false : await read($, pulse)
287
288 let desktopStepper: RenderElement | null = null
289 if (e.surface === 'desktop') {
290 const { Svg } = $.ui.resolve(e)
291 const states = steps(c)
292 const at = Math.min(states.filter(st => st === 'done').length + 1, FLOW.length)
293 desktopStepper = (
294 <Box width="100%" marginTop={1}>
295 <Svg
296 source={stepperSvg(states)}
297 alt={`${c.key}: ${c.column}, шаг ${at} из ${FLOW.length}`}
298 height={STEP_PX}
299 isInteractive
300 />
301 </Box>
302 )
303 }
304
305 return (
306 <Box flexDirection="column">
307 <Box flexDirection="row" width="100%">
308 <Box flexShrink={0}>
309 <Text dimColor wrap="truncate">slonk </Text>
310 <Text bold wrap="truncate">{c.key}</Text>
311 <Text wrap="truncate"> · </Text>
312 <Text bold wrap="truncate" color={GROUP_COLOR[c.group] ?? 'white'}>
313 {c.column}
314 </Text>
315 </Box>
316 <Box flexGrow={1} flexShrink={1} minWidth={0}>
317 {c.title !== '' && (
318 <Text dimColor wrap="truncate-end">
319 {' '}
320 {c.title}
321 </Text>
322 )}
323 </Box>
324 <Box flexShrink={0}>
325 {minutes !== null && minutes > 0 && <Text dimColor wrap="truncate"> · {minutes} мин назад</Text>}
326 <Text> </Text>
327 <Button key="refresh" label="Обновить" onPress={() => void refresh($)} />
328 <Button key="hide" label="Скрыть" onPress={() => update($, isHidden, () => true)} />
329 </Box>
330 </Box>
331 {desktopStepper ?? (
332 <Box flexDirection="row">
333 {steps(c).map((step, i) => {
334 const look = stepLook(step, isLit)
335 const width = cellWidth(e.props.bodyColumns)
336 return (
337 <Text key={`step-${i}`} backgroundColor={look.backgroundColor} color={look.borderColor}>
338 {i === 0 ? '' : ' '}
339 {look.backgroundColor ? ' '.repeat(width) : `▕${' '.repeat(Math.max(0, width - 2))}▏`}
340 </Text>
341 )
342 })}
343 </Box>
344 )}
345 </Box>
346 )
347 })
348}
349types/index.d.ts 30 lines1export type Card = {
2 /** Issue key, e.g. SLONK-37 */
3 key: string
4 title: string
5 /** Column (state name), e.g. "Code Review" */
6 column: string
7 /** Plane state group: backlog | unstarted | started | completed | cancelled */
8 group: string
9 /** MCP server the card was last seen through, e.g. slonk-developer */
10 server: string
11 /** Epoch ms of the last successful read */
12 checkedAt: number
13 /** Last refresh error, if the latest read failed */
14 error?: string
15 /** Position in FLOW of the last flow column the card was in (kept while Blocked) */
16 flowIndex?: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'slonk-card': {
22 card: Card | null
23 lastServer: string | null
24 isHidden: boolean
25 /** Toggles while a card is in progress: the stepper's current step flickers on it */
26 pulse: boolean
27 }
28 }
29}
30