Рисование в интерфейсе с помощью мода
Рисуйте панели, полосу над промптом, кнопки и текстовые поля из мода Claude Code, обрабатывайте нажатия и ввод, и сохраняйте состояние между перерисовками и сессиями.
Мод может рисовать свой собственный интерфейс в Claude Code и изменять части интерфейса, которые уже рисует Claude Code. Каждое место, где мод может рисовать, называется сайтом рендеринга, например панель, полоса над промптом или спиннер. Claude Code вызывает событие ui.render каждый раз, когда собирается рисовать сайт рендеринга, и ваш хук для этого события возвращает то, что нужно рисовать там.
На этой карте показано, где мод может рисовать в сессии терминала:
В более узком терминале панель находится над промптом вместо того, чтобы находиться рядом с транскриптом.
Создайте свой первый мод перед тем, как начать здесь. Начните с рабочего примера, который создает панель с двумя вкладками и счетчиком, затем прочитайте раздел для каждой части, которую вы хотите изменить.
Чтобы найти одно свойство или ограничение, см. справку.
Создание панели с вкладками
В этом разделе вы создаете мод, который добавляет команду /hello-tabs, и команда открывает панель. Панель — это боковая панель рядом с транскриптом в широком полноэкранном терминале или обрамленная область над промптом в противном случае. Эта панель показывает две вкладки, и вторая вкладка имеет кнопку, которая добавляет единицу к счетчику. Счет остается там после перезагрузки Claude Code.
Готовый мод выглядит так. Запись открывает панель, переключается на вторую вкладку, нажимает кнопку несколько раз и возвращается на первую вкладку:
Вкладки — это две кнопки в ряду. Мод отслеживает, какая из них активна, и рисует содержимое этой вкладки под рядом.
Создание плагина
Мод — это плагин с манифестом, hooks.json, который указывает на ваш код, и файл кода. Создание мода объясняет каждый из них. Создайте каталог с именем hello-tabs с каталогами .claude-plugin и hooks внутри него, затем сохраните первые два файла.
Сохраните манифест как hello-tabs/.claude-plugin/plugin.json:
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" }
}
Назовите точку входа в hello-tabs/hooks/hooks.json:
{
"modules": ["./register.js"]
}
Написание кода
В этом списке указано, что делает каждый хук, в том порядке, в котором они появляются в коде:
- Добавляет команду
/hello-tabsи загружает счет, сохраненный предыдущей сессией - Открывает панель при запуске этой команды
- Рисует содержимое панели: ряд вкладок и тело открытой вкладки
Две переменные уровня модуля, tab и count, содержат состояние панели.
Сохраните это как hello-tabs/hooks/register.js:
// The pane's id, used to open the pane and to recognize it when drawing
const PANE = 'hello-tabs'
// What the pane shows: which tab is open, and the counter's value
let tab = 'one'
let count = 0
export function register(on) {
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// Load the count an earlier session saved, if there is one
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// Runs when you type /hello-tabs
on('command.run', { command: 'hello-tabs' }, async ($) => {
// Open the pane, give it the keyboard, and let Esc close it
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// Print nothing in the transcript
return {}
})
// Runs each time Claude Code draws a pane
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Leave other mods' panes alone
if (e.requestId !== PANE) return next(e)
// Get the elements this app can draw
const { Box, Text, Button } = $.ui.resolve(e)
// Ask Claude Code to run this hook again
const redraw = () => $.ui.invalidate('ui.render')
// One tab: a button that switches to its tab when pressed
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// Dim the tab that isn't open
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// What goes under the tabs, depending on which one is open
const body =
tab === 'one'
? [Text({ children: ['This is the first tab.'] })]
: [
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
hotkey: 'a',
onPress: async () => {
count += 1
redraw()
// Save the count so it's there after a restart
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// The whole pane: the row of tabs, a blank line, then the body
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}
Каждый хук также делает что-то, что код не делает явным:
session.startтакже читает сохраненный счет из$.store, хранилища ключ-значение, которое сохраняется между сессиями.command.runтолько сообщает Claude Code, что панель существует. Открытие панели ничего не рисует само по себе: Claude Code затем вызываетui.render, чтобы спросить, что в ней находится.ui.renderвозвращает дерево элементов,Box, который содержит другие боксы, текст и кнопки, и создает его заново изtabиcountкаждый раз, когда он запускается.
Нажатие кнопки запускает ее обратный вызов onPress, который изменяет переменную и вызывает redraw. Claude Code затем запускает хук ui.render снова, и хук создает новое дерево из новых значений. Каждое интерактивное рисование использует этот цикл рендеринга: обратный вызов изменяет состояние, и хук рисует снова из нового состояния.
Открытие панели
В вашей оболочке запустите Claude Code с помощью claude --plugin-dir ./hello-tabs. В промпте Claude Code запустите /hello-tabs. Панель открывается с 1: One и 2: Two в верхней части. Нажмите 2, затем нажмите a, горячую клавишу для Add one, несколько раз. Счет растет.
Проверка того, что счет был сохранен
Нажмите Esc, чтобы закрыть панель, затем выйдите из сессии. В вашей оболочке запустите Claude Code снова с той же командой claude --plugin-dir ./hello-tabs, и в промпте Claude Code запустите /hello-tabs. Счет находится там, где вы его оставили.
Чтобы очистить счет, попросите мод вызвать $.store.delete('count'). Сохранение состояния охватывает, как долго длится каждый вид значения.
Выбор места для рисования
Хук ui.render запускается для каждого сайта рендеринга, если вы не сузите его до того, в котором хотите рисовать. Чтобы выбрать сайт рендеринга, передайте фильтр, называемый matcher, в качестве второго аргумента для on. { component: 'Pane' } запускает хук только для панелей. В хуке e.component называет сайт, e.surface говорит, какое приложение рисует, а e.props содержит собственные данные сайта. Для панели e.requestId — это id, с которым вы ее открыли.
Панель и полоса пусты, пока мод их не заполнит. Выберите вкладку, чтобы увидеть, что представляет собой каждая из них и как в ней рисовать:
Панель — это боковая панель рядом с транскриптом в широком полноэкранном терминале или обрамленная область над промптом в противном случае. При открытии нескольких панелей каждая получает вкладку, которая показывает ее название.
Панель появляется, когда ваш мод вызывает $.ui.open с id, который вы выбираете, как в $.ui.open({ id: 'hello-tabs' }). Открытие панели в нужное время охватывает другие поля и случаи, когда панель ждет более широкого терминала.
Чтобы рисовать в вашей панели, отфильтруйте по { component: 'Pane' } и проверьте, что e.requestId — это ваш id.
Полоса — это полоса прямо над полем ввода промпта. Она присутствует всегда, и все моды используют ее совместно.
Ваш хук возвращает дерево, чтобы показать что-то в полосе, или next(e), чтобы ничего не показывать. Дерево заменяет то, что моды после вашего рисуют там. Чтобы сохранить их вывод, поместите результат await next(e) среди дочерних элементов Box в вашем дереве.
Чтобы рисовать в полосе, отфильтруйте по { component: 'AbovePrompt' }.
Изменение того, что уже рисует Claude Code
Claude Code рисует большую часть своего интерфейса сам: сообщения, строки вызовов инструментов, спиннер и многое другое. Каждая из этих частей также является сайтом рендеринга, поэтому мод может изменить ее стиль или заменить ее. Чтобы изменить одну из них, отфильтруйте ваш хук ui.render по ее имени из этой таблицы:
| Сайт | Что это такое |
|---|---|
UserMessage, AssistantMessage |
Сообщение в транскрипте |
ToolUse, ToolResult, ToolGroup |
Строка вызова инструмента, его результат и свернутая группа вызовов |
CommandOutput |
Строка, которую напечатала команда |
AskUserQuestion |
Диалог, который Claude открывает, чтобы задать вам вопрос |
Spinner, ToolProgress, TurnDuration |
Строки состояния для хода: строка, которая анимируется, пока Claude работает, строка текущего прогресса работающего инструмента и строка, которая закрывает ход |
InfoNotice, SessionMode, PromptHint |
Строки состояния под логотипом, метки режима в нижнем колонтитуле и строка подсказки под промптом |
На сайте, который Claude Code уже рисует, ваш хук может изменить деталь, заменить рисование или оставить его в покое. Выберите вкладку, чтобы увидеть каждый из вариантов, применяемый к спиннеру. Примеры читают переменную calls, которую подсчитывает другой хук, как в учебном моде.
Чтобы сохранить рисование Claude Code и изменить одну его часть, передайте next копию события с измененными props. Этот хук изменяет текст после слова спиннера:
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Keep Claude Code's spinner, and change the text after its word
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
Спиннер сохраняет свою анимацию и свое слово, и ваш текст следует за словом:
Thinking · tool calls: 2…
Чтобы нарисовать что-то свое на месте сайта, верните дерево и не вызывайте next. Этот хук рисует одну строку текста там, где был бы спиннер:
on('ui.render', { component: 'Spinner' }, async ($, e) => {
const { Text } = $.ui.resolve(e)
// No call to next, so this line is drawn in the spinner's place
return Text({ children: ['Claude has made ' + calls + ' tool calls'] })
})
Пока Claude работает, показывается ваша строка, а спиннер Claude Code не показывается:
Claude has made 2 tool calls
Чтобы оставить сайт таким, каким его рисует Claude Code, верните next(e). Хук часто делает это для одних событий и не делает для других. Этот хук оставляет спиннер в покое, пока нечего считать:
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Nothing to show yet, so pass the event on unchanged
if (calls === 0) return next(e)
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
До первого вызова инструмента спиннер выглядит так, как он выглядит без мода:
Thinking…
На этих сайтах next(e) возвращает ссылку на рисование Claude Code, { type: 'engine', ref }, если только мод, который запускается после вашего, не вернул собственное дерево. Чтобы изменить то, что находится в этом рисовании, передайте next копию события с другими props, как это сделано на вкладке Change a detail. Вы можете вернуть ссылку как есть или поместить ее в Box рядом с собственными элементами:
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
const { Box, Text } = $.ui.resolve(e)
const theirs = await next(e)
return Box({ flexDirection: 'column', children: [theirs, Text({ children: ['under the spinner'] })] })
})
Пока Claude работает, спиннер анимируется как прежде, а под ним появляется under the spinner.
Запрос разрешения не является сайтом рендеринга, поэтому мод не может изменить то, что он показывает. Диалог вопроса, AskUserQuestion, является таким сайтом, поэтому мод может его изменить. Дерево для диалога должно содержать ссылку ровно один раз, а ваши элементы — над ней. В противном случае Claude Code рисует собственный диалог.
Терминал и приложение Desktop вызывают не одни и те же сайты. Pane, AbovePrompt, Spinner и сайты транскрипта работают в обоих. Несколько других строк состояния вызываются только в терминале. Таблица сайтов рендеринга указывает, где вызывается каждый из них.
Открытие панели в нужное время
Панель появляется только тогда, когда ваш мод ее открывает. То, как и когда вы ее открываете, определяет, получает ли она фокус клавиатуры, сколько места она запрашивает и появляется ли она вообще в узком терминале.
Чтобы открыть панель, вызовите $.ui.open с id, который вы выбираете. id — это имя панели: ваш хук ui.render проверяет его, и вы передаете его снова, чтобы закрыть панель.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
Чтобы закрыть панель, вызовите $.ui.close с id, с которым вы ее открыли:
await $.ui.close({ id: 'hello-tabs' })
Помимо id, $.ui.open принимает эти необязательные поля:
| Поле | Что оно делает |
|---|---|
title |
Метка вкладки панели, когда открыто несколько панелей |
focus |
Запрашивает фокус клавиатуры |
closeOnEscape |
Позволяет закрыть панель клавишей Esc |
holdToasts |
Удерживает всплывающие уведомления, небольшие уведомления от $.ui.toast, пока панель не закроется |
rows |
Высота, которую нужно запросить, когда панель находится над промптом. По умолчанию треть пространства. |
columns |
Ширина, которую нужно запросить, когда панель находится рядом с транскриптом |
focus, closeOnEscape и holdToasts являются необязательными и принимают только true. Чтобы не задавать одно из них, опустите его. Передача false выбрасывает ошибку, такую как ui.open: focus is true or left out. Чтобы установить одно из них условно, добавляйте поле только тогда, когда условие выполняется. Этот вызов запрашивает фокус клавиатуры, только когда items не пусто:
const pane = { id: 'hello-tabs', title: 'Hello tabs' }
await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)
Чтобы позволить команде открыть панель, пока Claude работает, добавьте immediate: true при регистрации команды. Без этого команда, введенная во время хода, ждет конца хода.
Когда панель ждет более широкого терминала
Панель, которую ваш мод открывает без запроса, не появляется в узком терминале, поэтому она не может захватить маленький экран. Появится ли она, зависит от того, что ее открыло:
- Открыто чем-то, что сделал пользователь, например командой, которую он запустил, или кнопкой, которую он нажал, — панель появляется при любой ширине
- Открыто вашим модом, действующим самостоятельно, например из таймера или хука
turn.start, — панель появляется только в терминале шириной не менее 144 столбцов. После того как пользователь сам открыл эту панель хотя бы раз, достаточно 110 столбцов.
Когда панель появляется, $.ui.open разрешается в { isPlaced: true }. Когда панель ждет, isPlaced имеет значение false, а reason — это строка, объясняющая причину. Ожидающая панель появляется, когда пользователь ее открывает или расширяет терминал. Чтобы сообщить, что что-то доступно, не открывая панель, вызовите $.ui.toast('Your message'), который показывает всплывающее уведомление.
Создание дерева из элементов
То, что возвращает хук ui.render, — это дерево элементов: описание того, что рисовать, состоящее из боксов, текста и элементов управления, вложенных друг в друга. Вы описываете рисование, и Claude Code рисует его в терминале или приложении Desktop.
Чтобы получить элементы, вызовите $.ui.resolve(e) в вашем хуке, как в const { Box, Text, Button } = $.ui.resolve(e). Каждый элемент — это функция. Вы передаете ей свойства, и вы помещаете элементы и строки, которые идут внутри него, в children.
Выберите вкладку, чтобы увидеть каждый из самых распространенных элементов и то, как терминал его рисует:
Text рисует строку с необязательным стилем, таким как bold и color:
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box расставляет то, что находится внутри него, в ряд или столбец. Этот помещает кнопку и строку текста рядом, два столбца отдельно:
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'more', label: 'Add one', onPress: addOne }),
Text({ children: ['Count: 0'] }),
],
})
[ Add one ] Count: 0
Button — это элемент управления, который пользователь может нажать. Он запускает ваш обратный вызов onPress. С plain: true он не имеет скобок и показывает его горячую клавишу:
Button({ key: 'more', label: 'Add one', onPress: addOne })
Button({ key: 'tab-one', label: 'One', hotkey: '1', plain: true, onPress: showTabOne })
[ Add one ]
1: One
Input — это текстовое поле. Он запускает ваш обратный вызов onSubmit с текстом, когда пользователь нажимает Enter:
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
value: '',
submitLabel: 'add',
onSubmit: addNote,
})
Note: Type a note and press Enter
В галерее интерфейсов есть примеры и снимки экрана большинства элементов. Эта таблица перечисляет каждый элемент:
| Элемент | Что он рисует | Где |
|---|---|---|
Box |
Контейнер flex. Принимает свойства макета, такие как flexDirection, columnGap, padding, borderStyle и width. |
Везде |
Text |
Стилизованный текст. Принимает color, bold, dimColor, italic и wrap. color — это ключ темы или цвет, такой как 'red'. wrap — это 'wrap', 'truncate', 'truncate-start', 'truncate-middle' или 'truncate-end'. |
Везде |
Button |
Элемент управления, который вызывает onPress |
Везде |
Link, Code, Markdown |
Ссылка с href и необязательной label, блок кода и текст, отформатированный так, как ответы Claude. Markdown принимает свое содержимое в свойстве text, а не в children, и нуждается в key, когда вы передаете onLinkPress. |
Везде |
Input, Select |
Текстовое поле и выпадающий список | Терминал, Desktop |
Svg |
Документ SVG | Desktop |
Client |
Область, нарисованная вторым файлом вашего, для анимации и ввода указателя. Этот файл не получает API модов. Он достигает ваших хуков только путем отправки данных, которые поступают как событие ui.message. |
Терминал, Desktop |
Raster, Image |
Сетка цветных ячеек и изображение | Терминал |
Если ваш модуль — это файл .tsx или .jsx, вы можете написать дерево как JSX. Сначала деструктурируйте элементы из $.ui.resolve(e).
Если дерево использует элемент, который приложение не имеет, свойство, которое элемент не принимает, или дочерний элемент, где его нет, Claude Code рисует свою собственную версию сайта.
В сессии, запущенной с --plugin-dir, строка транскрипта говорит об этом, например ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own. Лог отладки записывает это как ui.render (Pane): a hook returned a tree that does not validate с той же причиной. Ничего больше не появляется в сессии, поэтому когда рисование не показывается, проверьте эту строку или лог.
Рисование сетки цветных ячеек
Для тепловой карты, спарклайна или игровой доски в терминале нарисуйте один Raster, а не Box для каждой ячейки. Raster принимает key, его размер в columns и rows, и cells — строку base64, которая упаковывает каждую ячейку. Каждая ячейка — это три числа: кодовая точка символа, его цвет и цвет фона. Цвет — это 24-битное значение RGB в шестнадцатеричной записи, например 0xc62828 для красного. Значение 0x01000000, на единицу выше этого диапазона, означает цвет терминала по умолчанию.
Приложение Desktop не имеет Raster, поэтому проверьте e.surface и нарисуйте текст там. Это тело панели рисует тепловую карту три на два:
// The value that means "use the terminal's default color"
const DEFAULT_COLOR = 0x01000000
// Pack rows of [character, color] pairs into the one string a Raster takes
// One cell is three numbers: the character's code point, its color, and its background
function cellsOf(rows) {
const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
}
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Draw only in the pane opened with the id 'heat'
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// Two rows of three cells, each a block character and its color
const rows = [
[['█', 0x2e7d32], ['█', 0xf9a825], ['█', 0xc62828]],
[['█', 0x2e7d32], ['█', 0x2e7d32], ['█', 0xf9a825]],
]
if (e.surface !== 'terminal') {
return Text({ children: ['The heat map needs the terminal.'] })
}
return Box({
flexDirection: 'column',
children: [Raster({ key: 'grid', columns: 3, rows: 2, cells: cellsOf(rows) })],
})
})
В терминале панель показывает сетку:
Массив rows — это часть, которую вы бы изменили, и cellsOf превращает его в упакованную строку. Хук рисует только в панели, чей id — это heat, поэтому откройте один с $.ui.open({ id: 'heat' }) из команды, как пример hello-tabs открывает свою панель.
Каждый символ должен быть шириной в одну ячейку. Чтобы анимировать Raster, который уже на экране, вызовите $.ui.blit с id панели как requestId, key Raster, тем же размером и новыми ячейками. Для этого примера это $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }). Он перерисовывает только этот элемент без повторного запуска вашего хука ui.render.
Ответ на нажатия и ввод
Когда пользователь нажимает кнопку, вводит текст в поле или выбирает из списка, который нарисовал ваш мод, Claude Code вызывает обратный вызов этого элемента управления, который выполняется в вашем модуле. Каждый элемент управления принимает свои собственные обратные вызовы:
Button: принимаетonPress(e), гдеe.surface— это приложение, из которого пришло нажатиеInput: принимаетonSubmit(value)иonInput(value)Select: принимаетonSelect(value)с его выборами вoptions, список по крайней мере одного выбора с уникальными значениями, такой как[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
Тест нажимает или вводит текст в элемент управления по его key, поэтому дайте каждому элементу управления один. Каждое использование элемента управления также запускает ui.press, ui.input или ui.select с key в e.element, и другой мод может обработать эти события. Его хук запускается перед вашим обратным вызовом, поэтому он видит, что пользователь вводит в ваш Input, и может изменить это или ответить вместо вашего обратного вызова. API модов не имеет метода, который нажимает кнопку другого мода.
Фокус клавиатуры и горячие клавиши
Ваш мод никогда не читает клавиатуру сам. Пользователь нажимает клавишу, Claude Code решает, для какого из ваших элементов управления она предназначена, и запускается обратный вызов этого элемента управления. Кроме горячей клавиши цифры на полосе, это происходит только пока ваша панель или полоса имеет фокус клавиатуры. Остальное время клавиши идут в промпт.
Как панель получает фокус клавиатуры
Панель получает фокус клавиатуры, когда:
- Ваш мод открывает ее с
focus: trueиз команды или нажатия - Пользователь нажимает Ctrl+X, затем Tab
- Пользователь нажимает на нее
Claude Code предоставляет focus: true только пока промпт пуст и ничто другое не имеет фокус клавиатуры. Панель, которая открывается, пока пользователь печатает, не берет его нажатия клавиш.
Что делает каждая клавиша
Эта таблица перечисляет, что делает клавиша, пока ваша панель или полоса имеет фокус клавиатуры:
| Клавиша | Что она делает |
|---|---|
| Tab | Переходит к следующему элементу управления |
| Up и Down | Перемещаются между элементами управления, пока ваше рисование подходит. Когда панель или полоса имеет больше строк, чем может показать, они прокручивают ее. |
| Enter | Нажимает сфокусированный Button, отправляет сфокусированный Input или выбирает в Select |
| Горячая клавиша кнопки | Нажимает эту кнопку. Пока Input имеет фокус, каждая печатаемая клавиша идет в поле. |
| Esc | Возвращает фокус клавиатуры в промпт. С closeOnEscape: true, он также закрывает панель. |
Мод не может привязать Tab или клавиши со стрелками к чему-либо еще, поэтому игра управляется с помощью w, a, s и d.
Установка горячей клавиши и первого фокуса
Эти свойства элемента управления определяют, как клавиатура его достигает:
hotkey: чтобы позволить пользователю нажатьButtonодной клавишей, дайте емуhotkeyодной цифры или одной строчной буквы, как вhotkey: 'a'autoFocus: чтобы выбрать, какой элемент управления имеет фокус при открытии панели, добавьтеautoFocus: trueк нему. Свойство принимает толькоtrue, поэтому не указывайте его для остальных элементов управления.
То, как горячая клавиша показывается, зависит от кнопки и приложения:
| Кнопка | В терминале | В приложении Desktop |
|---|---|---|
| С скобками, по умолчанию | [ Add one ], без показанной горячей клавиши |
Метка с маленькой клавишей рядом |
С plain: true |
1: One |
Метка с маленькой клавишей рядом |
В терминале назовите клавишу в метке кнопки в скобках или используйте plain: true, чтобы пользователь мог видеть, что нажать. Справка элементов имеет другие правила Button: action, горячие клавиши цифр на полосе и две кнопки на одной горячей клавише.
Получение введенного текста и рисование строки для каждого элемента
Многие панели — это текстовое поле со списком под ним. Пример в этом разделе — панель заметок: вы вводите заметку и нажимаете Enter, чтобы добавить ее, и каждая заметка имеет кнопку x, которая удаляет ее. С двумя добавленными заметками терминал рисует панель таким образом:
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
Пример использует следующие техники:
- Получение введенного текста:
InputвызываетonSubmit(value)с текстом поля, когда пользователь нажимает Enter, иonInput(value)при каждом изменении - Рисование списка: отобразите ваши данные в одну строку каждый, и дайте каждой кнопке строки свой собственный
key
Этот хук рисует содержимое панели:
// The list the pane draws
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Draw only in the pane opened with the id 'notes'
if (e.requestId !== 'notes') return next(e)
const { Box, Text, Button, Input } = $.ui.resolve(e)
const redraw = () => $.ui.invalidate('ui.render')
return Box({
flexDirection: 'column',
children: [
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
// Draw the field empty each time, which clears it after a submit
value: '',
submitLabel: 'add',
autoFocus: true,
// Runs when you press Enter in the field
onSubmit: async (value) => {
// Ignore an empty line
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// One row for each note: a delete button, then the note's text
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// A key of its own, so each row's button can be told apart
key: 'delete-' + i,
label: 'x',
plain: true,
onPress: async () => {
notes = notes.filter((_, j) => j !== i)
redraw()
await $.store.set('notes', notes)
},
}),
Text({ children: [note] }),
],
}),
),
],
})
})
Чтобы попробовать панель:
- Добавить заметку: введите строку и нажмите Enter. Строка появляется как новая строка, и поле очищается.
- Удалить заметку: нажимайте Tab, пока кнопка
xзаметки не получит фокус, затем нажмите Enter.x— это метка кнопки, а не горячая клавиша, поэтому ввод буквы не нажимает ее.
Каждое изменение следует тому же циклу рендеринга, что и hello-tabs: обратный вызов изменяет notes, вызывает redraw и сохраняет список в $.store.
Поле очищается после каждой отправки из-за его свойства value. value — это текст, который поле содержит при его рисовании, и ввод пользователя заменяет его до тех пор, пока ваш хук не нарисует поле снова. Пример всегда рисует поле с ''.
Пример сохраняет заметки и не загружает их. Чтобы вернуть их в следующей сессии, прочитайте их в хуке session.start, как hello-tabs читает count.
Следующие свойства составляют строку поля, Note: Type a note and press Enter ⏎ add:
| Свойство | В примере | Что это такое |
|---|---|---|
label |
Note |
Текст перед полем. Терминал рисует : после него. |
placeholder |
Type a note and press Enter |
Тусклый текст, который показывается, пока поле пусто |
submitLabel |
add |
Слово после ⏎, которое говорит, что делает Enter |
Отправка Input не запускает ход, если ваш обратный вызов не вызывает $.prompt.submit.
Перерисовка сайта
Рисование — это снимок: оно показывает то, что ваш хук ui.render вернул в последний раз, когда хук запустился. Чтобы показать что-то новое, хук должен запуститься снова. Claude Code запускает его снова для некоторых изменений, и ваш мод просит остальное.
Когда Claude Code перерисовывает без запроса
Claude Code запускает ваш хук ui.render снова, когда пропсы сайта изменяются или ширина терминала изменяется. Он не запускает хук на таймере и не может сказать, когда переменная в вашем модуле изменяется.
Перерисовка при изменении ваших данных
Чтобы ваши сайты были нарисованы снова после изменения ваших собственных данных, вызовите $.ui.invalidate('ui.render'). Эта панель считает нажатия. Обратный вызов кнопки изменяет count, затем просит перерисовку:
let count = 0
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== 'counter') return next(e)
const { Box, Text, Button } = $.ui.resolve(e)
return Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
onPress: () => {
count += 1
// The data changed, so ask Claude Code to draw the pane again
$.ui.invalidate('ui.render')
},
}),
Text({ children: ['Count: ' + count] }),
],
})
})
Каждое нажатие поднимает число в панели. Пример hello-tabs оборачивает тот же вызов в свою функцию redraw.
Значение, которое вы сохраняете в $.state, не нуждается в вызове, потому что написание значения перерисовывает сайты, которые его читают.
Перерисовка на таймере
Чтобы сохранить часы, обратный отсчет или значение извне сессии в актуальном состоянии, перерисовывайте по расписанию. Запустите таймер в хуке session.start модуля. Если модуль уже имеет один, как hello-tabs, добавьте строку $.clock.every к нему:
on('session.start', async ($, e, next) => {
// Every 1000 milliseconds, ask Claude Code to draw your sites again
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})
Claude Code теперь запускает ваш хук ui.render один раз в секунду. Таймер останавливается при перезагрузке модуля, и новая копия модуля запускает свой собственный.
Как часто сайт может перерисовываться
Claude Code ограничивает частоту перерисовки сайта, поэтому ваш мод может вызывать $.ui.invalidate так часто, как изменяются его данные. О том, как часто может перерисовываться каждый сайт, см. таблицу лимитов.
Вызовы, которые приходят быстрее, чем лимит, объединяются в одну перерисовку. Эта перерисовка запускает ваш хук один раз, и хук читает ваши данные такими, какие они есть в этот момент, поэтому показывается последнее значение и значения между ними не показываются. Анимация не может работать быстрее, чем лимит.
Сохранение состояния
От того, где мод хранит значение, зависит, как долго оно существует: пока модуль не перезагрузится, пока сессия не закончится или от одной сессии к другой. Выбирайте по тому, как долго значение должно существовать:
| Сохраняйте это в | Это длится до | Используйте это для |
|---|---|---|
| Переменная уровня модуля | Модуль перезагружается, что происходит каждый раз, когда вы сохраняете файл во время разработки | Значения, которые вы можете потерять, как tab в hello-tabs |
$.state |
Сессия заканчивается, или пользователь запускает /clear, /resume или /branch |
Значения, от которых зависит отрисовка и которые должны пережить перезагрузку |
$.store |
Ваш мод удаляет его, или ни одна сессия не читает и не пишет в хранилище в течение cleanupPeriodDays. Хранилище — это хранилище ключ-значение, сохраненное как собственный файл JSON вашего плагина в ~/.claude/plugins/store/. |
Настройки, история, все, что пользователь ожидает найти в следующий раз |
$.store.get(key) разрешается в значение или undefined, а $.store.set(key, value) принимает любое значение JSON.
Сохранение значения в `$.state`
$.state хранит значения на протяжении сессии и выполняет перерисовку за вас. Это реактивное состояние: хук ui.render, который читает значение, подписывается на него, поэтому Claude Code перерисовывает это место каждый раз, когда вы записываете значение, и вам не нужно вызывать $.ui.invalidate. Значение в $.state также переживает перезагрузку модуля, чего переменная не делает.
Чтобы настроить его, объявите ваши значения, укажите в манифесте путь к объявлению, затем определите и используйте каждое значение. Примеры перемещают count из hello-tabs в $.state.
Объявление значений
Объявите значения в файле объявления типов. Внешний ключ — это имя вашего плагина, а каждая запись под ним — это значение и его тип. Сохраните это как hello-tabs/types/index.d.ts:
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
Указание манифеста на объявление
Чтобы claude plugin validate мог проверить ваш код по этому файлу, добавьте в манифест поле types с его путем:
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" },
"types": "./types/index.d.ts"
}
Определение, чтение и запись значения
В вашем модуле определите каждое значение со значением по умолчанию, прочитайте его при отрисовке и запишите его из обратного вызова. atom задает имя значения и его значение по умолчанию, read возвращает его, а update записывает его. Эти три помощника вызывают $.state.get и $.state.set за вас:
import { atom, read, update } from 'claude-code'
// At the top of the module: name the value and give its default
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// In the ui.render hook: read the value to draw it
const n = await read($, count)
// In a Button: write a new value from the old one
onPress: () => update($, count, (value) => value + 1)
Поскольку хук ui.render прочитал count, Claude Code запускает хук снова каждый раз, когда кнопка записывает его.
К коду применяются следующие правила:
- Пишите
pluginиkeyкак строковые литералы:claude plugin validateчитает их из вашего исходного кода - Объявите каждое значение в файле объявления типов: в противном случае валидация завершится ошибкой
hello-tabs.count is not declared - Записывайте из обратного вызова или хука другого события: хук
ui.renderможет читать состояние, но не может записывать его, поэтому записывайте изonPress,onSubmitили хука для другого события
Изменение `hello-tabs` для использования `$.state`
Чтобы переместить count в hello-tabs в $.state, измените каждую строку, которая его использует:
- В верхней части модуля: добавьте строку
importи заменитеlet count = 0на строкуatom - В хуке
ui.render: добавьте строкуreadпередtabButtonи отрисуйте'Count: ' + nвText - В кнопке Add one: замените
onPressна вариант из раздела Сохранение из более чем одной сессии, который сохраняет счетчик, а также записывает его - В хуке
session.start: замените две строки, которые читаютsaved, на вызовloadCountиз раздела Повторная загрузка сохраненного значения после/clear
Оставьте redraw для кнопок вкладок, потому что tab по-прежнему является переменной.
Повторная загрузка сохраненного значения после `/clear`
Если ваш мод копирует сохраненное значение из $.store в $.state при session.start, он должен скопировать его снова после /clear, /resume или /branch. Эти команды сбрасывают каждое значение $.state к значению по умолчанию, а session.start повторно не срабатывает. classic.SessionStart же срабатывает после каждой из них, с e.source, установленным в clear, resume или fork, поэтому скопируйте значение снова в хуке на это событие. В противном случае ваша отрисовка покажет значение по умолчанию, а обратный вызов, который сохраняет значение $.state, запишет значение по умолчанию поверх того, что вы сохранили.
Этот код загружает count из обоих хуков. Он основан на версии hello-tabs с $.state, где count — это атом, а update импортирован. Поместите loadCount выше register и добавьте вызов loadCount в уже имеющийся у вас хук session.start. classic.SessionStart также срабатывает при запуске и после сжатия контекста, которое не сбрасывает $.state, поэтому фильтр по source ограничивает хук тремя сбросами:
// Copy the saved count from $.store into $.state, or 0 if nothing is saved
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// Runs again after /clear, /resume, and /branch, which reports fork
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
await loadCount($)
return next(e)
})
С обоими хуками панель показывает после /clear сохраненный счетчик, а не 0, и следующее нажатие Add one прибавляет к сохраненному счетчику.
loadCount записывает сохраненное значение поверх того, что находится в $.state, а session.start срабатывает снова каждый раз, когда модуль перезагружается. Чтобы хранилище не отставало, сохраняйте при каждом изменении, как это делает кнопка Add one.
Чтобы проверить перезагрузку без сессии, протестируйте отрисовку после /clear.
Сохранение из более чем одной сессии
Все сессии на вашем компьютере, которые запускают ваш мод, используют одно общее $.store. get, за которым следует set, не является атомарной операцией. Когда две сессии каждая читают значение, изменяют его и записывают обратно, возникает гонка, и вторая запись заменяет первую.
Чтобы сделать это менее вероятным:
- Дайте каждому элементу собственный ключ:
setизменяет только свой ключ, поэтому сессии, которые записывают разные ключи, не перезаписывают друг друга - Читайте повторно прямо перед записью: для значения, которое изменяют несколько сессий, выполните
getдля ключа в обратном вызове и сформируйте новое значение на его основе, а не на основе копии, загруженной приsession.start. Запись другой сессии все равно будет потеряна, если она произойдет между вашимиgetиset.
Эта кнопка прибавляет единицу к тому, что сейчас содержится в хранилище, затем обновляет отрисовку:
onPress: async () => {
// Read what the store holds now, which another session may have changed
const saved = Number((await $.store.get('count')) ?? 0)
// Save the new count, then show it
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}
Если вторая сессия нажала свою кнопку три раза с момента запуска этой сессии, это нажатие покажет и сохранит счетчик, который включает эти три нажатия.
Следующие шаги
- Реагирование на события: питайте ваше рисование из вызовов инструментов и ходов
- Использование API модов: питайте ваше рисование из таймеров и вызовов модели
- Тестирование рисования: нажимайте ваши кнопки из теста, на более чем одной поверхности
- Сайты рендеринга и элементы: свойства каждого сайта и свойства каждого элемента