SLOPSHOPPER

model-tag

Per-message model and effort: put @haiku or @max anywhere in a prompt and that one turn runs on it

newstatusprompt
★ 1v0.2.1MITupdated 2026-10-08yyosifov/prompt-mods/mods/model-tag
A shopper browsing a rack in a slop shop
README

model-tag

Pick the model and effort for one message without leaving the prompt box.

explain this stack trace @haiku
@opus @max find the race condition
quick one @low

The tags are removed from the message, that turn runs on what they name (tool loops included), and the next message goes back to the session's settings. Subagents keep their own model.

No tags, no change: the mod does nothing until a message carries a tag. While a tagged turn runs, the status line shows what it runs on, like ▸ haiku · low (this turn).

Tags

TagModel
@opusclaude-opus-5-5
@sonnetclaude-sonnet-5-5
@haikuclaude-haiku-5-5
@fableclaude-fable-5-1
@claude-…any full model id, as written
::anythingany model string, as written

Add [1m] for the 1M context variant: @opus[1m].

Effort tag
@low @medium @high @xhigh @maxreasoning effort for this message

Model and effort tags combine: @haiku @low. A model that takes no effort setting ignores the effort tag.

  • @ only counts as a tag for model names and near misses, so @src/app.ts and @agent-reviewer stay normal mentions.
  • A typo like @hiaku or @hihg opens a picker with the closest match first. Dismiss it and the message is not sent.
  • A dim line in the transcript (▸ This message runs on haiku (…), low effort) and the status line confirm it.

Install

/plugin install model-tag --marketplace yyosifov/prompt-mods

Notes

  • Short names map to full ids in hooks/models.ts: mods send the model string to the API as written. PRs welcome when new models ship.
  • Want to switch the session's model without losing your draft? That's built in: Option+P (needs Option-as-Meta in your terminal), or Ctrl+S to stash the draft, run /model, and it comes back.
Source 4 files
hooks/register.ts 121 lines
1import type { EngineInterface, Register } from 'claude-code';
2
3import { EFFORT_PICKER, type Effort, isEffort } from './efforts';
4import { PICKER_OPTIONS, resolveModel, shortName } from './models';
5import { closestName, parseTags } from './parse';
6
7// What one tagged message asks for; a field left out keeps the session's.
8type Choice = { model?: { name: string; id: string }; effort?: Effort };
9
10async function pick($: EngineInterface, word: string, kind: 'model' | 'effort'): Promise<string | null> {
11  const guess = closestName(word)?.name;
12  const all = kind === 'model' ? PICKER_OPTIONS : EFFORT_PICKER;
13  const options = guess !== undefined && all.includes(guess) ? [guess, ...all.filter((o) => o !== guess)] : all;
14  try {
15    const answer = await $.ui.ask(`No ${kind} called "${word}". Which one should this message use?`, {
16      header: kind === 'model' ? 'Model' : 'Effort',
17      options,
18    });
19    return answer.toLowerCase();
20  } catch {
21    return null;
22  }
23}
24
25const label = (model: string, effort: string | number | undefined): string => (effort === undefined ? model : `${model} · ${effort}`);
26
27export const register: Register = (on) => {
28  const pending: Choice[] = [];
29  const byTurn = new Map<string, Choice>();
30
31  // A reload must not leave a line from an earlier load pinned.
32  on('session.start', ($, e, next) => {
33    $.ui.status(undefined);
34    return next(e);
35  });
36
37  on('prompt.submit', async ($, e, next) => {
38    const tags = parseTags(e.text);
39    if (tags === null) {
40      return next(e);
41    }
42
43    if (tags.text === '') {
44      return { drop: 'A model or effort tag needs a message to go with it' };
45    }
46
47    const choice: Choice = {};
48    if (tags.model !== undefined) {
49      let name = tags.model;
50      let id = resolveModel(name);
51      if (id === null) {
52        name = (await pick($, tags.model, 'model')) ?? '';
53        id = resolveModel(name);
54      }
55      if (id === null) {
56        return { drop: `No model called "${tags.model}"; the message was not sent` };
57      }
58      choice.model = { name, id };
59    }
60    if (tags.effort !== undefined) {
61      let effort: string | null = tags.effort;
62
63      if (!isEffort(effort)) {
64        effort = await pick($, tags.effort, 'effort');
65      }
66      if (effort === null || !isEffort(effort)) {
67        return { drop: `No effort called "${tags.effort}"; the message was not sent` };
68      }
69      choice.effort = effort;
70    }
71
72    if (e.turnId !== undefined) {
73      // Delivered into the running turn: its remaining steps take the choice.
74      byTurn.set(e.turnId, choice);
75    } else {
76      pending.push(choice);
77    }
78
79    const what = [choice.model && `${choice.model.name} (${choice.model.id})`, choice.effort && `${choice.effort} effort`];
80    $.ui.log(`▸ This message runs on ${what.filter(Boolean).join(', ')}`);
81
82    return next({ ...e, text: tags.text });
83  });
84
85  on('turn.start', ($, e, next) => {
86    const choice = pending.shift();
87    if (choice !== undefined) {
88      byTurn.set(e.turnId, choice);
89    }
90
91    return next(e);
92  });
93
94  on('turn.step', async function* ($, e, next) {
95    // Subagents keep the model they were given.
96    const isSubagent = e.agentId !== undefined;
97    if (isSubagent) {
98      return yield* next(e);
99    }
100
101    const choice = byTurn.get(e.turnId);
102    if (choice === undefined) {
103      return yield* next(e);
104    }
105
106    const model = choice.model?.id ?? e.model;
107    const effort = choice.effort ?? e.effort;
108    $.ui.status(`▸ ${label(shortName(model), effort)} (this turn)`);
109
110    return yield* next({ ...e, model, ...(effort === undefined ? {} : { effort }) });
111  });
112
113  on('turn.complete', ($, e, next) => {
114    if (e.agentId === undefined && byTurn.delete(e.turnId)) {
115      $.ui.status(undefined);
116    }
117
118    return next(e);
119  });
120};
121
hooks/efforts.ts 9 lines
1export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
2
3export type Effort = (typeof EFFORTS)[number];
4
5// AskUserQuestion takes four options; `xhigh` is still reachable under Other.
6export const EFFORT_PICKER = ['low', 'medium', 'high', 'max'];
7
8export const isEffort = (word: string): word is Effort => (EFFORTS as readonly string[]).includes(word);
9
hooks/models.ts 30 lines
1// `turn.step` sends the model string to the API as written, so short names
2// are mapped to full ids here. Update when new models ship.
3export const MODELS: Readonly<Record<string, string>> = {
4  opus: 'claude-opus-5-5',
5  sonnet: 'claude-sonnet-5-5',
6  haiku: 'claude-haiku-5-5',
7  fable: 'claude-fable-5-1',
8};
9
10export const PICKER_OPTIONS = Object.keys(MODELS);
11
12const LONG_CONTEXT = '[1m]';
13
14export const resolveModel = (name: string): string | null => {
15  const isLong = name.endsWith(LONG_CONTEXT);
16  const base = isLong ? name.slice(0, -LONG_CONTEXT.length) : name;
17  const id = MODELS[base] ?? (base.startsWith('claude-') ? base : null);
18
19  return id === null ? null : id + (isLong ? LONG_CONTEXT : '');
20};
21
22export const shortName = (id: string): string => {
23  const isLong = id.endsWith(LONG_CONTEXT);
24  const base = isLong ? id.slice(0, -LONG_CONTEXT.length) : id;
25  const known = Object.entries(MODELS).find(([, full]) => full === base)?.[0];
26  const name = known ?? base.replace(/^claude-/, '').replace(/-\d{8}$/, '');
27
28  return name + (isLong ? LONG_CONTEXT : '');
29};
30
hooks/parse.ts 96 lines
1import { EFFORTS } from './efforts';
2import { MODELS } from './models';
3
4// A tag stands on its own, at the start of the text or after whitespace, so
5// `Foo::bar()` and `me@opus.dev` are left alone.
6//   `@haiku`, `@low`: model and effort names and near misses (`@hiaku`), so
7//     `@src/app.ts` and `@agent-x` stay mentions
8//   `::anything`: any model string, passed through as written
9const AT_TAG = /(^|\s)@([a-z][\w.-]*(?:\[1m\])?)(?=\s|$)/gi;
10const COLON_TAG = /(^|\s)::([a-z][\w.\-[\]]*)(?=\s|$)/gi;
11const MODEL_NAMES = Object.keys(MODELS);
12const MAX_TYPOS = 2;
13// Shorter words must match exactly: `@me` is too far from anything to guess.
14const MIN_FUZZY_LENGTH = 4;
15
16export type Kind = 'model' | 'effort';
17
18export type Tags = { model?: string; effort?: string; text: string };
19
20const distance = (a: string, b: string): number => {
21  let row = Array.from({ length: b.length + 1 }, (_, j) => j);
22  for (let i = 1; i <= a.length; i++) {
23    const next = [i];
24    for (let j = 1; j <= b.length; j++) {
25      next[j] = Math.min((row[j] ?? 0) + 1, (next[j - 1] ?? 0) + 1, (row[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1));
26    }
27    row = next;
28  }
29
30  return row[b.length] ?? 0;
31};
32
33export const closestName = (word: string): { name: string; kind: Kind } | null => {
34  const base = word.toLowerCase().replace(/\[1m\]$/, '');
35  const limit = base.length >= MIN_FUZZY_LENGTH ? MAX_TYPOS : 0;
36  let best: { name: string; kind: Kind } | null = null;
37  let bestDistance = limit + 1;
38  const candidates: [string, Kind][] = [
39    ...MODEL_NAMES.map((name): [string, Kind] => [name, 'model']),
40    ...EFFORTS.map((name): [string, Kind] => [name, 'effort']),
41  ];
42  for (const [name, kind] of candidates) {
43    const nameDistance = distance(base, name);
44    if (nameDistance < bestDistance) {
45      best = { name, kind };
46      bestDistance = nameDistance;
47    }
48  }
49
50  return best;
51};
52
53const kindOf = (word: string): Kind | null => (/^claude-/i.test(word) ? 'model' : (closestName(word)?.kind ?? null));
54
55export const parseTags = (text: string): Tags | null => {
56  const found: { start: number; end: number; kind: Kind; word: string }[] = [];
57  for (const match of text.matchAll(AT_TAG)) {
58    const word = match[2] ?? '';
59    const kind = kindOf(word);
60    if (kind !== null) {
61      const start = (match.index ?? 0) + (match[1] ?? '').length;
62      found.push({ start, end: start + word.length + 1, kind, word });
63    }
64  }
65  for (const match of text.matchAll(COLON_TAG)) {
66    const word = match[2] ?? '';
67    const start = (match.index ?? 0) + (match[1] ?? '').length;
68    found.push({ start, end: start + word.length + 2, kind: 'model', word });
69  }
70  if (found.length === 0) {
71    return null;
72  }
73
74  let stripped = text;
75  for (const tag of [...found].sort((a, b) => b.start - a.start)) {
76    stripped = `${stripped.slice(0, tag.start).trimEnd()} ${stripped.slice(tag.end).trimStart()}`;
77  }
78
79  const first = (kind: Kind): string | undefined =>
80    found
81      .filter((t) => t.kind === kind)
82      .sort((a, b) => a.start - b.start)[0]
83      ?.word.toLowerCase();
84  const tags: Tags = { text: stripped.trim() };
85  const model = first('model');
86  const effort = first('effort');
87  if (model !== undefined) {
88    tags.model = model;
89  }
90  if (effort !== undefined) {
91    tags.effort = effort;
92  }
93
94  return tags;
95};
96