SLOPSHOPPER

next-steps

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

newbandpromptmodel
v0.5.0no licenseupdated 2026-10-26Pavelkv94/mods/next-steps
A shopper browsing a rack in a slop shop
README

mods

Claude Code mods.

ModWhat it does
agent-crabsSide 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-bandBand 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-branchesTerminal-only pane with the tree of this project's sessions and their forks; press a row to switch to it

Install

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.

Update

claude plugin update <mod>@pavelkv94-mods

smart-compact was renamed to context-band: uninstall the old one and install context-band.

Source 2 files
hooks/register.tsx 197 lines
1import { 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}
197
types/index.d.ts 10 lines
1export 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