mod로 인터페이스에 그리기
Claude Code mod에서 창, 프롬프트 위의 밴드, 버튼, 텍스트 필드를 그리고, 버튼 누름과 입력을 처리하며, 다시 그리기와 세션 간에 상태를 유지합니다.
mod는 Claude Code에 자체 인터페이스를 그릴 수 있으며, Claude Code가 이미 그리는 인터페이스의 일부를 변경할 수도 있습니다. mod가 그릴 수 있는 각 위치를 렌더링 지점이라고 하며, 창, 프롬프트 위의 밴드, 스피너 등이 이에 해당합니다. Claude Code는 렌더링 지점을 그리기 직전마다 ui.render 이벤트를 발생시키며, 해당 이벤트에 대한 훅은 그곳에 그릴 내용을 반환합니다.
다음 맵은 터미널 세션에서 mod가 그릴 수 있는 위치를 보여 줍니다.
더 좁은 터미널에서는 창이 트랜스크립트 옆이 아닌 프롬프트 위에 배치됩니다.
여기서 시작하기 전에 첫 번째 mod를 만들어 보십시오. 두 개의 탭과 카운터가 있는 창을 만드는 실습 예제부터 시작한 다음, 변경하려는 각 부분에 해당하는 섹션을 읽으십시오.
개별 prop이나 제한을 찾아보려면 레퍼런스를 참조하십시오.
탭이 있는 창 만들기
이 섹션에서는 /hello-tabs 명령을 추가하는 mod를 만들며, 이 명령은 창을 엽니다. 창은 너비가 넓은 전체 화면 터미널에서는 트랜스크립트 옆의 사이드바로, 그 외의 경우에는 프롬프트 위의 테두리가 있는 영역으로 표시됩니다. 이 창에는 두 개의 탭이 표시되며, 두 번째 탭에는 카운터를 1씩 늘리는 버튼이 있습니다. 카운트는 Claude Code를 다시 시작한 후에도 유지됩니다.
완성된 mod는 다음과 같습니다. 녹화 영상은 창을 열고, 두 번째 탭으로 전환한 뒤, 버튼을 몇 번 누르고, 첫 번째 탭으로 돌아갑니다.
탭은 한 줄로 나열된 두 개의 버튼입니다. 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로 저장합니다.
// The pane's id, used to open the pane and to recognize it when drawing
const PANE = 'hello-tabs'
// What the pane shows: which tab is open, and the counter's value
let tab = 'one'
let count = 0
export function register(on) {
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// Load the count an earlier session saved, if there is one
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// Runs when you type /hello-tabs
on('command.run', { command: 'hello-tabs' }, async ($) => {
// Open the pane, give it the keyboard, and let Esc close it
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// Print nothing in the transcript
return {}
})
// Runs each time Claude Code draws a pane
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Leave other mods' panes alone
if (e.requestId !== PANE) return next(e)
// Get the elements this app can draw
const { Box, Text, Button } = $.ui.resolve(e)
// Ask Claude Code to run this hook again
const redraw = () => $.ui.invalidate('ui.render')
// One tab: a button that switches to its tab when pressed
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// Dim the tab that isn't open
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// What goes under the tabs, depending on which one is open
const body =
tab === 'one'
? [Text({ children: ['This is the first tab.'] })]
: [
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
hotkey: 'a',
onPress: async () => {
count += 1
redraw()
// Save the count so it's there after a restart
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// The whole pane: the row of tabs, a blank line, then the body
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}
각 훅은 코드만으로는 드러나지 않는 작업도 수행합니다.
session.start는 세션 간에 유지되는 키-값 저장소인$.store에서 저장된 카운트도 읽습니다.command.run은 Claude Code에 창이 존재한다는 사실만 알립니다. 창을 여는 것만으로는 아무것도 그려지지 않습니다. 그 후 Claude Code가ui.render를 발생시켜 창에 무엇이 들어갈지 묻습니다.ui.render는 다른 박스, 텍스트, 버튼을 담는Box인 요소 트리를 반환하며, 실행될 때마다tab과count로부터 트리를 다시 빌드합니다.
버튼을 누르면 해당 onPress 콜백이 실행되어 변수를 변경하고 redraw를 호출합니다. 그러면 Claude Code가 ui.render 훅을 다시 실행하고, 훅은 새 값으로부터 새 트리를 빌드합니다. 모든 인터랙티브 그리기는 이 렌더 주기를 사용합니다. 콜백이 상태를 변경하면, 훅이 새 상태로부터 다시 렌더링합니다.
창 열기
셸에서 claude --plugin-dir ./hello-tabs로 Claude Code를 시작합니다. Claude Code 프롬프트에서 /hello-tabs를 실행합니다. 상단에 1: One과 2: Two가 표시된 창이 열립니다. 2를 누른 다음, Add one의 단축키인 a를 몇 번 누릅니다. 카운트가 올라갑니다.
카운트가 저장되었는지 확인하기
Esc를 눌러 창을 닫은 다음 세션을 종료합니다. 셸에서 동일한 claude --plugin-dir ./hello-tabs 명령으로 Claude Code를 다시 시작하고, Claude Code 프롬프트에서 /hello-tabs를 실행합니다. 카운트가 마지막으로 남겨 둔 값 그대로 유지되어 있습니다.
카운트를 지우려면 mod가 $.store.delete('count')를 호출하도록 합니다. 각 종류의 값이 얼마나 오래 유지되는지는 상태 유지하기에서 다룹니다.
그릴 위치 선택하기
ui.render 훅은 그리려는 렌더링 지점 하나로 범위를 좁히지 않으면 모든 렌더링 지점에서 실행됩니다. 렌더링 지점을 선택하려면 matcher라고 하는 필터를 on의 두 번째 인수로 전달합니다. { component: 'Pane' }은 pane에서만 훅을 실행합니다. 훅 안에서 e.component는 지점의 이름을, e.surface는 어느 앱이 그리고 있는지를 나타내며, e.props에는 해당 지점 고유의 데이터가 담깁니다. pane의 경우 e.requestId는 pane을 열 때 사용한 id입니다.
pane과 band는 mod가 채우기 전까지 비어 있습니다. 탭을 선택하여 각각이 무엇이며 어떻게 그리는지 확인하십시오.
pane은 너비가 넓은 전체 화면 터미널에서는 트랜스크립트 옆의 사이드바이며, 그 외의 경우에는 프롬프트 위의 테두리가 있는 영역입니다. 여러 pane이 열려 있으면 각 pane에 제목을 표시하는 탭이 생깁니다.
pane은 mod가 직접 정한 id로 $.ui.open을 호출할 때 나타납니다. 예: $.ui.open({ id: 'hello-tabs' }). 다른 필드와 pane이 더 넓은 터미널을 기다리는 경우는 적절한 시점에 pane 열기에서 다룹니다.
pane에 그리려면 { 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가 이미 그리는 지점에서 훅은 세부 사항을 변경하거나, 그리기를 대체하거나, 그대로 둘 수 있습니다. 탭을 선택하여 각 방식을 스피너에 적용한 예를 확인하십시오. 예제는 튜토리얼 mod에서처럼 다른 훅이 세는 calls 변수를 읽습니다.
Claude Code의 그리기를 유지하면서 일부만 변경하려면 props를 변경한 이벤트 복사본을 next에 전달합니다. 다음 훅은 스피너 단어 뒤의 텍스트를 변경합니다.
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Keep Claude Code's spinner, and change the text after its word
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
스피너는 애니메이션과 단어를 유지하고, 단어 뒤에 사용자가 지정한 텍스트가 이어집니다.
Thinking · tool calls: 2…
지점 대신 직접 만든 내용을 그리려면 트리를 반환하고 next를 호출하지 않습니다. 다음 훅은 스피너가 있을 자리에 텍스트 한 줄을 그립니다.
on('ui.render', { component: 'Spinner' }, async ($, e) => {
const { Text } = $.ui.resolve(e)
// No call to next, so this line is drawn in the spinner's place
return Text({ children: ['Claude has made ' + calls + ' tool calls'] })
})
Claude가 작업하는 동안 사용자가 지정한 줄이 표시되고 Claude Code의 스피너는 표시되지 않습니다.
Claude has made 2 tool calls
지점을 Claude Code가 그리는 그대로 두려면 next(e)를 반환합니다. 훅은 흔히 일부 이벤트에서만 이렇게 합니다. 다음 훅은 셀 호출이 생길 때까지 스피너를 그대로 둡니다.
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Nothing to show yet, so pass the event on unchanged
if (calls === 0) return next(e)
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
첫 번째 도구 호출 전에는 스피너가 mod가 없을 때와 똑같이 보입니다.
Thinking…
이러한 지점에서 next(e)는 뒤에 실행되는 mod가 자체 트리를 반환하지 않는 한 Claude Code의 그리기에 대한 참조인 { type: 'engine', ref }를 반환합니다. 그 그리기의 내용을 변경하려면 세부 사항 변경 탭에서처럼 prop을 다르게 한 이벤트 복사본을 next에 전달합니다. 참조를 그대로 반환하거나, Box 안에 직접 만든 요소와 나란히 배치할 수 있습니다.
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'] })] })
})
Claude가 작업하는 동안 스피너는 이전처럼 애니메이션되고, 그 아래에 under the spinner가 나타납니다.
권한 프롬프트는 렌더링 지점이 아니므로 mod가 표시 내용을 변경할 수 없습니다. 질문 대화 상자인 AskUserQuestion은 렌더링 지점이므로 mod가 변경할 수 있습니다. 대화 상자용 트리는 참조를 정확히 한 번 포함해야 하며, 직접 만든 요소는 그 위에 있어야 합니다. 그렇지 않으면 Claude Code가 자체 대화 상자를 그립니다.
터미널과 Desktop 앱이 발생시키는 지점이 모두 같지는 않습니다. Pane, AbovePrompt, Spinner 및 트랜스크립트 지점은 양쪽 모두에서 작동합니다. 일부 다른 상태줄은 터미널에서만 발생합니다. 각 지점이 발생하는 위치는 렌더링 지점 표에 나와 있습니다.
적절한 시점에 pane 열기
pane은 mod가 열 때만 나타납니다. pane을 여는 방법과 시점에 따라 키보드 포커스를 가져가는지, 얼마만큼의 공간을 요청하는지, 좁은 터미널에서 표시되는지 여부가 결정됩니다.
pane을 열려면 직접 정한 id로 $.ui.open을 호출합니다. id는 pane의 이름입니다. ui.render 훅이 이 값을 확인하며, pane을 닫을 때도 다시 전달합니다.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
pane을 닫으려면 열 때 사용한 id로 $.ui.close를 호출합니다.
await $.ui.close({ id: 'hello-tabs' })
$.ui.open은 id 외에 다음 선택적 필드를 받습니다.
| 필드 | 기능 |
|---|---|
title |
둘 이상의 pane이 열려 있을 때 pane의 탭 레이블 |
focus |
키보드 포커스를 요청 |
closeOnEscape |
Esc로 pane을 닫도록 설정 |
holdToasts |
pane이 닫힐 때까지 토스트($.ui.toast의 작은 알림)를 보류 |
rows |
pane이 프롬프트 위에 있을 때 요청할 높이. 기본값은 공간의 3분의 1입니다. |
columns |
pane이 트랜스크립트 옆에 있을 때 요청할 너비 |
focus, closeOnEscape, holdToasts는 선택 사항이며 true만 허용합니다. 사용하지 않으려면 생략하십시오. false를 전달하면 ui.open: focus is true or left out과 같은 오류가 발생합니다. 이 중 하나를 조건부로 설정하려면 조건이 충족될 때만 필드를 추가합니다. 다음 호출은 items가 비어 있지 않을 때만 키보드 포커스를 요청합니다.
const pane = { id: 'hello-tabs', title: 'Hello tabs' }
await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)
Claude가 작업하는 동안 명령으로 pane을 열 수 있게 하려면 명령을 등록할 때 immediate: true를 추가합니다. 이 설정이 없으면 턴 중에 입력한 명령은 턴이 끝날 때까지 대기합니다.
pane이 더 넓은 터미널을 기다리는 경우
사용자의 요청 없이 mod가 연 pane은 좁은 터미널에서 나타나지 않으므로 작은 화면을 차지할 수 없습니다. 표시 여부는 pane을 연 주체에 따라 달라집니다.
- 사용자가 실행한 명령이나 누른 버튼처럼 사용자의 동작으로 열린 경우, pane은 너비와 관계없이 나타납니다
- 타이머나
turn.start훅처럼 mod가 스스로 동작하여 열린 경우, pane은 너비가 144열 이상인 터미널에서만 나타납니다. 사용자가 해당 pane을 직접 한 번 연 후에는 110열이면 충분합니다.
pane이 나타나면 $.ui.open은 { isPlaced: true }로 resolve됩니다. pane이 대기 중이면 isPlaced는 false이고 reason은 그 이유를 설명하는 문자열입니다. 대기 중인 pane은 사용자가 열거나 터미널 너비를 넓히면 나타납니다. pane을 열지 않고 무언가를 사용할 수 있음을 알리려면 $.ui.toast('Your message')를 호출하여 토스트 알림을 표시합니다.
요소로 트리 만들기
ui.render 훅이 반환하는 것은 요소 트리입니다. 요소 트리는 무엇을 그릴지에 대한 설명으로, 서로 중첩된 박스, 텍스트, 컨트롤로 구성됩니다. 그릴 내용을 기술하면 Claude Code가 이를 터미널 또는 Desktop 앱에서 렌더링합니다.
요소를 가져오려면 const { Box, Text, Button } = $.ui.resolve(e)처럼 훅에서 $.ui.resolve(e)를 호출합니다. 각 요소는 함수입니다. 함수에 prop을 전달하고, 그 안에 들어갈 요소와 문자열은 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
인터페이스 갤러리에서 대부분의 요소에 대한 샘플과 스크린샷을 확인할 수 있습니다. 다음 표는 모든 요소를 나열합니다.
| 요소 | 그리는 내용 | 사용 위치 |
|---|---|---|
Box |
flex 컨테이너입니다. flexDirection, columnGap, padding, borderStyle, width 같은 레이아웃 prop을 받습니다. |
모든 곳 |
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은 내용을 children이 아닌 text prop으로 받으며, onLinkPress를 전달할 때는 key가 필요합니다. |
모든 곳 |
Input, Select |
텍스트 필드와 드롭다운 | 터미널, Desktop |
Svg |
SVG 문서 | Desktop |
Client |
애니메이션과 포인터 입력을 위해 별도로 작성한 두 번째 파일이 그리는 영역입니다. 이 파일은 mod API를 사용할 수 없습니다. 훅과는 데이터를 게시하는 방식으로만 통신하며, 게시된 데이터는 ui.message 이벤트로 전달됩니다. |
터미널, Desktop |
Raster, Image |
색상 셀 그리드와 이미지 | 터미널 |
모듈이 .tsx 또는 .jsx 파일이라면 트리를 JSX로 작성할 수 있습니다. 먼저 $.ui.resolve(e)에서 요소를 구조 분해하십시오.
트리에 앱에 없는 요소, 요소가 받지 않는 prop, 또는 자식이 들어갈 수 없는 곳의 자식이 사용되면 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로 기록됩니다. 세션에는 그 밖에 아무것도 표시되지 않으므로, 그린 내용이 나타나지 않는다면 해당 줄이나 로그를 확인하십시오.
색상 셀 그리드 그리기
터미널에서 히트맵, 스파크라인, 게임 보드를 그리려면 셀마다 Box를 사용하지 말고 Raster 하나를 그리십시오. Raster는 key, columns와 rows로 지정하는 크기, 그리고 모든 셀을 담은 base64 문자열인 cells를 받습니다. 각 셀은 세 개의 숫자로 구성됩니다. 문자의 코드 포인트, 문자 색상, 배경 색상입니다. 색상은 빨간색의 0xc62828처럼 16진수로 표기한 24비트 RGB 값입니다. 이 범위보다 1 큰 값인 0x01000000은 터미널의 기본 색상을 의미합니다.
Desktop 앱에는 Raster가 없으므로 e.surface를 확인하여 그곳에서는 텍스트를 그리십시오. 다음 창 본문은 3×2 히트맵을 그립니다.
// The value that means "use the terminal's default color"
const DEFAULT_COLOR = 0x01000000
// Pack rows of [character, color] pairs into the one string a Raster takes
// One cell is three numbers: the character's code point, its color, and its background
function cellsOf(rows) {
const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
}
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Draw only in the pane opened with the id 'heat'
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// Two rows of three cells, each a block character and its color
const rows = [
[['█', 0x2e7d32], ['█', 0xf9a825], ['█', 0xc62828]],
[['█', 0x2e7d32], ['█', 0x2e7d32], ['█', 0xf9a825]],
]
if (e.surface !== 'terminal') {
return Text({ children: ['The heat map needs the terminal.'] })
}
return Box({
flexDirection: 'column',
children: [Raster({ key: 'grid', columns: 3, rows: 2, cells: cellsOf(rows) })],
})
})
터미널에서는 창에 그리드가 표시됩니다.
변경할 부분은 rows 배열이며, cellsOf가 이를 압축된 문자열로 변환합니다. 이 훅은 id가 heat인 창에서만 그리므로, hello-tabs 예시가 창을 여는 것처럼 명령에서 $.ui.open({ id: 'heat' })로 창을 여십시오.
각 문자는 한 셀 너비여야 합니다. 이미 화면에 있는 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:options에 선택지를 담은onSelect(value)를 받습니다.options는 고유한 값을 가진 선택지를 하나 이상 포함하는 목록이며, 예를 들어[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]와 같습니다
테스트는 key를 통해 컨트롤을 누르거나 컨트롤에 입력하므로 각 컨트롤에 key를 지정합니다. 또한 컨트롤을 사용할 때마다 e.element에 key를 담아 ui.press, ui.input 또는 ui.select가 발생하며, 다른 mod가 이러한 이벤트를 처리할 수 있습니다. 해당 훅은 콜백보다 먼저 실행되므로 사용자가 Input에 입력하는 내용을 볼 수 있고, 이를 변경하거나 콜백 대신 응답할 수 있습니다. mods API에는 다른 mod의 버튼을 누르는 메서드가 없습니다.
키보드 포커스와 단축키
mod는 키보드를 직접 읽지 않습니다. 사용자가 키를 누르면 Claude Code가 그 키가 어느 컨트롤을 위한 것인지 결정하고, 해당 컨트롤의 콜백이 실행됩니다. 밴드의 숫자 단축키를 제외하면 이는 창이나 밴드에 키보드 포커스가 있는 동안에만 일어납니다. 그 외에는 키가 프롬프트로 전달됩니다.
창이 키보드 포커스를 얻는 방법
창은 다음과 같은 경우 키보드 포커스를 얻습니다.
- mod가 명령이나 누르기에서
focus: true로 창을 여는 경우 - 사용자가 Ctrl+X를 누른 다음 Tab을 누르는 경우
- 사용자가 창을 클릭하는 경우
Claude Code는 프롬프트가 비어 있고 다른 어떤 것도 키보드 포커스를 갖고 있지 않을 때만 focus: true를 허용합니다. 사용자가 입력하는 중에 열리는 창은 사용자의 키 입력을 가져가지 않습니다.
각 키의 동작
다음 표는 창이나 밴드에 키보드 포커스가 있는 동안 각 키가 수행하는 동작을 보여 줍니다.
| 키 | 동작 |
|---|---|
| Tab | 다음 컨트롤로 이동합니다 |
| Up 및 Down | 그린 내용이 들어맞는 동안에는 컨트롤 사이를 이동합니다. 창이나 밴드에 표시할 수 있는 것보다 많은 행이 있으면 스크롤합니다. |
| Enter | 포커스된 Button을 누르거나, 포커스된 Input을 제출하거나, Select에서 항목을 고릅니다 |
| 버튼의 단축키 | 해당 버튼을 누릅니다. Input에 포커스가 있는 동안에는 출력 가능한 모든 키가 필드로 전달됩니다. |
| Page Up, Page Down, Home 및 End | 창이나 밴드에 표시할 수 있는 것보다 많은 행이 있으면 창이나 밴드를 스크롤합니다 |
| Ctrl+X 다음 화살표 키 | 창 크기를 조정합니다. Left 또는 Up은 창에 더 많은 공간을 주고, Right 또는 Down은 그 공간을 되돌려 줍니다. |
| Ctrl+X 다음 X | 창의 필드 중 하나에 포커스가 있는 동안에도 창을 닫습니다 |
| Esc | 키보드 포커스를 프롬프트로 되돌립니다. closeOnEscape: true를 지정하면 창도 닫습니다. |
mod는 Tab이나 화살표 키를 다른 동작에 바인딩할 수 없으므로, 게임은 w, a, s, d로 조작합니다.
단축키와 첫 포커스 설정하기
컨트롤의 다음 prop은 키보드가 해당 컨트롤에 도달하는 방식을 결정합니다.
hotkey: 사용자가 키 하나로Button을 누를 수 있게 하려면hotkey: 'a'처럼 숫자 하나 또는 소문자 하나로 된hotkey를 지정합니다autoFocus: 창이 열릴 때 어느 컨트롤에 포커스를 둘지 선택하려면 해당 컨트롤에autoFocus: true를 추가합니다. 이 prop은true만 허용하므로 다른 컨트롤에서는 생략합니다.
단축키가 표시되는 방식은 버튼과 앱에 따라 다릅니다.
| 버튼 | 터미널에서 | Desktop 앱에서 |
|---|---|---|
| 대괄호 포함(기본값) | [ Add one ], 단축키는 표시되지 않음 |
레이블 옆에 작은 키가 표시됨 |
plain: true 사용 |
1: One |
레이블 옆에 작은 키가 표시됨 |
터미널에서는 사용자가 무엇을 눌러야 하는지 알 수 있도록 대괄호 버튼의 레이블에 키 이름을 넣거나 plain: true를 사용합니다. 요소 레퍼런스에서 action, 밴드의 숫자 단축키, 하나의 단축키에 연결된 두 버튼 등 Button의 다른 규칙을 확인할 수 있습니다.
입력을 받고 항목마다 행 그리기
많은 창은 텍스트 필드 아래에 목록이 있는 형태입니다. 이 섹션의 예시는 메모 창입니다. 메모를 입력하고 Enter를 눌러 추가하며, 각 메모에는 메모를 삭제하는 x 버튼이 있습니다. 메모 두 개를 추가하면 터미널은 창을 다음과 같이 그립니다.
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
이 예시는 다음 기법을 사용합니다.
- 입력 받기:
Input은 사용자가 Enter를 누르면 필드의 텍스트로onSubmit(value)를 호출하고, 변경이 있을 때마다onInput(value)를 호출합니다 - 목록 그리기: 데이터를 항목마다 한 행으로 매핑하고, 모든 행의 버튼에 고유한
key를 지정합니다
다음 훅은 창의 내용을 그립니다.
// The list the pane draws
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Draw only in the pane opened with the id 'notes'
if (e.requestId !== 'notes') return next(e)
const { Box, Text, Button, Input } = $.ui.resolve(e)
const redraw = () => $.ui.invalidate('ui.render')
return Box({
flexDirection: 'column',
children: [
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
// Draw the field empty each time, which clears it after a submit
value: '',
submitLabel: 'add',
autoFocus: true,
// Runs when you press Enter in the field
onSubmit: async (value) => {
// Ignore an empty line
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// One row for each note: a delete button, then the note's text
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// A key of its own, so each row's button can be told apart
key: 'delete-' + i,
label: 'x',
plain: true,
onPress: async () => {
notes = notes.filter((_, j) => j !== i)
redraw()
await $.store.set('notes', notes)
},
}),
Text({ children: [note] }),
],
}),
),
],
})
})
창을 사용해 보려면 다음을 수행합니다.
- 메모 추가: 한 줄을 입력하고 Enter를 누릅니다. 입력한 줄이 새 행으로 나타나고 필드가 비워집니다.
- 메모 삭제: 메모의
x버튼에 포커스가 갈 때까지 Tab을 누른 다음 Enter를 누릅니다.x는 버튼의 레이블이며 단축키가 아니므로, 해당 문자를 입력해도 버튼이 눌리지 않습니다.
각 변경은 hello-tabs와 동일한 렌더링 주기를 따릅니다. 콜백이 notes를 변경하고, redraw를 호출하고, 목록을 $.store에 저장합니다.
제출할 때마다 필드가 비워지는 것은 value prop 때문입니다. value는 필드를 그릴 때 필드에 담기는 텍스트이며, 훅이 필드를 다시 그릴 때까지는 사용자의 입력이 이를 대체합니다. 이 예시는 항상 ''로 필드를 그립니다.
이 예시는 메모를 저장하지만 불러오지는 않습니다. 다음 세션에서 메모를 다시 불러오려면 hello-tabs가 count를 읽는 방식처럼 session.start 훅에서 메모를 읽습니다.
다음 prop이 필드의 줄 Note: Type a note and press Enter ⏎ add를 구성합니다.
| Prop | 예시의 값 | 설명 |
|---|---|---|
label |
Note |
필드 앞의 텍스트입니다. 터미널은 그 뒤에 : 를 그립니다. |
placeholder |
Type a note and press Enter |
필드가 비어 있는 동안 표시되는 흐린 텍스트입니다 |
submitLabel |
add |
⏎ 뒤에 오는 단어로, Enter가 수행하는 동작을 나타냅니다 |
콜백이 $.prompt.submit을 호출하지 않는 한, Input을 제출해도 턴이 시작되지 않습니다.
사이트 다시 그리기
그리기는 스냅샷입니다. 즉, ui.render 훅이 마지막으로 실행되었을 때 반환한 내용을 보여 줍니다. 새로운 내용을 표시하려면 훅이 다시 실행되어야 합니다. Claude Code는 일부 변경에 대해서는 훅을 다시 실행하며, 나머지 경우에는 mod가 다시 그리기를 요청합니다.
요청 없이 Claude Code가 다시 그리는 경우
Claude Code는 사이트의 prop이 변경되거나 터미널의 너비가 변경되면 ui.render 훅을 다시 실행합니다. 타이머에 따라 훅을 실행하지는 않으며, 모듈 안의 변수가 변경되는 시점을 알 수도 없습니다.
데이터가 변경될 때 다시 그리기
자체 데이터가 변경된 후 사이트를 다시 그리려면 $.ui.invalidate('ui.render')를 호출합니다. 다음 창은 누른 횟수를 셉니다. 버튼의 콜백은 count를 변경한 다음 다시 그리기를 요청합니다.
let count = 0
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== 'counter') return next(e)
const { Box, Text, Button } = $.ui.resolve(e)
return Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
onPress: () => {
count += 1
// The data changed, so ask Claude Code to draw the pane again
$.ui.invalidate('ui.render')
},
}),
Text({ children: ['Count: ' + count] }),
],
})
})
버튼을 누를 때마다 창의 숫자가 올라갑니다. hello-tabs 예제는 동일한 호출을 redraw 함수로 감싸고 있습니다.
$.state에 보관하는 값은 이 호출이 필요하지 않습니다. 값을 쓰면 해당 값을 읽는 사이트가 다시 그려지기 때문입니다.
타이머에 따라 다시 그리기
시계, 카운트다운 또는 세션 외부의 값을 최신 상태로 유지하려면 일정에 따라 다시 그립니다. 모듈의 session.start 훅에서 타이머를 시작합니다. hello-tabs처럼 모듈에 이미 해당 훅이 있다면 $.clock.every 줄을 추가합니다.
on('session.start', async ($, e, next) => {
// Every 1000 milliseconds, ask Claude Code to draw your sites again
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})
이제 Claude Code는 ui.render 훅을 1초에 한 번씩 실행합니다. 타이머는 모듈이 다시 로드되면 중지되며, 모듈의 새 인스턴스가 자체 타이머를 시작합니다.
사이트를 다시 그릴 수 있는 빈도
Claude Code는 사이트의 다시 그리기를 제한하므로, mod는 데이터가 변경될 때마다 $.ui.invalidate를 호출해도 됩니다. 각 사이트를 다시 그릴 수 있는 빈도는 제한 표를 참조하세요.
제한보다 빠르게 들어오는 호출은 하나의 다시 그리기로 병합됩니다. 이 다시 그리기는 훅을 한 번 실행하며, 훅은 그 시점의 데이터를 읽으므로 최신 값이 표시되고 그 사이의 값은 표시되지 않습니다. 애니메이션은 제한보다 빠르게 실행될 수 없습니다.
상태 유지
mod가 값을 어디에 보관하는지에 따라 그 값이 유지되는 기간이 결정됩니다. 모듈이 다시 로드될 때까지, 세션이 끝날 때까지, 또는 한 세션에서 다음 세션까지 유지될 수 있습니다. 값이 유지되어야 하는 기간에 따라 선택합니다.
| 보관 위치 | 유지 기간 | 용도 |
|---|---|---|
| 모듈 수준 변수 | 모듈이 다시 로드될 때까지. 개발 중에는 파일을 저장할 때마다 다시 로드됩니다 | hello-tabs의 tab처럼 잃어도 되는 값 |
$.state |
세션이 끝나거나 사용자가 /clear, /resume, /branch를 실행할 때까지 |
그리기에 사용되며 다시 로드된 후에도 유지되어야 하는 값 |
$.store |
mod가 삭제하거나, cleanupPeriodDays 동안 어떤 세션도 스토어를 읽거나 쓰지 않을 때까지. 스토어는 키-값 스토어이며, ~/.claude/plugins/store/ 아래에 플러그인 전용 JSON 파일로 저장됩니다. |
설정, 기록 등 사용자가 다음에도 찾을 수 있기를 기대하는 모든 것 |
$.store.get(key)는 값 또는 undefined로 resolve되며, $.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'
// At the top of the module: name the value and give its default
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// In the ui.render hook: read the value to draw it
const n = await read($, count)
// In a Button: write a new value from the old one
onPress: () => update($, count, (value) => value + 1)
ui.render 훅이 count를 읽었기 때문에, 버튼이 값을 쓸 때마다 Claude Code가 훅을 다시 실행합니다.
코드에는 다음 규칙이 적용됩니다.
plugin과key를 문자열 리터럴로 작성합니다:claude plugin validate가 소스에서 이 값을 읽습니다- 모든 값을 타입 선언 파일에 선언합니다: 그렇지 않으면
hello-tabs.count is not declared와 함께 검증이 실패합니다 - 콜백이나 다른 이벤트의 훅에서 씁니다:
ui.render훅은 상태를 읽을 수는 있지만 쓸 수는 없으므로,onPress,onSubmit또는 다른 이벤트의 훅에서 씁니다
`$.state`를 사용하도록 `hello-tabs` 변경하기
hello-tabs의 count를 $.state로 옮기려면 이를 사용하는 모든 줄을 변경합니다.
- 모듈 상단:
import줄을 추가하고,let count = 0을atom줄로 바꿉니다 ui.render훅:tabButton앞에read줄을 추가하고,Text에'Count: ' + n을 그립니다- Add one 버튼:
onPress를 여러 세션에서 저장하기에 있는 것으로 바꿉니다. 이는 카운트를 쓸 뿐 아니라 저장도 합니다 session.start훅:saved를 읽는 두 줄을/clear후 저장된 값 다시 로드하기의loadCount호출로 바꿉니다
tab은 여전히 변수이므로 탭 버튼에는 redraw를 유지합니다.
`/clear` 후 저장된 값 다시 로드하기
mod가 session.start에서 $.store의 저장된 값을 $.state로 복사한다면, /clear, /resume, /branch 후에 다시 복사해야 합니다. 이러한 명령은 모든 $.state 값을 기본값으로 재설정하며, session.start는 다시 실행되지 않습니다. classic.SessionStart는 각 명령 후에 e.source가 clear, resume 또는 fork로 설정된 채 실행되므로, 이 이벤트의 훅에서 값을 다시 복사합니다. 그렇지 않으면 그리기에 기본값이 표시되고, $.state 값을 저장하는 콜백이 저장해 둔 값을 기본값으로 덮어씁니다.
다음 코드는 두 훅 모두에서 count를 로드합니다. 이는 count가 atom이고 update를 import한 $.state 버전의 hello-tabs를 기반으로 합니다. loadCount를 register 위에 두고, 이미 있는 session.start 훅에 loadCount 호출을 추가합니다. classic.SessionStart는 시작 시와 압축 후에도 실행되는데, 이때는 $.state가 재설정되지 않으므로 source 필터를 사용해 훅을 세 가지 재설정으로 제한합니다.
// Copy the saved count from $.store into $.state, or 0 if nothing is saved
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// Runs again after /clear, /resume, and /branch, which reports fork
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
await loadCount($)
return next(e)
})
두 훅을 모두 적용하면 /clear 후 창에 0이 아닌 저장된 카운트가 표시되며, 다음에 Add one을 누르면 저장된 카운트에 더해집니다.
loadCount는 저장된 값으로 $.state의 값을 덮어쓰며, session.start는 모듈이 다시 로드될 때마다 다시 실행됩니다. 스토어가 뒤처지지 않도록 하려면 Add one 버튼처럼 변경될 때마다 저장합니다.
세션 없이 다시 로드를 확인하려면 /clear 후 그리기를 테스트합니다.
여러 세션에서 저장하기
머신에서 mod를 실행하는 모든 세션은 하나의 $.store를 공유합니다. get 다음에 set을 실행하는 것은 원자적이지 않습니다. 두 세션이 각각 값을 읽고, 변경하고, 다시 쓰면 경쟁 상태가 발생하며, 두 번째 쓰기가 첫 번째 쓰기를 대체합니다.
이러한 가능성을 줄이려면 다음을 따릅니다.
- 각 항목에 고유한 키를 부여합니다:
set은 자신의 키만 변경하므로, 서로 다른 키에 쓰는 세션은 서로를 덮어쓰지 않습니다 - 쓰기 직전에 다시 읽습니다: 여러 세션이 변경하는 값의 경우, 콜백에서 키를
get하고session.start에서 로드한 복사본이 아니라 그 값으로 새 값을 만듭니다. 다른 세션의 쓰기가get과set사이에 발생하면 여전히 손실됩니다.
다음 버튼은 스토어에 현재 있는 값에 1을 더한 다음 그리기를 업데이트합니다.
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)
}
이 세션이 시작된 후 두 번째 세션이 자신의 버튼을 세 번 눌렀다면, 이번 누름은 그 세 번을 포함한 카운트를 표시하고 저장합니다.
다음 단계
- 이벤트에 반응하기: 도구 호출과 턴으로 드로잉에 데이터를 공급합니다
- mods API 사용하기: 타이머와 모델 호출로 드로잉에 데이터를 공급합니다
- 드로잉 테스트하기: 테스트에서 버튼을 눌러 보고, 여러 사용 환경에서 확인합니다
- 렌더링 지점 및 요소: 각 지점의 prop과 각 요소의 prop을 설명합니다