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:
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.
Xây dựng pane có tab
Phần tiêu đề “Xây dựng pane có tab”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.
Bước 1: Tạo plugin
Phần tiêu đề “Bước 1: Tạo plugin”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:
{ "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:
{ "modules": ["./register.js"]}Bước 2: Viết code
Phần tiêu đề “Bước 2: Viết code”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:
// 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ộ đếmlet 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.startcòn đọc con số đã lưu từ$.store, một key-value store tồn tại qua các session.command.runchỉ 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ạtui.renderđể hỏi cần vẽ gì bên trong.ui.rendertrả về cây element (element tree), mộtBoxchứa các box khác, text và nút bấm, và dựng lại cây này từtabvàcountmỗ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.
Bước 3: Mở pane
Phần tiêu đề “Bước 3: Mở pane”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.
Bước 4: Kiểm tra con số đã được lưu
Phần tiêu đề “Bước 4: Kiểm tra con số đã được lưu”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.
Chọn nơi để vẽ
Phần tiêu đề “Chọn nơi để vẽ”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:
| Site | Là gì |
|---|---|
UserMessage, AssistantMessage | Một message trong transcript |
ToolUse, ToolResult, ToolGroup | Dòng của một tool call, kết quả của nó, và một nhóm call đã thu gọn |
CommandOutput | Dòng mà một command in ra |
AskUserQuestion | Hộp thoại Claude mở ra để hỏi bạn |
Spinner, ToolProgress, TurnDuration | Cá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, PromptHint | Cá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.
Mở pane đúng lúc
Phần tiêu đề “Mở pane đúng lúc”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:
| Field | Tác dụng |
|---|---|
title | Nhãn tab của pane khi có nhiều hơn một pane đang mở |
focus | Xin keyboard focus |
closeOnEscape | Cho phép Esc đóng pane |
holdToasts | Giữ lại các toast, tức các thông báo nhỏ từ $.ui.toast, cho đến khi pane đóng |
rows | Chiề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. |
columns | Chiề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.
Khi pane chờ terminal rộng hơn
Phần tiêu đề “Khi pane chờ terminal rộng hơn”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.
Dựng cây từ các element
Phần tiêu đề “Dựng cây từ các element”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: 0Button 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: OneInput 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 EnterTrang 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:
| Element | Vẽ gì | Ở đâu |
|---|---|---|
Box | Một flex container. Nhận các prop layout như flexDirection, columnGap, padding, borderStyle và width. | Mọi nơi |
Text | Text 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 |
Button | Một control gọi onPress | Mọi nơi |
Link, Code, Markdown | Mộ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, Select | Một ô nhập text và một dropdown | Terminal, Desktop |
Svg | Một tài liệu SVG | Desktop |
Client | Mộ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, Image | Một lưới ô màu, và một bức ảnh | Terminal |
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ẽ lưới ô màu
Phần tiêu đề “Vẽ lưới ô màu”Để 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ềnfunction 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ả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.
Phản hồi khi bấm nút và gõ phím
Phần tiêu đề “Phản hồi khi bấm nút và gõ phím”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ậnonPress(e), trong đóe.surfacelà ứng dụng nơi thao tác bấm xuất phátInput: nhậnonSubmit(value)vàonInput(value)Select: nhậnonSelect(value), với các lựa chọn nằm trongoptions, 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.
Keyboard focus và hotkey
Phần tiêu đề “Keyboard focus và hotkey”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.
Cách pane nhận keyboard focus
Phần tiêu đề “Cách pane nhận keyboard focus”Pane nhận keyboard focus khi:
- Mod của bạn mở nó với
focus: truetừ 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ọ.
Mỗi phím làm gì
Phần tiêu đề “Mỗi phím làm gì”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ím | Tác dụng |
|---|---|
| Tab | Chuyển sang control tiếp theo |
| Up và Down | Di 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. |
| Enter | Bấm Button đang được focus, submit Input đang được focus, hoặc chọn trong Select |
| Hotkey của một nút | Bấ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à End | Cuộ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 |
| Esc | Trả 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.
Đặt hotkey và focus ban đầu
Phần tiêu đề “Đặt hotkey và focus ban đầu”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ộtButtonbằng một phím, đặthotkeylà 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êmautoFocus: truevào control đó. Prop này chỉ nhậntrue, 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út | Trong terminal | Trong ứng dụng Desktop |
|---|---|---|
| Có ngoặc vuông, mặc định | [ Add one ], không hiện hotkey | Nhãn kèm một phím nhỏ bên cạnh |
Với plain: true | 1: One | Nhã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.
Nhận input và vẽ một dòng cho mỗi mục
Phần tiêu đề “Nhận input và vẽ một dòng cho mỗi mục”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
InputgọionSubmit(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
keyriêng
Hook này vẽ nội dung pane:
// Danh sách mà pane vẽ ralet 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
xcủa ghi chú có focus, rồi nhấn Enter.xlà 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:
| Prop | Trong ví dụ | Là gì |
|---|---|---|
label | Note | Text đứng trước ô nhập. Terminal vẽ : phía sau nó. |
placeholder | Type a note and press Enter | Text mờ hiện khi ô nhập trống |
submitLabel | add | Từ đứ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.
Vẽ lại một site
Phần tiêu đề “Vẽ lại một site”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.
Khi nào Claude Code tự vẽ lại
Phần tiêu đề “Khi nào Claude Code tự vẽ lại”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.
Vẽ lại khi dữ liệu thay đổi
Phần tiêu đề “Vẽ lại khi dữ liệu 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ó.
Vẽ lại theo timer
Phần tiêu đề “Vẽ lại theo timer”Để 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.
Lưu state
Phần tiêu đề “Lưu state”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 trong | Tồn tại cho đến khi | Dùng cho |
|---|---|---|
| Một biến cấp module | Module reload, điều xảy ra mỗi lần bạn lưu file trong lúc phát triển | Những giá trị có thể mất, như tab trong hello-tabs |
$.state | Session kết thúc, hoặc user chạy /clear, /resume hay /branch | Những giá trị mà phần vẽ phụ thuộc vào và cần sống sót qua lần reload |
$.store | Mod 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.
Lưu giá trị trong $.state
Phần tiêu đề “Lưu giá trị trong $.state”$.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ị
Phần tiêu đề “Khai báo các giá trị”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:
declare module 'claude-code' { interface PluginState { 'hello-tabs': { tab: 'one' | 'two' count: number } }}Trỏ manifest tới file khai báo
Phần tiêu đề “Trỏ manifest tới file khai báo”Để 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:
{ "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"}Định nghĩa, đọc và ghi một giá trị
Phần tiêu đề “Định nghĩa, đọc và ghi một giá trị”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 địnhconst 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
pluginvàkeydướ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 hello-tabs sang dùng $.state
Phần tiêu đề “Chuyển hello-tabs sang dùng $.state”Để 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à thaylet count = 0bằng dòngatom - Trong hook
ui.render: thêm dòngreadtrướctabButton, và vẽ'Count: ' + ntrongText - Trong nút Add one: thay
onPressbằ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 đọcsavedbằng lời gọiloadCountở 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ạp lại giá trị đã lưu sau /clear
Phần tiêu đề “Nạp lại giá trị đã lưu sau /clear”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 reloadon('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.
Lưu từ nhiều session
Phần tiêu đề “Lưu từ nhiều session”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
setchỉ 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
getkey đó 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úcsession.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ầngetvà lầnsetcủ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 đó.
Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Phản ứng với event: lấy dữ liệu cho phần vẽ từ tool call và turn
- Dùng mods API: lấy dữ liệu cho phần vẽ từ timer và lời gọi model
- Kiểm thử phần vẽ: bấm các nút của bạn từ test, trên nhiều surface
- Render site và element: props của từng site và từng element
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.