SLOPSHOPPER

star-pins

A star in the desktop app's prompt footer that pins and unpins the chat in the sidebar

newspinnertoasttimer
★ 2v0.1.0MITupdated 2026-10-21Heuwzen/claude-runway/star-pins
A shopper browsing a rack in a slop shop
README

star-pins

A star in the Claude desktop app's prompt footer. Click it to pin the chat you're in to the top of the sidebar, and click it again to unpin.

☆   not pinned
★   pinned

What it shows

  • The chat's pin: an outline star while the chat isn't pinned, a filled one while it is. Pin or unpin from the sidebar instead and the star follows within 10 seconds.
  • One click: the star changes the moment you click it. If the app refuses the change, the star turns back and a note says why.
  • Where it sits: in the footer under the prompt box, after other mods' readouts such as MacLoad's and before the model name. The desktop app keeps the left side of the footer for itself.
  • Desktop only: pins live in the desktop app's sidebar, so in a terminal star-pins draws nothing.

How it works

star-pins uses the desktop app's own sidebar tools, the ones Claude uses when you ask it to pin a chat. It reads the chat's pin with get_session, again after each reply and every 10 seconds while the chat sits idle, and changes it with set_pinned.

The change runs as a tool call, so your permission settings apply to it as they do to Claude's own tool calls. In auto mode the change is refused until you allow the tool, in the permissions block of ~/.claude/settings.json:

"allow": ["mcp__ccd_sidebar__set_pinned"]

That rule also lets Claude pin and unpin chats without asking. It only changes the sidebar.

Source 2 files
hooks/register.tsx 135 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4const pinned = atom({ plugin: 'star-pins', key: 'pinned' } as const, null)
5const busy = atom({ plugin: 'star-pins', key: 'busy' } as const, false)
6
7// The desktop app's own servers: the same calls Claude makes with its sidebar tools.
8const SESSIONS = 'ccd_session_mgmt'
9const SET_PINNED = 'mcp__ccd_sidebar__set_pinned'
10// Catches a pin changed from the sidebar by hand while the session sits idle.
11const REFRESH_MS = 10_000
12// The button's address, which the ui.press hook below matches.
13const PIN = 'pin'
14// The state lives in the label's characters, the one thing the desktop footer
15// draws (no text styles, fills or borders; primary and secondary chips look
16// alike). A star in the footer's grey, outline while unpinned and filled while
17// pinned (the app itself records a pinned session as starred). A flag pair drew
18// too small: its font draws it so, and the footer takes no text size.
19const PINNED_ICON = '★'
20const UNPINNED_ICON = '☆'
21
22async function readPinned($: EngineInterface): Promise<boolean | null> {
23  try {
24    const result = await $.mcp.call(SESSIONS, 'get_session', { session_id: 'self' })
25    if (result.isError) return null
26    const text = result.content.find(block => block.type === 'text')?.text
27    if (text === undefined) return null
28    const value: unknown = JSON.parse(text).pinned
29    return typeof value === 'boolean' ? value : null
30  } catch {
31    return null
32  }
33}
34
35async function refresh($: EngineInterface) {
36  if (timer === undefined) {
37    timer = $.clock.every(REFRESH_MS, () => void refresh($))
38    // Once per load: a reload leaves the footer as the replaced module drew it
39    // until something redraws it.
40    $.ui.invalidate('ui.render')
41  }
42  const seen = presses
43  const value = await readPinned($)
44  // A read the sidebar answered before a press, or one taken while the change
45  // is in flight, would put the old icon back: the press's icon stands.
46  if (presses !== seen || (await read($, busy))) return
47  await update($, pinned, () => value)
48}
49
50// Started by this module's first refresh, whatever runs it; module variables
51// start over at a reload.
52let timer: Timer | undefined
53let presses = 0
54
55async function toggle($: EngineInterface) {
56  const current = await read($, pinned)
57  if (current === null || (await read($, busy))) return
58  presses += 1
59  await update($, busy, () => true)
60  // The icon turns at the press, in one redraw (a read-back after the change
61  // drew it again, and a click could look lost); a refused change turns it back.
62  await update($, pinned, () => !current)
63  const change = current ? 'unpin' : 'pin'
64  try {
65    // A tool call, run the way Claude's own calls run: made as a bare
66    // $.mcp.call, the change failed in the desktop app, while the
67    // same call from Claude worked and reads through $.mcp.call still do.
68    const outcome = await $.tool.call({
69      tool: SET_PINNED,
70      session_id: 'self',
71      pinned: !current,
72      consent: `The user pressed the pin button in the prompt footer to ${change} this session.`,
73    })
74    const reason = outcome.deny ?? (outcome.isError ? outcome.text : undefined)
75    if (reason !== undefined) {
76      await update($, pinned, () => current)
77      $.ui.toast(`The sidebar did not take the change: ${reason}`)
78    }
79  } catch (error) {
80    await update($, pinned, () => current)
81    $.ui.toast(`The sidebar did not take the change: ${error instanceof Error ? error.message : String(error)}`)
82  } finally {
83    await update($, busy, () => false)
84  }
85}
86
87export const register: Register = on => {
88  on('session.start', async ($, e, next) => {
89    const result = await next(e)
90    await refresh($)
91    return result
92  })
93
94  on('turn.complete', async ($, e, next) => {
95    const result = await next(e)
96    await refresh($)
97    return result
98  })
99
100  // Every press on the pin runs this module's code. Until the footer redraws
101  // after a reload it still shows the replaced module's button, whose own
102  // onPress runs in that unloaded module: the call fails there, and the toast
103  // says "The sidebar did not take the change". Not awaited, so the press is
104  // taken at once and never falls through to that onPress.
105  on('ui.press', { plugin: 'star-pins', element: PIN }, ($, e) => {
106    void toggle($)
107    return { element: e.element }
108  })
109
110  // The footer's mode labels, right of the prompt, not the band above it: the
111  // band holds one tree and Runway's rate-limit band never passes it on, so a
112  // button there showed or vanished with the plugins' load order. The labels
113  // drawn beneath (the engine's, any other plugin's) stay; the button leads
114  // them. The desktop draws this group after the status line's readouts
115  // (MacLoad), so there the pin sits between those and the model picker; the
116  // left side, by Auto, holds no slot a plugin draws in.
117  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
118    const state = await read($, pinned)
119    if (state === null) return next(e)
120
121    const below = await next(e)
122    const { Box, Button } = $.ui.resolve(e)
123
124    // An icon, not words, so it takes little room, given as the label, which is
125    // what the desktop draws. onPress runs only if the ui.press hook above
126    // was skipped.
127    return (
128      <Box>
129        <Button key={PIN} label={state ? PINNED_ICON : UNPINNED_ICON} onPress={() => void toggle($)} />
130        {below}
131      </Box>
132    )
133  })
134}
135
types/index.d.ts 14 lines
1// null until the first read, and for good where there is no desktop sidebar
2// (a terminal or cloud session): the footer is then left as it was.
3export type PinState = boolean | null
4
5declare module 'claude-code' {
6  interface PluginState {
7    'star-pins': {
8      pinned: PinState
9      // A pin change is in flight; presses wait for it.
10      busy: boolean
11    }
12  }
13}
14