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

Pick the model and thinking effort, right above the prompt.
Opus 5.5 ▾ high ▾
/plugin install model-info --marketplace katipally/modz
/config's Model row offers your account. Picking one runs Claude Code's own /model./effort offers for the current model, and refreshes after a model switch. Picking one runs /effort./effort says so in a toast.hooks/register.tsx 160 lines1import { 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}
160types/index.d.ts 10 lines1export 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