SLOPSHOPPER

model-info

Model and thinking effort pickers for the main agent, above the prompt

newbandtoasttimer
v0.3.0no licenseupdated 2026-10-10katipally/modz/mods/ui/model-info
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-info
› 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 Opus 5.5 ▾ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

model-info

Pick the model and thinking effort, right above the prompt.

Opus 5.5 ▾   high ▾

Install

/plugin install model-info --marketplace katipally/modz

Use

  • The model dropdown lists exactly what /config's Model row offers your account. Picking one runs Claude Code's own /model.
  • The effort dropdown lists the levels /effort offers for the current model, and refreshes after a model switch. Picking one runs /effort.
  • If a model does not support a level, /effort says so in a toast.
  • Shows the main agent only; subagents are ignored.
  • In the terminal each dropdown is a button that opens a row of options you can click (a terminal Select only answers the keyboard).
Source 2 files
hooks/register.tsx 160 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, BoxProps, ElementConstructor, EngineInterface, RenderElement, RenderNode, SelectOption } from 'claude-code'
3
4import type { Choices, Info } from '../types'
5
6const info = atom({ plugin: 'model-info', key: 'info' } as const, null)
7const choices = atom({ plugin: 'model-info', key: 'choices' } as const, { models: [], efforts: [] })
8const isConfirmed = atom({ plugin: 'model-info', key: 'isConfirmed' } as const, false)
9const openMenu = atom({ plugin: 'model-info', key: 'openMenu' } as const, null)
10const CONFIRM_MS = 1500
11
12const confirm = async ($: EngineInterface) => {
13  await update($, isConfirmed, () => true)
14  $.clock.after(CONFIRM_MS, () => void update($, isConfirmed, () => false))
15}
16const TINT = 'merged'
17
18// /effort's hint reads like "[low|medium|high|max|auto|ultracode [on|off]]": its bare words are the levels.
19const levelsOf = (hint: string) => hint.replace(/^[[<]|[\]>]$/g, '').split('|').filter(w => /^[a-z]+$/.test(w))
20const title = (alias: string) => alias[0]!.toUpperCase() + alias.slice(1)
21
22// "claude-opus-5-5" reads "Opus 5.5"; an id in another shape is shown as given.
23const modelName = (id: string) => {
24  const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d])/.exec(id)
25  return m ? `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ''}` : id
26}
27
28// Every modz mod places its tile in one shared band: a lead column (the bot), then a column of tiles, then an optional
29// right column. A column with no tiles is not drawn, and columns wrap under each other when the width runs out.
30const BAND = 'modz-band'
31const SLOTS: Record<string, 'lead' | 'left' | 'right'> = {
32  buddy: 'lead',
33  'usage-limits': 'left',
34  'cache-timer': 'left',
35  'model-info': 'left',
36  orchestrator: 'left',
37}
38const ORDER = Object.keys(SLOTS)
39const keyOf = (n: RenderNode) => (typeof n !== 'string' && n.type === 'Box' ? String(n.props?.key ?? '') : '')
40const rank = (n: RenderNode) => {
41  const i = ORDER.indexOf(keyOf(n))
42  return i < 0 ? ORDER.length : i
43}
44const pack = (Box: ElementConstructor<BoxProps>, below: RenderElement, tile: RenderElement) => {
45  const isEmpty = below.type === 'Box' && !below.children?.length
46  const tiles = keyOf(below) === BAND
47    ? (below.type === 'Box' ? below.children ?? [] : []).flatMap(col => (typeof col !== 'string' && col.type === 'Box' ? col.children ?? [] : [col]))
48    : isEmpty ? [] : [below]
49  const all = [...tiles, tile].sort((a, b) => rank(a) - rank(b))
50  const [lead, left, right] = (['lead', 'left', 'right'] as const).map(slot => all.filter(n => (SLOTS[keyOf(n)] ?? 'left') === slot))
51  return (
52    <Box key={BAND} flexDirection="row" flexWrap="wrap" columnGap={3}>
53      {lead!.length > 0 && <Box key="modz-lead" flexDirection="column" justifyContent="center">{lead}</Box>}
54      {left!.length > 0 && <Box key="modz-left" flexDirection="column" flexGrow={right!.length > 0 ? 1 : 0}>{left}</Box>}
55      {right!.length > 0 && <Box key="modz-right" flexDirection="column" alignItems="flex-end">{right}</Box>}
56    </Box>
57  )
58}
59
60
61// The model row of /config lists what this account can pick; listing the commands asks /effort for its hint.
62const loadChoices = async ($: EngineInterface) => {
63  const row = (await $.config.list()).find(r => r.key === 'model')
64  await update($, choices, c => ({ ...c, models: [...(row?.options ?? [])] }))
65  await $.command.list()
66}
67
68export const register: Register = on => {
69  on('session.start', async ($, e, next) => {
70    const model = await $.session.model()
71    await update($, info, i => ({ ...i, model }))
72    await loadChoices($)
73    return next(e)
74  })
75
76  on('command.describe', { command: 'effort' }, async ($, e, next) => {
77    if (e.argumentHint) {
78      const efforts = levelsOf(e.argumentHint)
79      await update($, choices, c => ({ ...c, efforts }))
80    }
81    return next(e)
82  })
83
84  on('turn.step', async function* ($, e, next) {
85    if (e.agentId === undefined) {
86      await update($, info, () => ({ model: e.model, effort: e.effort === undefined ? undefined : String(e.effort) }))
87    }
88    return yield* next(e)
89  })
90
91  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92    const below = await next(e)
93    if (e.props.hasSurvey) return below
94    const [i, c, ok, menu] = await Promise.all([read($, info), read($, choices), read($, isConfirmed), read($, openMenu)])
95    if (!i) return below
96    const elements = $.ui.resolve(e)
97    const { Box, Text } = elements
98
99    // Both run Claude Code's own /model and /effort, so the change is the session's, not a display.
100    const run = async (command: string, args: string) => {
101      const { text } = await $.command.run({ command, args })
102      if (text) $.ui.toast(text)
103    }
104    const setModel = async (alias: string) => {
105      await run('model', alias)
106      const model = await $.session.model()
107      await update($, info, cur => ({ ...cur, model }))
108      await $.command.list()
109      await confirm($)
110    }
111    const setEffort = async (effort: string) => {
112      await run('effort', effort)
113      await update($, info, cur => ({ model: cur?.model ?? i.model, effort }))
114      await confirm($)
115    }
116
117    const plain = (
118      <Box key="model-info" flexDirection="row">
119        <Text bold color={TINT}>{modelName(i.model)}</Text>
120        {i.effort && <Text dimColor> · {i.effort}</Text>}
121      </Box>
122    )
123    if (!('Select' in elements)) return pack(Box, below, plain)
124    const { Button, Select } = elements
125    // In the terminal a Select answers only the keyboard, so there a dropdown is a button that opens a row of
126    // option buttons a click can pick from; every other surface keeps its native Select.
127    const dropdown = (key: string, value: string | undefined, options: readonly SelectOption[], onSelect: (v: string) => unknown) => {
128      if (e.surface !== 'terminal') return <Select key={key} value={value} options={options} onSelect={onSelect} />
129      const isShown = menu === key
130      const labelOf = (v: string | undefined) => options.find(o => o.value === v)?.label ?? v ?? '…'
131      return (
132        <Box flexDirection="row" columnGap={1}>
133          <Button key={key} plain label={`${labelOf(value)} ${isShown ? '▴' : '▾'}`} onPress={() => update($, openMenu, m => (m === key ? null : key))} />
134          {isShown && options.filter(o => o.value !== value).map(o => (
135            <Button key={`${key}:${o.value}`} plain dimColor label={o.label ?? o.value} onPress={async () => {
136              await update($, openMenu, () => null)
137              await onSelect(o.value)
138            }} />
139          ))}
140        </Box>
141      )
142    }
143
144    // The alias the current model answers to; "default" only when nothing more specific matches.
145    const current = c.models.find(a => a !== 'default' && i.model.includes(a.replace(/\[.*\]$/, ''))) ?? i.model
146    const models = c.models.includes(current) ? c.models : [current, ...c.models]
147    const efforts = !i.effort || c.efforts.includes(i.effort) ? c.efforts : [i.effort, ...c.efforts]
148
149    return pack(Box, below, (
150      <Box key="model-info" flexDirection="row" flexWrap="wrap" columnGap={2}>
151        {dropdown('model', current, models.map(a => ({ value: a, label: a === current ? modelName(i.model) : title(a) })), setModel)}
152        {efforts.length > 0
153          ? dropdown('effort', i.effort, efforts.map(level => ({ value: level })), setEffort)
154          : i.effort && <Text dimColor>{i.effort}</Text>}
155        {ok && <Text color="success">✓</Text>}
156      </Box>
157    ))
158  })
159}
160
types/index.d.ts 10 lines
1export type Info = { model: string; effort?: string }
2
3export type Choices = { models: string[]; efforts: string[] }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'model-info': { info: Info | null; choices: Choices; isConfirmed: boolean; openMenu: string | null }
8  }
9}
10