SLOPSHOPPER

session-rail

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

panespinnercommandtoastprompt
v0.2.0no licenseupdated 2026-10-19ImTotem/claude-code-mods/plugins/session-rail
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-rail
│ ┃ 세션 ✕ › fix the failing auth test and add an audit log call │ ┃ ⟨Claude Code's own drawing⟩ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-rail │ ⎿ session-rail: 세션 패널을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ 세션 패널

Draws

Pane · 세션
⟨Claude Code's own drawing⟩
README

session-rail

다른 모드들이 카드를 꽂는 세션 패널 하나를 여는 모드입니다. 그 자체로는 카드가 없고, usage-rail(사용량 카드)과 todo-stack(할 일 카드)이 이 패널에 자기 카드를 넣습니다. 두 모드는 이 모드를 의존성으로 선언하므로, 둘 중 하나를 설치하면 함께 설치됩니다.

  • 세션이 시작되면 패널을 엽니다. 다만 엔진은 사용자가 요청하지 않은 패널을 화면 폭이 144칸 이상일 때만 붙입니다. 데스크톱 앱처럼 그보다 좁으면 첫 프롬프트를 보낼 때 엽니다.
  • 프롬프트 아래 오른쪽(모드 표시가 뜨는 자리)에 세션 패널 버튼이 늘 있습니다. 누르면 폭과 상관없이 열리고, 열려 있을 때 누르면 닫힙니다. 앱 상단 도구 모음에는 모드가 버튼을 넣을 자리가 없어서 이 자리에 둡니다.
  • 패널을 직접 닫거나 버튼으로 닫으면 그 세션에서는 프롬프트를 보내도 다시 열지 않습니다. 버튼이나 /session-rail, /usage-rail, /todo-stack으로 다시 엽니다. 패널 id는 어느 모드가 열든 하나의 패널을 가리키므로, 카드를 넣는 모드가 직접 열어도 같은 패널이 열립니다.
  • 패널을 붙일 수 없는 화면이면 그 이유를 알림으로 한 번 알려줍니다.

어디에 뜨나요

  • 터미널: 전체 화면 레이아웃(기본값)이면 대화 오른쪽에 세로 패널로 붙습니다(폭 110칸 이상). 그보다 좁거나 메인 화면 레이아웃(tmux, CLAUDE_CODE_NO_FLICKER=0)이면 프롬프트 바로 위에 붙습니다.
  • Claude 데스크톱 앱: 앱이 플러그인 패널을 두는 자리에 세션 탭으로 나옵니다. 앱의 세션 정보 레일(환경 · 컨텍스트 · 결과물) 안에는 들어가지 않습니다. 그 레일에는 모드가 그릴 수 있는 자리가 없습니다.

다른 모드에서 카드 넣기

{ 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 .
Source 2 files
hooks/register.tsx 132 lines
1import { 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}
132
types/index.d.ts 13 lines
1declare 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