SLOPSHOPPER

group-toggle

Switch the desktop app's session sidebar between grouping by folder and by state in one click: a line above the prompt and /group

newbandcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · group-toggle
› fix the failing auth test and add an audit log call ⏺ 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 › /group ⎿ group-toggle: Sidebar grouped by folder. ⟨Claude Code's own drawing⟩ Group sidebar: [ ● Folder ] [ ○ State ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Group sidebar: [ ● Folder ] [ ○ State ]
README

group-toggle

With many folders and sessions, switching the Code-tab sidebar between group by folder and group by state means scrolling up to its filter button every time. This mod puts the switch where it is always in view:

  • A line above the prompt: Group sidebar: ● Folder ○ State. Click Folder or State and the sidebar regroups at once; the filled dot shows the current grouping. Another mod's band above the prompt (claude-dj's now playing) stays, above this line.
  • /group: toggles between folder and state. /group folder and /group state pick one.

How it works

The mod calls the desktop app's own sidebar tools (ccd_sidebar): list_sections to read the current grouping when the session starts, set_view with group_by to change it. That is the same setting as the filter menu, so:

  • Choosing State also sets the status filter to active, as the menu does.
  • Choosing another grouping in the menu (date, none, ...) leaves both dots open; the buttons still work.

The app connects its sidebar tools a moment after a session starts, so the mod keeps trying for about 30 seconds. If the sidebar still cannot be read or changed (no app window open, a terminal session), the line shows a ⚠ with the reason.

Limits

  • Desktop app only: the sidebar tools exist only there.
Source 2 files
hooks/register.tsx 115 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Grouping } from '../types'
5
6const SIDEBAR = 'ccd_sidebar'
7const grouping = atom({ plugin: 'group-toggle', key: 'grouping' } as const, null as Grouping)
8const lastError = atom({ plugin: 'group-toggle', key: 'lastError' } as const, null as string | null)
9
10function textOf(content: { type: string; text?: string }[]) {
11  return content.map(block => (block.type === 'text' ? block.text ?? '' : '')).join('')
12}
13
14// The app connects its sidebar tools a moment after the session starts: until then
15// a call fails ("no connected MCP tool"). Keep trying for a while before warning.
16const RETRY_MS = 1000
17const RETRIES = 30
18
19async function refresh($: EngineInterface, attempt = 0) {
20  const retryOr = async (message: string) => {
21    if (attempt < RETRIES) {
22      $.clock.after(RETRY_MS, () => void refresh($, attempt + 1))
23      return
24    }
25    await update($, lastError, () => message)
26  }
27  try {
28    const result = await $.mcp.call(SIDEBAR, 'list_sections')
29    if (result.isError) {
30      await retryOr(textOf(result.content))
31      return
32    }
33    await update($, lastError, () => null)
34    const sections = JSON.parse(textOf(result.content)) as { id: string; group_by: string }[]
35    const by = sections.find(section => section.id === 'sessions')?.group_by
36    const now: Grouping = by === 'project' || by === 'state' ? by : 'other'
37    await update($, grouping, () => now)
38  } catch (error) {
39    await retryOr(String(error))
40  }
41}
42
43async function setGrouping($: EngineInterface, to: 'project' | 'state') {
44  let result
45  try {
46    result = await $.mcp.call(SIDEBAR, 'set_view', { group_by: to })
47  } catch (error) {
48    await update($, lastError, () => String(error))
49    $.ui.toast(`Sidebar not changed: ${String(error)}`)
50    return false
51  }
52  if (result.isError) {
53    $.ui.toast(`Sidebar not changed: ${textOf(result.content)}`)
54    return false
55  }
56  await update($, lastError, () => null)
57  await update($, grouping, () => to)
58  return true
59}
60
61export const register: Register = on => {
62  on('session.start', async ($, e, next) => {
63    await $.command.register({
64      name: 'group',
65      description: 'Group the sidebar by folder or state (no argument toggles)',
66      argumentHint: '[folder|state]',
67    })
68    void refresh($)
69    return next(e)
70  })
71
72  on('command.run', { command: 'group' }, async ($, e) => {
73    await refresh($)
74    const arg = e.args.trim().toLowerCase()
75    const to: 'project' | 'state' =
76      arg.startsWith('f') || arg.startsWith('p') ? 'project'
77      : arg.startsWith('s') ? 'state'
78      : (await read($, grouping)) === 'project' ? 'state' : 'project'
79    const ok = await setGrouping($, to)
80    return { text: ok ? `Sidebar grouped by ${to === 'project' ? 'folder' : 'state'}.` : 'Sidebar not changed.' }
81  })
82
83  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
84    const now = await read($, grouping)
85    const error = await read($, lastError)
86    if (e.props.hasSurvey) return next(e)
87
88    // Stack under whatever the other mods draw here (a music band, ...), never replace it.
89    const below = await next(e)
90    const { Box, Button, Text } = $.ui.resolve(e)
91    return (
92      <Box flexDirection="column">
93      {below}
94      <Box>
95        <Text dimColor>Group sidebar: </Text>
96        <Button
97          key="folder"
98          label={now === 'project' ? '● Folder' : '○ Folder'}
99          dimColor={now !== 'project'}
100          onPress={() => setGrouping($, 'project')}
101        />
102        <Text> </Text>
103        <Button
104          key="state"
105          label={now === 'state' ? '● State' : '○ State'}
106          dimColor={now !== 'state'}
107          onPress={() => setGrouping($, 'state')}
108        />
109        {error !== null && <Text dimColor> ⚠ {error.slice(0, 120)}</Text>}
110      </Box>
111      </Box>
112    )
113  })
114}
115
types/index.d.ts 8 lines
1export type Grouping = 'project' | 'state' | 'other' | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    'group-toggle': { grouping: Grouping; lastError: string | null }
6  }
7}
8