Aller au contenu

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초 · 마지막 도구 Bash

Ce 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.

turn-meter/
.claude-plugin/plugin.json # manifeste
hooks/hooks.json # emplacement du module de hooks
hooks/register.tsx # module de hooks
types/index.d.ts # contrat d'état
tests/band.test.tsx # tests

Contrairement à 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.

  1. Écrivez le manifeste. types indique 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 ».

  2. Indiquez l’emplacement du module de hooks. Le chemin est relatif à hooks.json.

    hooks/hooks.json
    { "modules": ["./register.tsx"] }
  3. Écrivez le contrat d’état. Déclarez dans PluginState le 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: number
    turn: TurnMeterTurn | null
    now: number
    lastTool: string | null
    isHidden: 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 ligne declares on $: nothing apparaît aussi ; ce n’est pas une erreur.

  4. É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 | undefined
    on('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 = undefined
    const 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-meter signifient « bande turn-meter masquée » et « bande turn-meter de nouveau affichée ».

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.hasSurvey est vrai, un sondage utilise la bande : on s’efface avec next(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 appeler next, la bande des mods situés sous vous n’est pas dessinée, car AbovePrompt est 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.

tests/band.test.tsx
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 const
const 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 AbovePrompt tout 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.start sans 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.now

La 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.

Fenêtre de terminal
claude plugin validate ./turn-meter
Validating 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 passed

Seul 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é.

Fenêtre de terminal
claude plugin test ./turn-meter
tests/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 fail
Ran 6 tests across 1 file. [0.25s]

Vous pouvez le charger pour cette session seulement, sans l’installer.

Fenêtre de terminal
claude --plugin-dir ./turn-meter

Une 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.

  • AbovePrompt n’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.start de 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.