/curtain hides private panes, bands, the status line and earlier conversation while you share your screen

Claude Code mods by huynl-96. Needs Claude Code 2.1.287 or newer.
claude plugin marketplace add huynl-96/claude-mods
claude plugin install curtain@claude-mods
Run /reload-plugins in any open session, or start a new one.
Then move curtain to the top of your plugin list. Installed mods run in the order enabledPlugins lists them in ~/.claude/settings.json, and a new install goes last. A mod listed ahead of curtain draws first, so its pane or band can't be covered. Put "curtain@claude-mods": true first in enabledPlugins, then start a new session. If it isn't first, curtain shows a toast at session start naming the plugin ahead of it.
/curtain hides what you don't want on a shared screen: the conversation from before you turned it on, your home folder in paths, other mods' panes (a file tree, a browser), the bands above the prompt, and your status line.

| Command | What it does |
|---|---|
/curtain | Turn the curtain on or off, in every session at once |
/curtain on / /curtain off | Set it directly |
/curtain settings | Pick what to hide, and set your shortcut |
/curtain key <keys> | Set the shortcut by typing it, such as /curtain key ctrl+x s |
While it's on:
· hidden by curtain. It errs on hiding: rows from a reload or a resumed session, and anything it can't place, stay hidden. Slash command output is hidden while the curtain is on, except /curtain's own. Turn it off and everything comes back.~, so /Users/you/app.ts reads ~/app.ts.filetree) and Terminal Browser (browser) are marked by default.● curtain on · /curtain off to stop.● curtain on, once you add the snippet below.


Open /curtain settings. Every pane opened while the mod is loaded is listed there; tick the ones to hide. Mods can't list each other's panes up front, so open a pane once for it to show up. Your choices are saved across sessions.
A mod can't redraw a custom status line, so the mod writes ~/.claude/curtain and your status line script reads it. Add this right after your script reads its input:
[ -s ~/.claude/curtain ] && { echo "● curtain on"; exit 0; }
Status lines only rerun on session events, so add "refreshInterval": 2 to statusLine in ~/.claude/settings.json for the switch to show within 2 seconds.
Open /curtain settings, type your shortcut in the Shortcut field (one key, or a two-key chord such as ctrl+x s) and press Enter. curtain writes it into ~/.claude/keybindings.json, keeps your other bindings, and turns on the shortcut switch. /curtain key <keys> does the same from the prompt.
A shortcut must start with ctrl or meta (Option on a Mac), so it never types into the prompt. Claude Code's reserved keys (ctrl+c, ctrl+d…), its built-in shortcuts (ctrl+g, ctrl+t, ctrl+x ctrl+k…) and keys you've already bound are refused. You type the shortcut rather than press it, because Claude Code takes chord prefixes like ctrl+x before a mod can see them.
Mods can't add their own shortcut actions, so curtain borrows app:toggleDiffNoiseFilter, which is unbound by default and only used by the old diff panel. While the shortcut is on, a small ○ / ◉ button sits in the band above the prompt; the key presses it.
Cmd keys: Claude Code doesn't see Cmd in most terminals. In Ghostty, map a Cmd shortcut to your chord in its config instead, for example keybind = super+shift+s=text:\x18s sends ctrl+x s.
The curtain is one switch for the whole machine. Turn it on in any session and every open session follows within a couple of seconds; a session you start while it's on starts covered, with a toast saying so. Turning it off anywhere turns it off everywhere. The switch lives in ~/.claude/curtain.state.
claude plugin validate ./curtain lists it. It hooks session start, its own command, pane opening, and rows being added, and drawing panes, transcript rows and the band above the prompt. It notes the ids of rows added while it's on, checks the switch file every 1.5 seconds, and makes no network calls and runs no programs.
HOME, ~/.claude/curtain.state (the switch), ~/.claude/settings.json (only the enabledPlugins order) and ~/.claude/keybindings.json.~/.claude/curtain.state and ~/.claude/curtain (the status line flag) when you turn it on or off, and ~/.claude/keybindings.json only when you save a shortcut.The GIFs are recorded with vhs in a throwaway project, so they show no real files or paths. See docs/tapes.
/curtain, the switch files are ~/.claude/curtain and ~/.claude/curtain.state, and the settings toggle hotkey is c. Settings saved by earlier versions don't carry over.key argument); it's written into keybindings.json, refusing reserved, built-in and already-bound keys. The shortcut now borrows app:toggleDiffNoiseFilter, since app:toggleReplTab opens the diff panel on Claude Code 2.1.296. It's one switch for every session: on in one, on in all, including sessions started later.enabledPlugins, since mods listed earlier can't be covered.~ in paths, remind about a leftover status line flag, opt-in keyboard shortcut.MIT
hooks/register.tsx 438 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Hide, SeenPane } from '../types'
5
6const PANE = 'curtain'
7const ALL: Hide = { panes: true, bands: true, status: true, history: true, paths: true, shortcut: false }
8// Hidden until the person picks their own: Filetree and Terminal Browser show file names and pages.
9const DEFAULT_PRIVATE = ['filetree', 'browser']
10// An engine action unbound by default and unhandled outside the old diff panel; bound to a key
11// in keybindings.json, it presses the curtain button from the prompt. (app:toggleReplTab
12// opens the diff panel on 2.1.296, so the button never got it.)
13const SHORTCUT_ACTION = 'app:toggleDiffNoiseFilter'
14const ROWS = ['UserMessage', 'AssistantMessage', 'ToolUse', 'ToolResult', 'ToolGroup', 'CommandOutput'] as const
15
16const isOn = atom({ plugin: 'curtain', key: 'isOn' } as const, false)
17const hide = atom({ plugin: 'curtain', key: 'hide' } as const, ALL)
18const privatePanes = atom({ plugin: 'curtain', key: 'privatePanes' } as const, DEFAULT_PRIVATE)
19// Every pane opened while this mod was loaded, so the settings can offer it.
20const seen = atom({ plugin: 'curtain', key: 'seen' } as const, [] as SeenPane[])
21
22// Rows added since the curtain went on: everything else in the transcript is hidden.
23// A message row is drawn under its uuid with the last group zeroed; a tool row under its tool_use_id.
24const freshRows = atom({ plugin: 'curtain', key: 'freshRows' } as const, [] as string[])
25const freshToolIds = atom({ plugin: 'curtain', key: 'freshToolIds' } as const, [] as string[])
26const rowKey = (id: string) => id.split('-').slice(0, 4).join('-')
27
28// The shortcut as keybindings.json binds it, and the last save's result.
29const shortcutKey = atom({ plugin: 'curtain', key: 'shortcutKey' } as const, '')
30const shortcutNote = atom({ plugin: 'curtain', key: 'shortcutNote' } as const, '')
31const RESERVED = ['ctrl+c', 'ctrl+d', 'ctrl+m', 'ctrl+i', 'ctrl+h', 'ctrl+[']
32// Claude Code's default chat and app shortcuts (code.claude.com/docs/en/keybindings), so a saved
33// shortcut never silently takes one over. Chords on ctrl+x are listed by their full sequence.
34const DEFAULTS: Record<string, string> = {
35 'ctrl+t': 'app:toggleTodos', 'ctrl+o': 'app:toggleTranscript', 'ctrl+r': 'history:search',
36 'ctrl+l': 'chat:clearInput', 'ctrl+j': 'chat:newline', 'ctrl+_': 'chat:undo', 'ctrl+g': 'chat:externalEditor',
37 'ctrl+s': 'chat:stash', 'ctrl+v': 'chat:imagePaste', 'ctrl+b': 'task:background', 'ctrl+enter': 'chat:sendNow',
38 'meta+p': 'chat:modelPicker', 'meta+o': 'chat:fastMode', 'meta+t': 'chat:thinkingToggle',
39 'ctrl+x ctrl+k': 'chat:killAgents', 'ctrl+x ctrl+e': 'chat:externalEditor', 'ctrl+x enter': 'chat:queueSubmit',
40 'ctrl+x ctrl+a': 'abovePrompt:toggle', 'ctrl+x ctrl+s': 'chat:sendNow', 'ctrl+x tab': 'abovePrompt:focus',
41 'ctrl+x ctrl+b': 'task:background', 'ctrl+x b': 'app:cycleDiffBase', 'ctrl+x x': 'pane:close',
42 'ctrl+x left': 'pane:grow', 'ctrl+x right': 'pane:shrink', 'ctrl+x up': 'pane:grow', 'ctrl+x down': 'pane:shrink',
43}
44
45const LABELS: Record<keyof Hide, string> = {
46 panes: 'Panes ticked below',
47 bands: 'Bands above the prompt (Reflect, Replay, …)',
48 status: 'Status line (folder, branch, cost)',
49 history: 'Conversation from before the curtain',
50 paths: 'Home folder in paths (shown as ~)',
51 shortcut: 'Keyboard shortcut (set it below)',
52}
53
54async function home($: EngineInterface) {
55 return (await $.env.get('HOME')) ?? ''
56}
57
58// The status line script reads this file: non-empty means the curtain is on.
59async function flagPath($: EngineInterface) {
60 return `${await home($)}/.claude/curtain`
61}
62
63// One switch for the whole machine: every session follows this file, so turning the curtain on
64// in one session covers every other session, including ones started later.
65async function statePath($: EngineInterface) {
66 return `${await home($)}/.claude/curtain.state`
67}
68
69// The shared switch: true/false, or undefined when it can't be read (keep what this session has).
70async function isOnEverywhere($: EngineInterface): Promise<boolean | undefined> {
71 const path = await statePath($)
72 try {
73 if (!(await $.fs.exists(path))) return false
74 return (await $.fs.read(path)).trim() === 'on'
75 } catch {
76 return undefined
77 }
78}
79
80// Set when writing the shared switch failed: this session holds its own state instead of
81// following a file that still says otherwise, until a write succeeds again.
82let pinned: boolean | undefined
83
84function shorten<T>(value: T, from: string): T {
85 if (from.length < 2) return value
86 if (typeof value === 'string') return value.split(from).join('~') as T
87 if (Array.isArray(value)) return value.map(v => shorten(v, from)) as T
88 if (value && typeof value === 'object') {
89 return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, shorten(v, from)])) as T
90 }
91 return value
92}
93
94// Only the fields a row draws as content; read-only props must pass unchanged.
95const CONTENT: Record<(typeof ROWS)[number], string[]> = {
96 UserMessage: ['text'],
97 AssistantMessage: ['text'],
98 ToolUse: ['input', 'output'],
99 ToolResult: ['output'],
100 ToolGroup: ['calls'],
101 CommandOutput: ['text'],
102}
103
104// Sets built once per write, rebuilt when the value's version moves.
105const ROWS_REF = { plugin: 'curtain', key: 'freshRows' } as const
106const TOOL_IDS_REF = { plugin: 'curtain', key: 'freshToolIds' } as const
107const lookups = new Map<string, { version: number; set: Set<string> }>()
108function cached(key: string, value: string[] | undefined, version: number) {
109 const hit = lookups.get(key)
110 if (hit && hit.version === version) return hit.set
111 const set = new Set<string>(value ?? [])
112 lookups.set(key, { version, set })
113 return set
114}
115async function lookup($: EngineInterface, key: 'freshRows' | 'freshToolIds') {
116 if (key === 'freshRows') {
117 const { value, version } = await $.state.get(ROWS_REF)
118 return cached(key, value, version)
119 }
120 const { value, version } = await $.state.get(TOOL_IDS_REF)
121 return cached(key, value, version)
122}
123
124// Fail closed: a row is earlier unless it was added after the curtain went on.
125async function isEarlier($: EngineInterface, component: (typeof ROWS)[number], id: string, props: Record<string, unknown>) {
126 if (component === 'UserMessage' || component === 'AssistantMessage') {
127 // A prompt just sent is drawn as a placeholder until its row is kept.
128 return id !== 'placeholder' && !(await lookup($, 'freshRows')).has(rowKey(id))
129 }
130 if (component === 'ToolUse' || component === 'ToolResult') return !(await lookup($, 'freshToolIds')).has(id)
131 if (component === 'ToolGroup') {
132 const ids = await lookup($, 'freshToolIds')
133 return ((props.calls ?? []) as { tool_use_id?: string }[]).some(c => !c.tool_use_id || !ids.has(c.tool_use_id))
134 }
135 // Slash command output: hide all of it but /curtain's own.
136 return props.command !== 'curtain'
137}
138
139type Bindings = { bindings?: { context?: string; bindings?: Record<string, string | null> }[] } & Record<string, unknown>
140
141async function keybindingsPath($: EngineInterface) {
142 return `${await home($)}/.claude/keybindings.json`
143}
144
145async function readBindings($: EngineInterface): Promise<Bindings> {
146 try {
147 return JSON.parse(await $.fs.read(await keybindingsPath($))) as Bindings
148 } catch {
149 return { $schema: 'https://www.schemastore.org/claude-code-keybindings.json', bindings: [] }
150 }
151}
152
153function boundKey(file: Bindings) {
154 for (const block of file.bindings ?? []) {
155 for (const [key, action] of Object.entries(block.bindings ?? {})) if (action === SHORTCUT_ACTION) return key
156 }
157 return ''
158}
159
160// One key or a two-key chord that starts with ctrl or meta, so it never types into the prompt.
161export function checkKey(keys: string): string | null {
162 const parts = keys.trim().split(/\s+/).filter(Boolean)
163 if (parts.length === 0 || parts.length > 2) return 'Use one key or a two-key chord, like ctrl+x s.'
164 if (RESERVED.includes(parts[0])) return `${parts[0]} is reserved by Claude Code.`
165 const taken = DEFAULTS[parts.join(' ')]
166 if (taken) return `${parts.join(' ')} is Claude Code's ${taken} shortcut. Pick another, such as ctrl+x s.`
167 if (!/^(ctrl|meta)\+./.test(parts[0])) return 'Start with ctrl or meta (Option), so the key never types into the prompt.'
168 return null
169}
170
171// Writes the shortcut into ~/.claude/keybindings.json, keeping every other binding.
172async function saveShortcut($: EngineInterface, raw: string) {
173 const keys = raw.trim().split(/\s+/).join(' ')
174 const problem = checkKey(keys)
175 if (problem) return problem
176 const file = await readBindings($)
177 const blocks = (file.bindings = Array.isArray(file.bindings) ? file.bindings : [])
178 for (const block of blocks) {
179 const taken = block.bindings?.[keys]
180 if (taken && taken !== SHORTCUT_ACTION) return `${keys} is already bound to ${taken} in keybindings.json.`
181 }
182 for (const block of blocks) {
183 for (const [key, action] of Object.entries(block.bindings ?? {})) if (action === SHORTCUT_ACTION) delete block.bindings![key]
184 }
185 let chat = blocks.find(b => b.context === 'Chat')
186 if (!chat) blocks.push((chat = { context: 'Chat', bindings: {} }))
187 chat.bindings = { ...(chat.bindings ?? {}), [keys]: SHORTCUT_ACTION }
188 await $.fs.write(await keybindingsPath($), JSON.stringify(file, null, 2) + '\n')
189 await update($, shortcutKey, () => keys)
190 const cur = await read($, hide)
191 if (!cur.shortcut) {
192 await update($, hide, () => ({ ...cur, shortcut: true }))
193 await $.store.set('hide', { ...cur, shortcut: true })
194 }
195 return `Shortcut saved: ${keys}`
196}
197
198// The private ids as last read, for the ui.open guard's error handler, which can't read state.
199let lastPrivate: string[] = DEFAULT_PRIVATE
200let lastOn = false
201
202async function isHidden($: EngineInterface, id: string) {
203 lastPrivate = await read($, privatePanes)
204 lastOn = await read($, isOn)
205 return lastOn && (await read($, hide)).panes && lastPrivate.includes(id)
206}
207
208// This session only: what it hides follows on/off.
209async function applyHere($: EngineInterface, on: boolean) {
210 if (on && !(await read($, isOn))) {
211 await update($, freshRows, () => [])
212 await update($, freshToolIds, () => [])
213 }
214 await update($, isOn, () => on)
215 lastOn = on
216 $.ui.status(on && !(await read($, hide)).bands ? '● curtain on' : undefined)
217 $.ui.invalidate('ui.render')
218}
219
220// Every session: write the shared switch and the status line flag, then follow it here.
221async function apply($: EngineInterface, on: boolean) {
222 try {
223 await $.fs.write(await statePath($), on ? 'on\n' : '')
224 pinned = undefined
225 } catch {
226 pinned = on
227 $.ui.toast(`Couldn't write ~/.claude/curtain.state, so the curtain is ${on ? 'on' : 'off'} in this session only.`)
228 }
229 try {
230 await $.fs.write(await flagPath($), on && (await read($, hide)).status ? 'on\n' : '')
231 } catch { /* status line flag is best effort */ }
232 await applyHere($, on)
233}
234
235async function follow($: EngineInterface) {
236 if (pinned !== undefined) return
237 const on = await isOnEverywhere($)
238 if (on !== undefined && on !== (await read($, isOn))) await applyHere($, on)
239}
240
241// Whether this mod draws first (see the order check at session start): only then can it cover a
242// private pane, so otherwise a private pane is refused instead of covered.
243// Fails closed: false until the order check has read the settings and found this mod first.
244let canCover = false
245
246// ponytail: polls the switch file every 1.5s; a file watch would react sooner if the host offers one.
247let ticker: Timer | undefined
248
249export const register: Register = on => {
250 on('session.start', async ($, e, next) => {
251 await $.command.register({
252 name: 'curtain',
253 description: 'Curtain: hide private panes, bands, the status line and earlier conversation while you share your screen',
254 argumentHint: '[on|off|settings|key <keys>]',
255 immediate: true,
256 })
257 const saved = (await $.store.get('hide')) as Partial<Hide> | undefined
258 if (saved) await update($, hide, () => ({ ...ALL, ...saved }))
259 const savedPanes = (await $.store.get('privatePanes')) as string[] | undefined
260 if (savedPanes) await update($, privatePanes, () => savedPanes)
261 const savedSeen = (await $.store.get('seen')) as SeenPane[] | undefined
262 if (savedSeen) await update($, seen, () => savedSeen)
263 // Installed mods run in enabledPlugins order, first outermost: a mod listed ahead of this
264 // one draws before it, so its pane or band can't be covered.
265 try {
266 const settings = JSON.parse(await $.fs.read(`${await home($)}/.claude/settings.json`)) as { enabledPlugins?: Record<string, boolean> }
267 // Only an enabled install is ordered by this list; --plugin-dir and dev loads aren't.
268 const enabled = Object.entries(settings.enabledPlugins ?? {}).filter(([, isEnabled]) => isEnabled).map(([key]) => key)
269 const first = enabled[0]
270 canCover = !enabled.slice(1).some(key => key.startsWith('curtain@'))
271 if (!canCover) {
272 $.ui.toast(`curtain can't cover mods listed before it: move "curtain@…" to the top of enabledPlugins in ~/.claude/settings.json (first now: ${first})`)
273 }
274 } catch {
275 canCover = false // settings unreadable: refuse private panes rather than trust a cover
276 }
277 const bound = boundKey(await readBindings($))
278 await update($, shortcutKey, () => bound)
279 await follow($)
280 if (await read($, isOn)) $.ui.toast('Curtain is on. /curtain off turns it off in every session.')
281 ticker?.cancel()
282 ticker = $.clock.every(1500, () => follow($))
283
284 return next(e)
285 })
286
287 // Note each row added while the curtain is on, so it shows; anything not noted stays hidden.
288 on('session.append', async ($, e, next) => {
289 if (!e.agentId && (await read($, isOn))) {
290 await update($, freshRows, rows => [...rows, rowKey(e.uuid)])
291 const blocks = Array.isArray(e.message.content) ? (e.message.content as { type?: string; id?: string; tool_use_id?: string }[]) : []
292 const ids = blocks.flatMap(b => (b.type === 'tool_use' && b.id ? [b.id] : b.type === 'tool_result' && b.tool_use_id ? [b.tool_use_id] : []))
293 if (ids.length) await update($, freshToolIds, list => [...list, ...ids])
294 }
295 return next(e)
296 })
297
298 on('command.run', { command: 'curtain' }, async ($, e) => {
299 const arg = e.args.trim()
300 if (arg === 'settings') {
301 await $.ui.open({ id: PANE, title: 'Curtain', focus: true, closeOnEscape: true })
302 return { text: 'Curtain settings opened.' }
303 }
304 if (arg === 'key' || arg.startsWith('key ')) {
305 const keys = arg.slice(3).trim()
306 if (!keys) return { text: `Curtain shortcut: ${(await read($, shortcutKey)) || 'none'}` }
307 return { text: await saveShortcut($, keys) }
308 }
309 const next = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isOn))
310 await apply($, next)
311
312 return { text: next ? 'Curtain on: private panes, bands, status line and earlier conversation hidden.' : 'Curtain off.' }
313 })
314
315 // Refuse a private pane behind the curtain when it can't be covered, then learn every pane that opens.
316 on('ui.open', async ($, e, next) => {
317 // Drawn first, it opens and gets covered, so it's back as it was when the curtain goes off.
318 if (!canCover && (await isHidden($, e.id))) {
319 return { deny: 'hidden by curtain (/curtain off to show it)' }
320 }
321 // Keep each pane's latest title: a title can name the folder it was opened on.
322 try {
323 const title = e.title ?? e.id
324 const known = await read($, seen)
325 if (e.id !== PANE && !known.some(p => p.id === e.id && p.title === title)) {
326 const list = [...known.filter(p => p.id !== e.id), { id: e.id, title }]
327 await update($, seen, () => list)
328 await $.store.set('seen', list)
329 }
330 } catch { /* the settings list is best effort */ }
331 return next(e)
332 }).catch(($, e, next) => {
333 // The guard failed: let it through only if it had already passed, or the pane isn't private.
334 if (next.called || !lastOn || !lastPrivate.includes(e.id)) return next(e)
335 return { deny: 'hidden by curtain (/curtain off to show it)' }
336 })
337
338 // Cover an open private pane rather than close it: another mod's pane can't be reopened
339 // from here, and covering lets it come back as it was when the curtain goes off.
340 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
341 if (!(await isHidden($, e.requestId))) return next(e)
342 const { Text } = $.ui.resolve(e)
343 return <Text dimColor>Hidden by curtain.</Text>
344 })
345
346 // Transcript rows: hide what was drawn before the curtain, shorten paths in the rest.
347 for (const component of ROWS) {
348 on('ui.render', { component }, async ($, e, next) => {
349 if (!(await read($, isOn))) return next(e)
350 const cur = await read($, hide)
351 if (cur.history && (await isEarlier($, component, e.requestId, e.props as Record<string, unknown>))) {
352 const { Text } = $.ui.resolve(e)
353 return <Text dimColor>· hidden by curtain</Text>
354 }
355 if (!cur.paths) return next(e)
356 const from = await home($)
357 const props: Record<string, unknown> = { ...e.props }
358 for (const key of CONTENT[component]) if (key in props) props[key] = shorten(props[key], from)
359 return next({ ...e, props } as typeof e)
360 })
361 }
362
363 // Answering without next() keeps the bands beneath this one from drawing.
364 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
365 const active = await read($, isOn)
366 const cur = await read($, hide)
367 const { Box, Button, Text } = $.ui.resolve(e)
368 const shortcut = cur.shortcut
369 ? <Button key="curtain-shortcut" label={active ? '◉' : '○'} action={SHORTCUT_ACTION} plain dimColor onPress={() => apply($, !active)} />
370 : null
371 if (e.props.hasSurvey || !active || !cur.bands) {
372 if (!shortcut) return next(e)
373 const below = await next(e)
374 return <Box flexDirection="column">{below}{shortcut}</Box>
375 }
376 return (
377 <Box flexDirection="row" gap={1}>
378 {shortcut}
379 <Text dimColor>● curtain on · /curtain off to stop</Text>
380 </Box>
381 )
382 })
383
384 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
385 const { Box, Button, Input, Text } = $.ui.resolve(e)
386 const cur = await read($, hide)
387 const ids = await read($, privatePanes)
388 const active = await read($, isOn)
389 const known = [...(await read($, seen))]
390 for (const id of ids) if (!known.some(p => p.id === id)) known.push({ id, title: id })
391 const current = await read($, shortcutKey)
392 const note = await read($, shortcutNote)
393 const save = async (value: string) => {
394 const message = await saveShortcut($, value)
395 await update($, shortcutNote, () => message)
396 }
397
398 const flip = async (k: keyof Hide) => {
399 const nextHide = { ...cur, [k]: !cur[k] }
400 await update($, hide, () => nextHide)
401 await $.store.set('hide', nextHide)
402 if (active) await apply($, true)
403 }
404 const flipPane = async (id: string) => {
405 const nextIds = ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]
406 await update($, privatePanes, () => nextIds)
407 await $.store.set('privatePanes', nextIds)
408 if (active) await apply($, true)
409 }
410
411 return (
412 <Box flexDirection="column" gap={1}>
413 <Button key="toggle" hotkey="c" label={active ? 'Turn off' : 'Turn on'} onPress={() => apply($, !active)} />
414 <Text dimColor>While the curtain is on, hide:</Text>
415 {(Object.keys(LABELS) as (keyof Hide)[]).map((k, i) => (
416 <Button key={k} hotkey={String(i + 1)} label={`${cur[k] ? '[x]' : '[ ]'} ${LABELS[k]}`} onPress={() => flip(k)} />
417 ))}
418 <Input
419 key="shortcut"
420 label={`Shortcut (now ${current || 'none'})`}
421 placeholder="ctrl+x s, meta+s, …"
422 submitLabel="Save"
423 onSubmit={save}
424 />
425 {note !== '' ? <Text dimColor>{note}</Text> : null}
426 <Text dimColor>Panes (any pane opened while this mod is loaded shows up here):</Text>
427 {known.map(p => (
428 <Button
429 key={`pane-${p.id}`}
430 label={`${ids.includes(p.id) ? '[x]' : '[ ]'} ${p.title}${p.title === p.id ? '' : ` (${p.id})`}`}
431 onPress={() => flipPane(p.id)}
432 />
433 ))}
434 </Box>
435 )
436 })
437}
438types/index.d.ts 9 lines1export type Hide = { panes: boolean; bands: boolean; status: boolean; history: boolean; paths: boolean; shortcut: boolean }
2export type SeenPane = { id: string; title: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 curtain: { isOn: boolean; hide: Hide; privatePanes: string[]; seen: SeenPane[]; freshRows: string[]; freshToolIds: string[]; shortcutKey: string; shortcutNote: string }
7 }
8}
9