Triages each message with a small model before the main model runs, and asks (native modal) whether to switch model/effort when another one fits better and the…

A Claude Code mod that triages every message before it reaches your main model.
AskUserQuestion modal opens with a summary box: switch, send anyway, or cancel and edit.turn.step) and is saved with /model at turn.complete.advisorModel configured, upward suggestions are skipped (the advisor already covers hard moments).$.store).claude plugin marketplace add StellaLunaSousa/triage-gate
claude plugin install triage-gate@triage-gate
Inside a session, run /reload-plugins (or restart) to load it.
claude plugin marketplace update triage-gate
claude plugin update triage-gate@triage-gate
Auto-update is off by default for any marketplace. To enable it: /plugin → Marketplaces → triage-gate → Enable auto-update. You only receive a new copy when version in plugin.json changes.
/triage-gate with one of:
| Argument | Effect | | :- | :- | | on / off | Turn the triage on or off | | modal | Open the modal on every divergent message (default) | | band | Hold the message in the band; the modal opens only when the message has images | | why | Print the last triage decision | | demo [1\|2] | Preview the modal (1 = one tier apart, 2 = two tiers apart) |
Very short messages, commands and continuations ("ok", "go ahead"…) skip the triage.
MODEL_IDS (claude-haiku-5-5, claude-sonnet-5-5, claude-opus-5-5). New model generations need a code change./effort notice bubble appears after an automatic switch.git clone https://github.com/StellaLunaSousa/triage-gate
cd triage-gate
claude plugin validate .
claude plugin validate ./plugins/triage-gate
The generated type declarations (plugins/triage-gate/.claude-plugin/types/) are not committed because they depend on your Claude Code version. See Get the types for your build, then run:
claude plugin test ./plugins/triage-gate
To try local changes: claude --plugin-dir ./plugins/triage-gate.
claude plugin uninstall triage-gate@triage-gate
claude plugin marketplace remove triage-gate
hooks/register.tsx 617 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Effort, Pending, Tier } from '../types'
5
6const pending = atom({ plugin: 'triage-gate', key: 'pending' } as const, null)
7const asking = atom({ plugin: 'triage-gate', key: 'asking' } as const, null)
8const override = atom({ plugin: 'triage-gate', key: 'override' } as const, null)
9const isOn = atom({ plugin: 'triage-gate', key: 'isOn' } as const, true)
10const isModal = atom({ plugin: 'triage-gate', key: 'isModal' } as const, true)
11const shownModel = atom({ plugin: 'triage-gate', key: 'shownModel' } as const, '')
12const shownEffort = atom({ plugin: 'triage-gate', key: 'shownEffort' } as const, '')
13const decision = atom({ plugin: 'triage-gate', key: 'decision' } as const, { verdict: 'Nenhuma mensagem avaliada ainda' })
14const isBandOpen = atom({ plugin: 'triage-gate', key: 'isBandOpen' } as const, true)
15
16export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus']
17
18export type Verdict = { family: Tier; effort: Effort; reason: string }
19
20export type Choice = 'switch' | 'send' | 'cancel'
21
22const EFFORT_PT: Record<Effort, string> = { low: 'baixo', medium: 'médio', high: 'alto' }
23
24const CONTINUATION = /^(sim|n[ãa]o|ok|pode|continu\w*|segu\w*|faz|vai|isso|beleza|certo|quero|bora|valeu|obrigad\w+|manda|tenta|vamos|testa|teste)\b/i
25
26export function isSkippable(text: string): boolean {
27 const trimmed = text.trim()
28 return trimmed.length < 12 || trimmed.startsWith('/') || (trimmed.length < 60 && CONTINUATION.test(trimmed))
29}
30
31export function familyOf(modelId: string): Tier | null {
32 const family = /(haiku|sonnet|opus)/i.exec(modelId)?.[1]?.toLowerCase()
33 return family === undefined ? null : (family as Tier)
34}
35
36export function capitalize(text: string): string {
37 return text.charAt(0).toUpperCase() + text.slice(1)
38}
39
40const MODEL = /^(?:claude[-\s])?(haiku|sonnet|opus|fable)(?:[-\s]?(\d{1,2})(?:[-.\s](\d{1,2}))?)?/i
41
42export function prettyModel(raw: string): string {
43 const match = MODEL.exec(raw.trim())
44 if (match === null) return raw.trim()
45 const version = match[2] === undefined ? '' : match[3] === undefined ? match[2] : `${match[2]}.${match[3]}`
46 const family = capitalize(match[1]!.toLowerCase())
47 return version === '' ? family : `${family} ${version}`
48}
49
50// turn.step rejects the bare alias ("haiku não está disponível"); it needs the full id.
51const MODEL_IDS: Record<Tier, string> = { haiku: 'claude-haiku-5-5', sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5' }
52
53export function modelIdFor(tier: Tier): string {
54 return MODEL_IDS[tier]
55}
56
57export function modelNotice(text: string | undefined): string | null {
58 const name = /Set model to `([^`]+)`/.exec(text ?? '')?.[1]
59 return name === undefined ? null : `Modelo da sessão: ${name}`
60}
61
62export function effortNotice(text: string | undefined): string | null {
63 const level = /Set effort level to (\w+)/.exec(text ?? '')?.[1]
64 return level === undefined ? null : `Esforço da sessão: ${level in EFFORT_PT ? EFFORT_PT[level as Effort] : level}`
65}
66
67const EFFORT_LABEL: Record<string, string> = { ...EFFORT_PT, xhigh: 'extra alto', max: 'máximo' }
68
69export function effortLabel(raw: unknown): string {
70 if (typeof raw === 'number') return String(raw)
71 if (typeof raw !== 'string' || raw === '') return ''
72 return EFFORT_LABEL[raw.toLowerCase()] ?? raw
73}
74
75export function effortFromOutput(text: string | undefined): string | null {
76 return /Set effort level to (\w+)/.exec(text ?? '')?.[1]?.toLowerCase() ?? null
77}
78
79export function effortWord(effort: Effort): string {
80 return EFFORT_PT[effort]
81}
82
83const LIMIT = 1500
84
85export function buildPrompt(text: string, lastReply: string): string {
86 const context = lastReply.trim() === '' ? '' : `Última resposta do assistente (contexto, cortada): """${lastReply.trim().slice(0, 300)}"""\n`
87 return [
88 'Escolha o modelo mais barato que dá conta da mensagem do usuário.',
89 'haiku: perguntas simples, pesquisa, textos, docs, planilhas, mensagens, uso do browser, edição pontual de código.',
90 'sonnet: código com várias etapas (rodar, testar, iterar), features, bugs.',
91 'opus: arquitetura, debug difícil, refactors grandes, planejamento, revisão crítica.',
92 'esforço: low (trivial), medium (padrão), high (várias etapas ou armadilhas).',
93 'Responda numa linha só, neste formato exato: modelo | esforço | motivo em até 8 palavras, em português.',
94 'Exemplo: haiku | low | pergunta simples de clima',
95 context + `Mensagem do usuário: """${text.trim().slice(0, LIMIT)}"""`,
96 ].join('\n')
97}
98
99export function parseVerdict(reply: string): Verdict | null {
100 const match = /^\s*(haiku|sonnet|opus)\s*\|\s*(low|medium|high)\s*\|\s*(.+?)\s*$/im.exec(reply)
101 if (match === null) return null
102 return { family: match[1]!.toLowerCase() as Tier, effort: match[2]!.toLowerCase() as Effort, reason: capitalize(match[3]!) }
103}
104
105export function gapOf(current: Tier, family: Tier): number {
106 return Math.abs(TIERS.indexOf(current) - TIERS.indexOf(family))
107}
108
109export type Price = { input: number; output: number; read: number }
110
111// $/MTok from the claude-api reference (cached 2026-10-06); Haiku's cache read is not listed, assumed 0.1x input.
112const PRICES: Record<Tier, Price> = {
113 haiku: { input: 0.1, output: 0.5, read: 0.01 },
114 sonnet: { input: 2, output: 10, read: 0.2 },
115 opus: { input: 4, output: 20, read: 0.2 },
116}
117const HAIKU_LONG: Price = { input: 0.5, output: 2.5, read: 0.05 }
118const HAIKU_LONG_FROM = 100_000
119// Claude Code writes with the 1-hour TTL, billed at 2x input.
120const WRITE = 2
121export const CACHE_TTL_MS = 60 * 60 * 1000
122const REPLY_TOKENS = 2000
123const PAYBACK_TURNS = 3
124
125export function priceFor(tier: Tier, tokens: number): Price {
126 return tier === 'haiku' && tokens > HAIKU_LONG_FROM ? HAIKU_LONG : PRICES[tier]
127}
128
129export function turnCost(tier: Tier, tokens: number, isWarm: boolean): number {
130 const price = priceFor(tier, tokens)
131 return (tokens * (isWarm ? price.read : price.input * WRITE) + REPLY_TOKENS * price.output) / 1_000_000
132}
133
134export type Economics = { isDown: boolean; isWorth: boolean; ratio: number; saving: number; payback: number }
135
136export function economicsOf(current: Tier, target: Tier, tokens: number, isWarm: boolean): Economics {
137 const stay = turnCost(current, tokens, isWarm)
138 const change = turnCost(target, tokens, false)
139 const saving = turnCost(current, tokens, true) - turnCost(target, tokens, true)
140 const extra = change - stay
141 const payback = extra <= 0 ? 0 : saving > 0 ? Math.ceil(extra / saving) : Infinity
142 return {
143 isDown: TIERS.indexOf(target) < TIERS.indexOf(current),
144 isWorth: payback <= PAYBACK_TURNS,
145 ratio: stay > 0 ? change / stay : 1,
146 saving: saving / turnCost(current, tokens, true),
147 payback,
148 }
149}
150
151export function costNote(econ: Economics, tokens: number): string | null {
152 const size = `~${Math.round(tokens / 1000)}K tokens`
153 if (econ.isDown) {
154 const pct = Math.round(econ.saving * 100)
155 return econ.payback === 0
156 ? `Contexto de ${size}: já fica ~${pct}% mais barato nesta mensagem.`
157 : `Contexto de ${size}: compensa em ${econ.payback} mensagens, depois ~${pct}% mais barato.`
158 }
159 return econ.ratio >= 3 ? `Contexto de ${size}: esta troca custa ~${Math.round(econ.ratio)}× mais nesta mensagem.` : null
160}
161
162const ADVISOR_RANK: Record<string, number> = { haiku: 0, sonnet: 1, opus: 2, fable: 3 }
163
164export function advisorRank(raw: unknown): number | null {
165 if (typeof raw !== 'string') return null
166 const family = /(haiku|sonnet|opus|fable)/i.exec(raw)?.[1]?.toLowerCase()
167 return family === undefined ? null : ADVISOR_RANK[family]!
168}
169
170export function advisorCovers(advisor: unknown, current: Tier, target: Tier): boolean {
171 const rank = advisorRank(advisor)
172 if (rank === null) return false
173 const to = TIERS.indexOf(target)
174 return to > TIERS.indexOf(current) && rank >= to && rank >= TIERS.indexOf(current)
175}
176
177export function asDecision(raw: unknown): Decision {
178 if (typeof raw === 'string') return { verdict: raw }
179 if (typeof raw === 'object' && raw !== null && typeof (raw as Decision).verdict === 'string') return raw as Decision
180 return { verdict: 'Nenhuma mensagem avaliada ainda' }
181}
182
183export function decisionText(d: Decision): string {
184 return [d.verdict, d.route === undefined ? '' : `${d.route}${d.reason === undefined ? '' : ` (${d.reason})`}`, d.detail ?? '']
185 .filter(part => part !== '')
186 .join(' · ')
187}
188
189export function switchedText(p: Pending): string {
190 return `Modelo alterado para ${capitalize(p.family)} com esforço ${effortWord(p.effort)}. O seletor do app pode continuar mostrando o anterior.`
191}
192
193export function failedText(p: Pending): string {
194 return `Não foi possível alterar para ${capitalize(p.family)}. Troque pelo seletor do app.`
195}
196
197type Palette = { border: string; fill: string; accent: string; text: string; muted: string }
198
199const CAUTION: Palette = { border: '#d97706', fill: '#3b1d06', accent: '#fbbf24', text: '#fffbeb', muted: '#fcd34d' }
200const BIG: Palette = { border: '#dc2626', fill: '#3b0a0a', accent: '#f87171', text: '#fef2f2', muted: '#fca5a5' }
201
202export function paletteFor(gap: number): Palette {
203 return gap >= 2 ? BIG : CAUTION
204}
205
206export function statusFor(gap: number): string {
207 return gap >= 2 ? '❚❚ Aguardando você' : '⇄ Troca sugerida'
208}
209
210export function headerFor(p: Pending): string {
211 return `${p.gap >= 2 ? '❚❚' : '⇄'} Triagem`
212}
213
214export function labelsFor(p: Pending): [string, string, string] {
215 return [`Trocar para ${capitalize(p.family)} e enviar`, 'Enviar mesmo assim', 'Cancelar e editar']
216}
217
218export function choiceOf(answer: string, p: Pending): Choice | null {
219 const [change, send, cancel] = labelsFor(p)
220 return answer === change ? 'switch' : answer === send ? 'send' : answer === cancel ? 'cancel' : null
221}
222
223export function questionFor(p: Pending) {
224 const [change, send, cancel] = labelsFor(p)
225 const family = capitalize(p.family)
226 const preview = [
227 `Atual: ${p.current}`,
228 `Recomendado: ${family}`,
229 `Esforço: ${effortWord(p.effort)}`,
230 `Motivo: ${p.reason}`,
231 ].join('\n')
232 return {
233 header: headerFor(p),
234 question: 'Como seguir com esta mensagem?',
235 multiSelect: false,
236 options: [
237 { label: change, description: `Passa para ${family} com esforço ${effortWord(p.effort)}.`, preview },
238 { label: send, description: `Segue com ${p.current}, sem trocar.` },
239 {
240 label: cancel,
241 description: p.hasImages
242 ? 'O texto volta para a caixa; as imagens ficam na conversa acima, é só copiar.'
243 : 'A mensagem volta para a caixa de texto.',
244 },
245 ],
246 }
247}
248
249async function refreshModel($: EngineInterface): Promise<void> {
250 try {
251 const name = prettyModel(String(await $.session.model()))
252 await update($, shownModel, () => name)
253 } catch {
254 return
255 }
256}
257
258async function readAdvisor($: { settings: { read: () => Promise<unknown> } }): Promise<unknown> {
259 try {
260 return ((await $.settings.read()) as Record<string, unknown>).advisorModel
261 } catch {
262 return undefined
263 }
264}
265
266export const register: Register = on => {
267 // 0 = unknown (fresh load): treated as a warm cache, which only makes downgrades rarer.
268 let lastTurnAt = 0
269 // Set synchronously right before $.ui.ask so the AskUserQuestion render never reads a stale atom.
270 let askedNow: Pending | null = null
271
272 on('session.start', async ($, e, next) => {
273 await $.command.register({
274 name: 'triage-gate',
275 description: 'Pre-message model triage: on | off | modal | band | why | demo [1|2].',
276 argumentHint: 'on | off | modal | band | why | demo [1|2]',
277 })
278 const result = await next(e)
279 await refreshModel($)
280 const saved = await $.store.get('isBandOpen')
281 if (typeof saved === 'boolean') await update($, isBandOpen, () => saved)
282 return result
283 })
284
285 on('command.run', { command: 'triage-gate' }, async ($, e) => {
286 const arg = e.args.trim().toLowerCase()
287 if (arg === 'on' || arg === 'off') await update($, isOn, () => arg === 'on')
288 if (arg === 'off') await update($, pending, () => null)
289 if (arg === 'modal' || arg === 'band') await update($, isModal, () => arg === 'modal')
290 if (arg === 'why') return { text: `Última decisão da triagem: ${decisionText(asDecision(await read($, decision)))}` }
291 if (arg.startsWith('demo')) {
292 const big = arg.endsWith('2')
293 const sample: Pending = {
294 text: 'demo',
295 current: big ? 'Opus 5.5' : 'Sonnet 5.5',
296 family: 'haiku',
297 effort: 'low',
298 reason: 'Pergunta simples de clima',
299 gap: big ? 2 : 1,
300 }
301 await update($, asking, () => sample)
302 let answer = ''
303 try {
304 answer = await $.ui.ask(questionFor(sample).question, { options: labelsFor(sample), header: headerFor(sample) })
305 } catch (error) {
306 answer = `(modal recusado: ${String(error)})`
307 }
308 await update($, asking, () => null)
309 return { text: `Demo do modal. Resposta: ${answer} → ${choiceOf(answer, sample) ?? 'nenhuma das opções'}` }
310 }
311 const state = (await read($, isOn)) ? 'ligada' : 'desligada'
312 const style = (await read($, isModal)) ? 'modal em todas as mensagens' : 'faixa (modal só com imagem)'
313 const advisor = await readAdvisor($)
314 const advisorText = advisorRank(advisor) === null ? 'sem advisor' : `advisor ${String(advisor)} (não sugere subir até ele)`
315 return { text: `Triagem antes da mensagem: ${state}, ${style}, ${advisorText}. Use /triage-gate on | off | modal | band | demo.` }
316 })
317
318 on('command.run', { command: 'model' }, async ($, e, next) => {
319 const result = await next(e)
320 await refreshModel($)
321 const notice = e.origin.kind === 'plugin' && e.origin.name.includes('triage-gate') ? modelNotice(result.text) : null
322 return notice === null ? result : { ...result, text: notice }
323 })
324
325 on('command.run', { command: 'effort' }, async ($, e, next) => {
326 const result = await next(e)
327 const level = effortFromOutput(result.text)
328 if (level !== null) await update($, shownEffort, () => level)
329 const notice = e.origin.kind === 'plugin' && e.origin.name.includes('triage-gate') ? effortNotice(result.text) : null
330 return notice === null ? result : { ...result, text: notice }
331 })
332
333 on('prompt.submit', async ($, e, next) => {
334 if (e.origin.kind !== 'composer' || e.turnId !== undefined) return next(e)
335 const decide = (d: Decision) => update($, decision, () => d)
336 await update($, pending, () => null)
337 if (!(await read($, isOn))) {
338 await decide({ verdict: 'Triagem desligada', detail: 'Use /triage-gate on para ligar.' })
339 return next(e)
340 }
341 if (isSkippable(e.text)) {
342 await decide({ verdict: 'Passou sem triagem', detail: 'Mensagem curta, comando ou continuação.' })
343 return next(e)
344 }
345
346 let current: Tier | null = null
347 let verdict: Verdict | null = null
348 let currentName = ''
349 try {
350 currentName = String(await $.session.model())
351 current = familyOf(currentName)
352 const rows = await $.session.messages()
353 const lastReply = rows.filter(row => row.role === 'assistant').at(-1)?.text ?? ''
354 const answer = await $.model.complete({
355 model: 'haiku',
356 prompt: buildPrompt(e.text, lastReply),
357 maxTokens: 60,
358 effort: 'low',
359 timeoutMs: 8000,
360 })
361 verdict = answer.isAnswered ? parseVerdict(answer.text) : null
362 } catch {
363 verdict = null
364 }
365 if (currentName !== '') await update($, shownModel, () => prettyModel(currentName))
366 if (current === null || verdict === null) {
367 await decide({ verdict: 'Passou sem triagem', detail: 'O classificador não respondeu a tempo.' })
368 return next(e)
369 }
370
371 const gap = gapOf(current, verdict.family)
372 const route = `${prettyModel(currentName)} → ${capitalize(verdict.family)}`
373 const reason = verdict.reason
374 if (gap === 0) {
375 await decide({ verdict: 'Sem troca: o modelo atual é o recomendado', route: prettyModel(currentName), reason })
376 return next(e)
377 }
378
379 let tokens = 0
380 try {
381 tokens = (await $.session.usage()).context.tokens ?? 0
382 } catch {
383 tokens = 0
384 }
385 const isWarm = lastTurnAt === 0 || (await $.clock.now()) - lastTurnAt < CACHE_TTL_MS
386 const econ = economicsOf(current, verdict.family, tokens, isWarm)
387 const size = `Contexto de ~${Math.round(tokens / 1000)}K tokens, cache ${isWarm ? 'ativo' : 'expirado'}`
388 if (econ.isDown && !econ.isWorth) {
389 const when = econ.payback === Infinity ? 'nunca se pagaria' : `só se pagaria em ${econ.payback} mensagens`
390 await decide({ verdict: 'Sem troca: descer não compensa', route, reason, detail: `${size}; a troca ${when}.` })
391 return next(e)
392 }
393 if (advisorCovers(await readAdvisor($), current, verdict.family)) {
394 await decide({ verdict: 'Sem troca: o advisor cobre a subida', route, reason, detail: 'O Opus é consultado nos momentos difíceis, sem perder o cache.' })
395 return next(e)
396 }
397 await decide({ verdict: 'Troca sugerida no modal', route, reason, detail: `${size}.` })
398
399 const p: Pending = {
400 text: e.text,
401 current: prettyModel(currentName),
402 family: verdict.family,
403 effort: verdict.effort,
404 reason: verdict.reason,
405 gap,
406 hasImages: e.attachments !== undefined,
407 note: tokens > 0 ? (costNote(econ, tokens) ?? undefined) : undefined,
408 }
409
410 if (e.attachments !== undefined || (await read($, isModal))) {
411 const askedAt = await $.clock.now()
412 askedNow = p
413 await update($, asking, () => p)
414 let answer = ''
415 let isRefused = false
416 try {
417 answer = await $.ui.ask(questionFor(p).question, { options: labelsFor(p), header: headerFor(p) })
418 } catch {
419 isRefused = (await $.clock.now()) - askedAt < 700
420 }
421 askedNow = null
422 await update($, asking, () => null)
423 if (isRefused) {
424 $.ui.toast('O modal da triagem não abriu; a mensagem foi enviada sem triagem.')
425 return next(e)
426 }
427 const choice = choiceOf(answer, p)
428 if (choice === 'switch') {
429 await update($, override, () => ({ model: p.family, effort: p.effort }))
430 $.ui.toast(switchedText(p))
431 return next(e)
432 }
433 if (choice === 'send') return next(e)
434 await $.prompt.fill({ text: e.text })
435 if (e.attachments !== undefined) $.ui.toast('As imagens não voltam para a caixa, mas ficam na conversa acima: copie e cole.')
436 return { drop: 'Envio cancelado: a mensagem voltou para a caixa de texto.' }
437 }
438
439 await update($, pending, () => p)
440 return { drop: 'Mensagem retida pela triagem: veja a faixa acima do campo de digitação.' }
441 })
442
443 on('turn.step', async function* ($, e, next) {
444 if (e.agentId !== undefined) return yield* next(e)
445 const chosen = await read($, override)
446 const sent = chosen === null ? e.effort : chosen.effort
447 const level = sent === undefined ? '' : String(sent)
448 if (level !== '' && level !== (await read($, shownEffort))) await update($, shownEffort, () => level)
449 if (chosen === null) return yield* next(e)
450 return yield* next({ ...e, model: modelIdFor(chosen.model), effort: chosen.effort })
451 })
452
453 on('turn.complete', async ($, e, next) => {
454 const chosen = await read($, override)
455 const result = await next(e)
456 lastTurnAt = await $.clock.now()
457 if (chosen === null) return result
458 await update($, override, () => null)
459 try {
460 await $.command.run({ command: 'model', args: chosen.model })
461 await $.command.run({ command: 'effort', args: chosen.effort })
462 } catch (error) {
463 $.ui.log(`[triage-gate] não consegui fixar o modelo da sessão: ${String(error).slice(0, 240)}`)
464 }
465 await refreshModel($)
466 return result
467 })
468
469 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
470 const p = askedNow ?? (await read($, asking))
471 if (p === null) return next(e)
472 const native = await next({ ...e, props: { ...e.props, questions: [questionFor(p)] } })
473
474 const { Box, Text } = $.ui.resolve(e)
475 const pal = paletteFor(p.gap)
476 const banner = (
477 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={2} paddingY={1} marginBottom={1}>
478 <Box flexDirection="row" justifyContent="space-between">
479 <Text bold>Triagem de modelo</Text>
480 <Text bold color={pal.accent}>
481 {statusFor(p.gap)}
482 </Text>
483 </Box>
484 <Box flexDirection="column" marginTop={1}>
485 <Text bold>
486 {p.current} → {capitalize(p.family)}
487 </Text>
488 <Text dimColor>
489 Esforço {effortWord(p.effort)} · {p.reason}
490 </Text>
491 {p.note === undefined ? null : <Text dimColor>{p.note}</Text>}
492 </Box>
493 </Box>
494 )
495 return (
496 <Box flexDirection="column">
497 {banner}
498 {native}
499 </Box>
500 )
501 })
502
503 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
504 if (e.props.hasSurvey) return next(e)
505 const p = await read($, pending)
506 if (p === null) {
507 const { Box, Text, Button } = $.ui.resolve(e)
508 const model = await read($, shownModel)
509 const effort = effortLabel(await read($, shownEffort))
510 const why = asDecision(await read($, decision))
511 const isOpen = await read($, isBandOpen)
512 const advisor = await readAdvisor($)
513 const advisorName = advisorRank(advisor) === null ? null : prettyModel(String(advisor))
514 const head = (
515 <Box flexDirection="row" flexGrow={1} justifyContent="space-between" alignItems="center" columnGap={2}>
516 <Box flexDirection="row" columnGap={1}>
517 <Text dimColor>Modelo em uso</Text>
518 <Text bold>{model === '' ? '…' : model}</Text>
519 {effort === '' ? null : <Text dimColor>· esforço {effort}</Text>}
520 {advisorName === null ? null : <Text dimColor>· advisor {advisorName}</Text>}
521 </Box>
522 <Button
523 key="toggle"
524 variant="secondary"
525 onPress={async () => {
526 await update($, isBandOpen, () => !isOpen)
527 await $.store.set('isBandOpen', !isOpen)
528 }}
529 >
530 {isOpen ? 'Minimizar' : 'Triagem'}
531 </Button>
532 </Box>
533 )
534 if (!isOpen) {
535 return (
536 <Box flexDirection="column" paddingX={1}>
537 {head}
538 </Box>
539 )
540 }
541 return (
542 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
543 {head}
544 <Box marginTop={1}>
545 <Text dimColor wrap="wrap">
546 Triagem: {decisionText(why)}
547 </Text>
548 </Box>
549 </Box>
550 )
551 }
552
553 const { Box, Text, Button } = $.ui.resolve(e)
554 const pal = paletteFor(p.gap)
555 const fill = { color: pal.text, backgroundColor: pal.fill }
556 const label = { bold: true, color: pal.muted, backgroundColor: pal.fill }
557 const strong = { bold: true, color: pal.text, backgroundColor: pal.fill }
558
559 const send = async (): Promise<void> => {
560 await update($, pending, () => null)
561 void $.prompt.submit({ text: p.text, asUser: true })
562 }
563
564 return (
565 <Box flexDirection="column" borderStyle="round" borderColor={pal.border} backgroundColor={pal.fill} paddingX={1}>
566 <Box flexDirection="row" columnGap={1}>
567 <Text color={pal.muted} backgroundColor={pal.fill}>
568 ◈ Triagem
569 </Text>
570 <Text bold color={pal.accent} backgroundColor={pal.fill}>
571 {statusFor(p.gap)}
572 </Text>
573 </Box>
574 <Box flexDirection="row" columnGap={1}>
575 <Text {...label}>Atual:</Text>
576 <Text {...strong}>{p.current}</Text>
577 <Text {...label}>→ Recomendado:</Text>
578 <Text {...strong}>{capitalize(p.family)}</Text>
579 <Text {...fill}>· Esforço {effortWord(p.effort)}</Text>
580 </Box>
581 <Box flexDirection="row" columnGap={1}>
582 <Text {...label}>Motivo:</Text>
583 <Text {...fill}>{p.reason}</Text>
584 </Box>
585 <Box flexDirection="row" columnGap={1} marginTop={1}>
586 <Button
587 key="switch"
588 variant="primary"
589 onPress={async () => {
590 const model = await $.command.run({ command: 'model', args: p.family })
591 if (model.exitCode) return $.ui.toast(failedText(p))
592 await $.command.run({ command: 'effort', args: p.effort })
593 $.ui.toast(switchedText(p))
594 await send()
595 }}
596 >
597 {`Trocar para ${capitalize(p.family)} e enviar`}
598 </Button>
599 <Button key="send" variant="secondary" onPress={() => void send()}>
600 Enviar mesmo assim
601 </Button>
602 <Button
603 key="edit"
604 variant="secondary"
605 onPress={async () => {
606 await update($, pending, () => null)
607 await $.prompt.fill({ text: p.text })
608 }}
609 >
610 Editar
611 </Button>
612 </Box>
613 </Box>
614 )
615 })
616}
617types/index.d.ts 25 lines1export type Tier = 'haiku' | 'sonnet' | 'opus'
2
3export type Effort = 'low' | 'medium' | 'high'
4
5export type Pending = {
6 text: string
7 current: string
8 family: Tier
9 effort: Effort
10 reason: string
11 gap: number
12 hasImages?: boolean
13 note?: string
14}
15
16export type Override = { model: Tier; effort: Effort }
17
18export type Decision = { verdict: string; route?: string; reason?: string; detail?: string }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'triage-gate': { pending: Pending | null; asking: Pending | null; override: Override | null; isOn: boolean; isModal: boolean; shownModel: string; shownEffort: string; decision: Decision; isBandOpen: boolean }
23 }
24}
25