One session pane that other mods add cards to, with a button at the right of the prompt footer to open and close it.

다른 모드들이 카드를 꽂는 세션 패널 하나를 여는 모드입니다. 그 자체로는 카드가 없고, usage-rail(사용량 카드)과 todo-stack(할 일 카드)이 이 패널에 자기 카드를 넣습니다. 두 모드는 이 모드를 의존성으로 선언하므로, 둘 중 하나를 설치하면 함께 설치됩니다.
/session-rail, /usage-rail, /todo-stack으로 다시 엽니다. 패널 id는 어느 모드가 열든 하나의 패널을 가리키므로, 카드를 넣는 모드가 직접 열어도 같은 패널이 열립니다.CLAUDE_CODE_NO_FLICKER=0)이면 프롬프트 바로 위에 붙습니다.세션 탭으로 나옵니다. 앱의 세션 정보 레일(환경 · 컨텍스트 · 결과물) 안에는 들어가지 않습니다. 그 레일에는 모드가 그릴 수 있는 자리가 없습니다.{ component: 'Pane', requestId: 'session-rail' }의 ui.render를 훅으로 걸고, next(e)가 돌려주는 트리를 자기 카드와 함께 감싸서 돌려줍니다. 위에 둘 카드는 next(e)보다 앞에, 아래에 둘 카드는 뒤에 놓으면 모드가 불러와지는 순서와 상관없이 순서가 유지됩니다.
on('ui.render', { component: 'Pane', requestId: 'session-rail' }, async ($, e, next) => {
const { Box, Text } = $.ui.resolve(e)
const others = await next(e)
return (
<Box flexDirection="column" gap={1}>
{others}
<Text>내 카드</Text>
</Box>
)
})
plugin.json에는 "dependencies": ["session-rail"]를 넣습니다.
/plugin install session-rail --marketplace ImTotem/claude-code-mods
보통은 usage-rail이나 todo-stack을 설치하면 함께 설치됩니다. 마켓플레이스는 이 저장소의 main 브랜치에 있습니다.
이 브랜치의 루트가 모드 폴더입니다.
claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.tsx 132 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// Other mods add their cards by hooking ui.render for
5// { component: 'Pane', requestId: 'session-rail' } and wrapping next(e).
6const PANE = 'session-rail'
7const TITLE = '세션'
8// Prompts the person typed, here or through Remote Control.
9const PERSONAL_ORIGINS: readonly string[] = ['composer', 'bridge']
10
11const isOpen = atom({ plugin: 'session-rail', key: 'isOpen' } as const, false)
12const isDismissed = atom({ plugin: 'session-rail', key: 'isDismissed' } as const, false)
13const isWarned = atom({ plugin: 'session-rail', key: 'isWarned' } as const, false)
14
15async function open($: EngineInterface) {
16 const opened = await $.ui.open({ id: PANE, title: TITLE })
17 await update($, isOpen, () => opened.isPlaced)
18
19 return opened
20}
21
22// The open session.start makes is unasked, and an unasked pane waits undrawn
23// below 144 columns, which an app's transcript seldom reaches; one made from
24// the person's own prompt or press is placed at any width.
25async function showPane($: EngineInterface) {
26 if (await read($, isDismissed)) return
27 const pane = (await $.ui.panes()).find(one => one.id === PANE)
28 if (pane?.isPlaced === true) return
29
30 const opened = await open($)
31 if (opened.isPlaced || (await read($, isWarned))) return
32
33 await update($, isWarned, () => true)
34 $.ui.toast(`세션 패널을 열지 못했습니다: ${opened.reason}`)
35}
36
37async function reopen($: EngineInterface) {
38 await update($, isDismissed, () => false)
39
40 return open($)
41}
42
43// The footer button: open the pane, or close it as the person's own close
44// would, so a prompt does not bring it back.
45async function toggle($: EngineInterface) {
46 if (!(await read($, isOpen))) return reopen($)
47
48 await update($, isDismissed, () => true)
49 await $.ui.close({ id: PANE })
50 await update($, isOpen, () => false)
51}
52
53export const register: Register = on => {
54 on('session.start', async ($, e, next) => {
55 await $.command.register({
56 name: 'session-rail',
57 description: '세션 패널(사용량 · 할 일 카드)을 엽니다',
58 })
59
60 if (e.isInteractive) await open($).catch(() => undefined)
61
62 return next(e)
63 })
64
65 on('command.run', { command: 'session-rail' }, async $ => {
66 const opened = await reopen($)
67
68 return {
69 text: opened.isPlaced ? '세션 패널을 열었습니다.' : `세션 패널을 열지 못했습니다: ${opened.reason}`,
70 }
71 })
72
73 on('prompt.submit', async ($, e, next) => {
74 if (e.isMeta !== true && PERSONAL_ORIGINS.includes(e.origin.kind)) {
75 await showPane($).catch(() => undefined)
76 }
77
78 return next(e)
79 }).catch(($, e, next) => next(e))
80
81 // usage-rail's and todo-stack's commands open this pane too.
82 on('ui.open', { id: PANE }, async ($, e, next) => {
83 const opened = await next(e)
84 if (opened.value?.isPlaced === true) {
85 await update($, isOpen, () => true)
86 await update($, isDismissed, () => false)
87 }
88
89 return opened
90 }).catch(($, e, next) => next(e))
91
92 on('ui.close', { id: PANE }, async ($, e, next) => {
93 await update($, isOpen, () => false)
94 if (e.origin.kind === 'person') await update($, isDismissed, () => true)
95
96 return next(e)
97 }).catch(($, e, next) => next(e))
98
99 // The pane itself: the cards the other mods add, stacked.
100 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
101 const { Box } = $.ui.resolve(e)
102 const cards = await next(e)
103
104 return (
105 <Box flexDirection="column" gap={1}>
106 {cards}
107 </Box>
108 )
109 })
110
111 // A button at the right of the prompt footer, beside the mode labels,
112 // that opens the pane and closes it again.
113 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
114 const { Box, Button } = $.ui.resolve(e)
115 const modes = await next(e)
116 const isShown = await read($, isOpen)
117
118 return (
119 <Box flexDirection="row" gap={1}>
120 {modes}
121 <Button
122 key="toggle-rail"
123 plain
124 dimColor={!isShown}
125 label="세션 패널"
126 onPress={() => toggle($)}
127 />
128 </Box>
129 )
130 })
131}
132types/index.d.ts 13 lines1declare module 'claude-code' {
2 interface PluginState {
3 'session-rail': {
4 // A surface draws the pane now; the footer button opens or closes it by this.
5 isOpen: boolean
6 // The person closed the pane by hand; a prompt does not reopen it.
7 isDismissed: boolean
8 // The pane could not be placed and the person was told once.
9 isWarned: boolean
10 }
11 }
12}
13