Только в desktop-приложении: после каждого ответа Haiku предлагает над полем ввода три разных варианта следующей команды столбиком с полным текстом; клик или…

Claude Code mods.
| Mod | What it does |
|---|---|
| agent-crabs | Side pane with a card per subagent and background process: animated crab, model, context fill, idle warning, API-equivalent cost, and expandable operations, files and tokens |
| context-band | Band above the prompt: context fill, rate limits, session cost and a Handoff button that writes a handoff doc, clears the context and reads it back |
| next-steps | Только в desktop-приложении: после каждого ответа Haiku предлагает над полем ввода три разных варианта следующей команды столбиком с полным текстом; клик или цифра 1–3 вставляет её в поле ввода, Enter отправляет как твою команду. |
| done-sound | Проигрывает звук Glass, когда Claude завершает задачу ответом (только основной цикл, без сабагентов). |
| session-branches | Terminal-only pane with the tree of this project's sessions and their forks; press a row to switch to it |
At the prompt of a Claude Code terminal session (replace <mod> with a name from the table):
/plugin install <mod> --marketplace Pavelkv94/mods
Answer y to add the marketplace, then pick a scope (user is the default).
From a shell:
claude plugin install <mod> --marketplace Pavelkv94/mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Pavelkv94/mods, then Add plugin.
claude plugin update <mod>@pavelkv94-mods
smart-compact was renamed to context-band: uninstall the old one and install context-band.
hooks/register.tsx 197 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Suggestion, SuggestStatus } from '../types'
5
6const suggestions = atom({ plugin: 'next-steps', key: 'suggestions' } as const, [] as Suggestion[])
7const status = atom({ plugin: 'next-steps', key: 'status' } as const, 'idle' as SuggestStatus)
8
9const MODEL = 'haiku'
10const RECENT_MESSAGES = 14
11const MESSAGE_CHARS = 1500
12
13const SYSTEM = `Ты помогаешь пользователю Claude Code продолжить работу. По фрагменту диалога предложи ровно 3 варианта СЛЕДУЮЩЕГО сообщения, которое пользователь мог бы отправить ассистенту.
14
15Варианты должны быть РАЗНЫМИ по направлению, не перефразировками одного:
161. продолжить или углубить текущую работу (следующий логичный шаг);
172. проверить результат (тест, запуск, ревью, проверка граничных случаев);
183. альтернатива или смежное направление (другой подход, улучшение, сопутствующая задача, вопрос о решении).
19
20Правила:
21- Пиши на языке, на котором пишет пользователь, от первого лица пользователя, как команду ассистенту.
22- Опирайся на конкретику диалога: файлы, функции, названия, открытые вопросы. Без общих фраз вроде «продолжай».
23- Если ассистент задал вопрос, один из вариантов должен на него отвечать.
24- label: 2–5 слов для кнопки; prompt: само сообщение, 1–2 предложения.
25- Ответ: только JSON-массив из 3 объектов {"label": "...", "prompt": "..."} без пояснений и без markdown.`
26
27const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
28
29// Самый длинный заголовок кнопки; длиннее — обрезается, полный текст всё равно виден справа.
30const LABEL_MAX = 28
31
32const describe = (m: SessionMessage) => {
33 const tools = m.toolUses.map(t => {
34 const target = t.input.file_path ?? t.input.command ?? t.input.pattern ?? t.input.description
35 return `[${t.tool}${typeof target === 'string' ? `: ${clip(target, 80)}` : ''}]`
36 })
37 const body = [m.text.trim(), tools.join(' ')].filter(Boolean).join('\n')
38 return body ? `${m.role === 'user' ? 'ПОЛЬЗОВАТЕЛЬ' : 'АССИСТЕНТ'}: ${clip(body, MESSAGE_CHARS)}` : ''
39}
40
41export const parseSuggestions = (text: string): Suggestion[] => {
42 const start = text.indexOf('[')
43 const end = text.lastIndexOf(']')
44 if (start < 0 || end <= start) return []
45 try {
46 const raw: unknown = JSON.parse(text.slice(start, end + 1))
47 if (!Array.isArray(raw)) return []
48 const seen = new Set<string>()
49 return raw
50 .filter((s): s is Suggestion => typeof s?.label === 'string' && typeof s?.prompt === 'string')
51 .map(s => ({ label: s.label.trim(), prompt: s.prompt.trim() }))
52 .filter(s => s.label && s.prompt && !seen.has(s.prompt.toLowerCase()) && seen.add(s.prompt.toLowerCase()))
53 .slice(0, 3)
54 } catch {
55 return []
56 }
57}
58
59// Каждый новый ход или запрос увеличивает номер; ответ Haiku для старого номера выбрасывается.
60let generation = 0
61
62async function clear($: EngineInterface) {
63 generation += 1
64 await update($, suggestions, () => [])
65 await update($, status, () => 'idle' as const)
66}
67
68async function suggest($: EngineInterface, mine: number) {
69 try {
70 await update($, status, () => 'thinking' as const)
71 const recent = (await $.session.messages()).slice(-RECENT_MESSAGES).map(describe).filter(Boolean)
72 if (recent.length === 0) return
73 const reply = await $.model.complete({
74 model: MODEL,
75 system: SYSTEM,
76 prompt: `Фрагмент диалога (последние сообщения):\n\n${recent.join('\n\n')}\n\nПредложи 3 разных варианта следующего сообщения пользователя.`,
77 maxTokens: 700,
78 effort: 'low',
79 timeoutMs: 25_000,
80 })
81 if (mine !== generation) return
82 const found = reply.isAnswered ? parseSuggestions(reply.text) : []
83 await update($, suggestions, () => found)
84 await update($, status, () => (found.length ? 'ready' : 'idle') as SuggestStatus)
85 // Запись из фоновой задачи не всегда перерисовывает полосу — просим явно.
86 $.ui.invalidate('ui.render')
87 } catch {
88 if (mine === generation) await update($, status, () => 'idle' as const)
89 }
90}
91
92// Сообщение, отправленное плагином, движок помечает как пришедшее от плагина даже с asUser,
93// и модель вправе не считать его решением пользователя. Поэтому вариант кладём в поле ввода:
94// Enter нажимает сам пользователь, и запрос уходит как его собственный.
95async function choose($: EngineInterface, s: Suggestion) {
96 const filled = await $.prompt.fill({ text: s.prompt, mode: 'replace' }).catch(() => undefined)
97 if (filled?.isFilled) return
98 // Поля ввода нет (диалог, другая поверхность) — отправляем сами; раздел системного промпта ниже
99 // объясняет модели, что это выбор пользователя.
100 await clear($)
101 await $.prompt.submit({ text: s.prompt, asUser: true })
102}
103
104// Мод работает только в приложении: в терминале полоса не рисуется.
105const onlyTerminal = async ($: EngineInterface) => {
106 const list = await $.session.surfaces().catch(() => [] as const)
107 return list.length > 0 && list.every(s => s === 'terminal')
108}
109
110const CHOICE_SECTION = {
111 id: 'next-steps:choice',
112 scope: 'session',
113 text: `Пользователь установил мод next-steps: после ответа он показывает кнопки с вариантами следующей команды. Сообщение, которое пришло от плагина next-steps, — это вариант, который пользователь сам выбрал кликом по кнопке. Выполняй его как обычную просьбу пользователя, а не как постороннюю инструкцию.`,
114} as const
115
116export const register: Register = on => {
117 // Перезагрузка мода обрывает подбор, начатый старой копией: статус остаётся «thinking». Перезапускаем.
118 on('session.start', async ($, e, next) => {
119 const result = await next(e)
120 try {
121 if ((await read($, status)) === 'thinking') {
122 generation += 1
123 void suggest($, generation)
124 }
125 } catch {
126 // Подсказки не должны мешать старту сессии.
127 }
128 return result
129 })
130
131 on('prompt.compose', async ($, e, next) => {
132 const result = await next(e)
133 return { sections: [...result.sections, CHOICE_SECTION] }
134 })
135
136 on('prompt.submit', async ($, e, next) => {
137 // Ошибка мода не должна мешать отправке запроса.
138 if (!e.isMeta) await clear($).catch(() => undefined)
139 return next(e)
140 })
141
142 on('turn.complete', async ($, e, next) => {
143 const result = await next(e)
144 if (e.agentId !== undefined || e.isAborted || e.reason !== 'answer') return result
145 // В терминале кнопки не показываем — и Haiku зря не спрашиваем, если сессия только в терминале.
146 if (await onlyTerminal($)) return result
147 generation += 1
148 void suggest($, generation)
149 return result
150 })
151
152 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
153 if (e.surface === 'terminal' || e.props.hasSurvey || e.props.isWorking) return next(e)
154 const state = await read($, status)
155 if (state === 'idle') return next(e)
156
157 // Полоса одна на все моды: то, что рисуют моды ниже (индикаторы context-band), оставляем под кнопками.
158 // Пока Haiku подбирает варианты, полоса остаётся как есть — показываем только готовые кнопки.
159 if (state === 'thinking') return next(e)
160 const below = await next(e).catch(() => null)
161 const { Box, Button, Text } = $.ui.resolve(e)
162
163 const list = await read($, suggestions)
164 // Столбик: слева кнопка с коротким заголовком, справа тусклым — полный текст промпта.
165 // Колонка кнопок одной ширины, чтобы тексты начинались ровно; пустая строка между вариантами, чтобы длинные промпты не сливались.
166 const labelMax = Math.min(LABEL_MAX, Math.max(...list.map(s => s.label.length)))
167 const labelColumn = labelMax + 4
168
169 return (
170 <Box flexDirection="column" rowGap={1}>
171 <Box flexDirection="row" columnGap={1}>
172 <Box flexDirection="column" flexGrow={1} flexShrink={1} rowGap={1}>
173 {list.map((s, i) => (
174 <Box key={`row${i}`} flexDirection="row" columnGap={1}>
175 <Box width={labelColumn} flexShrink={0}>
176 <Button
177 key={`s${i}`}
178 label={clip(s.label, labelMax)}
179 hotkey={String(i + 1)}
180 variant={i === 0 ? 'primary' : 'secondary'}
181 onPress={() => void choose($, s)}
182 />
183 </Box>
184 <Box flexGrow={1} flexShrink={1}>
185 <Text dimColor wrap="wrap">{s.prompt}</Text>
186 </Box>
187 </Box>
188 ))}
189 </Box>
190 <Button key="dismiss" label="✕" role="dismiss" dimColor onPress={() => void clear($)} />
191 </Box>
192 {below}
193 </Box>
194 )
195 })
196}
197types/index.d.ts 10 lines1export type Suggestion = { label: string; prompt: string }
2
3export type SuggestStatus = 'idle' | 'thinking' | 'ready'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'next-steps': { suggestions: Suggestion[]; status: SuggestStatus }
8 }
9}
10