SLOPSHOPPER

curtain

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

newpanebandrowscommandtoast
v0.3.0MITupdated 2026-10-15huynl-96/claude-mods/curtain
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · curtain
│ ┃ curtain ✕ · hidden by curtain │ ┃ [ Turn off ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ While the curtain is on, hide: ⎿ Read 6 lines │ ┃ · hidden by curtain │ ┃ [ [x] Panes ticked below ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ [x] Bands above the prompt (Reflect, Repla │ ┃ · hidden by curtain │ ┃ [ [x] Status line (folder, branch, cost) ] │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ [x] Conversation from before the curtain ] │ ┃ › /curtain │ ┃ [ [x] Home folder in paths (shown as ~) ] ⎿ curtain: Curtain on: private panes, bands, status line and earli │ ┃ │ ┃ [ [ ] Keyboard shortcut (set it below) ] │ ┃ │ ┃ Shortcut (now none): ctrl+x s, meta+s, … ⏎ S │ ┃ │ ┃ Panes (any pane opened while this mod is │ ┃ loaded shows up here): │ ┃ │ ┃ [ [x] filetree ] │ ┃ │ ┃ [ [x] browser ] │ ● curtain on · /curtain off to stop ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● curtain on · /curtain off to stop
Pane · curtain
[ Turn off ] While the curtain is on, hide: [ [x] Panes ticked below ] [ [x] Bands above the prompt (Reflect, Replay, …) ] [ [x] Status line (folder, branch, cost) ] [ [x] Conversation from before the curtain ] [ [x] Home folder in paths (shown as ~) ] [ [ ] Keyboard shortcut (set it below) ] Shortcut (now none): ctrl+x s, meta+s, … ⏎ Save Panes (any pane opened while this mod is loaded shows up here): [ [x] filetree ] [ [x] browser ]
Your message
· hidden by curtain
Claude's reply
· hidden by curtain
Tool row
· hidden by curtain
README

claude-mods

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

/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.

Curtain on and off: earlier conversation, the band and the status line hide, then come back

CommandWhat it does
/curtainTurn the curtain on or off, in every session at once
/curtain on / /curtain offSet it directly
/curtain settingsPick 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:

  • Earlier conversation: only rows added after you turned it on are shown; every other message and tool row collapses to · 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.
  • Paths in rows drawn while it's on show your home folder as ~, so /Users/you/app.ts reads ~/app.ts.
  • Panes you've marked private stay open but show "Hidden by curtain", and come back as they were when you turn it off. One opened while it's on opens covered. (If another mod is listed before curtain, it can't cover, so private panes are refused instead.) Filetree (filetree) and Terminal Browser (browser) are marked by default.
  • Bands above the prompt are replaced by one line: ● curtain on · /curtain off to stop.
  • Status line shows only ● curtain on, once you add the snippet below.

A private pane (Filetree) is covered while the curtain is on, and comes back as it was

New messages show while it's on, with the home folder shown as ~

Pick your own panes

/curtain settings: what to hide, the shortcut field, and which panes count as private

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.

Hide your status line

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.

Keyboard shortcut

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.

One switch for every session

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.

What it doesn't hide

  • New rows from subagents in their own agent view stay hidden while the curtain is on.
  • Names and secrets inside new messages. For that, see screen-guard.
  • Screen sharing can't be detected by a mod, so turning it on is up to you.

What it can do

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.

  • Reads HOME, ~/.claude/curtain.state (the switch), ~/.claude/settings.json (only the enabledPlugins order) and ~/.claude/keybindings.json.
  • Writes ~/.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.

Re-recording the GIFs

The GIFs are recorded with vhs in a throwaway project, so they show no real files or paths. See docs/tapes.

Changelog

  • 0.3.0: new name, curtain. The command is /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.
  • 0.2.4: fail closed. Private panes are refused unless the mod has confirmed it draws first, and a failing open-check refuses private panes instead of letting them through. An unreadable switch file, or a failed write to it, can no longer turn it off; a failed write keeps it on in that session and says so.
  • 0.2.3: set your shortcut in settings (or with the 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.
  • 0.2.2: warn at session start when another plugin is listed before it in enabledPlugins, since mods listed earlier can't be covered.
  • 0.2.1: a pane keeps its latest title in settings, since a title can name the folder it was opened on.
  • 0.2.0: hide the conversation from before it turned on, show the home folder as ~ in paths, remind about a leftover status line flag, opt-in keyboard shortcut.
  • 0.1.1: cover private panes instead of closing them, so they come back as they were.
  • 0.1.0: first release.

License

MIT

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