모드로 인터페이스에 그리기
Claude Code 모드에서 창, 프롬프트 위의 밴드, 버튼, 텍스트 필드를 그리고, 누름과 입력을 처리하며, 다시 그릴 때와 세션 간에 상태를 유지합니다.
모드는 Claude Code에서 자신의 인터페이스를 그릴 수 있고 Claude Code가 이미 그리는 인터페이스의 일부를 변경할 수 있습니다. 모드가 그릴 수 있는 각 위치를 렌더 사이트라고 하며, 창, 프롬프트 위의 밴드, 또는 스피너 같은 것들이 있습니다. Claude Code는 렌더 사이트를 그리려고 할 때마다 ui.render 이벤트를 발생시키고, 해당 이벤트에 대한 훅이 그곳에 그릴 내용을 반환합니다.
이 지도는 터미널 세션에서 모드가 그릴 수 있는 위치를 보여줍니다:
더 좁은 터미널에서는 창이 대화 기록 옆이 아니라 프롬프트 위에 있습니다.
여기서 시작하기 전에 첫 번째 모드를 만드세요. 두 개의 탭과 카운터가 있는 창을 만드는 작업 예제로 시작한 다음, 변경하려는 각 부분에 대한 섹션을 읽으세요.
한 가지 속성이나 제한을 찾으려면 참조를 보세요.
탭이 있는 창 만들기
이 섹션에서는 /hello-tabs 명령을 추가하는 모드를 만들고, 이 명령은 창을 엽니다. 창은 넓은 전체 화면 터미널에서는 대화 기록 옆의 사이드바이거나, 그렇지 않으면 프롬프트 위의 프레임된 영역입니다. 이 창은 두 개의 탭을 표시하고, 두 번째 탭에는 카운터에 1을 더하는 버튼이 있습니다. Claude Code를 다시 시작한 후에도 카운트는 여전히 있습니다.
완성된 모드는 다음과 같습니다. 녹화는 창을 열고, 두 번째 탭으로 전환하고, 버튼을 몇 번 누르고, 첫 번째 탭으로 돌아갑니다:
Claude Code에는 기본 제공 탭 요소가 없으므로 탭은 행의 두 버튼입니다. 모드는 어느 것이 활성인지 추적하고 그 탭의 내용을 행 아래에 그립니다.
플러그인 만들기
모드는 매니페스트, 코드를 가리키는 hooks.json, 그리고 코드 파일이 있는 플러그인입니다. 모드 만들기는 각각을 설명합니다. hello-tabs라는 디렉토리를 만들고 그 안에 .claude-plugin과 hooks 디렉토리를 만든 다음 처음 두 파일을 저장하세요.
매니페스트를 hello-tabs/.claude-plugin/plugin.json으로 저장하세요:
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" }
}
hello-tabs/hooks/hooks.json에서 진입점의 이름을 지정하세요:
{
"modules": ["./register.js"]
}
코드 작성
코드는 각 훅에서 하나씩 세 가지 작업을 수행합니다:
/hello-tabs명령 추가- 해당 명령을 실행할 때 창 열기
- 창의 내용 그리기: 탭 행과 열린 탭의 본문
두 개의 모듈 수준 변수인 tab과 count는 창의 상태를 유지합니다.
이를 hello-tabs/hooks/register.js로 저장하세요:
// 창의 id, 창을 열고 그릴 때 인식하는 데 사용됨
const PANE = 'hello-tabs'
// 창이 표시하는 것: 어느 탭이 열려 있는지, 그리고 카운터의 값
let tab = 'one'
let count = 0
export function register(on) {
// 첫 프롬프트 전에 실행되고, 다시 로드 후에도 실행됨
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// 이전 세션이 저장한 카운트를 로드합니다 (있는 경우)
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// /hello-tabs를 입력할 때 실행됨
on('command.run', { command: 'hello-tabs' }, async ($) => {
// 창을 열고, 키보드를 주고, Esc로 닫을 수 있게 함
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// 대화 기록에 아무것도 인쇄하지 않음
return {}
})
// Claude Code가 창을 그릴 때마다 실행됨
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// 다른 모드의 창은 그대로 두기
if (e.requestId !== PANE) return next(e)
// 이 앱이 그릴 수 있는 요소 가져오기
const { Box, Text, Button } = $.ui.resolve(e)
// Claude Code가 이 훅을 다시 실행하도록 요청
const redraw = () => $.ui.invalidate('ui.render')
// 한 탭: 누르면 해당 탭으로 전환하는 버튼
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// 열려 있지 않은 탭을 어둡게 함
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// 어느 탭이 열려 있는지에 따라 탭 아래에 가는 것
const body =
tab === 'one'
? [Text({ children: ['This is the first tab.'] })]
: [
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
hotkey: 'a',
onPress: async () => {
count += 1
redraw()
// 카운트를 저장하여 다시 시작 후에도 있도록 함
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// 전체 창: 탭 행, 빈 줄, 그 다음 본문
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}
각 훅은 코드가 명확하게 하지 않는 것도 수행합니다:
- **
session.start**는 또한$.store에서 저장된 카운트를 읽습니다. 이는 세션 간에 지속되는 키-값 저장소입니다. - **
command.run**은 Claude Code에 창이 존재한다고만 알립니다. 창을 열어도 아무것도 그려지지 않습니다: Claude Code는 그 다음ui.render를 발생시켜 그곳에 무엇을 넣을지 묻습니다. - **
ui.render**는 요소 트리를 반환합니다. 이는 다른 상자, 텍스트, 버튼을 보유하는Box이며, 실행될 때마다tab과count에서 다시 빌드합니다.
버튼을 누르면 onPress 콜백이 실행되고, 이는 변수를 변경하고 redraw를 호출합니다. Claude Code는 그 다음 ui.render 훅을 다시 실행하고, 훅은 새로운 값에서 새로운 트리를 빌드합니다. 모든 대화형 그리기는 그 렌더 사이클을 사용합니다: 콜백이 상태를 변경하고, 훅이 새로운 상태에서 다시 렌더링합니다.
창 열기
셸에서 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를 실행하세요. 카운트는 남겨둔 곳에 있습니다.
카운트를 지우려면 모드가 $.store.delete('count')를 호출하도록 하세요. 상태 유지는 각 종류의 값이 얼마나 오래 지속되는지를 다룹니다.
그리기 위치 선택
ui.render 훅은 원하는 위치로 좁히지 않는 한 모든 렌더 사이트에서 실행됩니다. 렌더 사이트를 선택하려면 매처라고 불리는 필터를 on의 두 번째 인수로 전달합니다. { component: 'Pane' }은 훅을 창에서만 실행합니다. 훅에서 e.component는 사이트의 이름을 지정하고, e.surface는 어떤 앱이 그리고 있는지 나타내며, e.props는 사이트 자체의 데이터를 보유합니다. 창의 경우 e.requestId는 열 때 사용한 id입니다.
두 사이트는 모드가 채울 때까지 비어 있으며, 창과 밴드입니다. 탭을 선택하여 각각이 무엇인지, 그리고 어떻게 그리는지 확인합니다:
창은 넓은 전체 화면 터미널에서 대화 옆의 사이드바이거나, 그렇지 않으면 프롬프트 위의 프레임 영역입니다. 여러 창이 열려 있으면 각각 제목을 표시하는 탭을 가집니다.
창은 모드가 $.ui.open을 id와 함께 호출할 때 나타나며, 예를 들어 $.ui.open({ id: 'hello-tabs' })입니다. 올바른 시간에 창 열기는 다른 필드와 창이 더 넓은 터미널을 기다릴 때를 다룹니다.
창에 그리려면 { component: 'Pane' }으로 필터링하고 e.requestId가 id인지 확인합니다.
Claude Code가 이미 그리는 것 변경
Claude Code는 대부분의 인터페이스를 자체적으로 그립니다: 메시지, 도구 호출 행, 스피너 등. 이러한 각 부분도 렌더 사이트이므로 모드는 이를 다시 스타일링하거나 대체할 수 있습니다. 하나를 변경하려면 이 표의 이름으로 ui.render 훅을 필터링합니다:
| Site | What it is |
|---|---|
UserMessage, AssistantMessage |
대화의 메시지 |
ToolUse, ToolResult, ToolGroup |
도구 호출의 행, 그 결과, 그리고 접힌 호출 실행 |
CommandOutput |
명령이 인쇄한 행 |
AskUserQuestion |
Claude가 질문을 하기 위해 열 수 있는 대화 |
Spinner, ToolProgress, TurnDuration |
턴의 상태 줄: Claude가 작업하는 동안 애니메이션되는 줄, 실행 중인 도구의 실시간 진행 줄, 턴을 닫는 줄 |
InfoNotice, SessionMode, PromptHint |
로고 아래의 상태 줄, 바닥글의 모드 레이블, 프롬프트 아래의 힌트 줄 |
Claude Code가 이미 그리는 사이트에서 훅에는 세 가지 선택이 있습니다: 세부 사항 변경, 그리기 대체, 또는 그대로 두기. 탭을 선택하여 각각을 스피너에 적용한 것을 확인합니다. 예제는 튜토리얼 모드처럼 다른 훅이 계산하는 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 + '…' } })
})
첫 번째 도구 호출 전에 스피너는 모드 없이 하는 방식으로 보입니다:
Thinking…
권한 프롬프트는 렌더 사이트가 아니므로 모드는 표시되는 것을 변경할 수 없습니다. 질문 대화 AskUserQuestion은 하나이므로 모드는 그것을 변경할 수 있습니다.
터미널과 Desktop 앱은 모두 동일한 사이트를 발생시키지 않습니다. Pane, AbovePrompt, Spinner, 그리고 대화 사이트는 둘 다에서 작동합니다. 몇 가지 다른 상태 줄은 터미널에서만 발생합니다. 렌더 사이트 표는 각각이 발생하는 위치를 나열합니다.
올바른 시간에 창 열기
창은 모드가 열 때만 나타납니다. 어떻게 그리고 언제 열 것인지는 키보드 포커스를 받는지, 얼마나 많은 공간을 요청하는지, 그리고 좁은 터미널에서 전혀 표시되는지 여부를 결정합니다.
창을 열려면 선택한 id로 $.ui.open을 호출합니다. id는 창의 이름입니다: ui.render 훅이 확인하고, 창을 닫을 때 다시 전달합니다.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
창을 닫으려면 열 때 사용한 id로 $.ui.close를 호출합니다:
await $.ui.close({ id: 'hello-tabs' })
id 외에도 $.ui.open은 다음 선택적 필드를 사용합니다:
| Field | What it does |
|---|---|
title |
둘 이상의 창이 열려 있을 때 창의 탭 레이블 |
focus |
키보드 포커스 요청 |
closeOnEscape |
Esc가 창을 닫게 합니다. true를 전달하거나 필드를 생략합니다. Claude Code는 false를 거부하기 때문입니다. |
holdToasts |
창이 닫힐 때까지 $.ui.toast의 작은 알림인 토스트를 유지합니다 |
rows |
창이 프롬프트 위에 있을 때 요청할 높이입니다. 기본값은 공간의 1/3입니다. |
columns |
창이 대화 옆에 있을 때 요청할 너비입니다 |
Claude가 작업하는 동안 명령이 창을 열 수 있도록 하려면 명령을 등록할 때 immediate: true를 추가합니다. 없으면 턴 중에 입력된 명령은 턴이 끝날 때까지 기다립니다.
창이 더 넓은 터미널을 기다릴 때
모드가 요청받지 않고 열 수 있는 창은 좁은 터미널에 나타나지 않으므로 작은 화면을 차지할 수 없습니다. 나타나는지 여부는 열 수 있는 것에 따라 다릅니다:
- 사용자가 한 것, 예를 들어 실행한 명령이나 누른 버튼과 같이 사용자가 한 것으로 열린 경우, 창은 모든 너비에서 나타납니다
- 모드가 자체적으로 작동하여 열린 경우, 예를 들어 타이머 또는
turn.start훅에서, 창은 최소 144개 열의 터미널에서만 나타납니다. 사용자가 그 창을 직접 한 번 열었으면 110개 열이면 충분합니다.
창이 나타나면 $.ui.open은 { isPlaced: true }로 해결됩니다. 창이 대기 중이면 isPlaced는 false이고 reason은 이유를 설명하는 문자열입니다. 대기 중인 창은 사용자가 열거나 터미널을 넓힐 때 나타납니다. 창을 열지 않고 무언가를 사용할 수 있다고 말하려면 $.ui.toast('Your message')를 호출합니다. 이는 몇 초 후에 사라지는 작은 알림을 표시합니다.
요소에서 트리 만들기
ui.render 훅이 반환하는 것은 요소 트리입니다: 상자, 텍스트, 그리고 서로 중첩된 컨트롤로 만든 그릴 것의 설명. 그리기를 설명하고, Claude Code는 터미널이나 데스크톱 앱에서 렌더링합니다.
요소를 얻으려면 훅에서 $.ui.resolve(e)를 호출하세요. 예를 들어 const { Box, Text, Button } = $.ui.resolve(e). 각 요소는 함수입니다. 속성을 전달하고, 그 안에 가는 요소와 문자열을 children에 넣습니다.
대부분의 그리기는 네 가지 요소를 사용합니다. 탭을 선택하여 각각과 터미널이 어떻게 그리는지 보세요:
Text는 bold와 color 같은 선택적 스타일링으로 문자열을 그립니다:
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box는 그 안에 있는 것을 행이나 열로 배열합니다. 이것은 버튼과 텍스트 줄을 나란히 놓고, 두 열 떨어져 있습니다:
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'more', label: 'Add one', onPress: addOne }),
Text({ children: ['Count: 0'] }),
],
})
[ Add one ] Count: 0
Button은 사용자가 누를 수 있는 컨트롤입니다. onPress 콜백을 실행합니다. plain: true로 괄호가 없고 핫키를 표시합니다:
Button({ key: 'more', label: 'Add one', onPress: addOne })
Button({ key: 'tab-one', label: 'One', hotkey: '1', plain: true, onPress: showTabOne })
[ Add one ]
1: One
Input은 텍스트 필드입니다. 사용자가 Enter를 누르면 텍스트로 onSubmit 콜백을 실행합니다:
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
value: '',
submitLabel: 'add',
onSubmit: addNote,
})
Note: Type a note and press Enter ⏎ add
이 표는 모든 요소를 나열합니다:
| 요소 | 무엇을 그리는가 | 어디서 |
|---|---|---|
Box |
플렉스 컨테이너. flexDirection, columnGap, padding, borderStyle, width 같은 레이아웃 속성을 사용합니다. |
모든 곳 |
Text |
스타일이 지정된 텍스트. color, bold, dimColor, italic, wrap을 사용합니다. color는 테마 키 또는 'red' 같은 색입니다. wrap은 'wrap', 'truncate', 'truncate-start', 'truncate-middle', 또는 'truncate-end'입니다. |
모든 곳 |
Button |
onPress를 호출하는 컨트롤 |
모든 곳 |
Link, Code, Markdown |
href와 선택적 label이 있는 링크, 코드 블록, Claude의 답변 방식으로 포맷된 텍스트. Markdown은 children이 아닌 text 속성에서 내용을 가져오고, onLinkPress를 전달할 때 key가 필요합니다. |
모든 곳 |
Input, Select |
텍스트 필드와 선택기 | 터미널, 데스크톱 |
Svg |
SVG 문서 | 데스크톱 |
Client |
두 번째 파일로 그려진 영역, 애니메이션과 포인터 입력용. 그 파일은 모드 API를 받지 않습니다. 데이터를 게시하여 훅에만 도달하며, 이는 ui.message 이벤트로 도착합니다. |
터미널, 데스크톱 |
Raster, Image |
색상 셀의 그리드, 그리고 그림 | 터미널 |
모듈이 .tsx 또는 .jsx 파일이면 트리를 JSX로 쓸 수 있습니다. $.ui.resolve(e)에서 요소를 분해하세요. 훅 모듈에는 요소 전역이 없기 때문입니다.
트리가 앱이 없는 요소, 요소가 사용하지 않는 속성, 또는 아무것도 가지 않는 곳에 자식을 사용하면 Claude Code는 자신의 버전의 사이트를 그립니다.
--plugin-dir로 시작한 세션에서 대화 기록 줄은 그렇게 말합니다. 예를 들어 ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own. 디버그 로그는 ui.render (Pane): a hook returned a tree that does not validate로 같은 이유로 기록합니다. 세션에 다른 것도 나타나지 않으므로 그리기가 표시되지 않으면 그 줄이나 로그를 확인하세요.
색상 셀의 그리드 그리기
열 지도, 스파크라인, 또는 터미널의 게임 보드의 경우 각 셀에 대해 하나의 Box가 아닌 하나의 Raster를 그리세요. Raster는 key, columns과 rows의 크기, 그리고 모든 셀을 하나의 문자열로 압축하는 cells를 사용합니다. 각 셀은 세 개의 숫자입니다: 문자의 코드 포인트, 색, 배경색. 색은 빨강, 녹색, 파랑 각각 두 자리의 16진수 숫자입니다. 예를 들어 빨강의 경우 0xc62828, 터미널의 기본값의 경우 0x01000000.
데스크톱 앱에는 Raster가 없으므로 e.surface를 확인하고 거기에 텍스트를 그리세요. 이 창 본문은 3x2 열 지도를 그립니다:
// "터미널의 기본 색을 사용"을 의미하는 값
const DEFAULT_COLOR = 0x01000000
// [문자, 색] 쌍의 행을 Raster가 사용하는 하나의 문자열로 압축
// 한 셀은 세 개의 숫자: 문자의 코드 포인트, 색, 배경색
function cellsOf(rows) {
const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
}
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// id가 'heat'인 창에서만 그리기
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// 3개의 셀 각각이 블록 문자와 색인 2행
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를 key로, 같은 크기, 그리고 새로운 셀로 $.ui.blit을 호출하세요. 이 예제의 경우 $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) })입니다. 그것은 ui.render 훅을 다시 실행하지 않고 그 하나의 요소를 다시 칠합니다.
누름과 입력에 응답
사용자가 버튼을 누르거나, 필드에 입력하거나, 모드가 그린 목록에서 선택하면 Claude Code는 해당 컨트롤에 준 함수를 호출하고, 모듈에서 실행됩니다. 각 컨트롤은 자신의 콜백을 사용합니다:
Button:onPress(e)를 사용합니다. 여기서e.surface는 누름이 온 앱입니다Input:onSubmit(value)와onInput(value)를 사용합니다Select:onSelect(value)를 사용합니다. 선택지는options에 있으며, 고유한 값을 가진 최소 하나의 선택지 목록입니다. 예를 들어[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
테스트는 key로 컨트롤을 누르거나 입력하므로 각각에 하나를 주세요. 컨트롤의 각 사용은 또한 ui.press, ui.input, 또는 ui.select를 e.element의 key로 발생시키고, 다른 모드는 이러한 이벤트를 훅할 수 있습니다. 그 훅은 콜백 전에 실행되므로 사용자가 Input에 입력하는 것을 보고 변경하거나 콜백 대신 답할 수 있습니다. 모드 API에는 다른 모드의 버튼을 누르는 메서드가 없습니다.
키보드 포커스와 핫키
모드는 절대 키보드를 직접 읽지 않습니다. 사용자가 키를 누르고, Claude Code는 어느 컨트롤이 그것인지 결정하고, 그 컨트롤의 콜백이 실행됩니다. 밴드의 숫자 핫키를 제외하고, 이는 창이나 밴드가 키보드 포커스를 가질 때만 발생합니다. 나머지 시간에는 키가 프롬프트로 갑니다.
창이 키보드 포커스를 얻는 방법
창은 세 가지 방법 중 하나로 키보드 포커스를 얻습니다:
- 모드가 명령이나 누름에서
focus: true로 열기 - 사용자가 Ctrl+X를 누른 다음 Tab
- 사용자가 클릭
Claude Code는 프롬프트가 비어 있고 다른 것이 키보드 포커스를 가지지 않을 때만 focus: true를 부여합니다. 사용자가 입력하는 동안 열리는 창은 그들의 키 입력을 가져가지 않습니다.
각 키가 하는 것
이 표는 창이나 밴드가 키보드 포커스를 가질 때 키가 하는 것을 나열합니다:
| 키 | 무엇을 하는가 |
|---|---|
| Tab | 다음 컨트롤로 이동 |
| 위 및 아래 | 그리기가 맞을 때 컨트롤 간 이동. 창이나 밴드가 표시할 수 있는 것보다 더 많은 행을 가지면 스크롤합니다. |
| Enter | 포커스된 Button을 누르거나, 포커스된 Input을 제출하거나, Select에서 선택 |
| 버튼의 핫키 | 그 버튼을 누릅니다. Input이 포커스를 가질 때 모든 인쇄 가능한 키는 필드로 갑니다. |
| Esc | 키보드 포커스를 프롬프트로 반환합니다. closeOnEscape: true로 창도 닫습니다. |
모드는 Tab이나 화살표 키를 다른 것에 바인드할 수 없으므로 게임은 w, a, s, d로 조종합니다.
핫키와 첫 포커스 설정
컨트롤의 두 속성이 키보드가 어떻게 도달하는지 결정합니다:
hotkey: 사용자가Button을 한 키로 누르도록 하려면hotkey: 'a'처럼 한 자리 또는 한 소문자의hotkey를 주세요autoFocus: 창이 열릴 때 어느 컨트롤이 포커스를 가지는지 선택하려면autoFocus: true를 추가하세요. 다른 것에서는 속성을 생략하세요. Claude Code는autoFocus: false를 거부합니다.
핫키가 표시되는 방식은 버튼과 앱에 따라 다릅니다:
| 버튼 | 터미널에서 | 데스크톱 앱에서 |
|---|---|---|
| 괄호 포함, 기본값 | [ Add one ], 핫키 표시 없음 |
옆에 작은 키가 있는 레이블 |
plain: true 포함 |
1: One |
옆에 작은 키가 있는 레이블 |
터미널에서 괄호가 있는 버튼의 레이블에 키의 이름을 지정하거나 plain: true를 사용하여 사용자가 누를 것을 볼 수 있도록 하세요. 요소 참조는 다른 Button 규칙을 가집니다: action, 밴드의 숫자 핫키, 그리고 한 핫키의 두 버튼.
입력된 텍스트를 가져오고 각 항목에 대해 행을 그리기
많은 창은 텍스트 필드와 그 아래 목록입니다. 이 섹션의 예제는 노트 창입니다: 노트를 입력하고 Enter를 눌러 추가하고, 각 노트에는 삭제하는 x 버튼이 있습니다. 두 개의 노트가 추가되면 터미널은 창을 이렇게 그립니다:
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
예제는 두 가지 기술을 사용합니다:
- 입력된 텍스트 가져오기:
Input은 사용자가 Enter를 누르면 필드의 텍스트로onSubmit(value)를 호출하고, 모든 변경에onInput(value)를 호출합니다 - 목록 그리기: 데이터를 각각 하나의 행으로 매핑하고, 모든 행의 버튼에 자신의
key를 주세요
이 훅은 창의 내용을 그립니다:
// 창이 그리는 목록
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// id가 'notes'인 창에서만 그리기
if (e.requestId !== 'notes') return next(e)
const { Box, Text, Button, Input } = $.ui.resolve(e)
const redraw = () => $.ui.invalidate('ui.render')
return Box({
flexDirection: 'column',
children: [
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
// 매번 필드를 비워서 그리기, 제출 후 지우기
value: '',
submitLabel: 'add',
autoFocus: true,
// 필드에서 Enter를 누를 때 실행
onSubmit: async (value) => {
// 빈 줄 무시
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// 각 노트에 대해 한 행: 삭제 버튼, 그 다음 노트의 텍스트
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// 자신의 키, 각 행의 버튼을 구별할 수 있도록
key: 'delete-' + i,
label: 'x',
plain: true,
onPress: async () => {
notes = notes.filter((_, j) => j !== i)
redraw()
await $.store.set('notes', notes)
},
}),
Text({ children: [note] }),
],
}),
),
],
})
})
창을 시도하려면:
- 노트 추가: 줄을 입력하고 Enter를 누르세요. 줄이 새 행으로 나타나고 필드가 비워집니다.
- 노트 삭제: Tab을 누르다가 노트의
x버튼이 포커스를 가질 때까지, 그 다음 Enter를 누르세요.x는 버튼의 레이블이고 핫키가 아니므로 문자를 입력해도 누르지 않습니다.
각 변경은 hello-tabs와 같은 렌더 사이클을 따릅니다: 콜백이 notes를 변경하고, redraw를 호출하고, 목록을 $.store에 저장합니다.
필드는 value 속성 때문에 각 제출 후 비워집니다. value는 필드가 그려질 때 보유하는 텍스트이고, 사용자의 입력은 훅이 필드를 다시 그릴 때까지 그것을 대체합니다. 예제는 항상 필드를 ''로 그립니다.
예제는 노트를 저장하고 로드하지 않습니다. 다음 세션에서 그들을 다시 가져오려면 hello-tabs가 count를 읽는 방식처럼 session.start 훅에서 읽으세요.
세 가지 속성이 필드의 줄을 구성합니다. Note: Type a note and press Enter ⏎ add:
| 속성 | 예제에서 | 무엇인가 |
|---|---|---|
label |
Note |
필드 앞의 텍스트. 터미널은 그 뒤에 : 를 그립니다. |
placeholder |
Type a note and press Enter |
필드가 비어 있는 동안 표시되는 흐린 텍스트 |
submitLabel |
add |
⏎ 뒤의 단어로 Enter가 하는 것을 말합니다 |
Input을 제출해도 $.prompt.submit을 호출하지 않으면 턴을 시작하지 않습니다.
사이트 다시 그리기
그리기는 스냅샷입니다: 훅이 마지막으로 실행되었을 때 ui.render 훅이 반환한 것을 보여줍니다. 새로운 것을 표시하려면 훅이 다시 실행되어야 합니다. Claude Code는 일부 변경에 대해 다시 실행하고, 모드는 나머지를 요청합니다.
Claude Code가 요청받지 않고 다시 그릴 때
Claude Code는 사이트의 속성이 변경되거나 터미널의 너비가 변경될 때 ui.render 훅을 다시 실행합니다. 타이머에서 훅을 실행하지 않고, 모듈의 변수가 변경되는 것을 알 수 없습니다.
데이터가 변경될 때 다시 그리기
데이터가 변경된 후 사이트를 다시 그리려면 $.ui.invalidate('ui.render')를 호출하세요. 이 창은 누름을 세습니다. 버튼의 콜백은 count를 변경한 다음 다시 그리기를 요청합니다:
let count = 0
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== 'counter') return next(e)
const { Box, Text, Button } = $.ui.resolve(e)
return Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
onPress: () => {
count += 1
// 데이터가 변경되었으므로 Claude Code에 창을 다시 그리도록 요청
$.ui.invalidate('ui.render')
},
}),
Text({ children: ['Count: ' + count] }),
],
})
})
각 누름은 창의 숫자를 올립니다. hello-tabs 예제는 같은 호출을 redraw 함수로 래핑합니다.
$.state에 보관하는 값은 호출이 필요하지 않습니다. 값을 쓰면 그것을 읽는 사이트가 다시 그려지기 때문입니다.
타이머에서 다시 그리기
시계, 카운트다운, 또는 세션 외부의 값을 최신 상태로 유지하려면 일정에 따라 다시 그리세요. 모듈의 session.start 훅에서 타이머를 시작하세요. 모듈이 이미 하나를 가지고 있으면, hello-tabs처럼, $.clock.every 줄을 추가하세요:
on('session.start', async ($, e, next) => {
// 1000밀리초마다 Claude Code에 사이트를 다시 그리도록 요청
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})
Claude Code는 이제 ui.render 훅을 초당 한 번 실행합니다. 모듈이 다시 로드되면 타이머가 중지되고, 새 복사본이 자신의 것을 시작합니다.
사이트가 얼마나 자주 다시 그릴 수 있는가
Claude Code는 사이트가 얼마나 자주 다시 그려지는지 제한하므로 모드는 데이터가 변경될 때마다 $.ui.invalidate를 호출할 수 있습니다. 보이는 창과 밴드는 다른 사이트보다 높은 제한을 가지고, 제한 표는 숫자를 가집니다.
제한보다 빠르게 오는 호출은 하나의 다시 그리기로 결합됩니다. 그 다시 그리기는 훅을 한 번 실행하고, 훅은 그 순간의 데이터를 읽으므로 최신 값이 표시되고 그 사이의 값은 표시되지 않습니다. 애니메이션은 제한보다 빠르게 실행될 수 없습니다.
상태 유지
모드는 값을 유지하는 세 곳이 있고, 값이 얼마나 오래 지속되는지에 따라 다릅니다: 모듈이 다시 로드될 때까지, 세션이 끝날 때까지, 또는 한 세션에서 다음 세션까지. 값이 얼마나 오래 지속되어야 하는지에 따라 선택하세요:
| 여기에 유지 | 지속되는 기간 | 사용 대상 |
|---|---|---|
| 모듈 수준 변수 | 모듈이 다시 로드될 때까지. 개발 중에 파일을 저장할 때마다 발생 | hello-tabs의 tab처럼 잃을 수 있는 값 |
$.state |
세션이 끝나거나 사용자가 /clear, /resume, 또는 /branch를 실행할 때까지 |
그리기가 의존하는 값으로 다시 로드를 생존해야 함 |
$.store |
모드가 삭제하거나, 세션이 cleanupPeriodDays 동안 저장소를 읽거나 쓰지 않을 때까지. 저장소는 ~/.claude/plugins/store/ 아래 플러그인 자신의 JSON 파일로 저장되는 키-값 저장소입니다. |
설정, 기록, 사용자가 다음에 찾을 것으로 예상하는 모든 것 |
$.store.get(key)는 값 또는 undefined로 해결되고, $.store.set(key, value)는 모든 JSON 값을 사용합니다.
`$.state`에 값 유지
$.state는 세션 길이의 값을 유지하고 당신을 위해 다시 그립니다. 이는 반응형 상태입니다: 값을 읽는 ui.render 훅이 그것을 구독하므로 Claude Code는 값을 쓸 때마다 그 사이트를 다시 그리고, $.ui.invalidate를 호출할 필요가 없습니다. $.state의 값은 또한 변수가 하지 않는 모듈의 다시 로드를 생존합니다.
설정하려면 값을 선언하고, 매니페스트를 선언으로 가리키고, 각 값을 정의하고 사용하세요. 예제는 hello-tabs에서 count를 $.state로 이동합니다.
값 선언
타입 파일에서 값을 선언하세요. 외부 키는 플러그인의 이름이고, 그 아래의 각 항목은 값과 그 타입입니다. 이를 hello-tabs/types/index.d.ts로 저장하세요:
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
매니페스트를 선언으로 가리키기
claude plugin validate가 코드를 해당 파일에 대해 확인하도록 하려면 선언의 경로로 매니페스트에 types 필드를 추가하세요:
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" },
"types": "./types/index.d.ts"
}
값 정의, 읽기, 쓰기
모듈에서 각 값을 기본값으로 정의하고, 그리는 동안 읽고, 콜백에서 쓰세요. atom은 값과 기본값의 이름을 지정하고, read는 반환하고, update는 씁니다. 세 가지 도우미는 $.state.get과 $.state.set을 호출합니다:
import { atom, read, update } from 'claude-code'
// 모듈의 맨 위: 값의 이름을 지정하고 기본값을 주기
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// ui.render 훅에서: 값을 읽어 그리기
const n = await read($, count)
// 버튼에서: 이전 값에서 새 값을 쓰기
onPress: () => update($, count, (value) => value + 1)
ui.render 훅이 count를 읽었기 때문에 Claude Code는 버튼이 그것을 쓸 때마다 훅을 다시 실행합니다.
코드에 세 가지 규칙이 적용됩니다:
plugin과key를 리터럴 문자열로 쓰기:claude plugin validate는 소스에서 읽습니다- 타입 파일에서 모든 값을 선언하기: 그렇지 않으면 검증이
hello-tabs.count is not declared로 실패합니다 - 콜백이나 다른 이벤트의 훅에서 쓰기:
ui.render훅은 상태를 읽을 수 있고 쓸 수 없으므로onPress,onSubmit, 또는 다른 이벤트의 훅에서 쓰세요
`hello-tabs`를 `$.state` 사용으로 변경
hello-tabs에서 count를 $.state로 이동하려면 그것을 사용하는 모든 줄을 변경하세요:
- 모듈의 맨 위:
import줄을 추가하고,let count = 0을atom줄로 대체 ui.render훅에서:tabButton전에read줄을 추가하고,Text에서'Count: ' + n을 그리기- Add one 버튼에서:
onPress를 한 세션 이상에서 저장의 것으로 대체합니다. 이는 카운트를 저장하고 쓰기도 합니다 session.start훅에서:saved를 읽는 두 줄을 /clear 후 저장된 값 다시 로드의loadCount호출로 대체
tab이 여전히 변수이므로 redraw를 유지하세요.
`/clear` 후 저장된 값 다시 로드
모드가 $.store에서 저장된 값을 session.start에서 $.state로 복사하면, /clear, /resume, 또는 /branch 후에 다시 복사해야 합니다. 이 명령은 모든 $.state 값을 기본값으로 되돌리고, session.start는 다시 발생하지 않습니다. classic.SessionStart는 각각 후에 발생하고, e.source는 clear, resume, 또는 fork로 설정되므로 값을 다시 복사하세요. 그렇지 않으면 그리기는 기본값을 표시하고, $.state 값을 저장하는 콜백은 저장한 것 위에 기본값을 씁니다.
이 코드는 두 훅에서 count를 로드합니다. $.state 버전의 hello-tabs를 기반으로 하며, count는 원자이고 update는 가져옵니다. loadCount를 register 위에 놓고, 이미 가지고 있는 session.start 훅에 loadCount 호출을 추가하세요. classic.SessionStart는 또한 시작 시 그리고 압축 후에 발생하며, 이는 $.state를 재설정하지 않으므로 source의 필터는 훅을 세 가지 재설정으로 유지합니다:
// $.store에서 저장된 카운트를 $.state로 복사하거나, 아무것도 저장되지 않으면 0
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// 첫 프롬프트 전에 실행되고, 다시 로드 후에도 실행됨
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// /clear, /resume, /branch 후에 다시 실행되며, fork를 보고합니다
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
await loadCount($)
return next(e)
})
두 훅이 제자리에 있으면 창은 /clear 후 저장된 카운트를 표시하고 0이 아니며, Add one의 다음 누름은 저장된 카운트에 추가합니다.
loadCount는 $.state의 값 위에 저장된 값을 쓰고, session.start는 모듈이 다시 로드될 때마다 다시 발생합니다. 저장소가 뒤처지지 않도록 하려면 모든 변경에 저장하세요. Add one 버튼이 하는 것처럼.
다시 로드를 세션 없이 확인하려면 /clear 후 그리기를 테스트하세요.
한 세션 이상에서 저장
머신의 모든 세션이 모드를 실행하면 하나의 $.store를 공유합니다. get 다음에 set은 원자적이지 않습니다. 두 세션이 각각 값을 읽고, 변경하고, 다시 쓸 때 경쟁하고, 두 번째 쓰기가 첫 번째를 대체합니다.
두 가지 선택이 그것을 덜 가능하게 합니다:
- 각 항목에 자신의 키를 주기:
set은 자신의 키만 변경하므로 다른 키를 쓰는 세션은 서로를 덮어쓰지 않습니다 - 쓰기 바로 전에 다시 읽기: 여러 세션이 변경하는 값의 경우, 콜백에서 키를
get하고session.start에서 로드한 복사본이 아닌 그것에서 새 값을 빌드하세요. 다른 세션의 쓰기는 여전히get과set사이에 착지하면 손실됩니다.
이 버튼은 저장소가 지금 보유하는 것에 1을 더한 다음 그리기를 업데이트합니다:
onPress: async () => {
// 저장소가 지금 보유하는 것을 읽기, 다른 세션이 변경했을 수 있음
const saved = Number((await $.store.get('count')) ?? 0)
// 새 카운트를 저장한 다음 표시
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}
두 번째 세션이 이 세션이 시작된 이후로 자신의 버튼을 세 번 눌렀다면, 이 누름은 그 세 개를 포함하는 카운트를 표시하고 저장합니다.