Рисование в интерфейсе с помощью мода
Рисуйте панели, полосу над приглашением, кнопки и текстовые поля из мода Claude Code, обрабатывайте нажатия и ввод, и сохраняйте состояние между перерисовками и сеансами.
Мод может рисовать свой собственный интерфейс в Claude Code и изменять части интерфейса, которые уже рисует Claude Code. Каждое место, где мод может рисовать, называется сайтом рендеринга, например панель, полоса над приглашением или спиннер. Claude Code вызывает событие ui.render каждый раз, когда собирается рисовать сайт рендеринга, и ваш хук для этого события возвращает то, что нужно рисовать там.
На этой карте показано, где мод может рисовать в сеансе терминала:
В более узком терминале панель находится над приглашением вместо того, чтобы находиться рядом с расшифровкой.
Создайте свой первый мод перед тем, как начать здесь. Начните с рабочего примера, который создает панель с двумя вкладками и счетчиком, затем прочитайте раздел для каждой части, которую вы хотите изменить.
Чтобы найти одно свойство или ограничение, см. справку.
Создание панели с вкладками
В этом разделе вы создаете мод, который добавляет команду /hello-tabs, и команда открывает панель. Панель — это боковая панель рядом с расшифровкой в широком полноэкранном терминале или обрамленная область над приглашением в противном случае. Эта панель показывает две вкладки, и вторая вкладка имеет кнопку, которая добавляет единицу к счетчику. Счет остается там после перезагрузки Claude Code.
Готовый мод выглядит так. Запись открывает панель, переключается на вторую вкладку, нажимает кнопку несколько раз и возвращается на первую вкладку:
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…
Приглашение разрешения не является сайтом рендеринга, поэтому мод не может изменить то, что оно показывает. Диалог вопроса, AskUserQuestion, является одним, поэтому мод может изменить это.
Терминал и приложение 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 закрытие панели. Передайте true или оставьте поле, потому что Claude Code отказывает false. |
holdToasts |
Удерживает всплывающие уведомления, небольшие уведомления от $.ui.toast, пока панель не закроется |
rows |
Высота, которую нужно запросить, когда панель находится над приглашением. По умолчанию треть пространства. |
columns |
Ширина, которую нужно запросить, когда панель находится рядом с расшифровкой |
Чтобы позволить команде открыть панель, пока 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 ⏎ add
Эта таблица перечисляет каждый элемент:
| Элемент | Что он рисует | Где |
|---|---|---|
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, который упаковывает каждую ячейку в одну строку. Каждая ячейка — это три числа: кодовая точка символа, его цвет и цвет фона. Цвет — это шестнадцатеричное число с двумя цифрами каждого для красного, зеленого и синего, например 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к нему. Оставьте свойство на других, потому что Claude Code отказываетautoFocus: false.
То, как горячая клавиша показывается, зависит от кнопки и приложения:
| Кнопка | В терминале | В приложении 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 из обоих хуков. Он строится на версии $.state hello-tabs, где 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 модов: питайте ваше рисование из таймеров и вызовов модели
- Тестирование рисования: нажимайте ваши кнопки из теста, на более чем одной поверхности
- Сайты рендеринга и элементы: свойства каждого сайта и свойства каждого элемента