Pas à pas : dessiner une bande au-dessus du champ de saisie
Vous allez créer de zéro un mod qui dessine une bande d’une ligne (AbovePrompt) juste au-dessus du champ de saisie. Voici le résultat final.
턴 3 · 진행 12초 · 마지막 도구 BashCe texte se lit « tour 3 · en cours 12 s · dernier outil Bash ». Tant qu’un tour est en cours, le temps écoulé augmente chaque seconde ; quand le tour se termine, la bande s’arrête sur 지난 턴 34초 (« dernier tour 34 s »). La commande /turn-meter masque la bande ou la réaffiche. Si un autre mod dessine déjà une bande au même endroit, la vôtre ne l’efface pas.
Version de référence : code qui a passé claude plugin validate et claude plugin test sur Claude Code 2.1.291. Les descriptions de l’API ont été recoupées avec la déclaration de types 2.1.290 (claude-code.d.ts). Les blocs de code ci-dessous reproduisent tels quels les fichiers qui ont passé les contrôles.
Structure des dossiers
Section intitulée « Structure des dossiers ».claude-plugin/plugin.json # manifestehooks/hooks.json # emplacement du module de hookshooks/register.tsx # module de hookstypes/index.d.ts # contrat d'étattests/band.test.tsx # testsContrairement à l’exemple de volet (Pane) de Créer votre premier mod, ce mod range ses valeurs dans $.state plutôt que dans des variables de module. Lors d’un rechargement à chaud, les variables de module disparaissent, alors que les valeurs de $.state restent jusqu’à la fin de la session.
Construction
Section intitulée « Construction »-
Écrivez le manifeste.
typesindique où ce mod a décrit son contrat d’état..claude-plugin/plugin.json {"name": "turn-meter","version": "0.1.0","description": "입력창 위에 턴 수, 지금 턴의 경과 시간, 마지막 도구를 한 줄로 보여 준다","author": {"name": "mods.guide"},"types": "./types/index.d.ts"}La description signifie : « Affiche sur une ligne, au-dessus du champ de saisie, le nombre de tours, le temps écoulé du tour en cours et le dernier outil ».
-
Indiquez l’emplacement du module de hooks. Le chemin est relatif à
hooks.json.hooks/hooks.json { "modules": ["./register.tsx"] } -
Écrivez le contrat d’état. Déclarez dans
PluginStatele nom et le type des valeurs que vous placerez dans$.state. Il s’agit d’ajouter la part de votre mod à l’interface que le fichier de déclaration de types décrit comme « empty by default ».types/index.d.ts export type TurnMeterTurn = { startedAt: number; endedAt: number | null }declare module 'claude-code' {interface PluginState {'turn-meter': {count: numberturn: TurnMeterTurn | nullnow: numberlastTool: string | nullisHidden: boolean}}}Le validateur lit ce fichier et énumère tel quel les cinq clés avec
declares state: turn-meter.count, …. Comme le mod n’ajoute aucune nouvelle fonction à l’objet$, la lignedeclares on $: nothingapparaît aussi ; ce n’est pas une erreur. -
Écrivez le module de hooks. Lisez d’abord le code complet, puis les explications par parties.
hooks/register.tsx import { atom, read, update } from 'claude-code'import type { Register, Timer } from 'claude-code'import type { TurnMeterTurn } from '../types'const count = atom({ plugin: 'turn-meter', key: 'count' } as const, 0)const turn = atom({ plugin: 'turn-meter', key: 'turn' } as const, null)const now = atom({ plugin: 'turn-meter', key: 'now' } as const, 0)const lastTool = atom({ plugin: 'turn-meter', key: 'lastTool' } as const, null)const isHidden = atom({ plugin: 'turn-meter', key: 'isHidden' } as const, false)export function formatSeconds(ms: number): string {const s = Math.max(0, Math.floor(ms / 1000))return s < 60 ? `${s}초` : `${Math.floor(s / 60)}분 ${s % 60}초`}export const register: Register = on => {let tick: Timer | undefinedon('session.start', async ($, e, next) => {await $.command.register({name: 'turn-meter',description: '입력창 위 턴 정보 띠를 켜고 끈다',})return next(e)})on('command.run', { command: 'turn-meter' }, async $ => {const hidden = await update($, isHidden, v => !v)return { text: hidden ? 'turn-meter 띠를 숨겼어요.' : 'turn-meter 띠를 다시 보여요.' }})on('turn.start', async ($, e, next) => {const startedAt = await $.clock.now()await update($, count, n => n + 1)await update($, turn, () => ({ startedAt, endedAt: null }))await update($, now, () => startedAt)tick?.cancel()tick = $.clock.every(1000, () => {void $.clock.now().then(t => update($, now, () => t))})return next(e)})on('tool.call', async ($, e, next) => {if (e.agentId === undefined) await update($, lastTool, () => String(e.tool))return next(e)}).catch(($, e, next) => next(e))on('turn.complete', async ($, e, next) => {if (e.agentId === undefined) {tick?.cancel()tick = undefinedconst endedAt = await $.clock.now()await update($, turn, t => (t === null ? null : { ...t, endedAt }))}return next(e)})on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {const t: TurnMeterTurn | null = await read($, turn)if (e.props.hasSurvey || t === null || (await read($, isHidden))) return next(e)const below = await next(e)const { Box, Text } = $.ui.resolve(e)const n = await read($, count)const tool = await read($, lastTool)const time =t.endedAt === null? `진행 ${formatSeconds((await read($, now)) - t.startedAt)}`: `지난 턴 ${formatSeconds(t.endedAt - t.startedAt)}`return (<Box flexDirection="column" width={e.props.bodyColumns}><Box key="meter"><Text dimColor wrap="truncate-end">턴 {n} · {time} · 마지막 도구 {tool ?? '없음'}</Text></Box>{below}</Box>)})}Les chaînes coréennes de ce fichier sont du texte affiché :
진행= « en cours »,지난 턴= « dernier tour »,턴= « tour »,마지막 도구= « dernier outil »,없음= « aucun »,초= « s »,분= « min ». Les messages de/turn-metersignifient « bande turn-meter masquée » et « bande turn-meter de nouveau affichée ».
Explication partie par partie
Section intitulée « Explication partie par partie »Cinq états. atom(ref, initial) est une valeur nommée dotée d’une valeur initiale. plugin et key doivent être écrits comme littéraux dans la source, car le validateur lit la source pour produire les listes state writes: et state reads:. On lit une valeur avec read($, atom) et on la modifie avec update($, atom, fn). update vérifie la version à l’écriture et réessaie si quelqu’un a écrit avant vous entre-temps.
La commande /turn-meter. On enregistre la commande avec $.command.register dans session.start, et le hook command.run la reçoit grâce au matcher { command: 'turn-meter' }. update renvoie la valeur modifiée, ce qui permet de choisir le message à afficher. Si vous n’appelez pas next et renvoyez { text }, ce texte devient la sortie de la commande.
Début de tour et minuteur d’une seconde. Dans turn.start, l’instant est mesuré avec $.clock.now(). Il n’y a pas de setTimeout dans un mod, donc la mise à jour à intervalle d’une seconde passe aussi par $.clock.every. Le minuteur ne change que la valeur now. La bande a lu now pendant son dessin ; quand la valeur change, le moteur la redessine. Il est inutile d’appeler $.ui.invalidate : lire $.state abonne déjà le dessin à la valeur.
Dernier outil. Le hook tool.call note seulement le nom de l’outil, puis passe la main avec next(e). Si e.agentId est présent, c’est l’appel d’un sous-agent et on l’ignore. Le .catch(($, e, next) => next(e)) ajouté à la fin garantit que l’outil s’exécute normalement même si l’enregistrement échoue. Le validateur considère tool.call comme un endroit où l’on peut refuser quelque chose et écrit gating hook with .catch: tool.call. Ce hook ne fait qu’observer ; en cas d’échec, il est juste de laisser passer.
Fin de tour. turn.complete arrive aussi pour les tours de sous-agents. On n’arrête le minuteur et on n’enregistre l’heure de fin que pour le tour principal (sans agentId).
Dessin de la bande. L’essentiel tient en trois lignes.
- Si
e.props.hasSurveyest vrai, un sondage utilise la bande : on s’efface avecnext(e). - S’il y a quelque chose à dessiner, on commence par récupérer le résultat du dessous avec
const below = await next(e). C’est la bande d’un autre mod, ou le résultat du moteur si personne n’a dessiné. - On renvoie votre ligne suivie de
{below}. Si vous renvoyez seulement votre arbre sans appelernext, la bande des mods situés sous vous n’est pas dessinée, carAbovePromptest un emplacement à une seule instance.
La largeur suit e.props.bodyColumns, c’est-à-dire la largeur moins les cinq colonnes qu’occupe l’indicateur de repli [-] du moteur.
Écrire les tests
Section intitulée « Écrire les tests »import { describe, expect, mock, test } from 'claude-code/testing'import type { On } from 'claude-code'
import { formatSeconds } from '../hooks/register'
const BAND = { plugin: 'turn-meter', component: 'AbovePrompt' } as constconst props = { hasSurvey: false, isWorking: true, maxRows: 10, bodyColumns: 80, scroll: { offset: 0, bodyRows: 9 }, view: {},}const SURFACES = ['terminal', 'desktop'] as const
// 엔진 자리: 다른 mod의 띠 하나, 그리고 도구·턴·명령의 바닥 응답function engine(on: On) { on('ui.render', { component: 'AbovePrompt' }, ($, e) => { const { Text } = $.ui.resolve(e) return <Text>다른 mod의 띠</Text> }) on('tool.call', () => ({ result: 'ok' }) as never) on('turn.start', ($, e) => ({ turnId: e.turnId })) on('turn.complete', ($, e) => ({ text: e.answer }))}
const toggle = { command: 'turn-meter', args: '', origin: { kind: 'composer' as const }, presentation: { isFullscreen: true, columns: 120 },}
describe('formatSeconds', () => { test('초와 분으로 적는다', () => { expect(formatSeconds(0)).toBe('0초') expect(formatSeconds(12_400)).toBe('12초') expect(formatSeconds(75_000)).toBe('1분 15초') })})
describe('띠', () => { test('첫 턴 전에는 그리지 않고 아래 띠를 그대로 둔다', async ($, on) => { engine(on) for (const surface of SURFACES) { const ui = await $.ui.mount({ ...BAND, surface, props }) expect(await ui.find({ key: 'meter' })).toBeUndefined() expect(await ui.find({ type: 'Text', text: '다른 mod의 띠' })).toBeDefined() await ui.unmount() } })
test('진행 중인 턴의 경과 시간과 마지막 도구를 보여 준다', async ($, on) => { engine(on) const clock = mock.clock(on, { now: 1_000_000 }) await $.turn.start({ text: '테스트 고쳐 줘', turnId: 't1' }) await $.tool.call({ tool: 'Bash', command: 'npm test' }) await clock.advance(12_000) for (const surface of SURFACES) { const ui = await $.ui.mount({ ...BAND, surface, props }) expect((await ui.find({ key: 'meter' }))?.text).toBe('턴 1 · 진행 12초 · 마지막 도구 Bash') expect(await ui.find({ type: 'Text', text: '다른 mod의 띠' })).toBeDefined() await ui.unmount() } })
test('턴이 끝나면 걸린 시간을 고정한다', async ($, on) => { engine(on) const clock = mock.clock(on, { now: 0 }) await $.turn.start({ text: '첫 번째', turnId: 't1' }) await clock.advance(5_000) await $.turn.complete({ answer: '끝', durationMs: 5_000, isAborted: false, turnId: 't1', reason: 'answer' } as never) await clock.advance(30_000) const ui = await $.ui.mount({ ...BAND, surface: 'terminal', props }) expect((await ui.find({ key: 'meter' }))?.text).toBe('턴 1 · 지난 턴 5초 · 마지막 도구 없음') })
test('서브에이전트의 도구 호출은 마지막 도구로 치지 않는다', async ($, on) => { engine(on) mock.clock(on) await $.turn.start({ text: '조사해 줘', turnId: 't1' }) await $.tool.call({ tool: 'Read', file_path: '/tmp/a.txt' }) await $.tool.call({ tool: 'Bash', command: 'ls', agentId: 'sub-1' } as never) const ui = await $.ui.mount({ ...BAND, surface: 'terminal', props }) expect((await ui.find({ key: 'meter' }))?.text).toMatch(/마지막 도구 Read$/) })
test('/turn-meter로 숨기고 다시 켠다', async ($, on) => { engine(on) mock.clock(on) await $.turn.start({ text: '아무거나', turnId: 't1' }) expect((await $.command.run(toggle)).text).toBe('turn-meter 띠를 숨겼어요.') const hidden = await $.ui.mount({ ...BAND, surface: 'terminal', props }) expect(await hidden.find({ key: 'meter' })).toBeUndefined() expect(await hidden.find({ type: 'Text', text: '다른 mod의 띠' })).toBeDefined() await hidden.unmount() expect((await $.command.run(toggle)).text).toBe('turn-meter 띠를 다시 보여요.') const shown = await $.ui.mount({ ...BAND, surface: 'terminal', props }) expect(await shown.find({ key: 'meter' })).toBeDefined() })})Les noms de tests sont en coréen : ils disent, dans l’ordre, « s’écrit en secondes et minutes », « ne dessine rien avant le premier tour et laisse intacte la bande du dessous », « montre le temps écoulé du tour en cours et le dernier outil », « fige la durée quand le tour se termine », « ne compte pas l’appel d’outil d’un sous-agent comme dernier outil » et « masque et réaffiche avec /turn-meter ». 다른 mod의 띠 signifie « la bande d’un autre mod ».
Les hooks enregistrés avec le on du test se placent sous tous les plugins, à la place du moteur. C’est pourquoi la fonction engine() fait deux choses.
- Elle imite la bande d’un autre mod. Le hook
AbovePrompttout en bas dessine다른 mod의 띠. Il sert à vérifier que votre bande n’efface pas cette ligne. - Elle remplit le fond. Le moteur de test lève une erreur s’il n’y a aucun hook qui réponde par le bas. Si vous appelez
turn.startsans fond, voici l’échec réel.
HooksError: no implementation for turn.start
nothing beneath the plugins answers turn.start: a test answers it with on('turn.start', ...)
the engine reported: turn-meter's turn.start hook was skipped: turn-meter: no implementation for clock.nowLa deuxième ligne est importante. Sans fond pour l’horloge non plus, votre hook turn.start est ignoré. C’est pourquoi chaque test qui manipule des tours installe mock.clock(on, …). Cette horloge n’avance que si le test la fait avancer avec clock.advance(12_000). Avancer de 12 secondes fait tourner douze fois le minuteur d’une seconde et donne 진행 12초. On n’attend jamais réellement 12 secondes.
Un test d’écran dessine la bande une fois avec $.ui.mount({ plugin, surface, component, props }), puis cherche les éléments avec find. surface n’a pas de valeur par défaut et doit être indiqué. Exécuter le même corps sur terminal et sur desktop permet aussi de vérifier que vous n’utilisez pas un élément présent sur un seul des deux écrans.
Lancer les contrôles
Section intitulée « Lancer les contrôles »claude plugin validate ./turn-meterValidating plugin manifest: …/turn-meter/.claude-plugin/plugin.json
❯ types ./types/index.d.ts declares on $: nothing (no EngineInterface member) ❯ types ./types/index.d.ts declares state: turn-meter.count, turn-meter.turn, turn-meter.now, turn-meter.lastTool, turn-meter.isHidden
Validating hooks: …/turn-meter/hooks/hooks.json
❯ ./register.tsx hooks: session.start, command.run{command=turn-meter}, turn.start, tool.call, turn.complete, ui.render{component=AbovePrompt} ❯ ./register.tsx gating hook with .catch: tool.call ❯ ./register.tsx calls: $.clock.every, $.clock.now, $.command.register, $.state.get, $.state.set, $.ui.resolve ❯ ./register.tsx state writes: turn-meter.count, turn-meter.isHidden, turn-meter.lastTool, turn-meter.now, turn-meter.turn ❯ ./register.tsx state reads: turn-meter.count, turn-meter.isHidden, turn-meter.lastTool, turn-meter.now, turn-meter.turn
✔ Validation passedSeul le début des chemins a été raccourci. Lors de la première exécution, un avertissement signalait l’absence de author ; il a disparu après l’ajout de author au manifeste. D’après la ligne calls:, ce mod ne touche ni aux fichiers, ni au réseau, ni aux processus. C’est le niveau « écran et mémoire seulement » dont parle le contrôle de sécurité.
claude plugin test ./turn-metertests/band.test.tsx:(pass) formatSeconds > 초와 분으로 적는다 [1.14ms](pass) 띠 > 첫 턴 전에는 그리지 않고 아래 띠를 그대로 둔다 [28.03ms](pass) 띠 > 진행 중인 턴의 경과 시간과 마지막 도구를 보여 준다 [25.08ms](pass) 띠 > 턴이 끝나면 걸린 시간을 고정한다 [14.53ms](pass) 띠 > 서브에이전트의 도구 호출은 마지막 도구로 치지 않는다 [11.61ms](pass) 띠 > /turn-meter로 숨기고 다시 켠다 [13.68ms]
6 pass 0 failRan 6 tests across 1 file. [0.25s]L’essayer dans une session
Section intitulée « L’essayer dans une session »Vous pouvez le charger pour cette session seulement, sans l’installer.
claude --plugin-dir ./turn-meterUne session lancée ainsi surveille le dossier et relit le module de hooks dès que vous enregistrez un fichier. Si la bande n’apparaît pas, consultez la lecture du journal de débogage dans Tests et débogage.
Limites à connaître
Section intitulée « Limites à connaître »AbovePromptn’est dessiné que sur les écrans terminal et bureau. Cette bande n’existe ni dans VS Code ni sur mobile.- Le minuteur d’une seconde est conservé dans une variable de module. Si un rechargement à chaud survient en plein tour, le minuteur de l’ancien environnement est abandonné ; le temps écoulé semble alors figé jusqu’au début du tour suivant.
- La vitesse de redessin de la bande est décidée par le moteur. Selon la déclaration de types, le redessin est limité à 10 fois par seconde au maximum, et à 30 fois pour une bande déployée dans le terminal. L’intervalle d’une seconde reste dans cette limite.
- Le nombre de tours compte les
turn.startde la boucle principale. Un tour de poursuite, sans prompt envoyé par une personne, compte lui aussi pour un.
Guide communautaire non officiel, sans lien avec Anthropic ni approuvé par Anthropic. Claude et Claude Code sont des marques d’Anthropic.