SLOPSHOPPER

clauinfo

Model and effort at a glance above the prompt, with a toast at start and on every change

newbandtoasttimer
v0.1.0no licenseupdated 2026-10-11Flo0806/fh-claude-mods/plugins/clauinfo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clauinfo
› fix the failing auth test and add an audit log call ╭──────────────╮ │ clauinfo │ ⏺ Read(src/auth.ts) │ Opus 5.5 · ? │ ⎿ 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 ⟨Claude Code's own drawing⟩ Opus 5.5 · ? ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Opus 5.5 · ?
README

clauinfo

Model and effort at a glance: one quiet line above the prompt, and a toast at start and on every change.

Opus 5.5 · medium
ModelLook
FableClaude orange, bold
OpusPlain text
SonnetBlue
HaikuYellow
EffortLook
low, mediumDimmed
highBright, bold
xhighYellow, bold
maxRed, bold

Notes

  • clauinfo only shows; it never changes the model or the effort.
  • ? after a model switch means the effort is not known yet; the next request fills it in. A model that takes no effort (Haiku) shows none.
  • While the conversation compacts the line steps aside for the engine's progress.

Known gap

Claude Code gives mods no way to read the current effort directly: there is $.session.model(), but no $.session.effort() and no event when model or effort change. clauinfo therefore combines three sources:

  • /effort <level> is taken from the command itself, right away.
  • The /model and /effort pickers save to the settings, which clauinfo checks once a second.
  • Every model request reports the effort it really sends, which settles everything else.

So an effort that holds for this session only and was picked in the menu (max) shows with the next prompt, not before. Once Claude Code offers a direct source, clauinfo will switch to it.

Install

/plugin install clauinfo --marketplace Flo0806/fh-claude-mods
Source 3 files
hooks/register.tsx 111 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Info } from '../types'
5import { effortLook, label, modelLook, parseModel, sameModel, savedEffort } from './info'
6
7// Everything that takes `$` and every state value lives in this file: the engine follows `$` and
8// reads state references only within the module's own file, never across an import.
9
10const info = atom({ plugin: 'clauinfo', key: 'info' } as const, null)
11const compacting = atom({ plugin: 'clauinfo', key: 'compacting' } as const, false)
12
13const saved = atom({ plugin: 'clauinfo', key: 'saved' } as const, null)
14
15const POLL_MS = 1000
16
17const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
18
19// Keeps the newest reading and toasts when it says something else than before. A first reading
20// toasts too, so every session starts by naming its model.
21const show = async ($: EngineInterface, next: Info) => {
22  const before = await read($, info)
23  await update($, info, () => next)
24  const changed =
25    before === null ||
26    !sameModel(before.model, next.model) ||
27    (next.effort !== undefined &&
28      before.effort !== next.effort &&
29      !(next.effort === null && before.effort === undefined))
30  if (changed) $.ui.toast(label(next))
31}
32
33// What the session and the settings hold, applied only where it moved since the last look: the
34// picker of /model and /effort writes there, while an effort just for this session (max) does not,
35// and must not be overwritten by the saved one.
36const look = async ($: EngineInterface) => {
37  const model = await $.session.model()
38  const effort = savedEffort(await $.settings.read({}), model)
39  const last = await read($, saved)
40  await update($, saved, () => ({ model, effort }))
41
42  const shown = await read($, info)
43  if (last === null || shown === null) return show($, { model, effort })
44  if (last.model !== model) return show($, { model, effort })
45  if (last.effort !== effort && effort !== undefined) return show($, { ...shown, effort })
46}
47
48export const register: Register = (on) => {
49  on('session.start', async ($, e, next) => {
50    await look($)
51    $.clock.every(POLL_MS, () => void look($))
52    return next(e)
53  })
54
55  // What a request of the main conversation really sends: a fallback model or a lowered effort
56  // shows here, and null for a model that takes none.
57  on('turn.step', async function* ($, e, next) {
58    if (e.agentId === undefined) {
59      const effort =
60        typeof e.effort === 'string' ? e.effort : e.effort === undefined ? null : String(e.effort)
61      await show($, { model: e.model, effort })
62    }
63    return yield* next(e)
64  })
65
66  // `/effort max` names the level right away, one the settings never see when it is for this session only.
67  on('command.run', async ($, e, next) => {
68    const result = await next(e)
69    const level = e.args.trim().toLowerCase()
70    const shown = await read($, info)
71    if (e.command === 'effort' && EFFORTS.includes(level) && shown)
72      await show($, { ...shown, effort: level })
73    else if (e.command === 'model' || e.command === 'effort') await look($)
74    return result
75  })
76
77  // Step aside while the main conversation compacts, so the engine's progress shows in the band.
78  on('session.compact', async ($, e, next) => {
79    if (e.trigger === 'precompute' || e.agentId !== undefined) return next(e)
80    await update($, compacting, () => true)
81    try {
82      return await next(e)
83    } finally {
84      await update($, compacting, () => false)
85    }
86  })
87
88  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89    const shown = await read($, info)
90    if (e.props.hasSurvey || shown === null || (await read($, compacting))) return next(e)
91
92    const { Box, Text } = $.ui.resolve(e)
93    const model = parseModel(shown.model)
94
95    // Stack below other plugins' bands instead of replacing them.
96    const beneath = await next(e)
97
98    return (
99      <Box flexDirection="column">
100        {beneath}
101        <Box gap={1}>
102          <Text {...modelLook(model.family)}>{model.name}</Text>
103          {shown.effort !== null && <Text dimColor>·</Text>}
104          {shown.effort === undefined && <Text dimColor>?</Text>}
105          {shown.effort && <Text {...effortLook(shown.effort)}>{shown.effort}</Text>}
106        </Box>
107      </Box>
108    )
109  })
110}
111
hooks/info.ts 78 lines
1import type { Color } from 'claude-code'
2
3import type { Info } from '../types'
4
5const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku'] as const
6type Family = (typeof FAMILIES)[number]
7
8export type Model = { family?: Family; version?: string; name: string }
9
10// Reads `claude-opus-5-5`, `opus[1m]` or `Opus 5.5` alike; an unknown model keeps its own name.
11export const parseModel = (raw: string): Model => {
12  const lower = raw.toLowerCase()
13  const family = FAMILIES.find((f) => lower.includes(f))
14  if (!family) return { name: raw }
15  const digits = lower
16    .slice(lower.indexOf(family) + family.length)
17    .match(/^[-\s]*(\d+)(?:[-.](\d+))?/)
18  const version = digits?.[1] && (digits[2] ? `${digits[1]}.${digits[2]}` : digits[1])
19  const name = family[0]!.toUpperCase() + family.slice(1)
20  return { family, version, name: version ? `${name} ${version}` : name }
21}
22
23// Same model when the family matches and neither names a different version: `opus` and `claude-opus-5-5` agree.
24export const sameModel = (a: string, b: string) => {
25  const x = parseModel(a)
26  const y = parseModel(b)
27  if (!x.family || !y.family) return a === b
28  return x.family === y.family && (!x.version || !y.version || x.version === y.version)
29}
30
31// Fable stands out, Opus is the everyday one, Haiku the small one worth noticing.
32export const modelLook = (family?: Family): { color?: Color; bold?: boolean } => {
33  switch (family) {
34    case 'fable':
35      return { color: 'claude', bold: true }
36    case 'sonnet':
37      return { color: 'suggestion' }
38    case 'haiku':
39      return { color: 'warning' }
40    default:
41      return { color: 'text' }
42  }
43}
44
45// Low and medium recede, high is bright, everything above warns.
46export const effortLook = (
47  effort: string,
48): { color?: Color; bold?: boolean; dimColor?: boolean } => {
49  switch (effort) {
50    case 'low':
51    case 'medium':
52      return { dimColor: true }
53    case 'high':
54      return { color: 'text', bold: true }
55    case 'xhigh':
56      return { color: 'warning', bold: true }
57    case 'max':
58      return { color: 'error', bold: true }
59    default:
60      return { dimColor: true }
61  }
62}
63
64export const label = ({ model, effort }: Info) =>
65  effort === null ? parseModel(model).name : `${parseModel(model).name} · ${effort ?? '?'}`
66
67// The effort saved for a model: its own entry under modelSettings, else the global effortLevel.
68export const savedEffort = (settings: Readonly<Record<string, unknown>>, model: string) => {
69  const perModel = settings.modelSettings
70  if (perModel && typeof perModel === 'object') {
71    for (const [id, entry] of Object.entries(perModel)) {
72      const level = (entry as { effortLevel?: unknown } | null)?.effortLevel
73      if (typeof level === 'string' && sameModel(id, model)) return level
74    }
75  }
76  return typeof settings.effortLevel === 'string' ? settings.effortLevel : undefined
77}
78
types/index.d.ts 16 lines
1// What the band shows: the model as the engine names it and its effort, undefined while unknown
2// (no request since a switch), null for a model that takes none.
3export type Info = {
4  model: string
5  effort?: string | null
6}
7
8// The last look at the session's model and the effort the settings keep for it.
9export type Saved = { model: string; effort?: string }
10
11declare module 'claude-code' {
12  interface PluginState {
13    clauinfo: { info: Info | null; saved: Saved | null; compacting: boolean }
14  }
15}
16