SLOPSHOPPER

next-step

Кнопка «Что дальше?» под последним ответом: дешёвая модель читает сессию, пишет, на чём остановились и что дальше, и предлагает готовые сообщения

newpanerowscommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-step
│ ┃ Что дальше? ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ undefined is not an object (evaluating ⏺ Read(src/auth.ts) │ ┃ 'tool.endsWith') ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Повторить ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /next-step │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Что дальше?
undefined is not an object (evaluating 'tool.endsWith') [ Повторить ]
README

next-step

версия Claude Code

Кнопка «Что дальше?» под последним ответом. Она напомнит, на чём вы остановились, и предложит, что написать дальше.

Кнопка «Что дальше?» под ответом

Панель «Что дальше?»

Как работает

  1. Под последним ответом Claude появляется кнопка «Что дальше?».
  2. Вы нажимаете её, и дешёвая модель читает сессию.
  3. В панели появляются два раздела: «На чём остановились» и «Что дальше».
  4. Ниже идут готовые сообщения карточками. Вы выбираете одно, и оно попадает в чат.

Новое сообщение в чате или /clear закрывают устаревшую панель и отменяют запрос, если он ещё идёт.

Команды

КомандаЧто делает
/next-stepОткрыть панель без кнопки

Настройка

Модель для сводки выбирается в настройках мода:

ЗначениеКогда брать
haiku (по умолчанию)Быстрее и дешевле
sonnetТочнее на длинных и запутанных сессиях

Установка

claude plugin marketplace add petrovyuri/claude-code-mods
claude plugin install next-step@petrovyuri-mods --scope user

В открытой сессии выполните /reload-plugins.

Изменения по версиям: CHANGELOG.md.

Source 6 files
hooks/register.tsx 249 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Phase } from '../types'
5import { briefOf, briefPrompt, describeNoReply, endsAnswer, lastAnswerOf } from './brief'
6import { afterFill } from './fill'
7import { PANE, drawPane, drawUnderReply } from './view'
8
9// The cheap model that reads the session unless the person picked another in /config.
10export const DEFAULT_MODEL = 'haiku'
11// Room for the JSON a model wraps two short summaries and four messages in.
12const MAX_TOKENS = 1_200
13const TIMEOUT_MS = 90_000
14
15const IDLE: Phase = { kind: 'idle' }
16
17const phase = atom({ plugin: 'next-step', key: 'phase' } as const, IDLE)
18const lastAnswer = atom({ plugin: 'next-step', key: 'lastAnswer' } as const, null as string | null)
19const cardsFailed = atom({ plugin: 'next-step', key: 'cardsFailed' } as const, false)
20
21const firstLine = (error: unknown): string =>
22  (error instanceof Error ? error.message : String(error)).split('\n')[0]?.trim() ?? ''
23
24// The model the person picked in /config; set at each load.
25let model = DEFAULT_MODEL
26// The call in flight: Cancel, a new prompt or a /clear stops it, and its late reply with it.
27let running: AbortController | null = null
28
29function stop(): void {
30  running?.abort()
31  running = null
32}
33
34async function dismiss($: EngineInterface): Promise<void> {
35  stop()
36  await update($, phase, () => IDLE)
37}
38
39// The pane has done its part, or what it shows is stale. Not open, it is left alone.
40async function closePane($: EngineInterface): Promise<void> {
41  try {
42    await $.ui.close({ id: PANE })
43  } catch {
44    // A hook kept it open: the person closes it.
45  }
46}
47
48// The model reads a digest of the session and answers where it stopped, what comes next
49// and the messages to pick.
50async function ask($: EngineInterface): Promise<void> {
51  // A press on a button drawn before the running call hid it.
52  if (running !== null) {
53    return
54  }
55  const controller = new AbortController()
56  running = controller
57  let next: Phase
58  try {
59    const request = briefPrompt(await $.session.messages())
60    if (request === null) {
61      running = null
62      $.ui.toast('В сессии пока нечего пересказывать')
63
64      return
65    }
66    await update($, phase, () => ({ kind: 'loading' }))
67    const reply = await $.model.complete(
68      { model, ...request, maxTokens: MAX_TOKENS, effort: 'low', timeoutMs: TIMEOUT_MS },
69      { signal: controller.signal },
70    )
71    if (reply.isAnswered) {
72      const brief = briefOf(reply.text)
73      next = brief === null ? { kind: 'failed', message: 'Модель вернула пустой ответ' } : { kind: 'ready', brief }
74    } else {
75      next = { kind: 'failed', message: describeNoReply(reply) }
76    }
77  } catch (error) {
78    next = { kind: 'failed', message: firstLine(error) || 'Не удалось получить сводку' }
79  }
80  // Cancelled, or the conversation moved on while the model read it: the reply is stale.
81  if (running !== controller) {
82    return
83  }
84  running = null
85  await update($, phase, () => next)
86}
87
88// From the button under the prompt and from /next-step: the pane opens on the brief, made
89// afresh unless one is being made or stands ready (its Обновить makes another).
90async function openBrief($: EngineInterface): Promise<void> {
91  if (running === null && (await read($, phase)).kind !== 'ready') {
92    void ask($)
93  }
94  const opened = await $.ui.open({ id: PANE, title: 'Что дальше?', focus: true, closeOnEscape: true })
95  if (!opened.isPlaced) {
96    $.ui.toast('Панель «Что дальше?» не помещается: расширьте окно')
97  }
98}
99
100// The picked message goes where the person types: after a draft they have, else in its
101// place. A surface that draws its own prompt box takes no fill: there it is sent as theirs.
102async function pick($: EngineInterface, text: string, surface: RenderSurface): Promise<void> {
103  const draft = await $.prompt.read()
104  const hasDraft = draft.text.trim() !== ''
105  const filled = await $.prompt.fill(hasDraft ? { text: `\n${text}`, mode: 'append' } : { text, mode: 'replace' })
106  switch (afterFill(filled)) {
107    case 'filled':
108      await update($, phase, () => IDLE)
109      await closePane($)
110
111      return
112    case 'dialog':
113      $.ui.toast('Сначала закройте диалог, затем выберите вариант снова')
114
115      return
116    case 'send': {
117      await update($, phase, () => IDLE)
118      await closePane($)
119      const sent = await $.prompt.submit({ text, asUser: true })
120      if (sent.drop !== undefined) {
121        $.ui.toast(`Сообщение не отправлено: ${sent.drop}`)
122      }
123
124      return
125    }
126    case 'copy': {
127      const copied = await $.ui.copy({ text, surface })
128      $.ui.toast(copied.isCopied ? 'Текст скопирован: вставьте его в поле ввода' : 'Не удалось вставить текст')
129    }
130  }
131}
132
133export const register: Register = (on, options) => {
134  model = typeof options.model === 'string' && options.model !== '' ? options.model : DEFAULT_MODEL
135  running = null
136
137  on('session.start', async ($, e, next) => {
138    // State outlives a reload of the mod; a call the reload cut short never ends.
139    if ((await read($, phase)).kind === 'loading') {
140      await update($, phase, () => IDLE)
141    }
142    // Loaded into a conversation already under way, the button goes under its last answer.
143    try {
144      const answer = lastAnswerOf(await $.session.messages())
145      await update($, lastAnswer, () => answer)
146    } catch {
147      // Nothing to read yet: the first answer shows the button.
148    }
149    await $.command.register({
150      name: 'next-step',
151      description: 'Коротко: на чём остановились и что делать дальше, с готовыми сообщениями на выбор',
152    })
153
154    return next(e)
155  })
156
157  on('command.run', { command: 'next-step' }, async $ => {
158    await openBrief($)
159
160    return {}
161  })
162
163  // A prompt entering the session moves the conversation on: the brief is stale, and the
164  // button waits for the answer to come.
165  on('prompt.submit', async ($, e, next) => {
166    if (running !== null || (await read($, phase)).kind !== 'idle') {
167      await dismiss($)
168      await closePane($)
169    }
170    if ((await read($, lastAnswer)) !== null) {
171      await update($, lastAnswer, () => null)
172    }
173
174    return next(e)
175  }).catch(($, e, next) => next(e))
176
177  on('turn.complete', async ($, e, next) => {
178    // A subagent's loop is no answer of the main conversation's.
179    if (e.agentId === undefined) {
180      await update($, lastAnswer, () => (e.answer.trim() === '' ? null : e.answer))
181    }
182
183    return next(e)
184  })
185
186  on('session.end', async ($, e, next) => {
187    if (e.reason === 'clear') {
188      await dismiss($)
189      await closePane($)
190      await update($, lastAnswer, () => null)
191    }
192
193    return next(e)
194  })
195
196  // The button sits under Claude's last reply: drawn on every surface, the desktop's
197  // included, and in a site no other plugin draws in. The reply itself is the engine's.
198  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
199    const answer = await read($, lastAnswer)
200    if (answer === null || e.props.isSummary === true || !endsAnswer(answer, e.props.text)) {
201      return next(e)
202    }
203    const reply = await next(e)
204
205    return drawUnderReply($.ui.resolve(e), reply, () => void openBrief($))
206  })
207
208  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
209    const surface = e.surface
210    // The desktop draws the cards with the surface module, unless it could not run it.
211    const asClient = surface === 'desktop' && !(await read($, cardsFailed))
212
213    return drawPane(
214      $.ui.resolve(e),
215      await read($, phase),
216      {
217        ask: () => void ask($),
218        cancel: () => void dismiss($),
219        pick: text => void pick($, text, surface),
220      },
221      asClient,
222    )
223  })
224
225  // A card clicked on the desktop: its module posts the message's number.
226  on('ui.message', async ($, e, next) => {
227    const data = e.data as { pick?: unknown } | null
228    const current = await read($, phase)
229    if (e.requestId === PANE && current.kind === 'ready' && typeof data?.pick === 'number') {
230      const option = current.brief.options[data.pick - 1]
231      if (option !== undefined) {
232        await pick($, option, e.surface)
233      }
234    }
235
236    return next(e)
237  })
238
239  // The desktop could not run the card module: the cards fall back to frames with a button.
240  on('ui.fault', async ($, e, next) => {
241    if (e.requestId === PANE && !(await read($, cardsFailed))) {
242      $.ui.log(`next-step: карточки не нарисовались (${e.reason}), показываю кнопки`, { to: 'debug' })
243      await update($, cardsFailed, () => true)
244    }
245
246    return next(e)
247  })
248}
249
hooks/brief.ts 271 lines
1import type { SessionMessage, ToolUseSummary } from 'claude-code'
2
3import type { Brief } from '../types'
4
5// What the model reads of the session: the cheap model needs the gist, not every byte.
6export const MAX_DIGEST = 30_000
7// One message, and one message with its tool calls, as the digest keeps them.
8export const MAX_TEXT = 1_500
9export const MAX_ENTRY = 4_000
10const MAX_ARGUMENT = 160
11const MAX_ERROR = 200
12// Kept for the note of how many messages the digest left out.
13const NOTE_ROOM = 40
14
15// What the pane shows of the reply: a few lines and buttons, not an essay.
16export const MAX_OPTIONS = 4
17export const MAX_OPTION = 300
18export const MAX_SUMMARY = 600
19
20// The rules the completion runs under. Written so the reply is one JSON object: nothing to
21// cut away but a fence or a sentence around it, which parseBrief skips as well.
22const RULES = [
23  'Ты помогаешь человеку вернуться к работе в сессии Claude Code.',
24  'Тебе дана выжимка разговора человека с Claude: реплики (длинные обрезаны), вызовы инструментов (строки со стрелкой →) и план задач, если он был.',
25  'Ответь одним JSON-объектом без markdown и без текста вокруг: {"status": "...", "next": "...", "options": ["...", "..."]}.',
26  'status — одно-два коротких предложения: на чём остановились, что сделано последним и что осталось незавершённым.',
27  'next — одно-два коротких предложения: что логично сделать дальше.',
28  'options — от 2 до 4 готовых сообщений, которые человек может отправить Claude следующим шагом.',
29  'Каждое — законченная просьба от лица человека, обращённая к Claude, конкретная (файлы, команды, задачи из разговора), до 200 символов.',
30  'Если последняя реплика Claude — вопрос к человеку, первый вариант — ответ на этот вопрос.',
31  'Пиши на языке разговора.',
32].join(' ')
33
34export type PlanItem = { content: string; status: 'pending' | 'in_progress' | 'completed' }
35
36const cut = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max)}…`)
37
38const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
39
40// What the engine wraps around a message: reminders and caveats are dropped whole, a slash
41// command's tags give way to the text they hold.
42const DROPPED = /<(system-reminder|local-command-caveat)>[\s\S]*?<\/\1>/g
43const UNWRAPPED = /<\/?(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>/g
44
45export function cleanText(text: string): string {
46  return text
47    .replace(DROPPED, '')
48    .replace(UNWRAPPED, ' ')
49    .replace(/[ \t]+\n/g, '\n')
50    .replace(/\n{3,}/g, '\n\n')
51    .trim()
52}
53
54// The task list: Claude Code's TodoWrite, or the desktop app's update_plan.
55const isPlanTool = (tool: string): boolean => tool === 'TodoWrite' || tool.endsWith('update_plan')
56
57const STATUSES: readonly string[] = ['pending', 'in_progress', 'completed']
58
59function isPlanItem(value: unknown): value is PlanItem {
60  if (typeof value !== 'object' || value === null) {
61    return false
62  }
63  const item = value as Record<string, unknown>
64
65  return typeof item.content === 'string' && typeof item.status === 'string' && STATUSES.includes(item.status)
66}
67
68const numbers = (value: unknown): number[] =>
69  Array.isArray(value) ? value.filter((n): n is number => typeof n === 'number') : []
70
71// One call of a plan tool over the plan so far: a whole list replaces it; update_plan's
72// `advance`, `done` and `start` move it on by its 1-based step numbers.
73export function applyPlanCall(plan: readonly PlanItem[], input: Record<string, unknown>): PlanItem[] {
74  const list = Array.isArray(input.todos) ? input.todos : Array.isArray(input.items) ? input.items : undefined
75  if (list !== undefined) {
76    return list.filter(isPlanItem).map(({ content, status }) => ({ content, status }))
77  }
78  const next = plan.map(item => ({ ...item }))
79  const set = (step: number, status: PlanItem['status']) => {
80    const item = next[step - 1]
81    if (item !== undefined) {
82      item.status = status
83    }
84  }
85  if (input.advance === true) {
86    const current = next.findIndex(item => item.status === 'in_progress')
87    if (current !== -1) {
88      set(current + 1, 'completed')
89    }
90    const following = next.findIndex((item, index) => index > current && item.status === 'pending')
91    if (following !== -1) {
92      set(following + 1, 'in_progress')
93    }
94  }
95  for (const step of numbers(input.done)) {
96    set(step, 'completed')
97  }
98  if (typeof input.start === 'number') {
99    set(input.start, 'in_progress')
100  }
101
102  return next
103}
104
105// The task list as the session last left it.
106export function planOf(messages: readonly SessionMessage[]): PlanItem[] {
107  let plan: PlanItem[] = []
108  for (const message of messages) {
109    for (const use of message.toolUses) {
110      if (isPlanTool(use.tool) && use.isError !== true) {
111        plan = applyPlanCall(plan, use.input)
112      }
113    }
114  }
115
116  return plan
117}
118
119const MARKS: Record<PlanItem['status'], string> = { completed: '[x]', in_progress: '[>]', pending: '[ ]' }
120
121export function describePlan(plan: readonly PlanItem[]): string {
122  return plan.map(item => `${MARKS[item.status]} ${oneLine(item.content)}`).join('\n')
123}
124
125// The argument that says what a call did: the command, the file, the pattern, the agent's task.
126const ARGUMENTS = ['command', 'file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'skill', 'description', 'prompt']
127
128export function describeToolUse(use: ToolUseSummary): string {
129  const key = ARGUMENTS.find(name => typeof use.input[name] === 'string' && use.input[name] !== '')
130  const argument = key === undefined ? '' : ` ${cut(oneLine(String(use.input[key])), MAX_ARGUMENT)}`
131  const line = `→ ${use.tool}${argument}`
132
133  return use.isError === true ? `${line} — ошибка: ${cut(oneLine(use.text ?? ''), MAX_ERROR)}` : line
134}
135
136// One message as the digest has it: who said what, then the calls an answer made. A plan
137// tool's call is left out, the plan being given whole; a user message that only carries tool
138// results is nothing, its calls' errors standing beside them.
139export function describeMessage(message: SessionMessage): string {
140  const lines: string[] = []
141  const text = cleanText(message.text)
142  if (text !== '') {
143    lines.push(`${message.role === 'user' ? 'Человек' : 'Claude'}: ${cut(text, MAX_TEXT)}`)
144  }
145  if (message.role === 'assistant') {
146    for (const use of message.toolUses) {
147      if (!isPlanTool(use.tool)) {
148        lines.push(describeToolUse(use))
149      }
150    }
151  }
152
153  return cut(lines.join('\n'), MAX_ENTRY)
154}
155
156// The session in at most `max` characters: its first message, the task it began with, then
157// as many of the latest as fit, with a note of how many were left out between.
158export function digest(messages: readonly SessionMessage[], max = MAX_DIGEST): string {
159  const entries = messages.map(describeMessage).filter(entry => entry !== '')
160  const [first, ...rest] = entries
161  if (first === undefined) {
162    return ''
163  }
164  const tail: string[] = []
165  let size = first.length + NOTE_ROOM
166  for (let index = rest.length - 1; index >= 0; index -= 1) {
167    const entry = rest[index] ?? ''
168    if (size + entry.length + 2 > max) {
169      break
170    }
171    tail.unshift(entry)
172    size += entry.length + 2
173  }
174  const skipped = rest.length - tail.length
175
176  return [first, ...(skipped > 0 ? [`… (пропущено сообщений: ${skipped})`] : []), ...tail].join('\n\n')
177}
178
179// The request for the model, or null when the session holds nothing to read yet.
180export function briefPrompt(messages: readonly SessionMessage[]): { system: string; prompt: string } | null {
181  const text = digest(messages)
182  if (text === '') {
183    return null
184  }
185  const plan = planOf(messages)
186  const parts = plan.length > 0 ? [`План задач, каким его оставила сессия:\n${describePlan(plan)}`] : []
187  parts.push(`Выжимка сессии, от начала к концу:\n\n${text}`)
188
189  return { system: RULES, prompt: parts.join('\n\n') }
190}
191
192const textOf = (value: unknown, max: number): string => (typeof value === 'string' ? cut(oneLine(value), max) : '')
193
194// The reply as the pane takes it: the JSON object in it, whatever fence or words surround
195// it; null when there is none, or it says nothing.
196export function parseBrief(text: string): Brief | null {
197  const start = text.indexOf('{')
198  const end = text.lastIndexOf('}')
199  if (start === -1 || end <= start) {
200    return null
201  }
202  let value: unknown
203  try {
204    value = JSON.parse(text.slice(start, end + 1))
205  } catch {
206    return null
207  }
208  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
209    return null
210  }
211  const reply = value as Record<string, unknown>
212  const options: string[] = []
213  for (const option of Array.isArray(reply.options) ? reply.options : []) {
214    const line = textOf(option, MAX_OPTION)
215    if (line !== '' && !options.includes(line) && options.length < MAX_OPTIONS) {
216      options.push(line)
217    }
218  }
219  const brief = { status: textOf(reply.status, MAX_SUMMARY), next: textOf(reply.next, MAX_SUMMARY), options }
220
221  return brief.status === '' && brief.next === '' && options.length === 0 ? null : brief
222}
223
224// A reply that is no JSON is still read: its words stand as the status, with nothing to pick.
225export function briefOf(text: string): Brief | null {
226  const brief = parseBrief(text)
227  if (brief !== null) {
228    return brief
229  }
230  const words = cut(oneLine(text.replace(/```[^\n]*/g, '')), MAX_SUMMARY)
231
232  return words === '' ? null : { status: words, next: '', options: [] }
233}
234
235// Why a completion came back without text: the three arms of its result that carry none.
236export type NoReply = {
237  reason: 'api-error' | 'empty-reply' | 'aborted'
238  status?: number | null
239  error?: string
240}
241
242export function describeNoReply(reply: NoReply): string {
243  if (reply.reason === 'aborted') {
244    return 'Модель не ответила вовремя'
245  }
246  if (reply.reason === 'empty-reply') {
247    return 'Модель вернула пустой ответ'
248  }
249
250  return `Модель не ответила (${reply.status ?? reply.error ?? 'без ответа'})`
251}
252
253// The block of a reply the button sits under: the one the turn's final text ends with. A
254// reply of several blocks (text, tool calls, text) ends with its last.
255export function endsAnswer(answer: string, block: string): boolean {
256  const tail = block.trim()
257
258  return tail !== '' && answer.trim().endsWith(tail)
259}
260
261// The final text of the conversation as it stands, when its last message is Claude's: what a
262// mod loaded into a resumed session has not seen a turn end with.
263export function lastAnswerOf(messages: readonly SessionMessage[]): string | null {
264  const last = messages.at(-1)
265  if (last === undefined || last.role !== 'assistant' || last.text.trim() === '') {
266    return null
267  }
268
269  return last.text
270}
271
hooks/fill.ts 18 lines
1import type { PromptFilled } from 'claude-code'
2
3// What a pick does once the prompt box answered: nothing more where it took the text; sent as
4// the person's message where the surface draws a box of its own (the engine's `no_composer`);
5// held while a dialog has the keys; copied where a hook kept the text out, its cause unknown.
6export type AfterFill = 'filled' | 'send' | 'dialog' | 'copy'
7
8export function afterFill(filled: Pick<PromptFilled, 'isFilled' | 'refusal'>): AfterFill {
9  if (filled.isFilled) {
10    return 'filled'
11  }
12  if (filled.refusal === 'no_composer') {
13    return 'send'
14  }
15
16  return filled.refusal === 'dialog' ? 'dialog' : 'copy'
17}
18
hooks/view.tsx 157 lines
1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2
3import type { Brief, Phase } from '../types'
4
5type Table = Elements[RenderSurface]
6
7export type PaneActions = {
8  ask: () => void
9  cancel: () => void
10  pick: (text: string) => void
11}
12
13// The pane the brief is read in, and the prefix of every key the mod draws.
14export const PANE = 'next-step'
15
16// The last reply as the engine draws it, the button under it: where the person reads what
17// Claude did, on every surface, and in no site another plugin draws in.
18export function drawUnderReply({ Box, Button }: Pick<Table, 'Box' | 'Button'>, reply: RenderElement, ask: () => void) {
19  return (
20    <Box key={`${PANE}:reply`} flexDirection="column">
21      {reply}
22      <Box key={`${PANE}:under`} flexDirection="row" justifyContent="flex-end" paddingTop={1}>
23        <Button key={`${PANE}:ask`} label="💡 Что дальше?" plain dimColor onPress={ask} />
24      </Box>
25    </Box>
26  )
27}
28
29// The pane's body: room around it, and the actions in a row at its right edge.
30function frame({ Box }: Table, actions: RenderElement, ...body: RenderElement[]) {
31  return (
32    <Box key={PANE} flexDirection="column" rowGap={1} paddingX={1} paddingY={1}>
33      {body}
34      <Box key={`${PANE}:actions`} flexDirection="row" justifyContent="flex-end" columnGap={1}>
35        {actions}
36      </Box>
37    </Box>
38  )
39}
40
41function drawIdle(table: Table, actions: PaneActions) {
42  const { Box, Text, Button } = table
43
44  return frame(
45    table,
46    <Button key={`${PANE}:refresh`} label="Собрать сводку" variant="primary" onPress={actions.ask} />,
47    <Box key={`${PANE}:status`}>
48      <Text dimColor>Здесь будет сводка: на чём остановились, что делать дальше и сообщения на выбор.</Text>
49    </Box>,
50  )
51}
52
53function drawLoading(table: Table, actions: PaneActions) {
54  const { Box, Text, Button } = table
55
56  return frame(
57    table,
58    <Button key={`${PANE}:cancel`} label="Отмена" dimColor onPress={actions.cancel} />,
59    <Box key={`${PANE}:status`}>
60      <Text dimColor>Изучаю сессию…</Text>
61    </Box>,
62  )
63}
64
65function drawFailed(table: Table, message: string, actions: PaneActions) {
66  const { Box, Text, Button } = table
67
68  return frame(
69    table,
70    <Button key={`${PANE}:refresh`} label="Повторить" onPress={actions.ask} />,
71    <Box key={`${PANE}:status`}>
72      <Text color="error">{message}</Text>
73    </Box>,
74  )
75}
76
77// One message to pick as a card. On the desktop the whole card is a click: a surface module
78// draws it (./card.tsx) and posts its number. A Button cannot be the card there: the desktop
79// draws a button's text on one line, cut where the pane ends. Elsewhere, and where the desktop
80// could not run the module, the card is a frame holding the wrapped text and a Выбрать button,
81// whose digit picks it while the pane holds the keys.
82function drawCard(table: Table, n: number, text: string, asClient: boolean, pick: () => void) {
83  const { Box, Text, Button } = table
84  if (asClient && 'Client' in table) {
85    const { Client } = table
86
87    return <Client key={`${PANE}:option:${n}`} module="./card.tsx" props={{ n, text }} width="100%" />
88  }
89
90  return (
91    <Box
92      key={`${PANE}:option:${n}:card`}
93      flexDirection="row"
94      columnGap={1}
95      alignItems="flex-start"
96      borderStyle="round"
97      borderDimColor
98      paddingX={1}
99    >
100      <Box key={`${PANE}:option:${n}:text`} flexGrow={1} flexShrink={1}>
101        <Text wrap="wrap">{text}</Text>
102      </Box>
103      <Button key={`${PANE}:option:${n}`} label="Выбрать" hotkey={String(n)} dimColor onPress={pick} />
104    </Box>
105  )
106}
107
108// Where the session stopped, what comes next, and the messages to pick as cards.
109function drawReady(table: Table, brief: Brief, actions: PaneActions, asClient: boolean) {
110  const { Box, Text, Button } = table
111
112  return frame(
113    table,
114    <Button key={`${PANE}:refresh`} label="Обновить" dimColor onPress={actions.ask} />,
115    ...(brief.status === ''
116      ? []
117      : [
118          <Box key={`${PANE}:status`} flexDirection="column">
119            <Text bold>На чём остановились</Text>
120            <Text>{brief.status}</Text>
121          </Box>,
122        ]),
123    ...(brief.next === ''
124      ? []
125      : [
126          <Box key={`${PANE}:next`} flexDirection="column">
127            <Text bold>Что дальше</Text>
128            <Text>{brief.next}</Text>
129          </Box>,
130        ]),
131    ...(brief.options.length === 0
132      ? []
133      : [
134          <Box key={`${PANE}:options`} flexDirection="column" rowGap={1}>
135            <Text dimColor>Выберите — сообщение попадёт в чат:</Text>
136            {brief.options.map((option, index) =>
137              drawCard(table, index + 1, option, asClient, () => actions.pick(option)),
138            )}
139          </Box>,
140        ]),
141  )
142}
143
144// `asClient`: the cards are drawn by the surface module, where the surface runs one.
145export function drawPane(table: Table, phase: Phase, actions: PaneActions, asClient: boolean) {
146  switch (phase.kind) {
147    case 'idle':
148      return drawIdle(table, actions)
149    case 'loading':
150      return drawLoading(table, actions)
151    case 'failed':
152      return drawFailed(table, phase.message, actions)
153    case 'ready':
154      return drawReady(table, phase.brief, actions, asClient)
155  }
156}
157
hooks/card.tsx 31 lines
1import type { ClientModule } from 'claude-code'
2
3// One message to pick, as the desktop draws it: a card the whole of which is a click, its
4// text wrapped over as many lines as it takes. A Button cannot be one: the desktop draws its
5// text on one line, cut where the pane ends.
6export type CardProps = { n: number; text: string }
7
8// What a card posts to the hooks module when it is clicked: which message, by its number.
9export type CardPick = { pick: number }
10
11const Card: ClientModule<CardProps> = (props, surface) => {
12  const { Box, Text } = surface.elements
13  // Registered on every draw: a later call replaces the listener, so it posts this draw's number.
14  surface.onPointer(event => {
15    if (event.type === 'down' && event.button === 'left') {
16      surface.post({ pick: props.n })
17    }
18  })
19
20  return (
21    <Box key="card" flexDirection="row" columnGap={1} borderStyle="round" borderDimColor paddingX={1}>
22      <Text dimColor>{String(props.n)}</Text>
23      <Box key="text" flexGrow={1} flexShrink={1}>
24        <Text wrap="wrap">{props.text}</Text>
25      </Box>
26    </Box>
27  )
28}
29
30export default Card
31
types/index.d.ts 25 lines
1// What the cheap model made of the session: where it stopped, what comes next, and the
2// messages the person may send Claude as the next step.
3export type Brief = { status: string; next: string; options: string[] }
4
5// The pane's state: nothing asked yet, the model reading, its brief, or why there is none.
6export type Phase =
7  | { kind: 'idle' }
8  | { kind: 'loading' }
9  | { kind: 'ready'; brief: Brief }
10  | { kind: 'failed'; message: string }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'next-step': {
15      phase: Phase
16      // The final text of the main conversation's last answer: the button sits under the
17      // reply block it ends with. Null while a turn runs, and before any answer.
18      lastAnswer: string | null
19      // The desktop could not run the card module: the cards are drawn as frames with a
20      // Выбрать button for the rest of the session.
21      cardsFailed: boolean
22    }
23  }
24}
25