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

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


Новое сообщение в чате или /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.
hooks/register.tsx 249 lines1import { 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}
249hooks/brief.ts 271 lines1import 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}
271hooks/fill.ts 18 lines1import 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}
18hooks/view.tsx 157 lines1import 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}
157hooks/card.tsx 31 lines1import 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
31types/index.d.ts 25 lines1// 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