使用 mod 在介面中繪製
從 Claude Code mod 繪製窗格、提示上方的帶狀區域、按鈕和文字欄位,處理按下和輸入,並在重新繪製和工作階段之間保持狀態。
mod 可以在 Claude Code 中繪製自己的介面,並更改 Claude Code 已經繪製的介面部分。mod 可以繪製的每個位置稱為渲染位置,例如窗格、提示上方的帶狀區域或微調器。Claude Code 在即將繪製渲染位置時會引發 ui.render 事件,而您對該事件的鉤子會返回要在那裡繪製的內容。
此地圖顯示 mod 可以在終端工作階段中的繪製位置:
在較窄的終端中,窗格位於提示上方而不是文字記錄旁邊。
在開始之前,請先建立您的第一個 mod。從已完成的範例開始,該範例建立一個具有兩個標籤和計數器的窗格,然後閱讀您想要更改的每個部分的部分。
若要查詢一個屬性或限制,請參閱參考。
建立具有標籤的窗格
在本部分中,您將建立一個 mod,該 mod 新增 /hello-tabs 命令,該命令會開啟一個窗格。窗格是在寬全螢幕終端中文字記錄旁邊的側邊欄,或在其他情況下是提示上方的框架區域。此窗格顯示兩個標籤,第二個標籤有一個按鈕,可將計數器加一。重新啟動 Claude Code 後,計數仍然存在。
完成的 mod 看起來像這樣。錄製會開啟窗格、切換到第二個標籤、按幾次按鈕,然後返回第一個標籤:
Claude Code 沒有內建的標籤元素,因此標籤是一列中的兩個按鈕。mod 會追蹤哪一個是活動的,並在列下方繪製該標籤的內容。
建立外掛程式
mod 是一個具有清單、指向您的程式碼的 hooks.json 和程式碼檔案的外掛程式。建立 mod 說明了每一個。建立一個名為 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:
// 窗格的 id,用於開啟窗格並在繪製時識別它
const PANE = 'hello-tabs'
// 窗格顯示的內容:哪個標籤是開啟的,以及計數器的值
let tab = 'one'
let count = 0
export function register(on) {
// 在您的第一個提示之前執行,並在重新載入後再次執行
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// 載入較早的工作階段儲存的計數(如果有的話)
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// 當您輸入 /hello-tabs 時執行
on('command.run', { command: 'hello-tabs' }, async ($) => {
// 開啟窗格,給它鍵盤焦點,並讓 Esc 關閉它
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// 在文字記錄中不列印任何內容
return {}
})
// 每次 Claude Code 繪製窗格時執行
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// 不理會其他 mod 的窗格
if (e.requestId !== PANE) return next(e)
// 取得此應用程式可以繪製的元素
const { Box, Text, Button } = $.ui.resolve(e)
// 要求 Claude Code 再次執行此鉤子
const redraw = () => $.ui.invalidate('ui.render')
// 一個標籤:一個按鈕,按下時切換到其標籤
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// 調暗不是開啟的標籤
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// 根據開啟的標籤,在標籤下方顯示的內容
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()
// 儲存計數,以便在重新啟動後仍然存在
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// 整個窗格:標籤列、空白行,然後是主體
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 鉤子,該鉤子從新值建立新樹。每個互動式繪製都使用該渲染週期:回呼更改狀態,鉤子從新狀態重新渲染。
開啟窗格
在您的 shell 中,使用 claude --plugin-dir ./hello-tabs 啟動 Claude Code。在 Claude Code 提示處,執行 /hello-tabs。一個窗格會開啟,頂部有 1: One 和 2: Two。按 2,然後按 a(Add one 的快捷鍵)幾次。計數上升。
檢查計數是否已儲存
按 Esc 關閉窗格,然後退出工作階段。在您的 shell 中,使用相同的 claude --plugin-dir ./hello-tabs 命令再次啟動 Claude Code,並在 Claude Code 提示處執行 /hello-tabs。計數在您留下的地方。
若要清除計數,請讓 mod 呼叫 $.store.delete('count')。保持狀態涵蓋每種值持續多長時間。
選擇繪製位置
ui.render 鉤子為每個渲染位置執行,除非您將其縮小到您想要繪製的位置。若要選擇渲染位置,請傳遞一個稱為匹配器的篩選器作為 on 的第二個引數。{ component: 'Pane' } 只為窗格執行鉤子。在鉤子中,e.component 命名位置,e.surface 說明哪個應用程式在繪製,e.props 保持位置自己的資料。對於窗格,e.requestId 是您用來開啟它的 id。
兩個位置在 mod 填充它們之前是空的,窗格和帶狀區域。選擇一個標籤以查看每個位置是什麼以及如何在其中繪製:
窗格是在寬全螢幕終端中文字記錄旁邊的側邊欄,或在其他情況下是提示上方的框架區域。開啟多個窗格時,每個窗格都會獲得一個顯示其標題的標籤。
當您的 mod 使用您選擇的 id 呼叫 $.ui.open 時,窗格會出現,如 $.ui.open({ id: 'hello-tabs' })。在正確的時間開啟窗格涵蓋其他欄位以及窗格何時等待更寬的終端。
若要在您的窗格中繪製,請篩選 { component: 'Pane' } 並檢查 e.requestId 是否為您的 id。
更改 Claude Code 已經繪製的內容
Claude Code 自己繪製大部分介面:訊息、工具呼叫列、微調器等。這些部分中的每一個也是一個渲染位置,因此 mod 可以重新設定樣式或替換它。若要更改一個,請在 ui.render 鉤子上篩選此表中的其名稱:
| 位置 | 它是什麼 |
|---|---|
UserMessage, AssistantMessage |
文字記錄中的訊息 |
ToolUse, ToolResult, ToolGroup |
工具呼叫的列、其結果和折疊的呼叫執行 |
CommandOutput |
命令列印的列 |
AskUserQuestion |
Claude 開啟以詢問您問題的對話框 |
Spinner, ToolProgress, TurnDuration |
輪次的狀態行:在 Claude 工作時動畫的行、執行中工具的即時進度行,以及關閉輪次的行 |
InfoNotice, SessionMode, PromptHint |
標誌下的狀態行、頁尾中的模式標籤,以及提示下的提示行 |
在 Claude Code 已經繪製的位置,您的鉤子有三個選擇:更改詳細資訊、替換繪製或不理會。選擇一個標籤以查看每個應用於微調器的選項。範例讀取另一個鉤子計數的 calls 變數,如教學 mod 中所示。
若要保持 Claude Code 的繪製並更改其一部分,請將 next 傳遞事件的副本,其中 props 已更改。此鉤子更改微調器單詞後的文字:
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// 保持 Claude Code 的微調器,並更改其單詞後的文字
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)
// 沒有呼叫 next,所以此行在微調器的位置繪製
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) => {
// 還沒有要顯示的內容,所以不變地傳遞事件
if (calls === 0) return next(e)
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
在第一個工具呼叫之前,微調器看起來就像沒有 mod 的方式:
Thinking…
權限提示不是渲染位置,因此 mod 無法更改其顯示的內容。問題對話框 AskUserQuestion 是一個,因此 mod 可以更改它。
終端和桌面應用程式不會引發所有相同的位置。Pane、AbovePrompt、Spinner 和文字記錄位置在兩者中都有效。其他一些狀態行僅在終端中引發。渲染位置表列出每個位置的引發位置。
在正確的時間開啟窗格
窗格只在您的 mod 開啟它時出現。您如何以及何時開啟它決定了它是否獲得鍵盤焦點、它要求多少空間,以及它是否在狹窄的終端中顯示。
若要開啟窗格,請使用您選擇的 id 呼叫 $.ui.open。id 是窗格的名稱:您的 ui.render 鉤子檢查它,您再次傳遞它以關閉窗格。
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
若要關閉窗格,請使用您用來開啟它的 id 呼叫 $.ui.close:
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。沒有它,在輪次期間輸入的命令會等待輪次結束。
當窗格等待更寬的終端時
您的 mod 開啟的窗格(未被要求)不會在狹窄的終端中出現,因此它無法接管小螢幕。它是否出現取決於開啟它的內容:
- 由使用者執行的操作開啟,例如他們執行的命令或他們按下的按鈕,窗格在任何寬度出現
- 由您的 mod 自行開啟,例如從計時器或
turn.start鉤子,窗格只在至少 144 列寬的終端中出現。使用者自己開啟該窗格一次後,110 列就足夠了。
當窗格出現時,$.ui.open 解析為 { isPlaced: true }。當窗格在等待時,isPlaced 是 false,reason 是說明原因的字串。當使用者開啟窗格或加寬終端時,等待的窗格會出現。若要說明某些內容可用而不開啟窗格,請呼叫 $.ui.toast('Your message'),它會顯示在幾秒後消失的小通知。
從元素建立樹
ui.render 鉤子返回的是元素樹:對要繪製的內容的描述,由相互嵌套的框、文字和控制項組成。您描述繪製,Claude Code 在終端或桌面應用程式中呈現它。
若要取得元素,請在您的鉤子中呼叫 $.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 是一個文字欄位。當使用者按 Enter 時,它使用文字執行您的 onSubmit 回呼:
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 |
彈性容器。採用佈局屬性,例如 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 中採用其內容,並在您傳遞 onLinkPress 時需要 key。 |
到處 |
Input, Select |
文字欄位和選擇器 | 終端、桌面 |
Svg |
SVG 文件 | 桌面 |
Client |
由您的第二個檔案繪製的區域,用於動畫和指標輸入。該檔案不取得 mod API。它只能通過發佈資料到達您的鉤子,該資料作為 ui.message 事件到達。 |
終端、桌面 |
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 表示終端的預設值。
桌面應用程式沒有 Raster,因此請檢查 e.surface 並在那裡繪製文字。此窗格主體繪製一個三乘二的熱力圖:
// 表示「使用終端的預設顏色」的值
const DEFAULT_COLOR = 0x01000000
// 將 [character, color] 對的列打包到 Raster 採用的一個字串中
// 一個儲存格是三個數字:字元的程式碼點、其顏色和其背景
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) => {
// 只在使用 id 'heat' 開啟的窗格中繪製
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// 三個儲存格的兩列,每個都是一個區塊字元及其顏色
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 的窗格中繪製,因此從命令開啟一個,如 hello-tabs 範例開啟其窗格。
每個字元必須是一個儲存格寬。若要動畫已在螢幕上的 Raster,請使用窗格的 id 作為 requestId、Raster 的 key、相同的大小和新儲存格呼叫 $.ui.blit。對於此範例,這是 $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) })。它重新繪製該一個元素,而不再次執行您的 ui.render 鉤子。
回應按下和輸入
當使用者按下按鈕、輸入欄位或從您的 mod 繪製的清單中選擇時,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 中,另一個 mod 可以鉤住這些事件。其鉤子在您的回呼之前執行,因此它會看到使用者輸入到您的 Input 中的內容,並可以更改它或代替您的回呼回答。mod API 沒有按下另一個 mod 按鈕的方法。
鍵盤焦點和快捷鍵
您的 mod 永遠不會自己讀取鍵盤。使用者按下一個鍵,Claude Code 決定它是為您的哪個控制項,該控制項的回呼執行。除了帶狀區域上的數字快捷鍵外,這只在您的窗格或帶狀區域具有鍵盤焦點時發生。其餘時間,鍵進入提示。
窗格如何獲得鍵盤焦點
窗格通過以下三種方式之一獲得鍵盤焦點:
- 您的 mod 使用
focus: true從命令或按下開啟它 - 使用者按 Ctrl+X 然後 Tab
- 使用者點擊它
Claude Code 只在提示為空且沒有其他內容具有鍵盤焦點時授予 focus: true。在使用者輸入時開啟的窗格不會接收他們的按鍵。
每個鍵執行的操作
此表列出當您的窗格或帶狀區域具有鍵盤焦點時鍵執行的操作:
| 鍵 | 它執行的操作 |
|---|---|
| Tab | 移動到下一個控制項 |
| 向上和向下 | 在您的繪製適合時在控制項之間移動。當窗格或帶狀區域的列數超過它可以顯示的列數時,它們會滾動它。 |
| Enter | 按下焦點 Button、提交焦點 Input 或在 Select 中選擇 |
| 按鈕的快捷鍵 | 按下該按鈕。當 Input 具有焦點時,每個可列印鍵都進入欄位。 |
| Esc | 將鍵盤焦點返回到提示。使用 closeOnEscape: true 時,它也會關閉窗格。 |
mod 無法將 Tab 或箭頭鍵綁定到其他任何內容,因此遊戲使用 w、a、s 和 d 進行轉向。
設定快捷鍵和第一個焦點
控制項上的兩個屬性決定鍵盤如何到達它:
hotkey:若要讓使用者使用一個鍵按下Button,請給它一個hotkey的一位數字或一個小寫字母,如hotkey: 'a'autoFocus:若要選擇窗格開啟時哪個控制項具有焦點,請將autoFocus: true新增到它。在其他項上省略屬性,因為 Claude Code 拒絕autoFocus: false。
快捷鍵的顯示方式取決於按鈕和應用程式:
| 按鈕 | 在終端中 | 在桌面應用程式中 |
|---|---|---|
| 帶括號,預設值 | [ 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在使用者按 Enter 時使用欄位的文字呼叫onSubmit(value),並在每次更改時呼叫onInput(value) - 繪製清單:將您的資料對應到每個一列,並給每列的按鈕其自己的
key
此鉤子繪製窗格的內容:
// 窗格繪製的清單
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// 只在使用 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',
// 每次繪製欄位為空,這在提交後清除它
value: '',
submitLabel: 'add',
autoFocus: true,
// 當您在欄位中按 Enter 時執行
onSubmit: async (value) => {
// 忽略空行
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// 每個筆記一列:刪除按鈕,然後是筆記的文字
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// 它自己的鍵,所以每列的按鈕可以區分
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 為某些更改再次執行它,您的 mod 要求其餘的。
當 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
// 資料已更改,因此要求 Claude Code 再次繪製窗格
$.ui.invalidate('ui.render')
},
}),
Text({ children: ['Count: ' + count] }),
],
})
})
每次按下都會提高窗格中的數字。hello-tabs 範例將相同的呼叫包裝在其 redraw 函式中。
您在 $.state 中保持的值不需要呼叫,因為寫入值會重新繪製讀取它的位置。
在計時器上重新繪製
若要保持時鐘、倒計時或來自工作階段外部的值為最新,請按計劃重新繪製。在模組的 session.start 鉤子中啟動計時器。如果模組已經有一個,如 hello-tabs 所做的,請將 $.clock.every 行新增到它:
on('session.start', async ($, e, next) => {
// 每 1000 毫秒,要求 Claude Code 再次繪製您的位置
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})
Claude Code 現在每秒執行您的 ui.render 鉤子一次。計時器在模組重新載入時停止,新副本啟動自己的。
位置可以重新繪製的頻率
Claude Code 限制重新繪製的頻率,因此您的 mod 可以在其資料更改時經常呼叫 $.ui.invalidate。可見窗格和帶狀區域的限制比其他位置更高,限制表有數字。
比限制更快到達的呼叫會合併為一次重新繪製。該重新繪製執行您的鉤子一次,鉤子在該時刻讀取您的資料,因此最新值顯示,介於兩者之間的值不顯示。動畫無法比限制更快執行。
保持狀態
mod 有三個地方可以保持值,它們在值持續多長時間方面有所不同:直到模組重新載入、直到工作階段結束或從一個工作階段到下一個工作階段。根據值必須持續多長時間選擇:
| 將其保持在 | 它持續到 | 用於 |
|---|---|---|
| 模組級變數 | 模組重新載入,這在開發期間每次儲存檔案時發生 | 您可以丟失的值,如 hello-tabs 中的 tab |
$.state |
工作階段結束,或使用者執行 /clear、/resume 或 /branch |
繪製依賴的值,應該在重新載入後存活 |
$.store |
您的 mod 刪除它,或沒有工作階段在 cleanupPeriodDays 內讀取或寫入存放區。存放區是一個鍵值存放區,儲存為您外掛程式自己的 JSON 檔案,位於 ~/.claude/plugins/store/ 下。 |
設定、歷史記錄、使用者期望下次找到的任何內容 |
$.store.get(key) 解析為值或 undefined,$.store.set(key, value) 採用任何 JSON 值。
在 `$.state` 中保持值
$.state 為工作階段的長度保持值,並為您重新繪製。它是反應式狀態:讀取值的 ui.render 鉤子訂閱它,因此 Claude Code 每次您寫入值時都會重新繪製該位置,您不呼叫 $.ui.invalidate。$.state 中的值也在模組重新載入後存活,變數不會。
若要設定它,請宣告您的值、將您的清單指向宣告,然後定義並使用每個值。範例將 hello-tabs 中的 count 移動到 $.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'
// 在模組的頂部:命名值並給出其預設值
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// 在 ui.render 鉤子中:讀取值以繪製它
const n = await read($, count)
// 在按鈕中:從舊值寫入新值
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`
若要將 hello-tabs 中的 count 移動到 $.state,請更改使用它的每一行:
- 在模組的頂部:新增
import行,並將let count = 0替換為atom行 - 在
ui.render鉤子中:在tabButton之前新增read行,並在Text中繪製'Count: ' + n - 在 Add one 按鈕中:將
onPress替換為從多個工作階段儲存中的按鈕,該按鈕儲存計數以及寫入它 - 在
session.start鉤子中:將讀取saved的兩行替換為在/clear後再次載入儲存的值中的loadCount呼叫
保持 redraw 用於標籤按鈕,因為 tab 仍然是變數。
在 `/clear` 後再次載入儲存的值
如果您的 mod 在 session.start 時將儲存的值從 $.store 複製到 $.state,則必須在 /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 上的篩選將鉤子保持在三個重設:
// 將儲存的計數從 $.store 複製到 $.state,如果沒有儲存任何內容,則為 0
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// 在您的第一個提示之前執行,並在重新載入後再次執行
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// 在 /clear、/resume 和 /branch 後再次執行,報告 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 後測試繪製。
從多個工作階段儲存
您機器上執行您的 mod 的每個工作階段都共享一個 $.store。get 後跟 set 不是原子的。當兩個工作階段各自讀取值、更改它並寫回時,它們會競爭,第二次寫入會替換第一次。
兩個選擇使這種情況不太可能:
- 給每個項目其自己的鍵:
set只更改其自己的鍵,因此寫入不同鍵的工作階段不會相互覆蓋 - 在寫入之前再次讀取:對於多個工作階段更改的值,在回呼中
get鍵並從該值建立新值,而不是從您在session.start時載入的副本。如果另一個工作階段的寫入落在您的get和set之間,仍然會丟失。
此按鈕現在新增一個到存放區保持的任何內容,然後更新繪製:
onPress: async () => {
// 讀取存放區現在保持的內容,另一個工作階段可能已更改
const saved = Number((await $.store.get('count')) ?? 0)
// 儲存新計數,然後顯示它
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}
如果第二個工作階段自此工作階段啟動以來按下了其自己的按鈕三次,此按下會顯示並儲存包含這三個的計數。
後續步驟
- 回應事件:從工具呼叫和輪次提供您的繪製
- 使用 mod API:從計時器和模型呼叫提供您的繪製
- 測試繪製:從測試按下您的按鈕,在多個表面上
- 渲染位置和元素:每個位置的屬性和每個元素的屬性