Mit einem Mod in der Oberfläche zeichnen
Zeichnen Sie mit einem Claude Code Mod Bereiche, ein Band über dem Prompt, Schaltflächen und Textfelder, verarbeiten Sie Tastendrücke und Eingaben, und bewahren Sie den Zustand zwischen Neuzeichnungen und Sitzungen.
Ein Mod kann seine eigene Oberfläche in Claude Code zeichnen und Teile der Oberfläche ändern, die Claude Code bereits zeichnet. Jede Stelle, an der ein Mod zeichnen kann, wird als Render-Stelle bezeichnet, etwa ein Bereich, das Band über dem Prompt oder der Spinner. Claude Code löst das Ereignis ui.render jedes Mal aus, bevor es eine Render-Stelle zeichnet, und Ihr Hook für dieses Ereignis gibt zurück, was dort gezeichnet werden soll.
Diese Übersicht zeigt, wo ein Mod in einer Terminal-Sitzung zeichnen kann:
In einem schmaleren Terminal befindet sich der Bereich über dem Prompt statt neben dem Transkript.
Erstellen Sie Ihren ersten Mod, bevor Sie hier beginnen. Starten Sie mit dem ausgearbeiteten Beispiel, das einen Bereich mit zwei Tabs und einem Zähler erstellt, und lesen Sie dann den Abschnitt zu jedem Teil, den Sie ändern möchten.
Um eine einzelne Prop oder ein Limit nachzuschlagen, lesen Sie die Referenz.
Einen Bereich mit Tabs erstellen
In diesem Abschnitt erstellen Sie einen Mod, der einen Befehl /hello-tabs hinzufügt, und der Befehl öffnet einen Bereich. Ein Bereich ist in einem breiten Vollbild-Terminal eine Seitenleiste neben dem Transkript, andernfalls ein umrahmter Bereich oberhalb des Prompts. Dieser Bereich zeigt zwei Tabs, und der zweite Tab enthält eine Schaltfläche, die einen Zähler um eins erhöht. Der Zählerstand bleibt erhalten, nachdem Sie Claude Code neu starten.
Der fertige Mod sieht so aus. Die Aufzeichnung öffnet den Bereich, wechselt zum zweiten Tab, drückt die Schaltfläche einige Male und kehrt zum ersten Tab zurück:
Die Tabs sind zwei Schaltflächen in einer Zeile. Der Mod merkt sich, welcher Tab aktiv ist, und zeichnet dessen Inhalt unterhalb der Zeile.
Das Plugin erstellen
Ein Mod ist ein Plugin mit einem Manifest, einer hooks.json, die auf Ihren Code verweist, und der Code-Datei. Einen Mod erstellen erklärt jede davon. Erstellen Sie ein Verzeichnis namens hello-tabs mit den Verzeichnissen .claude-plugin und hooks darin, und speichern Sie dann die ersten beiden Dateien.
Speichern Sie das Manifest als 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" }
}
Benennen Sie Ihren Einstiegspunkt in hello-tabs/hooks/hooks.json:
{
"modules": ["./register.js"]
}
Den Code schreiben
Diese Liste beschreibt, was jeder Hook tut, in der Reihenfolge, in der sie im Code erscheinen:
- Fügt den Befehl
/hello-tabshinzu und lädt den Zählerstand, den eine frühere Sitzung gespeichert hat - Öffnet den Bereich, wenn Sie diesen Befehl ausführen
- Zeichnet den Inhalt des Bereichs: die Zeile mit den Tabs und den Inhalt des geöffneten Tabs
Zwei Variablen auf Modulebene, tab und count, halten den Zustand des Bereichs.
Speichern Sie dies als 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,
],
})
})
}
Jeder Hook tut außerdem etwas, das der Code nicht deutlich macht:
session.startliest zusätzlich den gespeicherten Zählerstand aus$.store, einem Key-Value-Speicher, der zwischen Sitzungen erhalten bleibt.command.runteilt Claude Code nur mit, dass der Bereich existiert. Das Öffnen eines Bereichs zeichnet für sich genommen nichts: Claude Code löst anschließendui.renderaus, um zu erfragen, was hineingehört.ui.rendergibt den Elementbaum zurück, eineBox, die weitere Boxen, Text und Schaltflächen enthält, und baut ihn bei jeder Ausführung erneut austabundcountauf.
Das Drücken einer Schaltfläche führt deren onPress-Callback aus, der eine Variable ändert und redraw aufruft. Claude Code führt daraufhin den Hook ui.render erneut aus, und der Hook baut aus den neuen Werten einen neuen Baum auf. Jede interaktive Darstellung nutzt diesen Render-Zyklus: Ein Callback ändert den Zustand, und der Hook rendert erneut aus dem neuen Zustand.
Den Bereich öffnen
Starten Sie Claude Code in Ihrer Shell mit claude --plugin-dir ./hello-tabs. Führen Sie im Prompt von Claude Code /hello-tabs aus. Ein Bereich öffnet sich mit 1: One und 2: Two am oberen Rand. Drücken Sie 2 und dann einige Male a, das Tastenkürzel für Add one. Der Zählerstand steigt.
Prüfen, ob der Zählerstand gespeichert wurde
Drücken Sie Esc, um den Bereich zu schließen, und beenden Sie dann die Sitzung. Starten Sie Claude Code in Ihrer Shell erneut mit demselben Befehl claude --plugin-dir ./hello-tabs, und führen Sie im Prompt von Claude Code /hello-tabs aus. Der Zählerstand ist dort, wo Sie ihn verlassen haben.
Um den Zählerstand zurückzusetzen, lassen Sie den Mod $.store.delete('count') aufrufen. Zustand beibehalten beschreibt, wie lange jede Art von Wert erhalten bleibt.
Wählen Sie, wo gezeichnet wird
Ein ui.render-Hook wird für jede Render-Stelle ausgeführt, sofern Sie ihn nicht auf die Stelle eingrenzen, an der Sie zeichnen möchten. Um die Render-Stelle auszuwählen, übergeben Sie einen Filter, einen sogenannten Matcher, als zweites Argument an on. { component: 'Pane' } führt den Hook nur für Panes aus. Im Hook benennt e.component die Stelle, e.surface gibt an, welche App zeichnet, und e.props enthält die eigenen Daten der Stelle. Bei einem Pane ist e.requestId die id, mit der Sie es geöffnet haben.
Das Pane und das Band sind leer, bis ein Mod sie füllt. Wählen Sie einen Tab aus, um zu sehen, was jedes davon ist und wie Sie darin zeichnen:
Ein Pane ist eine Seitenleiste neben dem Transkript in einem breiten Vollbild-Terminal, andernfalls ein umrahmter Bereich über dem Prompt. Wenn mehrere Panes geöffnet sind, erhält jedes einen Tab, der seinen Titel anzeigt.
Ein Pane erscheint, wenn Ihr Mod $.ui.open mit einer von Ihnen gewählten id aufruft, etwa $.ui.open({ id: 'hello-tabs' }). Ein Pane zum richtigen Zeitpunkt öffnen behandelt die übrigen Felder und wann ein Pane auf ein breiteres Terminal wartet.
Um in Ihrem Pane zu zeichnen, filtern Sie auf { component: 'Pane' } und prüfen Sie, ob e.requestId Ihre id ist.
Das Band ist ein Streifen direkt über dem Prompt-Eingabefeld. Es ist immer vorhanden, und alle Mods teilen es sich.
Ihr Hook gibt einen Baum zurück, um etwas im Band anzuzeigen, oder next(e), um nichts anzuzeigen. Ein Baum ersetzt, was die Mods nach Ihrem dort zeichnen. Um deren Inhalte zu behalten, fügen Sie das Ergebnis von await next(e) unter die Kinder einer Box in Ihrem Baum ein.
Um im Band zu zeichnen, filtern Sie auf { component: 'AbovePrompt' }.
Ändern, was Claude Code bereits zeichnet
Claude Code zeichnet den Großteil seiner Oberfläche selbst: Nachrichten, Zeilen für Tool-Aufrufe, den Spinner und mehr. Jeder dieser Teile ist ebenfalls eine Render-Stelle, sodass ein Mod ihn umgestalten oder ersetzen kann. Um einen davon zu ändern, filtern Sie Ihren ui.render-Hook auf seinen Namen aus dieser Tabelle:
| Stelle | Was sie ist |
|---|---|
UserMessage, AssistantMessage |
Eine Nachricht im Transkript |
ToolUse, ToolResult, ToolGroup |
Die Zeile eines Tool-Aufrufs, sein Ergebnis und eine eingeklappte Gruppe von Aufrufen |
CommandOutput |
Die Zeile, die ein Befehl ausgegeben hat |
AskUserQuestion |
Der Dialog, den Claude öffnet, um Ihnen eine Frage zu stellen |
Spinner, ToolProgress, TurnDuration |
Statuszeilen für einen Turn: die Zeile, die animiert wird, während Claude arbeitet, die Live-Fortschrittszeile eines laufenden Tools und die Zeile, die einen Turn abschließt |
InfoNotice, SessionMode, PromptHint |
Statuszeilen unter dem Logo, die Modus-Bezeichnungen in der Fußzeile und die Hinweiszeile unter dem Prompt |
An einer Stelle, die Claude Code bereits zeichnet, kann Ihr Hook ein Detail ändern, die Darstellung ersetzen oder sie unverändert lassen. Wählen Sie einen Tab aus, um jede Variante auf den Spinner angewendet zu sehen. Die Beispiele lesen eine Variable calls, die ein anderer Hook hochzählt, wie im Tutorial-Mod.
Um die Darstellung von Claude Code beizubehalten und nur einen Teil davon zu ändern, übergeben Sie an next eine Kopie des Events mit geänderten props. Dieser Hook ändert den Text nach dem Wort des Spinners:
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 + '…' } })
})
Der Spinner behält seine Animation und sein Wort, und Ihr Text folgt auf das Wort:
Thinking · tool calls: 2…
Um anstelle der Stelle etwas Eigenes zu zeichnen, geben Sie einen Baum zurück und rufen Sie next nicht auf. Dieser Hook zeichnet eine Textzeile dort, wo sonst der Spinner wäre:
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'] })
})
Während Claude arbeitet, wird Ihre Zeile angezeigt und der Spinner von Claude Code nicht:
Claude has made 2 tool calls
Um die Stelle so zu belassen, wie Claude Code sie zeichnet, geben Sie next(e) zurück. Ein Hook tut das oft für manche Events und für andere nicht. Dieser Hook lässt den Spinner unverändert, bis es einen Aufruf zu zählen gibt:
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 + '…' } })
})
Vor dem ersten Tool-Aufruf sieht der Spinner so aus wie ohne den Mod:
Thinking…
An diesen Stellen gibt next(e) eine Referenz auf die Darstellung von Claude Code zurück, { type: 'engine', ref }, es sei denn, ein Mod, der nach Ihrem ausgeführt wird, hat einen eigenen Baum zurückgegeben. Um zu ändern, was in dieser Darstellung steht, übergeben Sie an next eine Kopie des Events mit anderen Props, wie es der Tab Ein Detail ändern tut. Sie können die Referenz unverändert zurückgeben oder sie in einer Box neben eigene Elemente setzen:
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'] })] })
})
Während Claude arbeitet, wird der Spinner wie zuvor animiert, und under the spinner erscheint darunter.
Die Berechtigungsabfrage ist keine Render-Stelle, daher kann ein Mod nicht ändern, was sie anzeigt. Der Fragedialog AskUserQuestion ist eine, sodass ein Mod ihn ändern kann. Ein Baum für den Dialog muss die Referenz genau einmal enthalten, mit Ihren Elementen darüber. Andernfalls zeichnet Claude Code seinen eigenen Dialog.
Das Terminal und die Desktop-App lösen nicht alle dieselben Stellen aus. Pane, AbovePrompt, Spinner und die Transkript-Stellen funktionieren in beiden. Einige andere Statuszeilen werden nur im Terminal ausgelöst. Die Tabelle der Render-Stellen listet auf, wo jede davon ausgelöst wird.
Ein Pane zum richtigen Zeitpunkt öffnen
Ein Pane erscheint nur, wenn Ihr Mod es öffnet. Wie und wann Sie es öffnen, entscheidet darüber, ob es den Tastaturfokus übernimmt, wie viel Platz es beansprucht und ob es in einem schmalen Terminal überhaupt angezeigt wird.
Um ein Pane zu öffnen, rufen Sie $.ui.open mit einer von Ihnen gewählten id auf. Die id ist der Name des Panes: Ihr ui.render-Hook prüft darauf, und Sie übergeben sie erneut, um das Pane zu schließen.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
Um das Pane zu schließen, rufen Sie $.ui.close mit der id auf, mit der Sie es geöffnet haben:
await $.ui.close({ id: 'hello-tabs' })
Neben id akzeptiert $.ui.open diese optionalen Felder:
| Feld | Funktion |
|---|---|
title |
Die Tab-Beschriftung des Panes, wenn mehr als ein Pane geöffnet ist |
focus |
Fordert den Tastaturfokus an |
closeOnEscape |
Sorgt dafür, dass Esc das Pane schließt |
holdToasts |
Hält Toasts, die kleinen Hinweise von $.ui.toast, zurück, bis das Pane geschlossen wird |
rows |
Die anzufordernde Höhe, wenn sich das Pane über dem Prompt befindet. Standardmäßig ein Drittel des Platzes. |
columns |
Die anzufordernde Breite, wenn sich das Pane neben dem Transkript befindet |
focus, closeOnEscape und holdToasts sind optional und akzeptieren nur true. Um eines davon wegzulassen, lassen Sie es aus. Die Übergabe von false löst einen Fehler wie ui.open: focus is true or left out aus. Um eines davon bedingt zu setzen, fügen Sie das Feld nur hinzu, wenn die Bedingung erfüllt ist. Dieser Aufruf fordert den Tastaturfokus nur an, wenn items nicht leer ist:
const pane = { id: 'hello-tabs', title: 'Hello tabs' }
await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)
Damit ein Befehl das Pane öffnen kann, während Claude arbeitet, fügen Sie immediate: true hinzu, wenn Sie den Befehl registrieren. Ohne diese Option wartet ein während eines Turns eingegebener Befehl, bis der Turn endet.
Wenn ein Pane auf ein breiteres Terminal wartet
Ein Pane, das Ihr Mod unaufgefordert öffnet, erscheint nicht in einem schmalen Terminal, damit es keinen kleinen Bildschirm vereinnahmen kann. Ob es erscheint, hängt davon ab, wodurch es geöffnet wurde:
- Durch eine Aktion des Benutzers geöffnet, etwa einen ausgeführten Befehl oder eine gedrückte Schaltfläche: Das Pane erscheint bei jeder Breite
- Durch Ihren Mod von sich aus geöffnet, etwa über einen Timer oder einen
turn.start-Hook: Das Pane erscheint nur in einem Terminal mit mindestens 144 Spalten Breite. Nachdem der Benutzer dieses Pane einmal selbst geöffnet hat, reichen 110 Spalten aus.
Wenn das Pane erscheint, wird $.ui.open zu { isPlaced: true } aufgelöst. Wenn das Pane wartet, ist isPlaced false, und reason ist ein String, der den Grund angibt. Ein wartendes Pane erscheint, wenn der Benutzer es öffnet oder das Terminal verbreitert. Um mitzuteilen, dass etwas verfügbar ist, ohne ein Pane zu öffnen, rufen Sie $.ui.toast('Your message') auf, das eine Toast-Benachrichtigung anzeigt.
Einen Baum aus Elementen erstellen
Was ein ui.render-Hook zurückgibt, ist ein Elementbaum: eine Beschreibung dessen, was gezeichnet werden soll, bestehend aus Boxen, Text und Steuerelementen, die ineinander verschachtelt sind. Sie beschreiben die Zeichnung, und Claude Code rendert sie im Terminal oder in der Desktop-App.
Um die Elemente zu erhalten, rufen Sie in Ihrem Hook $.ui.resolve(e) auf, etwa mit const { Box, Text, Button } = $.ui.resolve(e). Jedes Element ist eine Funktion. Sie übergeben ihr Props und legen die Elemente und Strings, die darin enthalten sein sollen, in children ab.
Wählen Sie einen Tab aus, um die gängigsten Elemente und ihre Darstellung im Terminal zu sehen:
Text zeichnet einen String, optional mit Formatierung wie bold und color:
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box ordnet seinen Inhalt in einer Zeile oder einer Spalte an. Dieses Beispiel platziert eine Schaltfläche und eine Textzeile nebeneinander, zwei Spalten voneinander entfernt:
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'more', label: 'Add one', onPress: addOne }),
Text({ children: ['Count: 0'] }),
],
})
[ Add one ] Count: 0
Button ist ein Steuerelement, das der Benutzer drücken kann. Es führt Ihren onPress-Callback aus. Mit plain: true hat es keine Klammern und zeigt sein Tastenkürzel an:
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 ist ein Textfeld. Es führt Ihren onSubmit-Callback mit dem Text aus, wenn der Benutzer die Eingabetaste drückt:
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
Die Oberflächengalerie enthält Beispiele und Screenshots der meisten Elemente. Diese Tabelle listet alle Elemente auf:
| Element | Was es zeichnet | Wo |
|---|---|---|
Box |
Einen Flex-Container. Akzeptiert Layout-Props wie flexDirection, columnGap, padding, borderStyle und width. |
Überall |
Text |
Formatierten Text. Akzeptiert color, bold, dimColor, italic und wrap. Eine color ist ein Theme-Schlüssel oder eine Farbe wie 'red'. Ein wrap ist 'wrap', 'truncate', 'truncate-start', 'truncate-middle' oder 'truncate-end'. |
Überall |
Button |
Ein Steuerelement, das onPress aufruft |
Überall |
Link, Code, Markdown |
Einen Link mit href und einem optionalen label, einen Codeblock und Text, der wie die Antworten von Claude formatiert ist. Markdown erhält seinen Inhalt in einer text-Prop, nicht in children, und benötigt einen key, wenn Sie onLinkPress übergeben. |
Überall |
Input, Select |
Ein Textfeld und ein Dropdown-Menü | Terminal, Desktop |
Svg |
Ein SVG-Dokument | Desktop |
Client |
Einen Bereich, der von einer zweiten Datei von Ihnen gezeichnet wird, für Animationen und Zeigereingaben. Diese Datei erhält keine Mods-API. Sie erreicht Ihre Hooks nur, indem sie Daten sendet, die als ui.message-Event ankommen. |
Terminal, Desktop |
Raster, Image |
Ein Raster aus farbigen Zellen und ein Bild | Terminal |
Wenn Ihr Modul eine .tsx- oder .jsx-Datei ist, können Sie den Baum als JSX schreiben. Destrukturieren Sie die Elemente zuerst aus $.ui.resolve(e).
Wenn ein Baum ein Element verwendet, das die App nicht kennt, eine Prop, die ein Element nicht akzeptiert, oder ein Kind an einer Stelle, an der keines vorgesehen ist, zeichnet Claude Code seine eigene Version der Stelle.
In einer mit --plugin-dir gestarteten Sitzung weist eine Zeile im Transkript darauf hin, etwa ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own. Das Debug-Log vermerkt dies als ui.render (Pane): a hook returned a tree that does not validate mit derselben Begründung. Sonst erscheint nichts in der Sitzung. Wenn eine Zeichnung also nicht angezeigt wird, prüfen Sie diese Zeile oder das Log.
Ein Raster aus farbigen Zellen zeichnen
Für eine Heatmap, eine Sparkline oder ein Spielbrett im Terminal zeichnen Sie ein einziges Raster statt einer Box für jede Zelle. Ein Raster erhält einen key, seine Größe in columns und rows sowie cells, einen Base64-String, der alle Zellen enthält. Jede Zelle besteht aus drei Zahlen: dem Codepoint des Zeichens, seiner Farbe und seiner Hintergrundfarbe. Eine Farbe ist ein 24-Bit-RGB-Wert in hexadezimaler Schreibweise, etwa 0xc62828 für ein Rot. Der Wert 0x01000000, eins über diesem Bereich, steht für die Standardfarbe des Terminals.
Die Desktop-App hat kein Raster. Prüfen Sie daher e.surface und zeichnen Sie dort Text. Dieser Pane-Inhalt zeichnet eine Heatmap mit drei mal zwei Zellen:
// 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) })],
})
})
Im Terminal zeigt der Pane das Raster an:
Das rows-Array ist der Teil, den Sie ändern würden, und cellsOf wandelt es in den gepackten String um. Der Hook zeichnet nur in einem Pane, dessen id heat ist. Öffnen Sie daher einen solchen Pane mit $.ui.open({ id: 'heat' }) aus einem Befehl heraus, so wie das hello-tabs-Beispiel seinen Pane öffnet.
Jedes Zeichen muss genau eine Zelle breit sein. Um ein Raster zu animieren, das bereits auf dem Bildschirm ist, rufen Sie $.ui.blit mit der id des Panes als requestId, dem key des Raster, derselben Größe und neuen Zellen auf. Für dieses Beispiel ist das $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }). Dadurch wird nur dieses eine Element neu gezeichnet, ohne Ihren ui.render-Hook erneut auszuführen.
Auf Tastendrücke und Eingaben reagieren
Wenn der Benutzer eine Schaltfläche drückt, in ein Feld tippt oder aus einer Liste auswählt, die Ihr Mod gezeichnet hat, ruft Claude Code den Callback dieses Steuerelements auf, der in Ihrem Modul ausgeführt wird. Jedes Steuerelement nimmt eigene Callbacks entgegen:
Button: nimmtonPress(e)entgegen, wobeie.surfacedie App ist, aus der der Tastendruck kamInput: nimmtonSubmit(value)undonInput(value)entgegenSelect: nimmtonSelect(value)entgegen, mit seinen Auswahlmöglichkeiten inoptions, einer Liste mit mindestens einer Auswahl mit eindeutigen Werten, etwa[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
Ein Test drückt ein Steuerelement oder tippt in es hinein, indem er es über seinen key anspricht, geben Sie also jedem Steuerelement einen. Jede Verwendung eines Steuerelements löst außerdem ui.press, ui.input oder ui.select mit dem key in e.element aus, und ein anderer Mod kann diese Ereignisse verarbeiten. Sein Hook läuft vor Ihrem Callback, sodass er sieht, was der Benutzer in Ihr Input eingibt, und es ändern oder anstelle Ihres Callbacks antworten kann. Die Mods-API hat keine Methode, mit der sich die Schaltfläche eines anderen Mods drücken lässt.
Tastaturfokus und Hotkeys
Ihr Mod liest die Tastatur nie selbst. Der Benutzer drückt eine Taste, Claude Code entscheidet, für welches Ihrer Steuerelemente sie bestimmt ist, und der Callback dieses Steuerelements wird ausgeführt. Abgesehen von einem Ziffern-Hotkey auf dem Band geschieht das nur, solange Ihr Bereich oder Band den Tastaturfokus hat. Die übrige Zeit gehen Tastendrücke an den Prompt.
Wie ein Bereich den Tastaturfokus erhält
Ein Bereich erhält den Tastaturfokus, wenn:
- Ihr Mod ihn mit
focus: trueaus einem Befehl oder einem Tastendruck heraus öffnet - Der Benutzer Ctrl+X und dann Tab drückt
- Der Benutzer darauf klickt
Claude Code gewährt focus: true nur, solange der Prompt leer ist und nichts anderes den Tastaturfokus hat. Ein Bereich, der sich öffnet, während der Benutzer tippt, übernimmt dessen Tastenanschläge nicht.
Was jede Taste bewirkt
Diese Tabelle listet auf, was eine Taste bewirkt, während Ihr Bereich oder Band den Tastaturfokus hat:
| Taste | Was sie bewirkt |
|---|---|
| Tab | Wechselt zum nächsten Steuerelement |
| Pfeil nach oben und unten | Wechseln zwischen Steuerelementen, solange Ihre Zeichnung hineinpasst. Wenn der Bereich oder das Band mehr Zeilen hat, als angezeigt werden können, scrollen sie ihn. |
| Enter | Drückt den fokussierten Button, sendet das fokussierte Input ab oder wählt in einem Select aus |
| Der Hotkey einer Schaltfläche | Drückt diese Schaltfläche. Solange ein Input den Fokus hat, geht jede druckbare Taste an das Feld. |
| Page Up, Page Down, Home und End | Scrollen Ihren Bereich oder Ihr Band, wenn er bzw. es mehr Zeilen hat, als angezeigt werden können |
| Ctrl+X und dann eine Pfeiltaste | Ändert die Größe Ihres Bereichs. Links oder nach oben gibt ihm mehr Platz, rechts oder nach unten gibt den Platz wieder ab. |
| Ctrl+X und dann X | Schließt Ihren Bereich, auch wenn eines seiner Felder den Fokus hat |
| Esc | Gibt den Tastaturfokus an den Prompt zurück. Mit closeOnEscape: true schließt sie außerdem den Bereich. |
Ein Mod kann Tab oder die Pfeiltasten nicht anderweitig belegen, daher steuert ein Spiel mit w, a, s und d.
Einen Hotkey und den ersten Fokus festlegen
Diese Props eines Steuerelements bestimmen, wie die Tastatur es erreicht:
hotkey: Damit der Benutzer einenButtonmit einer Taste drücken kann, geben Sie ihm einenhotkeyaus einer Ziffer oder einem Kleinbuchstaben, wie inhotkey: 'a'autoFocus: Um festzulegen, welches Steuerelement beim Öffnen des Bereichs den Fokus hat, fügen Sie ihmautoFocus: truehinzu. Die Prop akzeptiert nurtrue, lassen Sie sie also bei den anderen Steuerelementen weg.
Wie ein Hotkey angezeigt wird, hängt von der Schaltfläche und der App ab:
| Schaltfläche | Im Terminal | In der Desktop-App |
|---|---|---|
| Mit Klammern, der Standard | [ Add one ], ohne angezeigten Hotkey |
Die Beschriftung mit einer kleinen Taste daneben |
Mit plain: true |
1: One |
Die Beschriftung mit einer kleinen Taste daneben |
Nennen Sie im Terminal die Taste in der Beschriftung einer Schaltfläche mit Klammern oder verwenden Sie plain: true, damit der Benutzer sehen kann, was er drücken muss. Die Elementreferenz enthält die übrigen Button-Regeln: action, Ziffern-Hotkeys auf dem Band und zwei Schaltflächen auf einem Hotkey.
Texteingaben entgegennehmen und für jedes Element eine Zeile zeichnen
Viele Bereiche bestehen aus einem Textfeld mit einer Liste darunter. Das Beispiel in diesem Abschnitt ist ein Notizbereich: Sie tippen eine Notiz und drücken Enter, um sie hinzuzufügen, und jede Notiz hat eine x-Schaltfläche, die sie löscht. Mit zwei hinzugefügten Notizen zeichnet das Terminal den Bereich so:
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
Das Beispiel verwendet diese Techniken:
- Texteingaben entgegennehmen: Ein
InputruftonSubmit(value)mit dem Text des Felds auf, wenn der Benutzer Enter drückt, undonInput(value)bei jeder Änderung - Eine Liste zeichnen: Ordnen Sie Ihren Daten jeweils eine Zeile zu und geben Sie der Schaltfläche jeder Zeile einen eigenen
key
Dieser Hook zeichnet den Inhalt des Bereichs:
// 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] }),
],
}),
),
],
})
})
So probieren Sie den Bereich aus:
- Eine Notiz hinzufügen: Tippen Sie eine Zeile und drücken Sie Enter. Die Zeile erscheint als neue Zeile in der Liste, und das Feld wird geleert.
- Eine Notiz löschen: Drücken Sie Tab, bis die
x-Schaltfläche der Notiz den Fokus hat, und drücken Sie dann Enter. Dasxist die Beschriftung der Schaltfläche und kein Hotkey, daher wird sie durch Tippen des Buchstabens nicht gedrückt.
Jede Änderung folgt demselben Render-Zyklus wie hello-tabs: Der Callback ändert notes, ruft redraw auf und speichert die Liste in $.store.
Das Feld wird nach jedem Absenden wegen seiner value-Prop geleert. value ist der Text, den das Feld beim Zeichnen enthält, und die Eingaben des Benutzers ersetzen ihn, bis Ihr Hook das Feld erneut zeichnet. Das Beispiel zeichnet das Feld immer mit ''.
Das Beispiel speichert die Notizen, lädt sie aber nicht. Um sie in der nächsten Sitzung wiederherzustellen, lesen Sie sie in einem session.start-Hook ein, so wie hello-tabs count einliest.
Diese Props bilden die Zeile des Felds, Note: Type a note and press Enter ⏎ add:
| Prop | Im Beispiel | Was sie ist |
|---|---|---|
label |
Note |
Der Text vor dem Feld. Das Terminal zeichnet : dahinter. |
placeholder |
Type a note and press Enter |
Abgeblendeter Text, der angezeigt wird, solange das Feld leer ist |
submitLabel |
add |
Das Wort nach ⏎, das angibt, was Enter bewirkt |
Das Absenden eines Input startet keinen Turn, es sei denn, Ihr Callback ruft $.prompt.submit auf.
Einen Bereich neu zeichnen
Eine Zeichnung ist eine Momentaufnahme: Sie zeigt, was Ihr ui.render-Hook beim letzten Ausführen zurückgegeben hat. Damit etwas Neues angezeigt wird, muss der Hook erneut ausgeführt werden. Claude Code führt ihn bei einigen Änderungen erneut aus, und für die übrigen fordert Ihr Mod dies an.
Wann Claude Code ohne Aufforderung neu zeichnet
Claude Code führt Ihren ui.render-Hook erneut aus, wenn sich die Props des Bereichs oder die Breite des Terminals ändern. Der Hook wird nicht zeitgesteuert ausgeführt, und Claude Code kann nicht erkennen, wann sich eine Variable in Ihrem Modul ändert.
Neu zeichnen, wenn sich Ihre Daten ändern
Damit Ihre Bereiche nach einer Änderung Ihrer eigenen Daten neu gezeichnet werden, rufen Sie $.ui.invalidate('ui.render') auf. Dieses Panel zählt Tastendrücke. Der Callback der Schaltfläche ändert count und fordert dann ein Neuzeichnen an:
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] }),
],
})
})
Jeder Druck erhöht die Zahl im Panel. Das hello-tabs-Beispiel kapselt denselben Aufruf in seiner Funktion redraw.
Ein Wert, den Sie in $.state speichern, benötigt diesen Aufruf nicht, da das Schreiben des Werts die Bereiche neu zeichnet, die ihn lesen.
Zeitgesteuert neu zeichnen
Um eine Uhr, einen Countdown oder einen Wert von außerhalb der Sitzung aktuell zu halten, zeichnen Sie nach einem Zeitplan neu. Starten Sie einen Timer im session.start-Hook des Moduls. Wenn das Modul bereits einen hat, wie hello-tabs, fügen Sie die Zeile mit $.clock.every dort hinzu:
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 führt Ihren ui.render-Hook nun einmal pro Sekunde aus. Der Timer stoppt, wenn das Modul neu geladen wird, und die neue Instanz des Moduls startet ihren eigenen.
Wie oft ein Bereich neu gezeichnet werden kann
Claude Code drosselt das Neuzeichnen eines Bereichs, sodass Ihr Mod $.ui.invalidate so oft aufrufen kann, wie sich seine Daten ändern. Wie oft jeder Bereich neu gezeichnet werden kann, entnehmen Sie der Tabelle der Grenzwerte.
Aufrufe, die schneller als der Grenzwert eintreffen, werden zu einem einzigen Neuzeichnen zusammengefasst. Dieses Neuzeichnen führt Ihren Hook einmal aus, und der Hook liest Ihre Daten in ihrem Zustand zu diesem Zeitpunkt, sodass der neueste Wert angezeigt wird und die Zwischenwerte nicht. Eine Animation kann nicht schneller als der Grenzwert laufen.
Zustand speichern
Wo ein Mod einen Wert speichert, bestimmt, wie lange der Wert erhalten bleibt: bis das Modul neu geladen wird, bis die Sitzung endet oder von einer Sitzung zur nächsten. Wählen Sie danach, wie lange der Wert erhalten bleiben muss:
| Speichern in | Erhalten bis | Verwenden für |
|---|---|---|
| Einer Variablen auf Modulebene | Das Modul neu geladen wird, was während der Entwicklung bei jedem Speichern einer Datei geschieht | Werte, deren Verlust Sie in Kauf nehmen können, wie tab in hello-tabs |
$.state |
Die Sitzung endet oder der Benutzer /clear, /resume oder /branch ausführt |
Werte, von denen eine Darstellung abhängt und die ein Neuladen überstehen sollen |
$.store |
Ihr Mod ihn löscht oder keine Sitzung den Store für cleanupPeriodDays liest oder schreibt. Der Store ist ein Key-Value-Store, der als eigene JSON-Datei Ihres Plugins unter ~/.claude/plugins/store/ gespeichert wird. |
Einstellungen, Verlauf, alles, was der Benutzer beim nächsten Mal wiederfinden möchte |
$.store.get(key) wird zum Wert oder zu undefined aufgelöst, und $.store.set(key, value) akzeptiert jeden JSON-Wert.
Einen Wert in `$.state` speichern
$.state hält Werte für die Dauer einer Sitzung und zeichnet für Sie neu. Es handelt sich um reaktiven Zustand: Ein ui.render-Hook, der einen Wert liest, abonniert ihn, sodass Claude Code diese Stelle jedes Mal neu zeichnet, wenn Sie den Wert schreiben, und Sie $.ui.invalidate nicht aufrufen müssen. Ein Wert in $.state übersteht außerdem ein Neuladen des Moduls, was eine Variable nicht tut.
Zur Einrichtung deklarieren Sie Ihre Werte, verweisen in Ihrem Manifest auf die Deklaration und definieren und verwenden dann jeden Wert. Die Beispiele verschieben count aus hello-tabs in $.state.
Die Werte deklarieren
Deklarieren Sie die Werte in einer Typdeklarationsdatei. Der äußere Schlüssel ist der Name Ihres Plugins, und jeder Eintrag darunter ist ein Wert mit seinem Typ. Speichern Sie dies als hello-tabs/types/index.d.ts:
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
Im Manifest auf die Deklaration verweisen
Damit claude plugin validate Ihren Code anhand dieser Datei prüfen kann, fügen Sie dem Manifest ein Feld types mit ihrem Pfad hinzu:
{
"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"
}
Einen Wert definieren, lesen und schreiben
Definieren Sie in Ihrem Modul jeden Wert mit einem Standardwert, lesen Sie ihn beim Zeichnen und schreiben Sie ihn aus einem Callback. atom benennt einen Wert und seinen Standardwert, read gibt ihn zurück und update schreibt ihn. Die drei Hilfsfunktionen rufen $.state.get und $.state.set für Sie auf:
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)
Da der ui.render-Hook count gelesen hat, führt Claude Code den Hook jedes Mal erneut aus, wenn die Schaltfläche den Wert schreibt.
Für den Code gelten diese Regeln:
- Schreiben Sie
pluginundkeyals String-Literale:claude plugin validateliest sie aus Ihrem Quellcode - Deklarieren Sie jeden Wert in der Typdeklarationsdatei: Andernfalls schlägt die Validierung mit
hello-tabs.count is not declaredfehl - Schreiben Sie aus einem Callback oder dem Hook eines anderen Events: Ein
ui.render-Hook kann Zustand lesen, aber nicht schreiben, schreiben Sie also ausonPress,onSubmitoder einem Hook für ein anderes Event
`hello-tabs` auf `$.state` umstellen
Um count in hello-tabs nach $.state zu verschieben, ändern Sie jede Zeile, die ihn verwendet:
- Am Anfang des Moduls: Fügen Sie die
import-Zeile hinzu und ersetzen Sielet count = 0durch dieatom-Zeile - Im
ui.render-Hook: Fügen Sie dieread-Zeile vortabButtonhinzu und zeichnen Sie'Count: ' + nimText - In der Schaltfläche Add one: Ersetzen Sie
onPressdurch die Variante aus Aus mehr als einer Sitzung speichern, die den Zähler sowohl speichert als auch schreibt - Im
session.start-Hook: Ersetzen Sie die beiden Zeilen, diesavedlesen, durch denloadCount-Aufruf aus Einen gespeicherten Wert nach/clearerneut laden
Behalten Sie redraw für die Tab-Schaltflächen bei, da tab weiterhin eine Variable ist.
Einen gespeicherten Wert nach `/clear` erneut laden
Wenn Ihr Mod bei session.start einen gespeicherten Wert aus $.store in $.state kopiert, muss er ihn nach /clear, /resume oder /branch erneut kopieren. Diese Befehle setzen jeden $.state-Wert auf seinen Standardwert zurück, und session.start wird nicht erneut ausgelöst. classic.SessionStart wird jedoch nach jedem dieser Befehle ausgelöst, wobei e.source auf clear, resume oder fork gesetzt ist. Kopieren Sie den Wert daher in einem Hook auf dieses Event erneut. Andernfalls zeigt Ihre Darstellung den Standardwert, und ein Callback, der den $.state-Wert speichert, überschreibt Ihren gespeicherten Wert mit dem Standardwert.
Dieser Code lädt count aus beiden Hooks. Er baut auf der $.state-Version von hello-tabs auf, in der count ein Atom ist und update importiert wird. Platzieren Sie loadCount oberhalb von register und fügen Sie den loadCount-Aufruf dem bereits vorhandenen session.start-Hook hinzu. classic.SessionStart wird außerdem beim Start und nach der Komprimierung ausgelöst, was $.state nicht zurücksetzt, daher beschränkt der Filter auf source den Hook auf die drei Zurücksetzungen:
// 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)
})
Mit beiden Hooks zeigt der Bereich nach /clear den gespeicherten Zähler statt 0 an, und der nächste Klick auf Add one erhöht den gespeicherten Zähler.
loadCount überschreibt den Wert in $.state mit dem gespeicherten Wert, und session.start wird bei jedem Neuladen des Moduls erneut ausgelöst. Damit der Store nicht veraltet, speichern Sie bei jeder Änderung, wie es die Schaltfläche Add one tut.
Um das Neuladen ohne Sitzung zu prüfen, testen Sie die Darstellung nach /clear.
Aus mehr als einer Sitzung speichern
Alle Sitzungen auf Ihrem Rechner, die Ihren Mod ausführen, teilen sich einen $.store. Ein get gefolgt von einem set ist nicht atomar. Wenn zwei Sitzungen jeweils einen Wert lesen, ihn ändern und zurückschreiben, entsteht eine Race Condition, und der zweite Schreibvorgang ersetzt den ersten.
Um dies unwahrscheinlicher zu machen:
- Geben Sie jedem Element einen eigenen Schlüssel: Ein
setändert nur seinen eigenen Schlüssel, sodass Sitzungen, die unterschiedliche Schlüssel schreiben, sich nicht gegenseitig überschreiben - Lesen Sie direkt vor dem Schreiben erneut: Rufen Sie für einen Wert, den mehrere Sitzungen ändern, den Schlüssel im Callback mit
getab und bilden Sie den neuen Wert daraus, nicht aus einer Kopie, die Sie beisession.startgeladen haben. Der Schreibvorgang einer anderen Sitzung geht trotzdem verloren, wenn er zwischen Ihremgetund Ihremsetstattfindet.
Diese Schaltfläche erhöht den aktuellen Wert im Store um eins und aktualisiert dann die Darstellung:
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)
}
Wenn eine zweite Sitzung seit dem Start dieser Sitzung dreimal auf ihre eigene Schaltfläche geklickt hat, zeigt und speichert dieser Klick einen Zählerstand, der diese drei einschließt.
Nächste Schritte
- Auf Ereignisse reagieren: Versorgen Sie Ihre Zeichnung mit Daten aus Tool-Aufrufen und Turns
- Die Mods-API verwenden: Versorgen Sie Ihre Zeichnung mit Daten aus Timern und Modellaufrufen
- Eine Zeichnung testen: Betätigen Sie Ihre Schaltflächen aus einem Test heraus, auf mehr als einer Oberfläche
- Render-Stellen und Elemente: die Props jeder Render-Stelle und die Props jedes Elements