Band above the prompt with the current branch's GitHub PR and a Merge button (via gh)

Marketplace модов Claude Code.
| Мод | Что делает |
|---|---|
turn-stats | Полоса над полем ввода: время работы сессии, токены (включая субагентов), квота |
pr-merge | Полоса с PR текущей ветки и кнопкой Merge через gh (настройки: mergeMethod, deleteBranch) |
session-progress | Прогресс-бар задачи сессии по todo-списку и плану модели (настройка: language) |
copy-session-link | Кнопка «🔗 Ссылка» в футере поля ввода Claude Code Desktop: копирует ссылку claude:// на текущую сессию |
visual-answer-improve | Итоговые секции ответа в цветных рамках с вкладкой-меткой: «Требуется решение» (оранжевая), «Сделано» (зелёная), «Не сделано» (мягкая красная). Правило про секции прикладывается к промптам пользователя (настройки: decisionColor, doneColor, notDoneColor) |
image-library | Панель «Библиотека» — галерея изображений (png/jpg/webp/gif/svg) из папки проекта (без папок сборки и зависимостей: node_modules, .git, Library, bin, dist, build и т. п.), папок сессий Claude Code этого проекта и Codex (~/.codex/generated_images): миниатюры, фильтр по источнику и сессии, сортировка по дате, постраничная навигация; «🔍» или имя — просмотр на всю панель с листанием, «📋 путь» — полный путь к файлу в буфер обмена, «↗» — открыть в системном приложении. Открывается кнопкой «🖼 Библиотека» в футере поля ввода или командой /library. Только Windows: сканирование и миниатюры через PowerShell и System.Drawing, кэш в %LOCALAPPDATA%\claude-image-library (настройки: includeCodex, scanLimit) |
/plugin marketplace add nerevar48/nerevar-mods
/plugin install turn-stats@nerevar-mods
/plugin install pr-merge@nerevar-mods
/plugin install session-progress@nerevar-mods
/plugin install copy-session-link@nerevar-mods
/plugin install visual-answer-improve@nerevar-mods
/plugin install image-library@nerevar-mods
Или в ~/.claude/settings.json:
"extraKnownMarketplaces": {
"nerevar-mods": { "source": { "source": "github", "repo": "nerevar48/nerevar-mods" } }
},
"enabledPlugins": {
"turn-stats@nerevar-mods": true,
"pr-merge@nerevar-mods": true,
"session-progress@nerevar-mods": true,
"copy-session-link@nerevar-mods": true,
"visual-answer-improve@nerevar-mods": true,
"image-library@nerevar-mods": true
}hooks/register.tsx 277 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { Checks, Pr } from '../types'
5
6const PLUGIN = 'pr-merge'
7const FIELDS = 'number,title,state,isDraft,mergeable,headRefOid,headRefName,baseRefName,statusCheckRollup'
8const MERGE_TIMEOUT_MS = 120_000
9const PASSED_CONCLUSIONS = ['SUCCESS', 'NEUTRAL', 'SKIPPED']
10
11const pr = atom({ plugin: 'pr-merge', key: 'pr' } as const, null)
12const phase = atom({ plugin: 'pr-merge', key: 'phase' } as const, 'idle')
13
14// The band is shared, and the engine chains mods in no fixed order: each mod
15// files its section under a rank (time 10, PR 20, progress 30), the sections
16// beneath come up as one column, and the column is kept sorted by rank, the
17// rest (core's own drawing) last.
18const COLUMN = 'band-sections'
19const SECTION = 'band-section:'
20const RANK = 20
21
22function rankOf(node: RenderNode): number {
23 const key = typeof node === 'object' && node.type === 'Box' ? node.props?.key : undefined
24 const rank = typeof key === 'string' && key.startsWith(SECTION) ? Number(key.slice(SECTION.length)) : NaN
25 return Number.isFinite(rank) ? rank : Number.MAX_SAFE_INTEGER
26}
27
28function stacked(below: RenderElement, section: RenderElement): RenderElement {
29 const rest = below.type === 'Box' && below.props?.key === COLUMN ? (below.children ?? []) : [below]
30 const children = [section, ...rest].sort((a, b) => rankOf(a) - rankOf(b))
31 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children }
32}
33
34type Check = { __typename?: string; status?: string; conclusion?: string; state?: string }
35
36function checkResult(check: Check): keyof Checks {
37 if (check.__typename === 'CheckRun') {
38 if (check.status !== 'COMPLETED') return 'pending'
39 return PASSED_CONCLUSIONS.includes(check.conclusion ?? '') ? 'passed' : 'failed'
40 }
41 if (check.state === 'SUCCESS') return 'passed'
42 if (check.state === 'PENDING' || check.state === 'EXPECTED') return 'pending'
43 return 'failed'
44}
45
46function summarize(list: readonly Check[]): Checks {
47 const checks: Checks = { passed: 0, failed: 0, pending: 0 }
48 for (const check of list) checks[checkResult(check)] += 1
49 return checks
50}
51
52// One styled run of the PR line.
53type Part = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
54
55function checkParts({ passed, failed, pending }: Checks): Part[] {
56 if (passed + failed + pending === 0) return [{ text: 'нет проверок', isDim: true }]
57 const counts: Part[] = [{ text: `✓${passed}`, color: 'green' }]
58 if (failed > 0) counts.push({ text: `✗${failed}`, color: 'red' })
59 if (pending > 0) counts.push({ text: `…${pending}`, color: 'yellow' })
60 return counts.flatMap((part, i) => (i === 0 ? [part] : [{ text: ' ' }, part]))
61}
62
63function prParts(current: Pr): Part[] {
64 const dot: Part = { text: ' · ', isDim: true }
65 return [
66 { text: `PR #${current.number}`, color: 'magentaBright', isBold: true },
67 { text: ` ${current.title}` },
68 { text: ` → ${current.base}`, isDim: true },
69 dot,
70 ...checkParts(current.checks),
71 ...(current.hasConflicts ? [dot, { text: 'конфликт', color: 'red' }] : []),
72 ...(current.isDraft ? [dot, { text: 'черновик', color: 'yellow' }] : []),
73 ]
74}
75
76function firstLine(text: string): string {
77 return text.trim().split('\n')[0] ?? ''
78}
79
80function errorText(error: unknown): string {
81 return error instanceof Error ? error.message : String(error)
82}
83
84async function refresh($: EngineInterface): Promise<Pr | null> {
85 let next: Pr | null = null
86 try {
87 const { exitCode, stdout } = await $.process.run(['gh', 'pr', 'view', '--json', FIELDS])
88 const view = exitCode === 0 ? JSON.parse(stdout) : null
89 if (view?.state === 'OPEN') {
90 next = {
91 number: view.number,
92 title: view.title,
93 base: view.baseRefName,
94 head: view.headRefName,
95 headSha: view.headRefOid,
96 isDraft: view.isDraft,
97 hasConflicts: view.mergeable === 'CONFLICTING',
98 checks: summarize(view.statusCheckRollup ?? []),
99 }
100 }
101 } catch {
102 // No gh, no git repo or no PR for this branch: the band stays hidden.
103 }
104 await update($, pr, () => next)
105 return next
106}
107
108// One git step of the sync after the merge; a failure is reported and stops the sync.
109async function git($: EngineInterface, args: readonly string[]): Promise<boolean> {
110 try {
111 const { exitCode, stderr } = await $.process.run(['git', ...args], { timeoutMs: MERGE_TIMEOUT_MS })
112 if (exitCode === 0) return true
113 $.ui.toast(`git ${args[0]} не удался: ${firstLine(stderr)}`)
114 } catch (error) {
115 $.ui.toast(`git ${args[0]} не удался: ${errorText(error)}`)
116 }
117 return false
118}
119
120// Tracked changes only: untracked files do not block a switch of branch.
121async function hasLocalChanges($: EngineInterface): Promise<boolean> {
122 const { stdout } = await $.process.run(['git', 'status', '--porcelain', '--untracked-files=no'])
123 return stdout.trim() !== ''
124}
125
126function resolvePrompt(target: Pr): string {
127 return [
128 `PR #${target.number} (${target.head} → ${target.base}) конфликтует с ${target.base}, подготовь его к merge.`,
129 `Сделай git fetch, влей origin/${target.base} в ${target.head} и разреши конфликты, сохранив смысл изменений обеих сторон.`,
130 'Проверь, что затронутый код собирается, закоммить merge и запушь ветку.',
131 'Сам PR не мержь: после этого хода его смержит плагин pr-merge.',
132 ].join(' ')
133}
134
135type MergeOptions = { method: string; deleteBranch: boolean }
136
137// Merges on GitHub, then switches the checkout to the base branch and brings it up to date.
138async function merge($: EngineInterface, target: Pr, { method, deleteBranch }: MergeOptions): Promise<void> {
139 await update($, phase, () => 'merging')
140 try {
141 const argv = ['gh', 'pr', 'merge', String(target.number), `--${method}`, '--match-head-commit', target.headSha]
142 if (deleteBranch) argv.push('--delete-branch')
143 const { exitCode, stderr } = await $.process.run(argv, { timeoutMs: MERGE_TIMEOUT_MS })
144 if (exitCode !== 0) {
145 $.ui.toast(`Merge не удался: ${firstLine(stderr)}`)
146 return
147 }
148 const isSynced =
149 (await git($, ['switch', target.base])) && (await git($, ['fetch'])) && (await git($, ['pull', '--ff-only']))
150 $.ui.toast(
151 isSynced
152 ? `PR #${target.number} смержен, ${target.base} обновлён`
153 : `PR #${target.number} смержен, но ${target.base} не обновлён`,
154 )
155 } catch (error) {
156 $.ui.toast(`Merge не удался: ${errorText(error)}`)
157 } finally {
158 await update($, phase, () => 'idle')
159 await refresh($)
160 }
161}
162
163// The confirmed press: a conflicting PR goes to Claude first, the merge follows its turn.
164async function start($: EngineInterface, options: MergeOptions): Promise<void> {
165 if (await hasLocalChanges($)) {
166 $.ui.toast('Есть незакоммиченные изменения: закоммить или убери их перед merge')
167 await update($, phase, () => 'idle')
168 return
169 }
170 const target = await refresh($)
171 if (target === null) {
172 await update($, phase, () => 'idle')
173 return
174 }
175 if (target.hasConflicts) {
176 await update($, phase, () => 'resolving')
177 void $.prompt.submit({ text: resolvePrompt(target) })
178 return
179 }
180 await merge($, target, options)
181}
182
183// After Claude's resolving turn: merge once the PR no longer conflicts, else stop.
184async function resume($: EngineInterface, isAborted: boolean, options: MergeOptions): Promise<void> {
185 const target = await refresh($)
186 if (isAborted || target === null || target.hasConflicts || (await hasLocalChanges($))) {
187 $.ui.toast('Конфликты не разрешены до конца, merge отменён')
188 await update($, phase, () => 'idle')
189 return
190 }
191 await merge($, target, options)
192}
193
194export const register: Register = (on, options) => {
195 const mergeOptions: MergeOptions = {
196 method: String(options.mergeMethod),
197 deleteBranch: options.deleteBranch === true,
198 }
199 const { method } = mergeOptions
200
201 on('session.start', async ($, e, next) => {
202 // A reload ends a merge that was running; a resolving turn carries on.
203 if ((await read($, phase)) === 'merging') await update($, phase, () => 'idle')
204 await refresh($)
205 return next(e)
206 })
207
208 on('turn.complete', async ($, e, next) => {
209 const result = await next(e)
210 if (e.agentId === undefined && (await read($, phase)) === 'resolving') {
211 await resume($, e.isAborted, mergeOptions)
212 } else {
213 await refresh($)
214 }
215 return result
216 })
217
218 on('ui.press', { plugin: PLUGIN }, async ($, e) => {
219 if (e.element === 'refresh') await refresh($)
220 if (e.element === 'merge') await update($, phase, () => 'confirm')
221 if (e.element === 'cancel') await update($, phase, () => 'idle')
222 if (e.element === 'confirm') await start($, mergeOptions)
223 return { element: e.element }
224 })
225
226 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227 const current = await read($, pr)
228 if (e.props.hasSurvey || current === null) {
229 return next(e)
230 }
231
232 const below = await next(e)
233 const { Box, Button, Text } = $.ui.resolve(e)
234 const step = await read($, phase)
235 // During a running turn the resolving prompt would queue behind it, so no merge starts then.
236 const canMerge = !current.isDraft && !e.props.isWorking
237 const pressed = () => undefined
238
239 // The PR line gives way to the controls: it truncates, they keep their width.
240 return stacked(
241 below,
242 <Box key={`${SECTION}${RANK}`} flexDirection="column">
243 <Box columnGap={1}>
244 <Box flexShrink={1}>
245 <Text wrap="truncate-end">
246 {prParts(current).map(part => (
247 <Text color={part.color} dimColor={part.isDim} bold={part.isBold}>
248 {part.text}
249 </Text>
250 ))}
251 </Text>
252 </Box>
253 <Box flexShrink={0} columnGap={1}>
254 {step === 'merging' && <Text color="yellow">мержу…</Text>}
255 {step === 'resolving' && <Text color="yellow">Claude разрешает конфликты…</Text>}
256 {step === 'resolving' && <Button key="cancel" label="Отмена" onPress={pressed} />}
257 {step === 'confirm' && canMerge && (
258 <Text>
259 {current.hasConflicts ? 'Разрешить конфликты и смержить' : 'Смержить'} ({method}), затем на{' '}
260 {current.base}?
261 </Text>
262 )}
263 {step === 'confirm' && canMerge && (
264 <Button key="confirm" label="Подтвердить" variant="primary" onPress={pressed} />
265 )}
266 {step === 'confirm' && canMerge && <Button key="cancel" label="Отмена" onPress={pressed} />}
267 {step === 'idle' && canMerge && (
268 <Button key="merge" label={`Merge PR #${current.number} (${method})`} variant="primary" onPress={pressed} />
269 )}
270 {step === 'idle' && <Button key="refresh" label="Обновить" onPress={pressed} />}
271 </Box>
272 </Box>
273 </Box>,
274 )
275 })
276}
277types/index.d.ts 22 lines1export type Checks = { passed: number; failed: number; pending: number }
2
3export type Pr = {
4 number: number
5 title: string
6 base: string
7 head: string
8 headSha: string
9 isDraft: boolean
10 hasConflicts: boolean
11 checks: Checks
12}
13
14// resolving: Claude merges the base into the branch and fixes conflicts in its own turn.
15export type Phase = 'idle' | 'confirm' | 'resolving' | 'merging'
16
17declare module 'claude-code' {
18 interface PluginState {
19 'pr-merge': { pr: Pr | null; phase: Phase }
20 }
21}
22