SLOPSHOPPER

session-topics

Shows the session's topics and a per-session color above the prompt, so side-by-side sessions are easy to tell apart

newbandpromptmodeltimer
v0.1.1no licenseupdated 2026-10-08rotembm12/claude-mods/session-topics
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-topics
› 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 ▾ ● fix the failing auth test and ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▾ ● fix the failing auth test and
README

claude-mods

This repository is a Claude Code plugin marketplace named rotem-mods. It holds three mods (plugins that add panes, bands, and commands to Claude Code).

ModWhat it does
context-usageShows how full the context window of the session is, in a band above the prompt. The /context-bar command toggles the detail view.
session-topicsShows the topics of the session and a color for each session above the prompt. You can tell side-by-side sessions apart.
orchestratorMakes the session delegate work to subagents. A subagent runs on Haiku for simple, repetitive tasks and on Sonnet for hard or long tasks, never on Opus or Fable. The orchestrator splits work into tasks of about 150K tokens of subagent context each. The details button in its band lists the running and past subagents. Use /orchestrator on, /orchestrator off, or /orchestrator status.

Install

  1. Add the marketplace in Claude Code:
   /plugin marketplace add rotembm12/claude-mods
  1. Install each mod that you want:
   /plugin install context-usage@rotem-mods
   /plugin install session-topics@rotem-mods
   /plugin install orchestrator@rotem-mods

Update

To get new versions of the mods, run this command:

/plugin marketplace update rotem-mods
Source 2 files
hooks/register.tsx 223 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Saved } from '../types'
5
6const topics = atom({ plugin: 'session-topics', key: 'topics' } as const, [])
7const seed = atom({ plugin: 'session-topics', key: 'seed' } as const, null)
8const isMinimized = atom({ plugin: 'session-topics', key: 'isMinimized' } as const, false)
9
10const STORE_KEY = 'recent'
11const KEEP_SESSIONS = 50
12const MAX_TOPICS = 5
13const MINI_WIDTH = 24
14const RESUME_MESSAGES = 8
15
16// Readable on a dark or a light theme. Each session gets one, picked by its id,
17// so two sessions on the same subject still look different.
18const PALETTE = ['#e06c75', '#e5c07b', '#98c379', '#56b6c2', '#61afef', '#c678dd', '#d19a66', '#ff79c6']
19
20const SYSTEM = `You keep a short list of the topics a person worked on in one Claude Code session. The list helps them tell several terminals apart at a glance.
21
22Rules:
23- Each topic is 1 to 4 words and names the concrete subject (a feature, file, bug, page or tool). No trailing punctuation.
24- Keep the existing topics, in order. If the new messages continue the latest topic, you may sharpen its wording.
25- Add a topic at the end only when the messages start a clearly different subject.
26- Keep at most ${MAX_TOPICS} topics. Past that, merge the oldest two.
27- Messages with no subject ("yes", "continue", "thanks") leave the list as it is.
28- Write each topic in English, whatever language the messages use.
29
30Reply with the JSON array of strings only, oldest topic first.`
31
32function colorFor(id: string): string {
33  let hash = 2166136261
34  for (let i = 0; i < id.length; i++) hash = Math.imul(hash ^ id.charCodeAt(i), 16777619)
35  return PALETTE[(hash >>> 0) % PALETTE.length] ?? '#61afef'
36}
37
38function clip(text: string, width: number): string {
39  if (width <= 0) return ''
40  return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
41}
42
43// A slash command counts by its arguments alone: `/goal build X` is about X.
44// The desktop puts a system reminder in front of the first prompt; it is no topic.
45function subjectOf(text: string): string {
46  const trimmed = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
47  return trimmed.startsWith('/') ? trimmed.replace(/^\/\S*\s*/, '').trim() : trimmed
48}
49
50function seedOf(subject: string): string {
51  return clip(subject.split(/\s+/).slice(0, 6).join(' '), 40)
52}
53
54function parseTopics(reply: string): string[] | null {
55  const start = reply.indexOf('[')
56  const end = reply.lastIndexOf(']')
57  if (start < 0 || end <= start) return null
58  try {
59    const value: unknown = JSON.parse(reply.slice(start, end + 1))
60    if (!Array.isArray(value)) return null
61    const list = value
62      .filter((t): t is string => typeof t === 'string')
63      .map(t => clip(t.trim().replace(/[.!]+$/, ''), 40))
64      .filter(t => t.length > 0)
65      .slice(-MAX_TOPICS)
66    return list.length > 0 ? list : null
67  } catch {
68    return null
69  }
70}
71
72async function ask($: EngineInterface, current: string[], messages: string[]): Promise<string[] | null> {
73  const prompt = [
74    `Topics so far, oldest first: ${JSON.stringify(current)}`,
75    'Newest messages from the person, oldest first:',
76    ...messages.map(m => `<message>\n${m.slice(0, 1500)}\n</message>`),
77    'Reply with the updated JSON array.',
78  ].join('\n\n')
79  const reply = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt, maxTokens: 200, timeoutMs: 30_000 })
80  return reply.isAnswered ? parseTopics(reply.text) : null
81}
82
83async function loadAll($: EngineInterface): Promise<Record<string, Saved>> {
84  const value = await $.store.get(STORE_KEY)
85  return value !== null && typeof value === 'object' ? (value as Record<string, Saved>) : {}
86}
87
88// Kept per session id, so a resumed session shows its topics with no model call.
89async function save($: EngineInterface, list: string[]) {
90  const all = await loadAll($)
91  all[await $.session.id()] = { topics: list, at: await $.clock.now() }
92  const newest = Object.entries(all)
93    .sort((a, b) => b[1].at - a[1].at)
94    .slice(0, KEEP_SESSIONS)
95  await $.store.set(STORE_KEY, Object.fromEntries(newest))
96}
97
98// Module variables: a reload drops them, and the topics in $.state stay.
99let queue: string[] = []
100let isBusy = false
101let generation = 0
102
103async function drain($: EngineInterface) {
104  if (isBusy) return
105  isBusy = true
106  try {
107    while (queue.length > 0) {
108      const started = generation
109      const batch = queue.splice(0, queue.length)
110      const list = await ask($, await read($, topics), batch)
111      // A /clear while the model answered: that answer is about the old session.
112      if (list && started === generation) {
113        await update($, topics, () => list)
114        await save($, list)
115      }
116    }
117  } catch {
118    // A failed call leaves the topics as they were; the next prompt tries again.
119  } finally {
120    isBusy = false
121  }
122}
123
124// The model call runs on a timer of its own, so no prompt waits for it.
125function enqueue($: EngineInterface, messages: string[]) {
126  queue.push(...messages)
127  $.clock.after(0, () => void drain($))
128}
129
130async function reset($: EngineInterface) {
131  generation += 1
132  queue = []
133  await update($, topics, () => [])
134  await update($, seed, () => null)
135}
136
137export const register: Register = on => {
138  on('session.start', async ($, e, next) => {
139    const result = await next(e)
140    // A reload fires session.start again; the session's topics are still in $.state.
141    if ((await read($, topics)).length > 0) return result
142
143    const saved = (await loadAll($))[await $.session.id()]
144    if (saved && saved.topics.length > 0) {
145      await update($, topics, () => saved.topics)
146      return result
147    }
148
149    const earlier = (await $.session.messages())
150      .filter(m => m.role === 'user' && !m.text.trimStart().startsWith('<'))
151      .map(m => subjectOf(m.text))
152      .filter(s => s.length > 0)
153    if (earlier.length > 0) enqueue($, earlier.slice(-RESUME_MESSAGES))
154
155    return result
156  })
157
158  // Only what the person typed: a plugin's or a peer's prompt is no topic of theirs.
159  on('prompt.submit', async ($, e, next) => {
160    if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
161      const subject = subjectOf(e.text)
162      if (subject.length > 0) {
163        if ((await read($, seed)) === null) await update($, seed, () => seedOf(subject))
164        enqueue($, [subject])
165      }
166    }
167
168    return next(e)
169  }).catch(($, e, next) => next(e))
170
171  // No session.start follows a /clear, so the list starts over here.
172  on('session.end', async ($, e, next) => {
173    if (e.reason === 'clear') await reset($)
174
175    return next(e)
176  })
177
178  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179    // What other plugins drew in the band: drawn under this row, never replaced.
180    const below = await next(e)
181    if (e.props.hasSurvey) return below
182
183    const { Box, Button, Text } = $.ui.resolve(e)
184    const list = await read($, topics)
185    const first = await read($, seed)
186    const minimized = await read($, isMinimized)
187    const color = colorFor(await $.session.id())
188
189    const hasTopics = list.length > 0
190    const current = list[list.length - 1] ?? first ?? 'new session'
191    const earlier = list.slice(0, -1).reverse().join(' · ')
192
193    // The toggle, the dot and their spaces take four cells.
194    const room = e.props.bodyColumns - 4
195    const head = clip(current, minimized ? Math.min(MINI_WIDTH, room) : room)
196    const tail = minimized || earlier.length === 0 ? '' : clip(` · ${earlier}`, room - head.length)
197
198    const row = (
199      <Box>
200        <Button
201          key="topics-toggle"
202          plain
203          dimColor
204          label={minimized ? '▸' : '▾'}
205          onPress={() => update($, isMinimized, v => !v)}
206        />
207        <Text color={color}> ● </Text>
208        <Text color={color} bold={hasTopics} dimColor={!hasTopics}>
209          {head}
210        </Text>
211        {tail.length > 0 && <Text dimColor>{tail}</Text>}
212      </Box>
213    )
214
215    return below.type === 'engine' ? row : (
216      <Box flexDirection="column">
217        {row}
218        {below}
219      </Box>
220    )
221  })
222}
223
types/index.d.ts 8 lines
1export type Saved = { topics: string[]; at: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'session-topics': { topics: string[]; seed: string | null; isMinimized: boolean }
6  }
7}
8