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

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
| Model | Look |
|---|---|
| Fable | Claude orange, bold |
| Opus | Plain text |
| Sonnet | Blue |
| Haiku | Yellow |
| Effort | Look |
|---|---|
low, medium | Dimmed |
high | Bright, bold |
xhigh | Yellow, bold |
max | Red, bold |
? 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.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./model and /effort pickers save to the settings, which clauinfo checks once a second.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.
/plugin install clauinfo --marketplace Flo0806/fh-claude-modshooks/register.tsx 111 lines1import { 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}
111hooks/info.ts 78 lines1import 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}
78types/index.d.ts 16 lines1// 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