mod로 인터페이스 그리기 (Draw in the interface with a mod)
기준일: 2026-11-03
공식 기준: Draw in the interface with a mod
개요
mod는 Claude Code 안에 자기 인터페이스를 그릴 수 있고, Claude Code가 이미 그리는 부분도 바꿀 수 있습니다. mod가 그릴 수 있는 각 위치를 render site라고 부릅니다(pane, 프롬프트 위 band, 스피너 등). Claude Code는 render site를 그리기 직전마다 ui.render 이벤트를 발생시키고, 그 이벤트의 hook이 무엇을 그릴지 반환합니다.
터미널 세션(fullscreen 렌더링)에서 mod가 그릴 수 있는 곳은 다음과 같습니다.
- 새로 추가: 오른쪽 사이드바 pane, transcript 오른쪽 위 toast, transcript의 log 줄, 프롬프트 위 band, 프롬프트 아래 status 줄
- 다시 그리기: 메시지, 도구 호출 행, 스피너
- 프롬프트 입력 자체는 Claude Code 소유입니다.
좁은 터미널에서는 pane이 transcript 옆이 아니라 프롬프트 위에 놓입니다.
이 페이지를 보기 전에 첫 mod 만들기를 먼저 해 보세요. 탭 두 개와 카운터가 있는 pane 예제부터 따라 한 뒤, 바꾸고 싶은 부분의 절을 읽으면 됩니다. prop 하나나 limit 하나만 찾을 때는 reference를 봅니다.
핵심 개념
| 개념 | 설명 |
|---|---|
| render site | mod가 그릴 수 있는 위치. e.component로 이름을 확인 |
ui.render |
site를 그리기 직전에 발생하는 이벤트. hook이 element tree를 반환 |
| element tree | Box·Text·Button 등을 중첩한 "무엇을 그릴지"에 대한 설명 |
| render cycle | 콜백이 상태를 바꾸고 → 다시 그리기를 요청하고 → hook이 새 상태로 다시 그림 |
$.ui.invalidate('ui.render') |
site를 다시 그려 달라는 요청 |
$.state / $.store |
세션 동안 유지되는 반응형 상태 / 세션 간 유지되는 key-value 저장소 |
상세
탭이 있는 pane 만들기
/hello-tabs 명령을 추가하고, 그 명령이 pane을 여는 mod를 만듭니다. pane은 넓은 fullscreen 터미널에서는 transcript 옆 사이드바, 그 외에는 프롬프트 위 테두리 영역입니다. 이 pane은 탭 두 개를 보여 주고, 두 번째 탭에는 카운터를 1 올리는 버튼이 있습니다. 카운트는 Claude Code를 재시작해도 남아 있습니다.
완성된 모습: /hello-tabs를 입력하면 프롬프트 위에 pane이 열리고 상단에 1: One, 2: Two가 보이며 첫 탭에는 This is the first tab.이 표시됩니다. 두 번째 탭에는 Add one 버튼과 Count: 1이 나란히 있고 누를 때마다 숫자가 올라갑니다. 탭은 한 줄에 놓인 버튼 두 개이고, mod가 어느 탭이 열려 있는지 기억해 그 탭의 내용을 아래에 그립니다.
1. 플러그인 만들기
mod는 manifest, 코드를 가리키는 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"]
}
2. 코드 작성
코드에 나오는 순서대로 hook이 하는 일은 다음과 같습니다.
/hello-tabs명령을 추가하고, 이전 세션이 저장한 카운트를 불러옴- 그 명령을 실행하면 pane을 엶
- pane 내용(탭 줄과 열린 탭의 본문)을 그림
모듈 수준 변수 tab, count가 pane의 상태를 담습니다. 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: 세션 간 유지되는 key-value 저장소$.store에서 저장된 카운트도 읽습니다.command.run: pane이 존재한다는 것만 알립니다. pane을 연다고 무언가 그려지지 않으며, Claude Code가 이어서ui.render를 발생시켜 내용을 묻습니다.ui.render: 상자·텍스트·버튼을 담은Box, 즉 element tree를 반환하며, 실행될 때마다tab과count로 tree를 새로 만듭니다.
버튼을 누르면 onPress 콜백이 변수를 바꾸고 redraw를 호출합니다. 그러면 Claude Code가 ui.render hook을 다시 실행해 새 값으로 tree를 만듭니다. 모든 인터랙티브 그리기가 이 render cycle을 씁니다.
3. pane 열기
셸에서 claude --plugin-dir ./hello-tabs로 시작하고 프롬프트에서 /hello-tabs를 실행합니다. 2를 누른 뒤 Add one의 hotkey인 a를 몇 번 누르면 카운트가 올라갑니다.
4. 카운트가 저장됐는지 확인
Esc로 pane을 닫고 세션을 종료한 뒤, 같은 명령으로 다시 시작해 /hello-tabs를 실행하면 카운트가 그대로입니다. 지우려면 mod에서 $.store.delete('count')를 호출합니다. 값별 유지 기간은 상태 유지에 있습니다.
그릴 위치 고르기
ui.render hook은 범위를 좁히지 않으면 모든 render site에서 실행됩니다. on의 두 번째 인자로 필터(matcher)를 넘겨 site를 고릅니다. { component: 'Pane' }은 pane에서만 실행합니다. hook 안에서 e.component는 site 이름, e.surface는 그리는 앱, e.props는 site 고유 데이터입니다. pane에서는 e.requestId가 pane을 열 때 준 id입니다.
pane과 band는 mod가 채우기 전까지 비어 있습니다.
| site | 설명 | 그리는 법 |
|---|---|---|
| Pane | 넓은 fullscreen 터미널에서는 transcript 옆 사이드바, 그 외에는 프롬프트 위 테두리 영역. 여러 개 열리면 각각 title을 보여 주는 탭이 생김 | mod가 직접 고른 id로 $.ui.open({ id: 'hello-tabs' }) 호출. { component: 'Pane' }으로 필터하고 e.requestId가 내 id인지 확인 |
| Band above the prompt | 프롬프트 입력 바로 위 띠. 항상 있고 모든 mod가 공유 | { component: 'AbovePrompt' }로 필터. tree를 반환하면 표시, next(e)를 반환하면 아무것도 표시 안 함 |
band에 tree를 반환하면 나보다 뒤에 실행되는 mod들이 그린 것을 대체합니다. 그들 것을 유지하려면 await next(e) 결과를 내 tree의 Box children에 넣습니다.
Claude Code가 이미 그리는 것 바꾸기
메시지·도구 호출 행·스피너 등 Claude Code가 직접 그리는 부분도 render site라서 mod가 스타일을 바꾸거나 대체할 수 있습니다. 아래 이름으로 ui.render hook을 필터합니다.
| Site | 설명 |
|---|---|
UserMessage, AssistantMessage |
transcript의 메시지 |
ToolUse, ToolResult, ToolGroup |
도구 호출 행, 그 결과, 접힌 호출 묶음 |
CommandOutput |
명령이 출력한 행 |
AskUserQuestion |
Claude가 질문할 때 여는 대화상자 |
Spinner, ToolProgress, TurnDuration |
턴 상태 줄: Claude가 작업하는 동안 움직이는 줄, 실행 중 도구의 진행 줄, 턴을 마무리하는 줄 |
InfoNotice, SessionMode, PromptHint |
로고 아래 상태 줄, footer의 모드 라벨, 프롬프트 아래 힌트 줄 |
Pane, AskUserQuestion, Spinner, UserMessage, AssistantMessage, 도구 호출 site는 터미널·Desktop 앱·VS Code 확장 모두에서 동작합니다. 나머지가 어디서 발생하는지는 reference의 render sites 표를 봅니다.
이미 그려지는 site에서 hook은 세부만 바꾸거나, 통째로 대체하거나, 그대로 둘 수 있습니다. 아래 예제는 다른 hook이 세는 calls 변수를 읽습니다(튜토리얼 mod처럼).
세부 바꾸기: 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…
대체하기: tree를 반환하고 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 has made 2 tool calls가 보입니다.
그대로 두기: 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…으로 보입니다.
이런 site에서 next(e)는 Claude Code의 그림에 대한 참조 { type: 'engine', ref }를 반환합니다(뒤에 실행되는 mod가 자기 tree를 반환한 경우 제외). 참조를 그대로 반환해도 되고, 내 요소와 함께 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'] })] })
})
스피너는 그대로 움직이고 그 아래에 under the spinner가 나옵니다.
- 권한 프롬프트는 render site가 아니어서 mod가 바꿀 수 없습니다.
- 질문 대화상자
AskUserQuestion은 render site입니다. 이 대화상자용 tree는 참조를 정확히 한 번 담아야 하고, 내 요소는 그 위에 놓아야 합니다. 그렇지 않으면 Claude Code가 자기 대화상자를 그립니다.
적절한 때에 pane 열기
pane은 mod가 열 때만 나타납니다. 어떻게, 언제 여느냐에 따라 키보드 포커스를 가져가는지, 공간을 얼마나 요청하는지, 좁은 터미널에서 보이는지가 정해집니다.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
await $.ui.close({ id: 'hello-tabs' })
id는 pane의 이름입니다. ui.render hook이 이 값을 확인하고, 닫을 때도 같은 값을 넘깁니다. id 외의 선택 필드:
| 필드 | 동작 |
|---|---|
title |
pane이 여러 개 열렸을 때 탭 라벨 |
focus |
키보드 포커스 요청 |
closeOnEscape |
Esc로 pane 닫기 |
holdToasts |
터미널에서 이 pane이 보이는 동안 toast를 보류. 대화상자 뒤에 toast 보류 참고 |
rows |
프롬프트 위에 놓일 때 요청할 높이. 기본값은 공간의 1/3 |
columns |
transcript 옆에 놓일 때 요청할 너비. 최대 터미널 너비의 45%. 사용자가 transcript 옆 pane 크기를 (이번 또는 이전 세션에서) 조정했다면 그 너비로 열림. 그 너비에도 45% 한도 적용 |
- VS Code 확장은
rows,columns를 무시하고 직접 크기를 정합니다. focus,closeOnEscape,holdToasts는true만 받습니다. 끄려면 필드를 생략하세요.false를 넘기면ui.open: focus is true or left out같은 오류가 납니다. 조건부로 넣으려면 조건이 맞을 때만 필드를 추가합니다.
const pane = { id: 'hello-tabs', title: 'Hello tabs' }
await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)
- Claude가 작업 중일 때도 명령으로 pane을 열려면 명령 등록 시
immediate: true를 추가합니다. 없으면 턴 중에 입력한 명령은 턴이 끝날 때까지 기다립니다.
대화상자 뒤에 toast 보류
사용자가 답하고 떠나는 대화상자 성격의 pane이면 holdToasts: true를 넘겨, 고민하는 동안 toast가 뜨지 않게 합니다. 터미널에서 그 pane이 보이는 동안 보류되고, 그사이 발생한 toast는 보류가 끝날 때까지 기다립니다. 내 mod가 $.ui.toast로 띄운 것뿐 아니라 다른 mod의 toast와 Claude Code 자체의 짧은 알림도 보류됩니다. 계속 열어 두는 pane에는 쓰지 마세요.
pane이 더 넓은 터미널을 기다리는 경우
요청받지 않고 mod가 스스로 연 pane은 좁은 터미널에서 나타나지 않습니다.
- 사용자 행동으로 열림(명령 실행, 버튼 누름 등): 너비와 무관하게 표시
- mod가 스스로 엶(타이머,
turn.starthook 등): 144열 이상에서만 표시. 사용자가 그 pane을 한 번 직접 열었다면 이후엔 110열이면 충분
표시되면 $.ui.open이 { isPlaced: true }로 resolve됩니다. 대기 중이면 isPlaced가 false이고 reason에 이유가 문자열로 들어 있습니다. 대기 중인 pane은 사용자가 열거나 터미널을 넓히면 나타납니다. pane 없이 알리기만 하려면 $.ui.toast('Your message')를 씁니다.
VS Code 확장에서 그리기
VS Code 확장에서는 확장 버전 2.1.299 이상에서 mod가 그린 것이 채팅 패널에 나타납니다. feature flag를 가져오지 않는 세션(예: Amazon Bedrock)은 2.1.304 이상이 필요합니다. 패널용 ui.render hook에서 e.surface는 vscode입니다. 패널이 그리는 site·요소와 무시하는 prop은 reference의 render sites·elements 표를 봅니다.
터미널과 패널 양쪽에서 동작하게 하려면 다음 차이를 고려합니다.
| 항목 | VS Code 패널 동작 |
|---|---|
| Pane | 800px 이상 패널: 대화 옆에 dock, e.props.placement가 'dock', e.viewport.isFullscreen이 true. 더 좁으면 프롬프트 상자 위에 그려지고 placement가 'inline', 12행까지 보이고 그 이상은 스크롤 |
| Toast·status 줄 | 프롬프트 상자 바로 위 한 스택(좁은 패널이면 pane도 함께), mod당 status 줄 하나. 권한 요청이나 대화상자가 보이는 동안 스택 숨김 |
| 라벨 | mod가 그리거나 다시 쓴 것을 프레임으로 감싸고 플러그인 이름 표시. 예: From the token-chart plugin, Edited by the token-chart plugin |
| 키보드 | 버튼 hotkey 없음. 클릭하거나 포커스 후 Enter |
| 키보드 포커스 | 패널에 포커스가 있고, Claude가 대기 중이며 대기 중인 질문이 없고, 프롬프트 상자가 비어 있을 때만 pane의 focus: true 허용 |
| 질문 대화상자 | AskUserQuestion에서 패널 자체 질문 카드를 유지하고 그 위에 내 요소를 그림. next에 넘긴 props는 카드를 바꾸지 않음 |
| 프롬프트 제안 | $.prompt.suggest는 isShown: false로 resolve되고 아무것도 표시 안 함(패널이 흐린 제안 텍스트를 Claude 자체 제안에 씀) |
| 텍스트 한도 | status 줄은 200자 또는 2행, toast는 500자에서 잘림 |
요소로 tree 만들기
ui.render hook이 반환하는 것은 element tree입니다. 상자·텍스트·컨트롤을 중첩해 무엇을 그릴지 설명하면, Claude Code가 사용자가 연 앱(터미널, Desktop 앱, VS Code 확장)에 맞게 렌더링합니다.
요소는 hook 안에서 $.ui.resolve(e)로 얻습니다(예: const { Box, Text, Button } = $.ui.resolve(e)). 각 요소는 함수이고, props를 넘기며 안에 들어갈 요소와 문자열은 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면 괄호 없이 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: 텍스트 필드. 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, borderStyle, width 등 레이아웃 prop. columnGap, padding 같은 간격 prop은 숫자 또는 'xs'~`'xl'` 단계 이름 |
전체 |
Text |
스타일 텍스트. color, bold, dimColor, italic, wrap, weight 등. 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, VS Code |
Svg |
SVG 문서 | Desktop, VS Code |
Client |
내 두 번째 파일이 그리는 영역(애니메이션·포인터 입력용). 그 파일은 mods API가 없고, 데이터를 post하면 ui.message 이벤트로 hook에 도착. 로드·그리기·실행에 실패하면 hook이 ui.fault 이벤트를 받음 |
터미널, Desktop |
Raster, Image |
색 셀 격자, 그림 | 터미널 |
- 모듈이
.tsx또는.jsx면 tree를 JSX로 써도 됩니다. 먼저$.ui.resolve(e)에서 요소를 구조 분해하세요. - 그림이 안 보이면 tree에 요소가 받지 않는 prop이나 children이 들어갈 수 없는 곳의 child가 있는지 확인하세요. 그런 tree를 반환한 hook은 실패한 것으로 처리되고,
hook skipped줄이 mod와 tree가 어긴 규칙을 알려 줍니다. pane 안의Text에bogusProp이라는 prop을 주면 다음과 같습니다.
my-mod: ui.render hook skipped: returned the wrong shape (a tree Pane refuses: Text prop "bogusProp" is not allowed)
Desktop 앱과 VS Code의 Link
Desktop 앱과 VS Code 확장에서 Link는 href가 다음을 만족하지 않으면 일반 텍스트로 그려집니다. 터미널에는 적용되지 않습니다.
- scheme과 host:
https:URL 또는http://localhost:3000같은http://localhostURL @금지: 경로나 쿼리의@는%40으로- 표기: host 뒤
/누락을 제외하면new URL(href).href가 반환하는 형태 그대로. 즉 대문자 host, 공백,https:URL의:443은 불가
Client가 실패할 때
터미널에서 Client가 실행하는 파일이 실패하면 my-mod: Client client/spinner.js: boom 같은 흐린 줄이 Client 자리에 들어가고, 나머지 그림은 그대로 보입니다. mod가 ui.fault를 처리하면 Claude Code가 site를 다시 그립니다.
색 셀 격자 그리기
터미널에서 heat map, sparkline, 게임 보드는 셀마다 Box를 쓰지 말고 Raster 하나로 그립니다. Raster는 key, 크기 columns·rows, 모든 셀을 담은 base64 문자열 cells를 받습니다. 셀 하나는 숫자 세 개(문자의 code point, 글자색, 배경색)입니다. 색은 0xc62828(빨강) 같은 16진 24비트 RGB 값이며, 그 범위 바로 위인 0x01000000은 터미널 기본색을 뜻합니다.
터미널 외 앱에는 Raster가 없으므로 e.surface를 확인해 텍스트를 그립니다. 3×2 heat map 예:
// 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) })],
})
})
터미널 pane에 위 줄은 초록·주황·빨강, 아래 줄은 초록·초록·주황 블록이 그려집니다. 바꿀 부분은 rows 배열이고 cellsOf가 이를 압축 문자열로 바꿉니다. 이 hook은 id가 heat인 pane에서만 그리므로 명령에서 $.ui.open({ id: 'heat' })로 엽니다.
- 각 문자는 1셀 너비여야 합니다.
- 이미 화면에 있는
Raster를 애니메이션하려면$.ui.blit에 paneid를requestId로,Raster의key, 같은 크기, 새 cells를 넘깁니다. 예:$.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }).ui.renderhook을 다시 실행하지 않고 그 요소만 다시 칠합니다.
누름과 입력에 반응하기
사용자가 mod가 그린 버튼을 누르거나, 필드에 입력하거나, 목록에서 고르면 Claude Code가 그 컨트롤의 콜백을 내 모듈에서 실행합니다.
Button:onPress(e).e.surface는 누름이 발생한 앱Input:onSubmit(value),onInput(value)Select:onSelect(value). 선택지는options에 넣으며, 값이 고유한 선택지가 하나 이상 있어야 함. 예:[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
key가 필요한 컨트롤
모든 Input과 Select에는 key: 'size'처럼 key prop을 주세요. Button은 label prop이나 문자열 child 하나가 있으면 key 없이도 되며, 그때 key는 label 값이나 그 문자열입니다.
테스트와 다른 mod는 key로 컨트롤을 식별합니다.
- 테스트: 테스트는 key로 컨트롤을 누르거나 입력합니다.
- 다른 mod: Claude Code는 컨트롤의 콜백을 실행하기 전에
e.element에 컨트롤의 key를 담아ui.press,ui.input,ui.select를 발생시킵니다. 그 이벤트를 처리하는 다른 mod의 hook은 내Input에 입력된 내용을 보고 바꾸거나, 내 콜백이 실행되지 않게 할 수 있습니다. mods API에는 그 mod가 내 버튼을 누를 메서드가 없습니다.
needs a key나 is drawn twice 메시지가 나오면 메시지별 원인을 보세요.
키보드 포커스와 hotkey
mod는 키보드를 직접 읽지 않습니다. 사용자가 키를 누르면 Claude Code가 어느 컨트롤 몫인지 정하고 그 콜백을 실행합니다. band의 숫자 hotkey나 action 바인딩(reference의 elements 참고)을 제외하면, 이는 내 pane이나 band에 키보드 포커스가 있을 때만 일어납니다. 그 외에는 키가 프롬프트로 갑니다. VS Code 확장에서는 버튼에 hotkey가 없어 클릭하거나 Enter를 누르고, 필드에는 타이핑합니다.
pane이 키보드 포커스를 얻는 경우
- mod가 명령이나 누름에서
focus: true로 열 때 - 사용자가 Ctrl+X 다음 Tab을 누를 때
- 사용자가 클릭할 때(터미널에서는 fullscreen 렌더링 필요)
Claude Code는 프롬프트가 비어 있고 다른 것이 포커스를 갖고 있지 않을 때만 focus: true를 허용합니다. 사용자가 입력 중일 때 열린 pane은 키 입력을 가져가지 않습니다.
포커스가 있을 때 각 키의 동작
| 키 | 동작 |
|---|---|
| Tab | 다음 컨트롤로 이동 |
| Up / Down | 그림이 다 보일 때는 컨트롤 간 이동. pane/band 행이 보이는 것보다 많으면 스크롤 |
| Enter | 포커스된 Button 누르기, Input 제출, Select에서 선택 |
| 버튼 hotkey | 그 버튼 누르기. Input에 포커스가 있으면 모든 출력 가능 키는 필드로 감 |
| Page Up, Page Down, Home, End | 행이 보이는 것보다 많을 때 pane/band 스크롤 |
| Ctrl+X 다음 방향키 | pane 크기 조절. Left/Up은 넓히고 Right/Down은 줄임. transcript 옆 pane은 터미널 너비 45%에서 멈춤 |
| Ctrl+X 다음 X | 필드에 포커스가 있어도 pane 닫기 |
| Ctrl+X 다음 Ctrl+A | mod가 band에 그리는 중이면 band를 한 줄로 접거나 다시 펼침. 프롬프트에 포커스가 있어도 동작 |
| Esc | 포커스를 프롬프트로 되돌림. closeOnEscape: true면 pane도 닫음 |
mod는 Tab이나 방향키를 다른 동작에 바인딩할 수 없습니다. 그래서 게임은 w, a, s, d로 조작합니다.
hotkey와 첫 포커스 지정
hotkey:Button을 키 하나로 누르게 하려면 숫자 한 자리나 소문자 한 글자를 줍니다(예:hotkey: 'a').autoFocus: pane이 열릴 때 포커스를 받을 컨트롤에autoFocus: true를 줍니다. VS Code 확장에서는 아무 효과가 없으며(prop이 없는 것처럼 그려짐) 코드를 바꿀 필요는 없습니다.true만 받으므로 다른 컨트롤에서는 생략합니다.
hotkey 표시 방식:
| 버튼 | 터미널 | Desktop 앱 | VS Code 확장 |
|---|---|---|---|
| 괄호 있는 기본형 | [ Add one ], hotkey 표시 없음 |
라벨 옆에 작은 키 | 라벨만, hotkey 동작 안 함 |
plain: true |
1: One |
라벨 옆에 작은 키 | 라벨만, hotkey 동작 안 함 |
터미널에서는 사용자가 무엇을 눌러야 하는지 보이도록 괄호 버튼의 라벨에 키를 적거나 plain: true를 씁니다. action, band의 숫자 hotkey, 한 hotkey에 버튼 두 개 같은 나머지 Button 규칙은 elements reference에 있습니다.
사용자가 band를 접을 때
터미널에서 mod가 band에 그리는 동안 사용자는 band를 한 줄로 접을 수 있습니다.
[-]클릭: band가 펼쳐져 있으면 첫 행 오른쪽 끝에 이 표시가 그려집니다. fullscreen 렌더링에서 클릭하면 접힙니다.- Ctrl+X 다음 Ctrl+A: 프롬프트·band·pane 어디에 포커스가 있어도 동작합니다.
abovePrompt:toggleaction으로 다른 키에 바인딩할 수 있습니다.
접힌 band는 mod들이 그린 것을 모두 숨기고, 사용자가 설정한 펼치기 키를 알려 주는 흐린 한 줄만 보여 줍니다.
▸ plugin panel hidden · ctrl+x ctrl+a or click to show
- 같은 키(fullscreen에서는 그 줄 클릭)로 다시 펼칩니다. 그때까지 band 안의 버튼은 hotkey, 빈 프롬프트에 입력한 숫자,
action키바인딩 무엇으로도 눌리지 않습니다. - 접을 때 band에 포커스가 있었다면 포커스는 프롬프트로 돌아가고, 접혀 있는 동안 Ctrl+X 다음 Tab은 band를 건너뜁니다.
/clear후에도 접힌 채 유지되고, 다음에 Claude Code를 시작하면 다시 펼쳐집니다.- mod는 band를 펼치거나 접힘 여부를 알 수 없습니다.
ui.renderhook은 계속 실행되고e.props에도 표시가 없습니다. 사용자가 반드시 답해야 하는 것을 band에만 두지 마세요.
입력을 받고 항목마다 행 그리기
텍스트 필드 아래 목록이 있는 pane이 흔합니다. 예제는 notes pane입니다. 노트를 입력하고 Enter로 추가하며, 각 노트의 x 버튼으로 삭제합니다. 노트 두 개를 추가하면 터미널에 이렇게 그려집니다.
╭────────────────────────────────────────────────────────✕─╮
│ Note: Type a note and press Enter ⏎ add │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
위 테두리의 ✕는 pane을 닫는 Claude Code 자체 표시입니다. 쓰인 기법:
- 입력 받기:
Input은 Enter를 누르면onSubmit(value), 바뀔 때마다onInput(value)를 호출 - 목록 그리기: 데이터를 항목당 한 행으로 map하고, 행마다 버튼에 고유
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',
// The text the field starts with
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. 새 행이 생기고 필드가 비워집니다.
- 노트 삭제: Tab으로 해당 노트의
x버튼에 포커스를 옮긴 뒤 Enter.x는 라벨이지 hotkey가 아니므로 글자를 눌러도 눌리지 않습니다.
변경은 hello-tabs와 같은 render cycle을 따릅니다(콜백이 notes를 바꾸고 redraw 호출, 목록을 $.store에 저장). 사용자가 입력하는 중에 hook이 pane을 다시 그려도, 매번 같은 value를 넘기면 필드는 입력 중인 텍스트를 유지합니다. 내 텍스트로 바꾸려면 다른 value를 넘깁니다. 예제는 저장만 하고 불러오지는 않으므로, 다음 세션에 복원하려면 hello-tabs가 count를 읽듯 session.start hook에서 읽습니다.
필드 줄 Note: Type a note and press Enter ⏎ add를 이루는 props:
| Prop | 예제 값 | 의미 |
|---|---|---|
label |
Note |
필드 앞 텍스트. 터미널은 뒤에 : 를 그림 |
placeholder |
Type a note and press Enter |
필드가 비어 있을 때 보이는 흐린 텍스트 |
value |
'' |
필드 시작 텍스트. 비어 있어 placeholder가 보임 |
submitLabel |
add |
⏎ 뒤에서 Enter가 하는 일을 알려 주는 단어 |
Input을 제출해도 콜백이 $.prompt.submit을 호출하지 않는 한 턴은 시작되지 않습니다.
site 다시 그리기
그림은 스냅샷입니다. ui.render hook이 마지막으로 반환한 것을 보여 주므로, 새로운 것을 보이려면 hook이 다시 실행돼야 합니다. 일부 변경은 Claude Code가 알아서 다시 그리고, 나머지는 mod가 요청합니다.
Claude Code가 알아서 다시 그리는 경우
- site의 props가 바뀌거나 터미널 너비가 바뀔 때
- site 안의
Client가 실패하고 mod가ui.fault를 처리할 때:ui.faulthook이 반환한 뒤 hook을 한 번 더 실행하므로,ui.renderhook이Client를 빼고 그릴 수 있음
타이머로 실행하지 않으며, 모듈 변수가 바뀌었는지도 알 수 없습니다.
데이터가 바뀔 때 다시 그리기
내 데이터가 바뀐 뒤에는 $.ui.invalidate('ui.render')를 호출합니다.
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에 둔 값은 쓰는 순간 그 값을 읽은 site가 다시 그려지므로 이 호출이 필요 없습니다.
타이머로 다시 그리기
시계, 카운트다운, 세션 바깥의 값을 최신으로 유지하려면 주기적으로 다시 그립니다. 모듈의 session.start hook에서 타이머를 시작합니다(hello-tabs처럼 이미 hook이 있으면 $.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)
})
이제 ui.render hook이 1초마다 실행됩니다. 타이머는 모듈이 다시 로드되면 멈추고, 새 모듈 인스턴스가 자기 타이머를 시작합니다.
site를 얼마나 자주 다시 그릴 수 있나
Claude Code가 site 다시 그리기를 throttle하므로, 데이터가 바뀔 때마다 $.ui.invalidate를 호출해도 됩니다. site별 빈도는 reference의 limits 표를 봅니다. 한도보다 빠른 호출은 한 번의 다시 그리기로 합쳐지고, 그때 hook은 그 순간의 데이터를 읽습니다. 그래서 최신 값만 보이고 중간 값은 보이지 않으며, 애니메이션도 한도보다 빠를 수 없습니다.
상태 유지
값을 어디에 두느냐에 따라 유지 기간이 달라집니다.
| 보관 위치 | 유지 기간 | 용도 |
|---|---|---|
| 모듈 수준 변수 | 모듈 재로드까지(개발 중 파일을 저장할 때마다 재로드) | 잃어도 되는 값. hello-tabs의 tab |
$.state |
세션 종료, 또는 사용자가 /clear, /resume, /branch 실행 시까지 |
재로드 후에도 남아야 하는, 그림이 의존하는 값 |
$.store |
mod가 삭제하거나, 어떤 세션도 cleanupPeriodDays 동안 저장소를 읽거나 쓰지 않을 때까지. ~/.claude/plugins/store/ 아래 플러그인 전용 JSON 파일로 저장되는 key-value 저장소 |
설정, 기록 등 사용자가 다음에도 찾을 것 |
$.store.get(key)는 값 또는 undefined로 resolve되고, $.store.set(key, value)는 아무 JSON 값이나 받습니다.
$.state에 값 보관
$.state는 세션 동안 값을 보관하고 알아서 다시 그립니다. 반응형 상태라서 값을 읽은 ui.render hook이 그 값을 구독하고, 값을 쓸 때마다 Claude Code가 해당 site를 다시 그립니다. $.ui.invalidate를 호출할 필요가 없고, 변수와 달리 모듈 재로드 후에도 남습니다.
설정 순서: 값 선언 → manifest가 선언을 가리키게 함 → 값 정의·사용. 예제는 hello-tabs의 count를 $.state로 옮깁니다.
값 선언: 타입 선언 파일에서, 바깥 키는 플러그인 이름, 그 아래 항목은 값과 타입입니다. hello-tabs/types/index.d.ts:
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
manifest가 선언을 가리키게 하기: claude plugin validate가 코드를 이 파일과 대조하도록 manifest에 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는 값을 씁니다. 세 helper가 $.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 hook이 count를 읽었으므로, 버튼이 값을 쓸 때마다 hook이 다시 실행됩니다. 규칙:
plugin과key는 문자열 리터럴로:claude plugin validate가 소스에서 읽습니다.- 각
atom결과는const에 보관:let으로 선언하면takes a source the scan can read로 검증 실패 - 모든 값을 타입 선언 파일에 선언: 아니면
hello-tabs.count is not declared로 검증 실패 - 쓰기는 콜백이나 다른 이벤트의 hook에서:
ui.renderhook은 상태를 읽을 수만 있고 쓸 수 없으므로onPress,onSubmit, 다른 이벤트 hook에서 씁니다.
hello-tabs를 $.state로 바꾸기: count를 쓰는 모든 줄을 바꿉니다.
- 모듈 상단:
import줄 추가,let count = 0을atom줄로 교체 ui.renderhook:tabButton앞에read줄 추가,Text에'Count: ' + n표시- Add one 버튼:
onPress를 여러 세션에서 저장하기의 것으로 교체(저장과 쓰기를 함께 함) session.starthook:saved를 읽는 두 줄을/clear후 저장값 다시 불러오기의loadCount호출로 교체
tab은 여전히 변수이므로 탭 버튼용 redraw는 유지합니다.
/clear 후 저장값 다시 불러오기
session.start에서 $.store 값을 $.state로 복사하는 mod는 /clear, /resume, /branch 후에 다시 복사해야 합니다. 이 명령들은 모든 $.state 값을 기본값으로 되돌리고, session.start는 다시 발생하지 않습니다. 대신 classic.SessionStart는 각 명령 뒤에 e.source가 clear, resume, fork로 설정되어 발생하므로, 여기서 다시 복사합니다. 그러지 않으면 그림이 기본값을 보여 주고, $.state 값을 저장하는 콜백이 저장소에 기본값을 덮어씁니다.
아래 코드는 두 hook에서 count를 불러옵니다. count가 atom이고 update를 import한 $.state 버전 hello-tabs 기준입니다. loadCount는 register 위에 두고, 기존 session.start hook에 loadCount 호출을 추가합니다. classic.SessionStart는 시작 시와 compaction 후에도 발생하는데 이때는 $.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)
})
두 hook이 있으면 /clear 후에도 pane이 0이 아니라 저장된 카운트를 보여 주고, 다음 Add one이 저장값에 더합니다. loadCount는 저장값으로 $.state 값을 덮어쓰고, session.start는 모듈이 재로드될 때마다 다시 발생합니다. 저장소가 뒤처지지 않도록 Add one 버튼처럼 변경할 때마다 저장하세요. 세션 없이 재로드를 확인하려면 /clear 후 그림 테스트를 봅니다.
여러 세션에서 저장하기
내 컴퓨터에서 mod를 실행하는 모든 세션이 하나의 $.store를 공유합니다. get 후 set은 atomic하지 않습니다. 두 세션이 각각 값을 읽고 바꿔 다시 쓰면 경쟁이 생겨 두 번째 쓰기가 첫 번째를 대체합니다. 가능성을 줄이려면:
- 항목마다 별도 key:
set은 자기 key만 바꾸므로, 다른 key를 쓰는 세션끼리는 덮어쓰지 않습니다. - 쓰기 직전에 다시 읽기: 여러 세션이 바꾸는 값은
session.start에서 불러온 사본이 아니라 콜백 안에서get한 값으로 새 값을 만듭니다. 그래도 내get과set사이에 들어온 다른 세션의 쓰기는 잃습니다.
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)
}
이 세션이 시작된 뒤 두 번째 세션이 버튼을 세 번 눌렀다면, 이번 누름은 그 세 번을 포함한 카운트를 보여 주고 저장합니다.
체크리스트
-
ui.renderhook에{ component: ... }matcher를 주고, pane이면e.requestId를 확인해 다른 mod의 pane은next(e)로 넘겼다. - 상태를 바꾸는 콜백마다
$.ui.invalidate('ui.render')를 호출하거나$.state를 썼다. -
$.ui.open의focus·closeOnEscape·holdToasts에false를 넘기지 않고 생략했다. - mod가 스스로 여는 pane은 좁은 터미널(144열 미만)에서 대기할 수 있음을 고려해
isPlaced를 확인하거나 toast로 알렸다. - 컨트롤마다 고유
key를 주었고, 터미널에서 hotkey가 보이도록 라벨이나plain: true를 썼다. - 터미널 전용 요소(
Raster,Image)나 Desktop/VS Code 전용 요소(Svg)는e.surface로 분기했다. - 그림이 안 보이면 transcript나 debug log의
ui.render hook skipped:줄을 확인했다. -
$.state를 쓴다면 타입 선언·manifesttypes·classic.SessionStart재로드까지 갖췄다. - 사용자가 반드시 답해야 하는 내용을 band에만 두지 않았다.
다음 단계
- React to events: 도구 호출과 턴으로 그림에 데이터를 공급합니다.
- Use the mods API: 타이머와 모델 호출로 그림에 데이터를 공급합니다.
- Test a mod: 테스트에서 버튼을 누르고 여러 surface에서 확인합니다.
- Mods reference: site별 props와 요소별 props를 확인합니다.
- Interface gallery: 요소별 샘플 코드를 봅니다.
기준일
이 문서는 2026-11-03에 공식 Draw in the interface with a mod 페이지를 기준으로 작성했습니다. VS Code 확장 버전 요건(2.1.299, 2.1.304)은 공식 원문에 명시된 값입니다.