Bỏ qua để đến nội dung

Vẽ giao diện bằng mod

Mod có thể vẽ giao diện riêng trong Claude Code và thay đổi những phần giao diện Claude Code đã vẽ sẵn. Mỗi vị trí mà mod có thể vẽ được gọi là một render site, ví dụ một pane, band phía trên prompt, hoặc spinner. Claude Code kích hoạt event ui.render mỗi khi sắp vẽ một render site, và hook của bạn cho event đó trả về thứ cần vẽ ở đó.

Sơ đồ dưới đây cho thấy những vị trí mod có thể vẽ trong một session terminal:

Sơ đồ một session Claude Code trong terminal. Mod có thể thêm pane dạng sidebar bên phải, toast ở góc trên bên phải transcript, một dòng log trong transcript, band phía trên prompt và status line dưới prompt. Mod có thể vẽ lại message, dòng tool call và spinner. Ô prompt là của Claude Code. Sơ đồ một session Claude Code trong terminal. Mod có thể thêm pane dạng sidebar bên phải, toast ở góc trên bên phải transcript, một dòng log trong transcript, band phía trên prompt và status line dưới prompt. Mod có thể vẽ lại message, dòng tool call và spinner. Ô prompt là của Claude Code.

Trong terminal hẹp hơn, pane nằm phía trên ô prompt thay vì bên cạnh transcript.

Hãy xây dựng mod đầu tiên trước khi đọc trang này. Bắt đầu với ví dụ hoàn chỉnh, xây dựng một pane có hai tab và một bộ đếm, rồi đọc phần tương ứng với từng thứ bạn muốn thay đổi.

Trong phần này bạn xây dựng một mod thêm command /hello-tabs, và command này mở một pane. Pane là một sidebar bên cạnh transcript khi terminal ở chế độ fullscreen đủ rộng, hoặc là một vùng có khung phía trên ô prompt trong các trường hợp còn lại. Pane này có hai tab, và tab thứ hai có một nút cộng thêm một vào bộ đếm. Con số vẫn còn đó sau khi bạn khởi động lại Claude Code.

Mod hoàn chỉnh trông như video dưới đây. Video mở pane, chuyển sang tab thứ hai, bấm nút vài lần, rồi quay về tab đầu:

Hai tab thực chất là hai nút nằm trên một hàng. Mod ghi nhớ tab nào đang mở và vẽ nội dung của tab đó bên dưới hàng nút.

Mod là một plugin gồm manifest, một file hooks.json trỏ tới code của bạn, và file code. Trang Tạo một mod giải thích từng file. Tạo thư mục hello-tabs với hai thư mục con .claude-plugin và hooks, rồi lưu hai file đầu tiên.

Lưu manifest thành hello-tabs/.claude-plugin/plugin.json:

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" }
}

Khai báo entry point trong hello-tabs/hooks/hooks.json:

hello-tabs/hooks/hooks.json
{
"modules": ["./register.js"]
}

Danh sách dưới cho biết mỗi hook làm gì, theo thứ tự xuất hiện trong code:

  • Thêm command /hello-tabs, và nạp con số mà session trước đã lưu
  • Mở pane khi bạn chạy command đó
  • Vẽ nội dung pane: hàng tab và phần thân của tab đang mở

Hai biến cấp module, tab và count, giữ state của pane.

Lưu nội dung sau thành hello-tabs/hooks/register.js:

hello-tabs/hooks/register.js
// id của pane, dùng để mở pane và nhận ra nó khi vẽ
const PANE = 'hello-tabs'
// Những gì pane hiển thị: tab nào đang mở, và giá trị bộ đếm
let tab = 'one'
let count = 0
export function register(on) {
// Chạy trước prompt đầu tiên của bạn, và chạy lại sau mỗi lần reload
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// Nạp con số mà session trước đã lưu, nếu có
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// Chạy khi bạn gõ /hello-tabs
on('command.run', { command: 'hello-tabs' }, async ($) => {
// Mở pane, trao keyboard cho nó, và cho phép Esc đóng pane
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// Không in gì ra transcript
return {}
})
// Chạy mỗi khi Claude Code vẽ một pane
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Bỏ qua pane của các mod khác
if (e.requestId !== PANE) return next(e)
// Lấy các element mà ứng dụng này vẽ được
const { Box, Text, Button } = $.ui.resolve(e)
// Yêu cầu Claude Code chạy lại hook này
const redraw = () => $.ui.invalidate('ui.render')
// Một tab: một nút, khi bấm thì chuyển sang tab của nó
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// Làm mờ tab không được mở
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// Nội dung bên dưới hàng tab, tùy tab nào đang mở
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()
// Lưu con số để nó còn đó sau khi khởi động lại
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// Toàn bộ pane: hàng tab, một dòng trống, rồi phần thân
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}

Mỗi hook còn làm thêm một việc mà code không thể hiện rõ:

  • session.start còn đọc con số đã lưu từ $.store, một key-value store tồn tại qua các session.
  • command.run chỉ báo cho Claude Code biết pane tồn tại. Bản thân việc mở pane không vẽ gì cả: sau đó Claude Code mới kích hoạt ui.render để hỏi cần vẽ gì bên trong.
  • ui.render trả về cây element (element tree), một Box chứa các box khác, text và nút bấm, và dựng lại cây này từ tab và count mỗi lần chạy.

Bấm một nút sẽ chạy callback onPress của nó, callback này đổi giá trị một biến rồi gọi redraw. Claude Code sau đó chạy lại hook ui.render, và hook dựng một cây mới từ các giá trị mới. Mọi giao diện tương tác đều dùng vòng render (render cycle) này: một callback thay đổi state, rồi hook render lại từ state mới.

Trong shell, khởi động Claude Code bằng claude --plugin-dir ./hello-tabs. Tại prompt của Claude Code, chạy /hello-tabs. Một pane mở ra với 1: One và 2: Two ở trên cùng. Nhấn 2, rồi nhấn a, hotkey của nút Add one, vài lần. Con số tăng dần.

Nhấn Esc để đóng pane, rồi thoát session. Trong shell, khởi động lại Claude Code với cùng lệnh claude --plugin-dir ./hello-tabs, rồi chạy /hello-tabs tại prompt. Con số vẫn ở đúng chỗ bạn đã dừng.

Để xóa con số, cho mod gọi $.store.delete('count'). Phần Lưu state mô tả mỗi loại giá trị tồn tại được bao lâu.

Một hook ui.render chạy cho mọi render site, trừ khi bạn thu hẹp nó về đúng site bạn muốn vẽ. Để chọn render site, truyền một bộ lọc, gọi là matcher, làm tham số thứ hai của on. { component: 'Pane' } khiến hook chỉ chạy cho pane. Bên trong hook, e.component là tên site, e.surface cho biết ứng dụng nào đang vẽ, và e.props chứa dữ liệu riêng của site. Với pane, e.requestId là id bạn đã dùng để mở nó.

Pane và band đều trống cho đến khi một mod vẽ vào. Dưới đây là mỗi loại là gì và cách vẽ vào nó:

Pane. Pane là một sidebar bên cạnh transcript khi terminal ở chế độ fullscreen đủ rộng, hoặc một vùng có khung phía trên ô prompt trong các trường hợp còn lại. Khi có nhiều pane cùng mở, mỗi pane có một tab hiện tiêu đề của nó.

Pane xuất hiện khi mod của bạn gọi $.ui.open với một id do bạn chọn, như $.ui.open({ id: 'hello-tabs' }). Phần Mở pane đúng lúc mô tả các field khác và khi nào pane phải chờ terminal rộng hơn.

Để vẽ vào pane của bạn, lọc theo { component: 'Pane' } và kiểm tra e.requestId có đúng là id của bạn không.

Band phía trên prompt. Band là một dải nằm ngay phía trên ô nhập prompt. Nó luôn tồn tại, và mọi mod dùng chung nó.

Hook của bạn trả về một cây để hiển thị gì đó trong band, hoặc next(e) để không hiển thị gì. Một cây sẽ thay thế những gì các mod chạy sau mod của bạn vẽ ở đó. Để giữ phần của họ, đặt kết quả của await next(e) vào trong children của một Box trong cây của bạn.

Để vẽ vào band, lọc theo { component: 'AbovePrompt' }.

Thay đổi những gì Claude Code đã vẽ sẵn

Phần tiêu đề “Thay đổi những gì Claude Code đã vẽ sẵn”

Claude Code tự vẽ phần lớn giao diện của nó: message, dòng tool call, spinner, và nhiều thứ khác. Mỗi phần đó cũng là một render site, nên mod có thể đổi style hoặc thay thế nó. Để thay đổi một phần, lọc hook ui.render theo tên của nó trong bảng sau:

SiteLà gì
UserMessage, AssistantMessageMột message trong transcript
ToolUse, ToolResult, ToolGroupDòng của một tool call, kết quả của nó, và một nhóm call đã thu gọn
CommandOutputDòng mà một command in ra
AskUserQuestionHộp thoại Claude mở ra để hỏi bạn
Spinner, ToolProgress, TurnDurationCác dòng trạng thái của một turn: dòng chuyển động trong lúc Claude làm việc, dòng tiến độ trực tiếp của một tool đang chạy, và dòng kết thúc turn
InfoNotice, SessionMode, PromptHintCác dòng trạng thái dưới logo, nhãn mode ở footer, và dòng gợi ý dưới prompt

Tại một site Claude Code đã vẽ sẵn, hook của bạn có thể đổi một chi tiết, thay thế toàn bộ phần vẽ, hoặc để nguyên. Ba ví dụ dưới áp dụng từng cách cho spinner. Các ví dụ đọc biến calls mà một hook khác đếm, như trong mod hướng dẫn.

Đổi một chi tiết. Để giữ phần vẽ của Claude Code và chỉ đổi một phần của nó, truyền cho next một bản copy của event với props đã thay đổi. Hook này đổi đoạn text phía sau chữ của spinner:

on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Giữ spinner của Claude Code, đổi đoạn text phía sau chữ của nó
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})

Spinner giữ nguyên hiệu ứng chuyển động và chữ của nó, còn text của bạn nằm ngay sau chữ:

Thinking · tool calls: 2…

Thay thế phần vẽ. Để vẽ thứ của riêng bạn vào chỗ của site, trả về một cây và không gọi next. Hook này vẽ một dòng text vào chỗ của spinner:

on('ui.render', { component: 'Spinner' }, async ($, e) => {
const { Text } = $.ui.resolve(e)
// Không gọi next, nên dòng này được vẽ thay cho spinner
return Text({ children: ['Claude has made ' + calls + ' tool calls'] })
})

Trong lúc Claude làm việc, dòng của bạn hiện ra và spinner của Claude Code thì không:

Claude has made 2 tool calls

Để nguyên. Để site được vẽ như Claude Code vẫn vẽ, trả về next(e). Một hook thường làm vậy với một số event và không làm với số khác. Hook này để nguyên spinner cho đến khi có call để đếm:

on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Chưa có gì để hiển thị, nên chuyển event đi tiếp nguyên vẹn
if (calls === 0) return next(e)
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})

Trước tool call đầu tiên, spinner trông giống hệt khi không có mod:

Thinking…

Tại những site này, next(e) trả về một tham chiếu tới phần vẽ của Claude Code, { type: 'engine', ref }, trừ khi một mod chạy sau mod của bạn đã trả về cây riêng của nó. Để thay đổi nội dung bên trong phần vẽ đó, truyền cho next một bản copy của event với props khác, như cách Đổi một chi tiết ở trên. Bạn có thể trả về tham chiếu nguyên trạng, hoặc đặt nó trong một Box cạnh các element của bạn:

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'] })] })
})

Trong lúc Claude làm việc, spinner chuyển động như trước, và dòng under the spinner xuất hiện bên dưới nó.

Permission prompt không phải là render site, nên mod không thể thay đổi những gì nó hiển thị. Hộp thoại hỏi đáp, AskUserQuestion, thì là render site, nên mod thay đổi được. Một cây cho hộp thoại này phải chứa tham chiếu đúng một lần, với các element của bạn nằm phía trên nó. Nếu không, Claude Code sẽ vẽ hộp thoại của chính nó.

Terminal và ứng dụng Desktop không hỗ trợ cùng một tập site. Pane, AbovePrompt, Spinner và các site trong transcript hoạt động ở cả hai. Một vài dòng trạng thái khác chỉ có trong terminal. Bảng render site liệt kê mỗi site có ở đâu.

Pane chỉ xuất hiện khi mod của bạn mở nó. Cách mở và thời điểm mở quyết định pane có nhận keyboard focus không, xin bao nhiêu chỗ, và có hiển thị trong terminal hẹp hay không.

Để mở pane, gọi $.ui.open với một id do bạn chọn. id là tên của pane: hook ui.render của bạn kiểm tra nó, và bạn truyền lại nó để đóng pane.

await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })

Để đóng pane, gọi $.ui.close với id bạn đã dùng để mở:

await $.ui.close({ id: 'hello-tabs' })

Ngoài id, $.ui.open nhận các field tùy chọn sau:

FieldTác dụng
titleNhãn tab của pane khi có nhiều hơn một pane đang mở
focusXin keyboard focus
closeOnEscapeCho phép Esc đóng pane
holdToastsGiữ lại các toast, tức các thông báo nhỏ từ $.ui.toast, cho đến khi pane đóng
rowsChiều cao xin cấp khi pane nằm phía trên prompt. Mặc định là một phần ba không gian.
columnsChiều rộng xin cấp khi pane nằm cạnh transcript

focus, closeOnEscape và holdToasts là tùy chọn và chỉ nhận giá trị true. Để không dùng, bỏ hẳn field đó đi. Truyền false sẽ ném lỗi kiểu ui.open: focus is true or left out. Để đặt một trong các field này theo điều kiện, chỉ thêm field khi điều kiện đúng. Lời gọi sau chỉ xin keyboard focus khi items không rỗng:

const pane = { id: 'hello-tabs', title: 'Hello tabs' }
await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)

Để một command mở được pane ngay cả khi Claude đang làm việc, thêm immediate: true khi đăng ký command. Nếu không có nó, command gõ trong lúc một turn đang chạy sẽ phải chờ turn kết thúc.

Một pane mà mod của bạn tự mở khi không ai yêu cầu sẽ không xuất hiện trong terminal hẹp, để nó không chiếm hết màn hình nhỏ. Pane có xuất hiện hay không phụ thuộc vào thứ đã mở nó:

  • Được mở bởi hành động của user, như một command họ chạy hoặc một nút họ bấm: pane xuất hiện ở mọi độ rộng
  • Được mở bởi mod tự hành động, như từ một timer hoặc một hook turn.start: pane chỉ xuất hiện trong terminal rộng ít nhất 144 cột. Sau khi user đã tự mở pane đó một lần, 110 cột là đủ.

Khi pane xuất hiện, $.ui.open trả về { isPlaced: true }. Khi pane đang chờ, isPlaced là false và reason là một chuỗi giải thích lý do. Pane đang chờ sẽ xuất hiện khi user mở nó hoặc nới rộng terminal. Để báo rằng có thứ gì đó sẵn sàng mà không mở pane, gọi $.ui.toast('Your message') để hiện một thông báo toast.

Thứ mà một hook ui.render trả về là một cây element: một bản mô tả những gì cần vẽ, gồm các box, text và control lồng vào nhau. Bạn mô tả phần vẽ, còn Claude Code render nó trong terminal hoặc ứng dụng Desktop.

Để lấy các element, gọi $.ui.resolve(e) trong hook, như const { Box, Text, Button } = $.ui.resolve(e). Mỗi element là một hàm. Bạn truyền props cho nó, và đặt các element cùng chuỗi nằm bên trong vào children.

Dưới đây là các element thường dùng nhất và cách terminal vẽ chúng.

Text vẽ một chuỗi, với style tùy chọn như bold và color:

Text({ children: ['This is the first tab.'] })
This is the first tab.

Box sắp xếp các thứ bên trong nó thành một hàng hoặc một cột. Box này đặt một nút và một dòng text cạnh nhau, cách nhau hai cột:

Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'more', label: 'Add one', onPress: addOne }),
Text({ children: ['Count: 0'] }),
],
})
[ Add one ] Count: 0

Button là một control user có thể bấm. Nó chạy callback onPress của bạn. Với plain: true, nút không có ngoặc vuông và hiển thị hotkey:

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 là một ô nhập text. Nó chạy callback onSubmit với nội dung text khi user nhấn 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

Trang Thư viện phần tử giao diện có ví dụ và ảnh chụp màn hình của hầu hết các element. Bảng dưới liệt kê mọi element:

ElementVẽ gìỞ đâu
BoxMột flex container. Nhận các prop layout như flexDirection, columnGap, padding, borderStyle và width.Mọi nơi
TextText có style. Nhận color, bold, dimColor, italic và wrap. color là một theme key hoặc một màu như 'red'. wrap là 'wrap', 'truncate', 'truncate-start', 'truncate-middle' hoặc 'truncate-end'.Mọi nơi
ButtonMột control gọi onPressMọi nơi
Link, Code, MarkdownMột link có href và label tùy chọn, một khối code, và text được định dạng giống câu trả lời của Claude. Markdown nhận nội dung qua prop text, không qua children, và cần key khi bạn truyền onLinkPress.Mọi nơi
Input, SelectMột ô nhập text và một dropdownTerminal, Desktop
SvgMột tài liệu SVGDesktop
ClientMột vùng được vẽ bởi một file thứ hai của bạn, dùng cho animation và thao tác con trỏ. File đó không có mods API. Nó liên lạc với hook của bạn bằng cách post dữ liệu, dữ liệu này đến dưới dạng event ui.message. Nếu nó không nạp được, không vẽ được hoặc lỗi khi chạy, hook của bạn nhận event ui.fault.Terminal, Desktop
Raster, ImageMột lưới ô màu, và một bức ảnhTerminal

Nếu module của bạn là file .tsx hoặc .jsx, bạn có thể viết cây bằng JSX. Hãy destructure các element từ $.ui.resolve(e) trước.

Nếu một cây dùng element mà ứng dụng không có, một prop mà element không nhận, hoặc đặt child vào chỗ không được phép, Claude Code sẽ vẽ phiên bản của chính nó cho site đó.

Trong một session khởi động bằng --plugin-dir, một dòng trong transcript sẽ báo điều này, ví dụ ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own. Debug log ghi nó là ui.render (Pane): a hook returned a tree that does not validate kèm cùng lý do. Ngoài ra không có gì khác hiện trong session, nên khi phần vẽ không xuất hiện, hãy kiểm tra dòng đó hoặc log.

Để vẽ heat map, sparkline hay bàn cờ game trong terminal, hãy vẽ một Raster thay vì một Box cho mỗi ô. Raster nhận một key, kích thước theo columns và rows, và cells, một chuỗi base64 đóng gói tất cả các ô. Mỗi ô gồm ba số: code point của ký tự, màu chữ và màu nền. Một màu là giá trị RGB 24-bit dạng hexa, ví dụ 0xc62828 là màu đỏ. Giá trị 0x01000000, lớn hơn phạm vi đó một đơn vị, nghĩa là màu mặc định của terminal.

Ứng dụng Desktop không có Raster, nên hãy kiểm tra e.surface và vẽ text ở đó. Phần thân pane dưới đây vẽ một heat map ba cột hai hàng:

// Giá trị có nghĩa là "dùng màu mặc định của terminal"
const DEFAULT_COLOR = 0x01000000
// Đóng gói các hàng gồm cặp [ký tự, màu] thành chuỗi duy nhất mà Raster nhận
// Một ô là ba số: code point của ký tự, màu chữ và màu nền
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) => {
// Chỉ vẽ trong pane được mở với id 'heat'
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// Hai hàng, mỗi hàng ba ô, mỗi ô là một ký tự khối và màu của nó
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) })],
})
})

Trong terminal, pane hiển thị lưới:

Một pane trong terminal chứa lưới nhỏ các khối màu, hai hàng ba cột. Hàng trên là xanh lá, vàng hổ phách và đỏ. Hàng dưới là xanh lá, xanh lá và vàng hổ phách.

Mảng rows là phần bạn sẽ thay đổi, còn cellsOf biến nó thành chuỗi đã đóng gói. Hook chỉ vẽ trong pane có id là heat, nên hãy mở một pane như vậy bằng $.ui.open({ id: 'heat' }) từ một command, giống cách ví dụ hello-tabs mở pane của nó.

Mỗi ký tự phải rộng đúng một ô. Để tạo animation cho một Raster đang hiển thị trên màn hình, gọi $.ui.blit với id của pane làm requestId, key của Raster, cùng kích thước, và các ô mới. Với ví dụ này, lời gọi là $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }). Nó vẽ lại đúng element đó mà không cần chạy lại hook ui.render.

Khi user bấm một nút, gõ vào một ô nhập, hoặc chọn từ một danh sách mà mod của bạn vẽ, Claude Code gọi callback của control đó, và callback chạy trong module của bạn. Mỗi control nhận các callback riêng:

  • Button: nhận onPress(e), trong đó e.surface là ứng dụng nơi thao tác bấm xuất phát
  • Input: nhận onSubmit(value) và onInput(value)
  • Select: nhận onSelect(value), với các lựa chọn nằm trong options, một danh sách có ít nhất một lựa chọn với các value không trùng nhau, như [{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]

Test bấm hoặc gõ vào một control thông qua key của nó, nên hãy đặt key cho mọi control. Mỗi lần dùng một control cũng kích hoạt ui.press, ui.input hoặc ui.select với key nằm trong e.element, và mod khác có thể xử lý các event đó. Hook của mod kia chạy trước callback của bạn, nên nó thấy được những gì user gõ vào Input của bạn và có thể thay đổi hoặc trả lời thay cho callback của bạn. Mods API không có method nào để bấm nút của mod khác.

Mod của bạn không bao giờ tự đọc bàn phím. User nhấn một phím, Claude Code quyết định phím đó dành cho control nào của bạn, và callback của control đó chạy. Ngoại trừ hotkey dạng chữ số trên band, điều này chỉ xảy ra khi pane hoặc band của bạn đang có keyboard focus. Những lúc khác, phím bấm đi vào ô prompt.

Pane nhận keyboard focus khi:

  • Mod của bạn mở nó với focus: true từ một command hoặc một lần bấm nút
  • User nhấn Ctrl+X rồi Tab
  • User click vào nó

Claude Code chỉ cấp focus: true khi ô prompt đang trống và không có gì khác đang giữ keyboard focus. Một pane mở ra trong lúc user đang gõ sẽ không cướp phím của họ.

Bảng dưới liệt kê tác dụng của từng phím khi pane hoặc band của bạn có keyboard focus:

PhímTác dụng
TabChuyển sang control tiếp theo
Up và DownDi chuyển giữa các control khi phần vẽ vừa khung. Khi pane hoặc band có nhiều hàng hơn số hàng hiển thị được, hai phím này dùng để cuộn.
EnterBấm Button đang được focus, submit Input đang được focus, hoặc chọn trong Select
Hotkey của một nútBấm nút đó. Khi một Input đang có focus, mọi phím in được đều đi vào ô nhập.
Page Up, Page Down, Home và EndCuộn pane hoặc band của bạn khi nó có nhiều hàng hơn số hàng hiển thị được
Ctrl+X rồi một phím mũi tênĐổi kích thước pane. Left hoặc Up cho pane thêm chỗ, Right hoặc Down trả lại chỗ.
Ctrl+X rồi XĐóng pane, kể cả khi một ô nhập của nó đang có focus
EscTrả keyboard focus về ô prompt. Với closeOnEscape: true, nó cũng đóng pane.

Mod không thể gán Tab hay các phím mũi tên cho việc khác, nên một game sẽ điều khiển bằng w, a, s và d.

Các prop sau trên một control quyết định cách bàn phím tiếp cận nó:

  • hotkey: để user bấm một Button bằng một phím, đặt hotkey là một chữ số hoặc một chữ cái thường, như hotkey: 'a'
  • autoFocus: để chọn control nào có focus khi pane mở ra, thêm autoFocus: true vào control đó. Prop này chỉ nhận true, nên hãy bỏ nó đi ở các control khác.

Cách hotkey hiển thị phụ thuộc vào nút và ứng dụng:

NútTrong terminalTrong ứng dụng Desktop
Có ngoặc vuông, mặc định[ Add one ], không hiện hotkeyNhãn kèm một phím nhỏ bên cạnh
Với plain: true1: OneNhãn kèm một phím nhỏ bên cạnh

Trong terminal, hãy ghi tên phím vào nhãn của nút có ngoặc vuông, hoặc dùng plain: true, để user thấy cần nhấn phím nào. Phần tham chiếu element có các quy tắc khác của Button: action, hotkey chữ số trên band, và hai nút dùng chung một hotkey.

Nhiều pane là một ô nhập text với một danh sách bên dưới. Ví dụ trong phần này là một pane ghi chú: bạn gõ một ghi chú rồi nhấn Enter để thêm, và mỗi ghi chú có một nút x để xóa. Sau khi thêm hai ghi chú, terminal vẽ pane như sau:

╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯

Ví dụ dùng các kỹ thuật sau:

  • Nhận input: một Input gọi onSubmit(value) với nội dung ô nhập khi user nhấn Enter, và onInput(value) ở mỗi lần thay đổi
  • Vẽ danh sách: ánh xạ dữ liệu của bạn thành mỗi mục một dòng, và đặt cho nút của mỗi dòng một key riêng

Hook này vẽ nội dung pane:

// Danh sách mà pane vẽ ra
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Chỉ vẽ trong pane được mở với 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',
// Lần nào cũng vẽ ô nhập trống, nhờ đó ô được xóa sau khi submit
value: '',
submitLabel: 'add',
autoFocus: true,
// Chạy khi bạn nhấn Enter trong ô nhập
onSubmit: async (value) => {
// Bỏ qua dòng trống
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// Mỗi ghi chú một dòng: nút xóa, rồi nội dung ghi chú
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// key riêng, để phân biệt nút của từng dòng
key: 'delete-' + i,
label: 'x',
plain: true,
onPress: async () => {
notes = notes.filter((_, j) => j !== i)
redraw()
await $.store.set('notes', notes)
},
}),
Text({ children: [note] }),
],
}),
),
],
})
})

Để thử pane:

  • Thêm ghi chú: gõ một dòng rồi nhấn Enter. Dòng đó xuất hiện thành một hàng mới, và ô nhập được xóa trống.
  • Xóa ghi chú: nhấn Tab cho đến khi nút x của ghi chú có focus, rồi nhấn Enter. x là nhãn của nút chứ không phải hotkey, nên gõ chữ x không bấm được nút.

Mỗi thay đổi đi theo cùng vòng render như hello-tabs: callback thay đổi notes, gọi redraw, và lưu danh sách vào $.store.

Ô nhập trở về trống sau mỗi lần submit là nhờ prop value. value là nội dung ô nhập lúc được vẽ, và những gì user gõ sẽ thay thế nó cho đến khi hook của bạn vẽ lại ô nhập. Ví dụ luôn vẽ ô nhập với ''.

Ví dụ có lưu ghi chú nhưng không nạp lại. Để chúng quay lại ở session sau, hãy đọc chúng trong một hook session.start, giống cách hello-tabs đọc count.

Các prop sau tạo nên dòng của ô nhập, Note: Type a note and press Enter ⏎ add:

PropTrong ví dụLà gì
labelNoteText đứng trước ô nhập. Terminal vẽ : phía sau nó.
placeholderType a note and press EnterText mờ hiện khi ô nhập trống
submitLabeladdTừ đứng sau ⏎, cho biết Enter làm gì

Submit một Input không bắt đầu turn mới, trừ khi callback của bạn gọi $.prompt.submit.

Một phần vẽ là một bản chụp (snapshot): nó hiển thị những gì hook ui.render trả về lần cuối hook chạy. Để hiển thị thứ gì mới, hook phải chạy lại. Claude Code tự chạy lại hook với một số thay đổi, còn lại mod của bạn phải tự yêu cầu.

Claude Code chạy lại hook ui.render khi props của site thay đổi hoặc chiều rộng terminal thay đổi. Khi một Client trong site bị lỗi và mod của bạn có xử lý ui.fault, Claude Code chạy hook thêm một lần sau khi các hook ui.fault trả về, để hook ui.render có thể bỏ Client ra. Claude Code không chạy hook theo timer, và cũng không thể biết khi nào một biến trong module của bạn thay đổi.

Để các site của bạn được vẽ lại sau khi dữ liệu của bạn thay đổi, gọi $.ui.invalidate('ui.render'). Pane dưới đây đếm số lần bấm. Callback của nút thay đổi count, rồi yêu cầu vẽ lại:

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
// Dữ liệu đã đổi, nên yêu cầu Claude Code vẽ lại pane
$.ui.invalidate('ui.render')
},
}),
Text({ children: ['Count: ' + count] }),
],
})
})

Mỗi lần bấm, con số trong pane tăng lên. Ví dụ hello-tabs bọc cùng lời gọi này trong hàm redraw của nó.

Một giá trị bạn giữ trong $.state thì không cần lời gọi này, vì việc ghi giá trị sẽ tự vẽ lại các site đang đọc nó.

Để giữ cho một đồng hồ, một bộ đếm ngược, hoặc một giá trị từ bên ngoài session luôn cập nhật, hãy vẽ lại theo lịch. Khởi động một timer trong hook session.start của module. Nếu module đã có hook này, như hello-tabs, hãy thêm dòng $.clock.every vào đó:

on('session.start', async ($, e, next) => {
// Cứ mỗi 1000 mili giây, yêu cầu Claude Code vẽ lại các site của bạn
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})

Claude Code giờ chạy hook ui.render của bạn mỗi giây một lần. Timer dừng khi module reload, và phiên bản module mới sẽ khởi động timer của riêng nó.

Một site được vẽ lại thường xuyên đến mức nào

Phần tiêu đề “Một site được vẽ lại thường xuyên đến mức nào”

Claude Code giới hạn tần suất (throttle) vẽ lại của một site, nên mod của bạn có thể gọi $.ui.invalidate thường xuyên bằng tần suất dữ liệu thay đổi. Để biết mỗi site được vẽ lại tối đa bao nhiêu lần, xem bảng giới hạn.

Các lời gọi đến nhanh hơn giới hạn sẽ được gộp lại thành một lần vẽ lại. Lần vẽ lại đó chạy hook của bạn một lần, và hook đọc dữ liệu của bạn đúng như nó đang có ở thời điểm đó, nên giá trị mới nhất được hiển thị còn các giá trị ở giữa thì không. Animation không thể chạy nhanh hơn giới hạn này.

Nơi mod lưu một giá trị quyết định giá trị đó tồn tại bao lâu: cho đến khi module reload, cho đến khi session kết thúc, hay từ session này sang session khác. Hãy chọn theo thời gian giá trị cần tồn tại:

Lưu trongTồn tại cho đến khiDùng cho
Một biến cấp moduleModule reload, điều xảy ra mỗi lần bạn lưu file trong lúc phát triểnNhững giá trị có thể mất, như tab trong hello-tabs
$.stateSession kết thúc, hoặc user chạy /clear, /resume hay /branchNhững giá trị mà phần vẽ phụ thuộc vào và cần sống sót qua lần reload
$.storeMod của bạn xóa nó, hoặc không session nào đọc hay ghi store trong suốt cleanupPeriodDays. Store là một key-value store, được lưu thành một file JSON riêng của plugin trong ~/.claude/plugins/store/.Settings, lịch sử, bất cứ thứ gì user mong lần sau vẫn còn

$.store.get(key) trả về giá trị hoặc undefined, còn $.store.set(key, value) nhận mọi giá trị JSON.

$.state giữ giá trị trong suốt một session, và tự vẽ lại giúp bạn. Đây là reactive state: một hook ui.render đọc một giá trị sẽ đăng ký theo dõi (subscribe) giá trị đó, nên Claude Code vẽ lại site đó mỗi khi bạn ghi giá trị, và bạn không phải gọi $.ui.invalidate. Giá trị trong $.state cũng sống sót qua lần reload module, điều mà một biến thông thường không làm được.

Để thiết lập, hãy khai báo các giá trị, trỏ manifest tới file khai báo, rồi định nghĩa và dùng từng giá trị. Các ví dụ dưới chuyển count của hello-tabs vào $.state.

Khai báo các giá trị trong một file khai báo type. Key ngoài cùng là tên plugin của bạn, và mỗi mục bên dưới là một giá trị cùng type của nó. Lưu nội dung sau thành hello-tabs/types/index.d.ts:

hello-tabs/types/index.d.ts
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}

Để claude plugin validate kiểm tra được code của bạn với file đó, thêm field types vào manifest với đường dẫn của file:

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" },
"types": "./types/index.d.ts"
}

Trong module, định nghĩa mỗi giá trị kèm giá trị mặc định, đọc nó khi vẽ, và ghi nó từ một callback. atom đặt tên cho một giá trị và giá trị mặc định của nó, read trả về giá trị, còn update ghi giá trị. Ba helper này gọi $.state.get và $.state.set giúp bạn:

import { atom, read, update } from 'claude-code'
// Ở đầu module: đặt tên giá trị và cho giá trị mặc định
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// Trong hook ui.render: đọc giá trị để vẽ
const n = await read($, count)
// Trong một Button: ghi giá trị mới dựa trên giá trị cũ
onPress: () => update($, count, (value) => value + 1)

Vì hook ui.render đã đọc count, Claude Code chạy lại hook mỗi khi nút ghi giá trị này.

Các quy tắc sau áp dụng cho code:

  • Viết plugin và key dưới dạng string literal: claude plugin validate đọc chúng từ mã nguồn của bạn
  • Khai báo mọi giá trị trong file khai báo type: nếu không, validate sẽ lỗi với hello-tabs.count is not declared
  • Ghi từ một callback hoặc từ hook của event khác: hook ui.render được đọc state nhưng không được ghi, nên hãy ghi từ onPress, onSubmit, hoặc hook của một event khác

Để chuyển count trong hello-tabs vào $.state, sửa mọi dòng dùng đến nó:

  • Ở đầu module: thêm dòng import, và thay let count = 0 bằng dòng atom
  • Trong hook ui.render: thêm dòng read trước tabButton, và vẽ 'Count: ' + n trong Text
  • Trong nút Add one: thay onPress bằng phiên bản ở phần Lưu từ nhiều session, phiên bản này vừa ghi vừa lưu con số
  • Trong hook session.start: thay hai dòng đọc saved bằng lời gọi loadCount ở phần Nạp lại giá trị đã lưu sau /clear

Giữ redraw cho các nút tab, vì tab vẫn là một biến thông thường.

Nếu mod của bạn copy một giá trị đã lưu từ $.store vào $.state lúc session.start, nó phải copy lại sau /clear, /resume hoặc /branch. Các command này đặt mọi giá trị $.state về mặc định, và session.start không chạy lại. Tuy nhiên classic.SessionStart thì có chạy sau mỗi command đó, với e.source là clear, resume hoặc fork, nên hãy copy lại giá trị trong một hook cho event này. Nếu không, phần vẽ sẽ hiện giá trị mặc định, và một callback lưu giá trị $.state sẽ ghi đè giá trị mặc định lên thứ bạn đã lưu.

Đoạn code dưới nạp count từ cả hai hook. Nó dựa trên phiên bản hello-tabs dùng $.state, trong đó count là một atom và update đã được import. Đặt loadCount phía trên register, và thêm lời gọi loadCount vào hook session.start bạn đã có. classic.SessionStart cũng chạy lúc khởi động và sau khi compact, những lúc này không reset $.state, nên bộ lọc theo source giới hạn hook ở đúng ba trường hợp reset:

// Copy con số đã lưu từ $.store vào $.state, hoặc 0 nếu chưa lưu gì
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// Chạy trước prompt đầu tiên của bạn, và chạy lại sau mỗi lần reload
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// Chạy lại sau /clear, /resume và /branch (được báo là fork)
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
await loadCount($)
return next(e)
})

Với cả hai hook, pane hiển thị con số đã lưu sau /clear chứ không phải 0, và lần bấm Add one tiếp theo cộng thêm vào con số đã lưu.

loadCount ghi giá trị trong store đè lên giá trị trong $.state, và session.start chạy lại mỗi khi module reload. Để store không bị tụt lại phía sau, hãy lưu ở mỗi lần thay đổi, như nút Add one đang làm.

Để kiểm tra việc nạp lại mà không cần session, hãy kiểm thử phần vẽ sau /clear.

Mọi session trên máy bạn có chạy mod đều dùng chung một $.store. Một lần get rồi set không phải là thao tác nguyên tử (atomic). Khi hai session cùng đọc một giá trị, thay đổi nó và ghi lại, chúng sẽ tranh nhau (race), và lần ghi sau sẽ đè lên lần ghi trước.

Để giảm khả năng xảy ra điều này:

  • Cho mỗi mục một key riêng: một lần set chỉ thay đổi key của nó, nên các session ghi các key khác nhau không đè lên nhau
  • Đọc lại ngay trước khi ghi: với một giá trị mà nhiều session cùng thay đổi, hãy get key đó trong callback và tạo giá trị mới từ kết quả vừa đọc, không dùng bản copy đã nạp lúc session.start. Lần ghi của session khác vẫn có thể bị mất nếu nó rơi vào giữa lần get và lần set của bạn.

Nút dưới đây cộng một vào giá trị mà store đang giữ, rồi cập nhật phần vẽ:

onPress: async () => {
// Đọc giá trị store đang giữ, có thể đã bị session khác thay đổi
const saved = Number((await $.store.get('count')) ?? 0)
// Lưu con số mới, rồi hiển thị nó
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}

Nếu một session thứ hai đã bấm nút của nó ba lần kể từ khi session này bắt đầu, lần bấm này sẽ hiển thị và lưu một con số đã tính cả ba lần đó.

Bài tiếp theo: Thư viện phần tử giao diện - Xem từng element một mod vẽ được, kèm code mẫu và ảnh chụp terminal.