Dessine les blocs vega-lite des réponses et des aperçus AskUserQuestion en vraies images (PNG dans Ghostty, SVG sur le desktop).

<img alt="charts" src="assets/logo/charts-banner.png" width="520">
Charts and diagrams in Claude Code, drawn as images instead of code blocks.
Install | What it draws | Usage | How it works | Development
I got tired of reading Vega-Lite JSON in my terminal, so I wrote a mod that draws it.
charts is a Claude Code mod. When Claude writes a `vega-lite , `mermaid or `dot block, the mod replaces it with the picture: a PNG in the terminal, an SVG in the desktop app. The stored message still holds the source, so Claude keeps reading the data, not the image.
It also nudges Claude to draw more. Ask for a comparison and you get a bar chart. Ask how a system works and you get a diagram first, then the parts the diagram can't say.
<img alt="Claude answering with a bar chart in the terminal" src="assets/screens/01-bar.png" width="900">
You need:
PATHMods are still early access, so Claude Code only loads them with this variable set. Add it to ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }
}
claude plugin marketplace add https://gitlab.com/ThomasTartrau/charts.git
claude plugin install charts@tartrau-mods
From inside a session, /plugin marketplace add and /plugin install do the same thing.
Claude Code installs the renderer's npm dependencies during the install. Start a new session and ask something with numbers in it.
claude plugin update charts
Any `vega-lite block, with the mod's theme and a size that fits the terminal.
<img alt="A line chart in a Claude reply" src="assets/screens/02-line.png" width="900">
`mermaid flowcharts, sequence, state, class and ER diagrams, and `dot graphs. Mermaid pie and gantt blocks are turned into Vega-Lite charts so they share the same theme.
<img alt="A mermaid flowchart in a Claude reply" src="assets/screens/04-flowchart.png" width="900"> <img alt="A mermaid sequence diagram in a Claude reply" src="assets/screens/03-sequence.png" width="560">
When Claude asks you to pick between options (AskUserQuestion) and each one comes with a chart, charts merges them into one image with a shared scale. Three bars of different heights mean something again. The question moves to the band above the prompt, with the image next to the options.
<img alt="AskUserQuestion with one chart per option, on a shared scale" src="assets/screens/05-ask-chart.png" width="900">
Options that are designs rather than numbers get a diagram each. The image follows the option you move to.
<img alt="AskUserQuestion with an architecture diagram for the focused option" src="assets/screens/06-ask-diagram.png" width="900">
Every image has an "ouvrir en grand" button that opens a full-size PNG in your image viewer.
Nothing to call. Ask a question where a chart would help and Claude draws one.
The /charts command shows and changes the settings. They are kept across sessions.
| Command | Effect |
|---|---|
/charts | Show the current settings |
/charts on / off | Turn the mod on or off |
/charts theme dark / light | Match your terminal's background |
/charts width <20-255> | Maximum chart width, in terminal columns (default 100) |
/charts rows <3-80> | Maximum chart height, in terminal rows (default 22) |
/charts cell <0.3-0.8> | Width/height ratio of a terminal cell (default 0.5) |
/charts reset | Back to the defaults |
flowchart LR
A[Claude reply] --> B[charts mod]
B -->|spec + theme| C[renderer<br/>node process]
C --> D[(~/.cache/<br/>claude-charts)]
C -->|PNG| E[Terminal]
C -->|SVG| F[Desktop app]
hooks/ is the mod: TypeScript hooks that Claude Code loads. It finds chart blocks in each reply, draws them, adds a short guide to the system prompt, and handles AskUserQuestion.renderer/ is a small Node script built on Vega-Lite, beautiful-mermaid, Viz.js (Graphviz) and resvg. The mod starts it once per image.~/.cache/claude-charts, keyed by a hash of the spec. Scrolling back over an old chart doesn't start Node again.When a block can't be drawn, usually an invalid spec, you get the source back with the error under it.
Load the folder straight from disk, without installing it:
claude --plugin-dir /path/to/charts
A --plugin-dir copy takes priority over an installed copy with the same name.
npm install # renderer dependencies
claude plugin validate . # manifest and hooks module
claude plugin test . # mod tests (tests/*.test.ts)
npm test # renderer tests
hooks/register.tsx 539 lines1// charts : les blocs ```vega-lite (graphiques), ```mermaid et ```dot (schémas) des réponses,
2// et les aperçus des options AskUserQuestion (posées dans la bande au-dessus du
3// prompt), sont dessinés en vraies images (PNG via le protocole d'image de
4// Ghostty, SVG sur le desktop). Le message stocké garde la source : le modèle
5// voit toujours les données.
6import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
7
8import {
9 answerFreely,
10 askedOf,
11 confirm,
12 hasDrawings,
13 isOnOther,
14 isSafeId,
15 linesOf,
16 outcomeOf,
17 pressOption,
18 questionsOf,
19 showOption,
20 showOther,
21 startAsk,
22 withoutPreviewCharts,
23 type Asked,
24 type AskState,
25 type Outcome,
26 type Question,
27 type Step,
28} from './ask'
29import { combinedSpec } from './compare'
30import { altOf, drawingOf, type Drawn } from './drawings'
31import { chartBlocksOf, parseSpec, segmentsOf } from './fences'
32import { applyCommand, DEFAULT_PREFS, GUIDE, prefsFrom, statusOf, STORE_KEY, USAGE, type Prefs } from './prefs'
33import {
34 chartColumns,
35 chartRows,
36 clearMemo,
37 maxHeightFor,
38 openInViewer,
39 PX_PER_COLUMN,
40 renderChart,
41 type Drawing,
42 type Run,
43} from './render'
44import { displayOf, hasPlaceholder, restoredFrom, START, type StreamState } from './streaming'
45
46const COMMAND = 'charts'
47/** retrait du texte d'une réponse sous la puce, en colonnes */
48const INDENT = 2
49/** hauteur max des aperçus AskUserQuestion : le moteur refuse 12 lignes ou plus autour du dialogue */
50const ASK_ROWS = 10
51/** hauteur max d'un schéma, en multiple de la hauteur max d'un graphique */
52const DIAGRAM_ROWS_FACTOR = 2
53/** largeur max de la colonne des options, dans la bande au-dessus du prompt */
54const OPTIONS_COLUMNS = 44
55/**
56 * Un hook n'a que 10 s de temps propre, mais un appel `$` en cours ne compte pas :
57 * l'appel attend la réponse dans un `sh` qui sort quand la bande écrit son fichier.
58 */
59const WAITER = ['/bin/sh', '-c', 'while [ ! -f "$1" ]; do sleep 0.1; done; cat "$1"; rm -f "$1"', 'charts-ask']
60const WAIT_MS = 600_000
61/** relances du `sh` avant de rendre la main au dialogue du moteur (30 x 10 min) */
62const WAIT_TRIES = 30
63
64let prefs: Prefs = DEFAULT_PREFS
65/**
66 * Aperçus graphiques d'un AskUserQuestion en cours, par tool_use_id. Le dialogue
67 * ne prend pas la réécriture de ses props au rendu : on la fait dans tool.call,
68 * et le rendu retrouve ici les specs que les props n'ont plus.
69 */
70const pendingAsks = new Map<string, Asked>()
71/** La question posée dans la bande, une à la fois ; `file` est celui qu'attend le `sh`. */
72let activeAsk: { file: string; state: AskState; isDone: boolean } | null = null
73/** l'instance de la bande au-dessus du prompt, vue au dernier rendu */
74let bandId: string | null = null
75/** la bande a le clavier : les flèches marchent, inutile d'indiquer ctrl+x tab */
76let bandHasKeys = false
77/** Le message qui s'écrit : son id, et le bloc de code ouvert dans ce qui en est déjà affiché. */
78let streaming: { messageId: string; state: StreamState } | null = null
79/** Le texte d'origine des blocs affichés avec une ligne de remplacement, par message : un redessin ne relit pas la session. */
80const restored = new Map<string, string>()
81
82/** Rend l'image en grand, fond plein, et l'ouvre dans la visionneuse du système. */
83function openLarge($: EngineInterface, drawing: Drawing) {
84 const run: Run = (argv, init) => $.process.run(argv, init)
85 openInViewer(run, $.plugin.root, drawing, prefs.theme)
86 .then(error => error && $.ui.toast(`charts : ouverture impossible : ${error}`))
87 .catch(err => $.ui.log(`charts : ouverture impossible : ${err}`))
88}
89
90/**
91 * Le texte d'un bloc fini avec ses sources : le moteur le dessine avec le texte affiché
92 * au streaming, où une ligne tient la place de chaque graphique ; la source est relue
93 * dans le message enregistré. Introuvable, le texte reste tel quel.
94 */
95async function sourceOf($: EngineInterface, requestId: string, text: string): Promise<string> {
96 if (!hasPlaceholder(text)) return text
97 const known = restored.get(requestId)
98 if (known !== undefined) return known
99 const messages = await $.session.messages().catch(err => {
100 $.ui.log(`charts : messages de la session illisibles : ${err}`)
101 return []
102 })
103 // sans agentId, toujours la liste (jamais de deny pour la conversation principale)
104 const replies = Array.isArray(messages) ? messages.filter(m => m.role === 'assistant').map(m => m.text) : []
105 const found = restoredFrom(text, replies)
106 if (found === null) return text
107 restored.set(requestId, found)
108 return found
109}
110
111/** Un graphique ou un schéma prêt à dessiner, ou l'erreur à montrer sous sa source. */
112async function chartElement(
113 $: EngineInterface,
114 e: RenderInput<'AssistantMessage'> | RenderInput<'AskUserQuestion'> | RenderInput<'AbovePrompt'>,
115 drawn: Drawn,
116 source: string,
117 columns: number,
118 maxRows: number,
119 /** dans une réponse, l'adresse du bouton « ouvrir en grand » ; absente, rendu compact (dialogue, bande) */
120 openKey?: string,
121): Promise<RenderElement> {
122 const t = $.ui.resolve(e)
123 const isCompact = openKey === undefined
124 const error = (reason: string) => (
125 <t.Text color="red" dimColor wrap={isCompact ? 'truncate' : 'wrap'}>
126 {`charts : ${reason}`}
127 </t.Text>
128 )
129 // compact (au-dessus d'un dialogue, 12 lignes au plus) : l'erreur seule, sans la source
130 const fallback = (reason: string) =>
131 isCompact ? error(reason) : (
132 <t.Box flexDirection="column">
133 <t.Markdown text={'```\n' + source + '\n```'} />
134 {error(reason)}
135 </t.Box>
136 )
137 if ('error' in drawn) return fallback(drawn.error)
138
139 const surface = e.surface
140 const run: Run = (argv, init) => $.process.run(argv, init)
141 const rendered = await renderChart(run, $.plugin.root, drawn, {
142 width: columns * PX_PER_COLUMN,
143 maxHeight: maxHeightFor(maxRows, prefs.cellAspect),
144 theme: prefs.theme,
145 format: surface === 'terminal' ? 'png' : 'svg',
146 })
147 if (!rendered.ok) return fallback(rendered.error)
148
149 const alt = altOf(drawn)
150 const withOpen = (image: RenderElement) =>
151 openKey === undefined ? image : (
152 <t.Box flexDirection="column">
153 {image}
154 <t.Button key={openKey} plain dimColor onPress={() => openLarge($, drawn)}>
155 ouvrir en grand
156 </t.Button>
157 </t.Box>
158 )
159 if ('Image' in t && rendered.file) {
160 // un schéma ou une spec composite peut sortir plus étroit que demandé
161 let width = Math.min(columns, Math.ceil(rendered.width / PX_PER_COLUMN))
162 const natural = chartRows(width, rendered.width, rendered.height, prefs.cellAspect)
163 // plus haute que la place (hauteur fixée par la spec) : réduite en largeur aussi, sinon écrasée
164 if (natural > maxRows) width = Math.max(1, Math.round((width * maxRows) / natural))
165 const rows = Math.min(maxRows, natural)
166 return withOpen(<t.Image source={{ file: rendered.file, format: 'png' }} columns={width} rows={rows} alt={alt} />)
167 }
168 if ('Svg' in t && rendered.svg) {
169 if (rendered.svg.length > 131_072) return fallback('SVG trop lourd pour la surface (plus de 128 Kio)')
170 return withOpen(<t.Svg source={rendered.svg} alt={alt} width={rendered.width} />)
171 }
172 return fallback(`surface ${surface} sans image`)
173}
174
175
176/** Attend ce que le panneau écrit ; un `sh` qui ne démarre pas rend la main au dialogue du moteur. */
177async function outcomeFrom($: EngineInterface, file: string, signal: AbortSignal): Promise<Outcome> {
178 for (let tries = 0; tries < WAIT_TRIES; tries++) {
179 // tour interrompu : les appels `$` de ce hook sont rejetés, inutile de relancer
180 if (signal.aborted) return { cancel: true }
181 // le délai passé sans réponse rejette : on relance l'attente
182 const waited = await $.process.run([...WAITER, file], { timeoutMs: WAIT_MS }).then(
183 run => outcomeOf(run.stdout) ?? { classic: true as const },
184 () => null,
185 )
186 if (waited) return waited
187 }
188 return { classic: true }
189}
190
191/** Écrit l'issue de la question, une seule fois : le `sh` qui l'attend sort. */
192function finishAsk($: EngineInterface, outcome: Outcome) {
193 if (!activeAsk || activeAsk.isDone) return
194 activeAsk.isDone = true
195 // la bande se retire tout de suite, sans attendre que le `sh` sorte
196 $.ui.invalidate('ui.render')
197 $.fs.write(activeAsk.file, JSON.stringify(outcome)).catch(err => $.ui.log(`charts : réponse non transmise : ${err}`))
198}
199
200/** Une option, « Valider » ou « Autre » : la question avance, ou les réponses partent. */
201function stepAsk($: EngineInterface, change: (state: AskState) => Step) {
202 if (!activeAsk || activeAsk.isDone) return
203 const step = change(activeAsk.state)
204 if ('answers' in step) return finishAsk($, step)
205 activeAsk.state = step.state
206 $.ui.invalidate('ui.render')
207}
208
209/** Taille de l'image ouverte en grand quand la bande assemble les options en un graphique. */
210const LARGE_COMBINED = { width: 1400, height: 700 }
211
212/**
213 * L'aperçu de l'option affichée : son texte et ses graphiques ou schémas, à la taille donnée,
214 * et ce que « ouvrir en grand » dessine (le premier, ou le graphique assemblé).
215 */
216async function askPreview(
217 $: EngineInterface,
218 e: RenderInput<'AbovePrompt'>,
219 question: Question,
220 focus: number,
221 columns: number,
222 rows: number,
223): Promise<{ element: RenderElement; large: Drawing | null }> {
224 const t = $.ui.resolve(e)
225 // que des graphiques vega-lite : une seule image, toutes les options sur les mêmes échelles
226 const charts = question.options.flatMap((o, i) => {
227 const blocks = o.preview ? chartBlocksOf(o.preview) : []
228 const parsed = blocks.length === 1 && blocks[0]!.kind === 'vega-lite' ? parseSpec(blocks[0]!.source) : null
229 return parsed && 'spec' in parsed ? [{ index: i + 1, label: o.label, spec: parsed.spec }] : []
230 })
231 if (charts.length >= 2 && charts.length === question.options.filter(o => o.preview).length) {
232 const spec = combinedSpec(charts, columns * PX_PER_COLUMN, maxHeightFor(rows, prefs.cellAspect))
233 return {
234 element: await chartElement($, e, { kind: 'vega-lite', spec }, '', columns, rows),
235 large: { kind: 'vega-lite', spec: combinedSpec(charts, LARGE_COMBINED.width, LARGE_COMBINED.height) },
236 }
237 }
238
239 const preview = question.options[focus]?.preview
240 if (!preview) return { element: <t.Text dimColor>(pas d'aperçu pour cette option)</t.Text>, large: null }
241 const segments = segmentsOf(preview, chartBlocksOf(preview))
242 const large = segments.flatMap(s => (s.kind === 'chart' ? [drawingOf(s.block)] : [])).find((d): d is Drawing => !('error' in d)) ?? null
243 // le texte de l'aperçu d'abord : les images se partagent les lignes qui restent
244 const textRows = segments.reduce((sum, s) => sum + (s.kind === 'text' ? linesOf(s.text, columns) : 0), 0)
245 const imageCount = segments.filter(s => s.kind === 'chart').length
246 const imageRows = Math.max(3, Math.floor((rows - textRows) / Math.max(1, imageCount)))
247 const parts = await Promise.all(
248 segments.map(segment =>
249 segment.kind === 'text'
250 ? <t.Markdown text={segment.text} />
251 : chartElement($, e, drawingOf(segment.block), segment.block.source, columns, imageRows),
252 ),
253 )
254 return { element: <t.Box flexDirection="column">{parts}</t.Box>, large }
255}
256
257export const register: Register = on => {
258 on('session.start', async ($, e, next) => {
259 const started = await next(e)
260 prefs = prefsFrom(await $.store.get(STORE_KEY).catch(() => undefined))
261 await $.command
262 .register({
263 name: COMMAND,
264 description: 'Graphiques vega-lite et schémas dot dessinés en images : on|off, theme, width, rows, cell, reset (charts)',
265 argumentHint: '[on|off|theme dark|light|width N|rows N|cell R|reset]',
266 immediate: true,
267 })
268 .catch(err => $.ui.log(`charts : /${COMMAND} non enregistrée : ${err}`))
269 return started
270 })
271
272 on('command.run', { command: COMMAND }, async ($, e) => {
273 const applied = applyCommand(prefs, e.args)
274 if ('error' in applied) return { text: `${applied.error}\n${USAGE}` }
275 if (applied.prefs !== prefs) {
276 prefs = applied.prefs
277 await $.store.set(STORE_KEY, prefs).catch(err => $.ui.log(`charts : écriture du store impossible : ${err}`))
278 clearMemo()
279 $.ui.invalidate('ui.render')
280 }
281 return { text: statusOf(prefs) }
282 })
283
284 // Le modèle apprend à montrer avant d'écrire : graphiques et schémas par défaut.
285 on('prompt.compose', async ($, e, next) => {
286 const composed = await next(e)
287 if (!prefs.enabled || e.surfaces.length === 0) return composed
288 return { sections: [...composed.sections, { id: 'charts:guide', text: GUIDE, scope: 'session' as const }] }
289 })
290
291 // Phase 1, au streaming : la source d'un graphique ne défile pas, une ligne tient sa
292 // place jusqu'à l'image (le bloc fini, plus bas).
293 on('classic.MessageDisplay', async ($, e, next) => {
294 const shown = await next(e)
295 if (!prefs.enabled) return shown
296 const state = streaming?.messageId === e.message_id ? streaming.state : START
297 const display = displayOf(state, shown.displayContent ?? e.delta)
298 streaming = e.final ? null : { messageId: e.message_id, state: display.state }
299 return { ...shown, displayContent: display.text }
300 })
301
302 // Phase 1 : graphiques et schémas dans les réponses, à la place de leur bloc.
303 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
304 if (!prefs.enabled) return next(e)
305 const text = await sourceOf($, e.requestId, e.props.text)
306 if (text !== e.props.text) e = { ...e, props: { ...e.props, text } }
307 const blocks = chartBlocksOf(e.props.text)
308 if (blocks.length === 0) return next(e)
309
310 const t = $.ui.resolve(e)
311 const chartSize = { columns: chartColumns(e.viewport?.columns, prefs.maxColumns, INDENT), rows: prefs.maxRows }
312 // un schéma prend toute la largeur et deux fois la hauteur d'un graphique : réduit, il devient illisible
313 const diagramSize = { columns: chartColumns(e.viewport?.columns, 255, INDENT), rows: prefs.maxRows * DIAGRAM_ROWS_FACTOR }
314 const segments = segmentsOf(e.props.text, blocks)
315 const [first, ...rest] = segments
316
317 // Le premier segment texte passe par le moteur, qui garde la puce et le style.
318 const lead = first?.kind === 'text' ? first.text : first?.block.kind === 'vega-lite' ? 'Graphique :' : 'Schéma :'
319 const children: RenderElement[] = [await next({ ...e, props: { ...e.props, text: lead } })]
320 const tail = first?.kind === 'text' ? rest : segments
321
322 // un process node par image : rendus en parallèle
323 const bodies = await Promise.all(
324 tail.map((segment, i) => {
325 if (segment.kind === 'text') return <t.Markdown text={segment.text} />
326 const drawn = drawingOf(segment.block)
327 // un camembert ou un gantt mermaid est devenu un graphique : taille de graphique
328 const size = 'kind' in drawn && drawn.kind !== 'vega-lite' ? diagramSize : chartSize
329 return chartElement($, e, drawn, segment.block.source, size.columns, size.rows, `open-${i}`)
330 }),
331 )
332 for (const body of bodies) children.push(<t.Box paddingLeft={INDENT}>{body}</t.Box>)
333 return <t.Box flexDirection="column" gap={1}>{children}</t.Box>
334 })
335
336 // Phase 2 : un aperçu qui se dessine fait poser la question dans la bande
337 // au-dessus du prompt, là où s'ouvre le dialogue, l'image à côté des options.
338 // Le dialogue du moteur reste le repli.
339 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
340 const questions = questionsOf(e.questions)
341 const id = e.tool_use_id
342 if (!prefs.enabled || !questions || !id || !hasDrawings(questions)) return next(e)
343
344 // le dialogue du moteur : aperçus sans JSON, graphiques au-dessus
345 const classic = () => {
346 const asked = askedOf(questions)
347 if (!asked) return next(e)
348 pendingAsks.set(id, asked)
349 return next({ ...e, questions: withoutPreviewCharts(questions) }).finally(() => pendingAsks.delete(id))
350 }
351 // la bande n'existe que sur le terminal et le desktop ; une question à la fois
352 // surfaces illisibles : pas de bande sûre, le dialogue du moteur
353 const surfaces = await $.session.surfaces().catch(() => [])
354 const hasBand = surfaces.some(s => s === 'terminal' || s === 'desktop')
355 const home = await $.env.get('HOME')
356 if (activeAsk || !hasBand || !home || !isSafeId(id)) return classic()
357
358 const file = `${home}/.cache/claude-charts/asks/${id}.json`
359 activeAsk = { file, state: startAsk(questions), isDone: false }
360 bandHasKeys = false
361 // le tour interrompu (Échap au prompt, ctrl+c) libère l'attente
362 next.signal.addEventListener('abort', () => finishAsk($, { cancel: true }), { once: true })
363 $.ui.invalidate('ui.render')
364 // le moteur ne donne le clavier à la bande qu'après ctrl+x tab ou un clic : on tente, il peut refuser
365 if (bandId) {
366 const focused = await $.ui.focus({ requestId: bandId, key: 'option-1' }).catch(err => ({ deny: String(err) }))
367 if (focused.deny) $.ui.log(`charts : focus de la bande refusé : ${focused.deny}`, { to: 'debug' })
368 else bandHasKeys = true
369 }
370
371 const outcome = await outcomeFrom($, file, next.signal).finally(() => {
372 activeAsk = null
373 $.ui.invalidate('ui.render')
374 })
375 if ('classic' in outcome) return classic()
376 if ('cancel' in outcome) return { deny: "L'utilisateur a annulé la question sans répondre." }
377 return { result: { questions, answers: outcome.answers } }
378 })
379
380 // Les flèches posent le focus sur une option : son aperçu s'affiche.
381 on('ui.focus', async ($, e, next) => {
382 const moved = await next(e)
383 if (!activeAsk || activeAsk.isDone || e.component !== 'AbovePrompt' || moved.deny) return moved
384 if (e.plugin !== undefined && e.plugin !== $.plugin.name) return moved
385 bandHasKeys = true
386 const option = /^option-(\d+)$/.exec(e.element ?? '')
387 const state = activeAsk.state
388 const shown = option ? showOption(state, Number(option[1]) - 1) : e.element === 'autre' ? showOther(state) : state
389 if (shown !== activeAsk.state) {
390 activeAsk.state = shown
391 $.ui.invalidate('ui.render')
392 }
393 return moved
394 })
395
396 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
397 bandId = e.requestId
398 const state = activeAsk?.state
399 const question = state?.questions[state.index]
400 if (!state || !question || activeAsk?.isDone || e.props.hasSurvey) return next(e)
401
402 const t = $.ui.resolve(e)
403 const count = state.questions.length
404 const width = e.props.bodyColumns
405 const title = `${question.header} ${question.question}${count > 1 ? ` (${state.index + 1}/${count})` : ''}`
406 const head = (
407 <t.Box flexDirection="row" gap={1}>
408 <t.Text inverse bold>{` ${question.header} `}</t.Text>
409 <t.Text bold wrap="wrap">{question.question}</t.Text>
410 {count > 1 ? <t.Text dimColor>{`(${state.index + 1}/${count})`}</t.Text> : null}
411 </t.Box>
412 )
413 // Toutes les options d'un coup, une ligne chacune, à gauche ; la description
414 // entière de l'option sous le focus et son aperçu à droite.
415 const options = question.options.map((option, i) => {
416 const isShown = i === state.focus
417 const mark = question.multiSelect ? (state.picked.includes(i) ? '[x]' : '[ ]') : isShown ? '>' : ' '
418 return (
419 <t.Box flexDirection="row" gap={1}>
420 <t.Text color="cyan" bold>{mark}</t.Text>
421 <t.Button key={`option-${i + 1}`} plain hotkey={String(i + 1)} autoFocus={i === 0 ? true : undefined} onPress={() => stepAsk($, s => pressOption(s, i))}>
422 {option.label}
423 </t.Button>
424 </t.Box>
425 )
426 })
427 const onOther = isOnOther(state)
428 const description = onOther ? 'Tape ta réponse puis Entrée.' : (question.options[state.focus]?.description ?? '')
429 // la réponse libre (« Other » du dialogue), là où la surface a un champ de saisie
430 const other =
431 'Input' in t ? (
432 <t.Input key="autre" label="Autre" placeholder="réponse libre" submitLabel="envoyer" onSubmit={(text: string) => stepAsk($, s => answerFreely(s, text))} />
433 ) : null
434 const choose = question.multiSelect ? 'Entrée : cocher' : 'Entrée : choisir'
435 // bas depuis le prompt vient de global/keybindings.json ; ctrl+x tab est celui du moteur
436 const keys = bandHasKeys
437 ? `haut/bas : voir l'option ${choose} Échap : annuler`
438 : `bas (ou ctrl+x tab) : aller aux options chiffre : voir, deux fois : choisir`
439
440 // la colonne des options à la largeur du plus long libellé (marque, « 1: », libellé),
441 // ou du champ Autre avec le focus, qui ajoute « ⏎ envoyer » (mesuré en session)
442 const longest = Math.max(...question.options.map(o => [...o.label].length + 7), 'Autre: réponse libre ⏎ envoyer'.length + 2)
443 const left = Math.min(OPTIONS_COLUMNS, Math.floor(width * 0.4), longest)
444 const right = Math.max(20, width - left - 2)
445 // Tout doit tenir dans maxRows : titre, écart, corps, pied. Le corps fait la
446 // hauteur des options ou de la description et de l'aperçu, ce qui est le plus haut.
447 const bodyRows = Math.max(question.options.length + 1, e.props.maxRows - linesOf(title, width) - 2)
448 // La description est repliée en entier, jamais coupée. Une image dans l'aperçu : pas de
449 // ligne vide sous elle, pour laisser à l'image les quelques lignes que la bande a.
450 const shownPreview = onOther ? undefined : question.options[state.focus]?.preview
451 const isDrawn = shownPreview !== undefined && chartBlocksOf(shownPreview).length > 0
452 const descriptionRows = !description ? 0 : linesOf(description, right) + (isDrawn ? 0 : 1)
453 // sur « Autre », pas d'aperçu : celui de la dernière option vue tromperait
454 const preview = onOther ? null : await askPreview($, e, question, state.focus, right, bodyRows - descriptionRows)
455 const large = preview?.large
456 const footer = (
457 <t.Box flexDirection="row" gap={2}>
458 {question.multiSelect ? (
459 <t.Button key="valider" variant="primary" onPress={() => stepAsk($, confirm)}>
460 Valider
461 </t.Button>
462 ) : null}
463 <t.Button key="annuler" dimColor onPress={() => finishAsk($, { cancel: true })}>
464 Annuler
465 </t.Button>
466 {large ? (
467 <t.Button key="ouvrir" plain dimColor onPress={() => openLarge($, large)}>
468 ouvrir en grand
469 </t.Button>
470 ) : null}
471 <t.Text dimColor wrap="truncate">{keys}</t.Text>
472 </t.Box>
473 )
474 return (
475 <t.Box flexDirection="column">
476 {head}
477 <t.Box flexDirection="row" gap={2} marginTop={1}>
478 <t.Box flexDirection="column" width={left}>
479 {options}
480 {other}
481 </t.Box>
482 <t.Box flexDirection="column" width={right}>
483 {description ? <t.Text dimColor wrap="wrap">{description}</t.Text> : null}
484 {description && !isDrawn ? <t.Text> </t.Text> : null}
485 {preview?.element}
486 </t.Box>
487 </t.Box>
488 {footer}
489 </t.Box>
490 )
491 })
492
493 // Phase 2, au rendu : une image au-dessus de la boîte de dialogue.
494 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
495 if (!prefs.enabled) return next(e)
496 const questions = questionsOf(e.props.questions)
497 if (!questions) return next(e)
498 const asked = pendingAsks.get(e.requestId) ?? askedOf(questions)
499 if (!asked) return next(e)
500
501 // sans effet quand tool.call a déjà réécrit ; utile si le dialogue vient d'ailleurs
502 const dialog = await next({ ...e, props: { ...e.props, questions: withoutPreviewCharts(questions) } })
503
504 // Le moteur n'accepte que 12 lignes autour du dialogue (il additionne les
505 // enfants d'une rangée) et rien en dessous : une seule image au-dessus, qui
506 // assemble les options sur des échelles partagées.
507 const valid = asked.charts.flatMap(item => {
508 const parsed = parseSpec(item.block.source)
509 return 'spec' in parsed ? [{ index: item.index, label: item.label, spec: parsed.spec }] : []
510 })
511 const broken = asked.charts.filter(item => !valid.some(v => v.index === item.index)).map(item => item.index)
512
513 const t = $.ui.resolve(e)
514 const columns = chartColumns(e.viewport?.columns, Math.max(prefs.maxColumns, 120), INDENT)
515 // mesuré avec claude plugin test : la ligne d'erreur en plus coûte 2 lignes à l'image
516 const rows = broken.length > 0 ? ASK_ROWS - 2 : ASK_ROWS
517 const parts: RenderElement[] = []
518 if (valid.length > 0) {
519 const spec = combinedSpec(valid, columns * PX_PER_COLUMN, maxHeightFor(rows, prefs.cellAspect))
520 parts.push(await chartElement($, e, { kind: 'vega-lite', spec }, '', columns, rows))
521 }
522 if (broken.length > 0) {
523 parts.push(
524 <t.Text color="red" dimColor wrap="truncate">
525 {`charts : aperçu illisible pour l'option ${broken.join(', ')}`}
526 </t.Text>,
527 )
528 }
529 return (
530 <t.Box flexDirection="column" gap={1}>
531 <t.Box flexDirection="column" paddingLeft={INDENT}>
532 {parts}
533 </t.Box>
534 {dialog}
535 </t.Box>
536 )
537 })
538}
539hooks/ask.ts 167 lines1// La question posée dans la bande au-dessus du prompt, à la place du dialogue
2// AskUserQuestion, quand un aperçu est un graphique ou un schéma : son état, sans moteur ni rendu.
3import { chartBlocksOf, withoutCharts, type ChartBlock } from './fences'
4
5export type Option = { label: string; description: string; preview?: string }
6export type Question = { question: string; header: string; options: Option[]; multiSelect: boolean }
7export type Answers = Record<string, string>
8/** Ce que le panneau rend à l'appel de l'outil : des réponses, une annulation, ou le dialogue du moteur. */
9export type Outcome = { answers: Answers } | { cancel: true } | { classic: true }
10
11/** Un tool_use_id sert de nom de fichier : rien d'autre que lettres, chiffres, `_` et `-`. */
12export const isSafeId = (id: string) => /^[A-Za-z0-9_-]{1,128}$/.test(id)
13
14export type AskState = {
15 questions: Question[]
16 /** la question affichée */
17 index: number
18 /** l'option dont l'aperçu est affiché, 0 pour la première */
19 focus: number
20 /** les options cochées de la question affichée (multiSelect) */
21 picked: number[]
22 answers: Answers
23}
24
25export type Step = { state: AskState } | { answers: Answers }
26
27export function questionsOf(value: unknown): Question[] | null {
28 if (!Array.isArray(value)) return null
29 const ok = value.every(q => typeof q === 'object' && q !== null && Array.isArray(Reflect.get(q, 'options')))
30 return ok ? (value as Question[]) : null
31}
32
33/** Vrai quand au moins un aperçu contient un bloc à dessiner (vega-lite, mermaid, dot). */
34export function hasDrawings(questions: readonly Question[]): boolean {
35 return questions.some(q => q.options.some(o => !!o.preview && chartBlocksOf(o.preview).length > 0))
36}
37
38// Repli sur le dialogue du moteur (pas de bande) : les graphiques vega-lite des
39// aperçus sont dessinés au-dessus de lui, et retirés de ses aperçus texte.
40
41const PREVIEW_NOTE = '(graphique affiché au-dessus de la question)'
42
43export type Asked = { header: string; charts: { index: number; label: string; block: ChartBlock }[] }
44
45/** Les aperçus vega-lite de la première question qui en a, ou null. */
46export function askedOf(questions: readonly Question[]): Asked | null {
47 for (const q of questions) {
48 const charts = q.options.flatMap((o, i) => {
49 const block = o.preview ? chartBlocksOf(o.preview).find(b => b.kind === 'vega-lite') : undefined
50 return block ? [{ index: i + 1, label: o.label, block }] : []
51 })
52 if (charts.length > 0) return { header: q.header, charts }
53 }
54 return null
55}
56
57export function withoutPreviewCharts(questions: readonly Question[]): Question[] {
58 return questions.map(q => ({
59 ...q,
60 options: q.options.map(o => (o.preview ? { ...o, preview: withoutCharts(o.preview, PREVIEW_NOTE) } : o)),
61 }))
62}
63
64/**
65 * Lignes qu'occupe `text` replié mot à mot sur `width` colonnes, comme `wrap="wrap"`.
66 * La bande doit tenir en entier : plus haute, le moteur la fait défiler et les
67 * flèches défilent au lieu de passer d'une option à l'autre.
68 */
69export function linesOf(text: string, width: number): number {
70 const columns = Math.max(1, width)
71 return text.split('\n').reduce((sum, line) => {
72 let lines = 1
73 let used = 0
74 for (const word of line.split(/\s+/).filter(Boolean)) {
75 const length = [...word].length
76 if (used === 0) used = length
77 else if (used + 1 + length <= columns) used += 1 + length
78 else {
79 lines++
80 used = length
81 }
82 // un mot plus long que la ligne est coupé
83 while (used > columns) {
84 lines++
85 used -= columns
86 }
87 }
88 return sum + lines
89 }, 0)
90}
91
92export function startAsk(questions: Question[]): AskState {
93 return { questions, index: 0, focus: 0, picked: [], answers: {} }
94}
95
96/**
97 * Le chiffre d'une option : il montre son aperçu ; en choix unique, le même
98 * chiffre une seconde fois la choisit ; en choix multiple, il la coche ou la décoche.
99 */
100export function pressOption(state: AskState, option: number): Step {
101 const question = state.questions[state.index]
102 if (!question || option < 0 || option >= question.options.length) return { state }
103 if (question.multiSelect) {
104 const picked = state.picked.includes(option) ? state.picked.filter(i => i !== option) : [...state.picked, option]
105 return { state: { ...state, focus: option, picked } }
106 }
107 return option === state.focus ? confirm(state) : { state: { ...state, focus: option } }
108}
109
110/** Les flèches posent le focus sur une option : son aperçu s'affiche, rien n'est choisi. */
111export function showOption(state: AskState, option: number): AskState {
112 const question = state.questions[state.index]
113 if (!question || option < 0 || option >= question.options.length || option === state.focus) return state
114 return { ...state, focus: option }
115}
116
117/** Le focus sur le champ « Autre » : aucune option n'est affichée, aucun aperçu. */
118export function showOther(state: AskState): AskState {
119 const question = state.questions[state.index]
120 return question && state.focus !== question.options.length ? { ...state, focus: question.options.length } : state
121}
122
123/** Vrai quand le focus est sur le champ « Autre » plutôt que sur une option. */
124export function isOnOther(state: AskState): boolean {
125 return state.focus >= (state.questions[state.index]?.options.length ?? 0)
126}
127
128/** Valide la question affichée : l'option affichée, ou les cochées ; puis la suivante, ou les réponses. */
129export function confirm(state: AskState): Step {
130 const question = state.questions[state.index]
131 if (!question) return { answers: state.answers }
132 // sur « Autre », c'est le champ qui répond, avec son propre Entrée
133 if (isOnOther(state) && (!question.multiSelect || state.picked.length === 0)) return { state }
134 const chosen = question.multiSelect && state.picked.length > 0 ? state.picked.toSorted((a, b) => a - b) : [state.focus]
135 return answer(state, chosen.map(i => question.options[i]?.label ?? '').join(', '))
136}
137
138/** Le champ « Autre » : le texte tapé répond à la question affichée ; vide, il ne fait rien. */
139export function answerFreely(state: AskState, text: string): Step {
140 return text.trim() ? answer(state, text.trim()) : { state }
141}
142
143function answer(state: AskState, text: string): Step {
144 const question = state.questions[state.index]
145 if (!question) return { answers: state.answers }
146 const answers = { ...state.answers, [question.question]: text }
147 if (state.index + 1 >= state.questions.length) return { answers }
148 return { state: { ...state, index: state.index + 1, focus: 0, picked: [], answers } }
149}
150
151/** Relit ce que le panneau a écrit pour l'appel en attente ; null quand ce n'en est pas. */
152export function outcomeOf(text: string): Outcome | null {
153 let value: unknown
154 try {
155 value = JSON.parse(text)
156 } catch {
157 return null
158 }
159 if (typeof value !== 'object' || value === null) return null
160 if (Reflect.get(value, 'cancel') === true) return { cancel: true }
161 if (Reflect.get(value, 'classic') === true) return { classic: true }
162 const answers: unknown = Reflect.get(value, 'answers')
163 if (typeof answers !== 'object' || answers === null) return null
164 const entries = Object.entries(answers)
165 return entries.every(([, v]) => typeof v === 'string') ? { answers: Object.fromEntries(entries) as Answers } : null
166}
167hooks/compare.ts 24 lines1// Les graphiques des options d'une question AskUserQuestion, assemblés en une
2// seule spec hconcat : une image, des échelles partagées, donc comparables.
3
4export type OptionChart = { index: number; label: string; spec: Record<string, unknown> }
5
6/** place prise par l'axe y et ses labels, à gauche de chaque graphique */
7const AXIS_ROOM = 56
8/** place prise par le titre de l'option et l'axe x */
9const CHROME = 58
10
11export function combinedSpec(items: readonly OptionChart[], totalWidth: number, totalHeight: number): Record<string, unknown> {
12 const perWidth = Math.max(60, Math.floor(totalWidth / Math.max(1, items.length)) - AXIS_ROOM)
13 const perHeight = Math.max(40, totalHeight - CHROME)
14 return {
15 hconcat: items.map(({ index, label, spec }) => {
16 // $schema et config n'ont de sens qu'au sommet ; la taille est imposée
17 const { $schema: _schema, config: _config, width: _w, height: _h, title: _t, ...rest } = spec
18 return { ...rest, title: `${index}. ${label}`, width: perWidth, height: perHeight }
19 }),
20 resolve: { scale: { x: 'shared', y: 'shared', color: 'shared' } },
21 spacing: 16,
22 }
23}
24hooks/drawings.ts 33 lines1// Ce qu'un bloc devient pour le renderer, et le texte alternatif de son image.
2import { parseSpec, type ChartBlock } from './fences'
3import { mermaidChart } from './mermaid-charts'
4import type { Drawing } from './render'
5
6export type Drawn = Drawing | { error: string }
7
8/** Un bloc à dessiner ; un camembert ou un gantt mermaid devient un graphique vega-lite. */
9export function drawingOf(block: ChartBlock): Drawn {
10 if (block.kind === 'vega-lite') {
11 const parsed = parseSpec(block.source)
12 return 'spec' in parsed ? { kind: 'vega-lite', spec: parsed.spec } : parsed
13 }
14 const chart = block.kind === 'mermaid' ? mermaidChart(block.source) : null
15 if (chart) return 'spec' in chart ? { kind: 'vega-lite', spec: chart.spec } : chart
16 return { kind: block.kind, source: block.source }
17}
18
19export function altOf(drawing: Drawing): string {
20 if (drawing.kind !== 'vega-lite') {
21 // mermaid : le type en tête (sequenceDiagram) ; dot : le nom du graphe
22 const name =
23 drawing.kind === 'mermaid'
24 ? drawing.source.trim().split(/\s/)[0]
25 : /^\s*(?:strict\s+)?(?:di)?graph\s+"?([^\s{"]+)/i.exec(drawing.source)?.[1]
26 return name ? `schéma ${name}` : 'schéma'
27 }
28 const pick = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null)
29 const { title, description } = drawing.spec
30 const titleText = typeof title === 'object' && title !== null ? Reflect.get(title, 'text') : title
31 return pick(description) ?? pick(titleText) ?? 'graphique vega-lite'
32}
33hooks/fences.ts 112 lines1// Repérage des blocs ```vega-lite, ```mermaid et ```dot fermés dans un texte markdown, et
2// découpage du texte en segments texte / graphique, dans l'ordre.
3
4export type BlockKind = 'vega-lite' | 'mermaid' | 'dot'
5
6export type ChartBlock = {
7 /** graphique de données (vega-lite) ou schéma (mermaid, Graphviz DOT) */
8 kind: BlockKind
9 /** début du bloc entier, ligne d'ouverture comprise */
10 start: number
11 /** fin du bloc entier, ligne de fermeture comprise */
12 end: number
13 /** le contenu entre les deux lignes de fence : JSON vega-lite ou texte DOT */
14 source: string
15}
16
17export type Segment = { kind: 'text'; text: string } | { kind: 'chart'; block: ChartBlock }
18
19export type ParsedSpec = { spec: Record<string, unknown> } | { error: string }
20
21const LANGS = new Map<string, BlockKind>([
22 ['vega-lite', 'vega-lite'],
23 ['vegalite', 'vega-lite'],
24 ['vl', 'vega-lite'],
25 ['mermaid', 'mermaid'],
26 ['dot', 'dot'],
27 ['graphviz', 'dot'],
28])
29
30// Ouverture : indentation, 3+ backticks ou tildes, info string. La fermeture
31// doit utiliser le même caractère, au moins aussi long, seule sur sa ligne.
32const OPEN = /^([ \t]*)(`{3,}|~{3,})[ \t]*([^\s`]*)[^\n]*$/
33
34/** La ligne ouvre un bloc de code : sa fence, et son type quand c'est un graphique ou un schéma. */
35export function fenceOpening(line: string): { fence: string; kind: BlockKind | undefined } | null {
36 const match = OPEN.exec(line)
37 if (!match) return null
38 return { fence: match[2] ?? '```', kind: LANGS.get((match[3] ?? '').toLowerCase()) }
39}
40
41/** La ligne ferme le bloc ouvert par `fence`. */
42export function closesFence(line: string, fence: string): boolean {
43 const trimmed = line.trim()
44 return trimmed.length >= fence.length && trimmed[0] === fence[0] && [...trimmed].every(c => c === fence[0])
45}
46
47export function chartBlocksOf(text: string): ChartBlock[] {
48 const blocks: ChartBlock[] = []
49 const lines = text.split('\n')
50 let offset = 0
51 let open: { start: number; fence: string; kind: BlockKind | undefined; bodyStart: number } | null = null
52
53 for (const line of lines) {
54 const lineEnd = offset + line.length
55 if (open === null) {
56 const opening = fenceOpening(line)
57 if (opening) open = { start: offset, ...opening, bodyStart: lineEnd + 1 }
58 } else {
59 if (closesFence(line, open.fence)) {
60 if (open.kind) {
61 const source = text.slice(open.bodyStart, Math.max(open.bodyStart, offset - 1)).trim()
62 if (source.length > 0) blocks.push({ kind: open.kind, start: open.start, end: lineEnd, source })
63 }
64 open = null
65 }
66 }
67 offset = lineEnd + 1
68 }
69 // un bloc encore ouvert (réponse en streaming) n'est pas un graphique
70 return blocks
71}
72
73export function segmentsOf(text: string, blocks: readonly ChartBlock[]): Segment[] {
74 const segments: Segment[] = []
75 let cursor = 0
76 for (const block of blocks) {
77 const before = text.slice(cursor, block.start).replace(/\n+$/, '')
78 if (before.trim().length > 0) segments.push({ kind: 'text', text: before })
79 segments.push({ kind: 'chart', block })
80 cursor = block.end
81 }
82 const after = text.slice(cursor).replace(/^\n+/, '')
83 if (after.trim().length > 0) segments.push({ kind: 'text', text: after })
84 return segments
85}
86
87export function parseSpec(source: string): ParsedSpec {
88 let value: unknown
89 try {
90 value = JSON.parse(source)
91 } catch (error) {
92 return { error: `JSON invalide : ${error instanceof Error ? error.message : String(error)}` }
93 }
94 if (value === null || typeof value !== 'object' || Array.isArray(value)) {
95 return { error: 'la spec doit être un objet JSON' }
96 }
97 return { spec: value as Record<string, unknown> }
98}
99
100/** Remplace chaque bloc vega-lite par une courte mention, pour un aperçu texte ; un schéma reste. */
101export function withoutCharts(text: string, label: string): string {
102 const blocks = chartBlocksOf(text).filter(block => block.kind === 'vega-lite')
103 if (blocks.length === 0) return text
104 let out = ''
105 let cursor = 0
106 for (const block of blocks) {
107 out += text.slice(cursor, block.start) + label
108 cursor = block.end
109 }
110 return out + text.slice(cursor)
111}
112hooks/prefs.ts 83 lines1// Préférences du mod, gardées dans $.store, et la commande /charts qui les change.
2import type { Theme } from './render'
3
4export type Prefs = {
5 enabled: boolean
6 theme: Theme
7 /** largeur maximale d'un graphique, en colonnes de terminal */
8 maxColumns: number
9 /** hauteur maximale d'un graphique, en lignes de terminal */
10 maxRows: number
11 /** largeur / hauteur d'une cellule du terminal (Ghostty, JetBrains Mono : environ 0,5) */
12 cellAspect: number
13}
14
15export const DEFAULT_PREFS: Prefs = { enabled: true, theme: 'dark', maxColumns: 100, maxRows: 22, cellAspect: 0.5 }
16
17export const STORE_KEY = 'prefs'
18
19export function prefsFrom(stored: unknown): Prefs {
20 if (stored === null || typeof stored !== 'object') return DEFAULT_PREFS
21 const merged = { ...DEFAULT_PREFS, ...(stored as Partial<Prefs>) }
22 const isValid =
23 typeof merged.enabled === 'boolean' &&
24 (merged.theme === 'dark' || merged.theme === 'light') &&
25 Number.isFinite(merged.maxColumns) &&
26 Number.isFinite(merged.maxRows) &&
27 Number.isFinite(merged.cellAspect)
28 return isValid ? merged : DEFAULT_PREFS
29}
30
31export function statusOf(p: Prefs): string {
32 return [
33 `charts ${p.enabled ? 'on' : 'off'}`,
34 `thème ${p.theme}`,
35 `largeur max ${p.maxColumns} col`,
36 `hauteur max ${p.maxRows} lignes`,
37 `ratio cellule ${p.cellAspect}`,
38 ].join(' | ')
39}
40
41export const USAGE =
42 '/charts [on|off] | theme dark|light | width <20-255> | rows <3-80> | cell <0.3-0.8> | reset'
43
44/** Applique les arguments de /charts ; renvoie les nouvelles préférences ou une erreur. */
45export function applyCommand(p: Prefs, args: string): { prefs: Prefs } | { error: string } {
46 const [word = '', value = ''] = args.trim().toLowerCase().split(/\s+/)
47 const n = Number(value.replace(',', '.'))
48 switch (word) {
49 case '':
50 case 'status':
51 return { prefs: p }
52 case 'on':
53 return { prefs: { ...p, enabled: true } }
54 case 'off':
55 return { prefs: { ...p, enabled: false } }
56 case 'reset':
57 return { prefs: DEFAULT_PREFS }
58 case 'theme':
59 return value === 'dark' || value === 'light' ? { prefs: { ...p, theme: value } } : { error: 'thème : dark ou light' }
60 case 'width':
61 return Number.isInteger(n) && n >= 20 && n <= 255 ? { prefs: { ...p, maxColumns: n } } : { error: 'width : entier de 20 à 255' }
62 case 'rows':
63 return Number.isInteger(n) && n >= 3 && n <= 80 ? { prefs: { ...p, maxRows: n } } : { error: 'rows : entier de 3 à 80' }
64 case 'cell':
65 return n >= 0.3 && n <= 0.8 ? { prefs: { ...p, cellAspect: n } } : { error: 'cell : nombre de 0.3 à 0.8' }
66 default:
67 return { error: `argument inconnu : ${word}. ${USAGE}` }
68 }
69}
70
71export const GUIDE = [
72 '# Charts and diagrams by default',
73 'This session draws ```vega-lite (data charts), ```mermaid and ```dot (diagrams) code blocks as real images (terminal and desktop). The person understands a picture faster than prose: show, then tell. Do not wait to be asked for a chart or a diagram.',
74 'Add a diagram, unprompted, whenever you explain how something works or is built: architecture, components and their calls, a pipeline or data flow, a request lifecycle, who talks to whom and in what order, a state machine, data models and their relations, a decision flow. Then explain only what the diagram cannot show.',
75 'Diagrams: write ```mermaid by default, picking the type that fits: flowchart TD or LR (architecture, pipelines, decisions, subgraph for layers or services), sequenceDiagram (calls in time order between actors), stateDiagram-v2 (lifecycles, statuses), classDiagram (types and their links), erDiagram (tables and relations), gantt (a plan or schedule: sections, tasks with dates as YYYY-MM-DD or YYYY-MM-DD HH:mm, durations like 3d, after id, milestone), pie (a share of a whole, at most 8 slices). No mindmap, timeline or journey. Use ```dot only for a large graph that needs automatic layout with clusters.',
76 'Diagram style: short labels (2 to 4 words, <br/> in mermaid or \\n in dot for a second line), edge labels only when they carry information, at most about 20 nodes; split a bigger system into several diagrams. Do not set colors, classDef, style, themes, fonts or init directives: the mod applies its theme. Never use ASCII art for diagrams.',
77 'Add a vega-lite block, unprompted, whenever an answer holds any of: two or more comparable quantities (durations, costs, sizes, counts, scores); a trend or anything over time; a before/after; a distribution; a ranking. For a share of a whole write a mermaid pie, for a schedule a mermaid gantt.',
78 'Lead with the chart, then one or two sentences: the conclusion and the one or two figures that matter. No table that repeats what the chart shows, no ASCII chart.',
79 'Spec: Vega-Lite v6 JSON, data inline in data.values, a short title. Do not set width, height, colors, background or config: the mod applies size and theme. One chart per block, at most 8 series, one y axis; several measures of different units go in separate blocks.',
80 'Use measured figures. When a value is an estimate, say so in the title ("estimation") and never present it as measured.',
81 'AskUserQuestion: whenever the options differ on something measurable (time, cost, size, risk, effort, perf), give each option a preview holding one vega-lite block of the same shape (same fields, same mark), so the mod lines them up on one shared scale inside the question. Qualitative options can be scored 1 to 5 on two to four criteria, titled as an estimation. When the options are designs, flows or architectures, give each option a preview holding one mermaid diagram of that option instead: the person sees the diagram of the option they look at.',
82].join('\n')
83hooks/render.ts 132 lines1// Appel du moteur de rendu (renderer/render.mjs, un process node) et calcul de
2// la taille en cellules. Le module de hooks n'a ni Node ni DOM : vega tourne
3// dans ce process séparé, qui écrit le PNG que le terminal lit lui-même.
4import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
5
6/** `$.process.run`, passé depuis register.tsx ($ ne traverse pas les imports). */
7export type Run = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
8
9export type Theme = 'dark' | 'light'
10export type Format = 'png' | 'svg'
11
12export type Rendered =
13 | { ok: true; file?: string; svg?: string; width: number; height: number }
14 | { ok: false; error: string; isTransient?: boolean }
15
16/** Pixels CSS dessinés par colonne de terminal ; le PNG est rendu en x2. */
17export const PX_PER_COLUMN = 8
18
19const NODES = ['node', '/opt/homebrew/bin/node', '/usr/local/bin/node']
20/** macOS, puis Linux */
21const OPENERS = ['/usr/bin/open', 'xdg-open']
22const MEMO_SIZE = 64
23const memo = new Map<string, Promise<Rendered>>()
24
25export function chartColumns(viewportColumns: number | undefined, maxColumns: number, indent: number): number {
26 const available = (viewportColumns ?? 100) - indent - 2
27 return Math.max(20, Math.min(maxColumns, available, 255))
28}
29
30/** Hauteur maximale en pixels CSS pour tenir dans `maxRows` lignes de terminal. */
31export function maxHeightFor(maxRows: number, cellAspect: number): number {
32 return Math.round((maxRows * PX_PER_COLUMN) / cellAspect)
33}
34
35/** Lignes de terminal pour garder le ratio du PNG (cellAspect = largeur / hauteur d'une cellule). */
36export function chartRows(columns: number, width: number, height: number, cellAspect: number): number {
37 const rows = Math.round((columns * cellAspect * height) / width)
38 return Math.max(3, Math.min(rows, 255))
39}
40
41function isRendered(value: unknown): value is Rendered {
42 return typeof value === 'object' && value !== null && typeof Reflect.get(value, 'ok') === 'boolean'
43}
44
45async function runRenderer(run: Run, root: string, input: string): Promise<Rendered> {
46 const script = `${root}/renderer/render.mjs`
47 let lastError = 'node introuvable'
48 for (const node of NODES) {
49 const attempt = await run([node, script], { stdin: input, timeoutMs: 20_000 }).then(
50 ran => ({ ran }),
51 (error: unknown) => ({ failed: error instanceof Error ? error.message : String(error) }),
52 )
53 if ('failed' in attempt) {
54 // Seul un binaire absent fait essayer le suivant. Un rendu abandonné (zoom :
55 // le moteur tue le process) ou trop long n'a rien à voir avec ce binaire.
56 if (!attempt.failed.includes('failed to start')) return { ok: false, error: attempt.failed, isTransient: true }
57 lastError = attempt.failed
58 continue
59 }
60 const { ran } = attempt
61 if (ran.exitCode !== 0 && ran.stdout.trim() === '') {
62 return { ok: false, error: ran.stderr.trim().slice(0, 300) || `exit ${ran.exitCode}` }
63 }
64 try {
65 const parsed: unknown = JSON.parse(ran.stdout)
66 return isRendered(parsed) ? parsed : { ok: false, error: 'réponse du renderer illisible' }
67 } catch {
68 return { ok: false, error: `sortie du renderer illisible : ${ran.stdout.slice(0, 120)}` }
69 }
70 }
71 return { ok: false, error: lastError }
72}
73
74/** Ce que le renderer dessine : une spec vega-lite, ou la source texte d'un schéma. */
75export type Drawing = { kind: 'vega-lite'; spec: Record<string, unknown> } | { kind: 'mermaid' | 'dot'; source: string }
76
77export function renderChart(
78 run: Run,
79 root: string,
80 drawing: Drawing,
81 options: { width: number; maxHeight: number; theme: Theme; format: Format; scale?: number; opaque?: boolean },
82): Promise<Rendered> {
83 const input = JSON.stringify({ ...drawing, ...options })
84 const cached = memo.get(input)
85 if (cached) {
86 // remis en fin de file : les graphiques encore affichés restent en mémoire
87 memo.delete(input)
88 memo.set(input, cached)
89 // l'échec passager d'un autre rendu (abandonné pendant un zoom) ne vaut pas pour celui-ci : une relance
90 return cached.then(rendered => {
91 if (rendered.ok || !rendered.isTransient) return rendered
92 const fresh = memo.get(input)
93 return fresh && fresh !== cached ? fresh : startRender(run, root, input)
94 })
95 }
96 return startRender(run, root, input)
97}
98
99function startRender(run: Run, root: string, input: string): Promise<Rendered> {
100 const pending = runRenderer(run, root, input)
101 // le cache disque du renderer prend le relais : on ne garde que les plus récents (SVG lourds)
102 if (memo.size >= MEMO_SIZE) memo.delete(memo.keys().next().value!)
103 memo.set(input, pending)
104 // un échec passager n'est pas gardé : le rendu suivant relance node
105 void pending.then(rendered => {
106 if (!rendered.ok && rendered.isTransient && memo.get(input) === pending) memo.delete(input)
107 })
108 return pending
109}
110
111/**
112 * « Ouvrir en grand » : un graphique sur 1400 px, un schéma à sa taille naturelle, en x2
113 * et sur fond plein (la visionneuse n'a pas le fond du terminal), ouvert par le système.
114 * Rend l'erreur à montrer, ou null.
115 */
116export async function openInViewer(run: Run, root: string, drawing: Drawing, theme: Theme): Promise<string | null> {
117 const size = drawing.kind === 'vega-lite' ? { width: 1400, maxHeight: 900 } : { width: 8000, maxHeight: 8000 }
118 const rendered = await renderChart(run, root, drawing, { ...size, theme, format: 'png', scale: 2, opaque: true })
119 if (!rendered.ok) return rendered.error
120 if (!rendered.file) return "le renderer n'a pas écrit de PNG"
121 for (const opener of OPENERS) {
122 // rejeté : ce binaire n'existe pas ici, on essaie le suivant
123 const ran = await run([opener, rendered.file], { timeoutMs: 10_000 }).catch(() => null)
124 if (ran) return ran.exitCode === 0 ? null : ran.stderr.trim() || `${opener} : exit ${ran.exitCode}`
125 }
126 return `ni ${OPENERS.join(' ni ')} ne démarre`
127}
128
129export function clearMemo(): void {
130 memo.clear()
131}
132hooks/streaming.ts 93 lines1// Pendant le streaming, le moteur ne dessine un bloc de texte qu'une fois ce bloc
2// terminé : un graphique n'apparaît qu'à la fin de la réponse, et sa source défile
3// en attendant. Le texte qui défile cache donc la source d'un graphique ou d'un
4// schéma et la remplace, à la fence fermante, par une ligne qui dit où il viendra.
5
6import { chartBlocksOf, closesFence, fenceOpening, parseSpec, type BlockKind } from './fences'
7
8export type StreamState = {
9 /** le bloc de code ouvert dans le texte déjà vu ; `kind` absent pour un code ordinaire, laissé visible */
10 open: { fence: string; kind: BlockKind | undefined; body: string[] } | null
11}
12
13export const START: StreamState = { open: null }
14
15/** Le titre d'une spec vega-lite, quand elle en a un lisible. */
16function titleOf(source: string): string | undefined {
17 const parsed = parseSpec(source)
18 if (!('spec' in parsed)) return undefined
19 const title = parsed.spec.title
20 const text = typeof title === 'object' && title !== null && !Array.isArray(title) ? (title as { text?: unknown }).text : title
21 const line = Array.isArray(text) ? text.join(' ') : text
22 return typeof line === 'string' && line.trim() ? line.trim() : undefined
23}
24
25/** La ligne qui tient la place d'un bloc fermé jusqu'à son image. */
26export function placeholderOf(kind: BlockKind, source: string): string {
27 if (kind !== 'vega-lite') return `*Schéma ${kind} : dessiné à la fin de la réponse*`
28 const title = titleOf(source)
29 return title ? `*Graphique "${title}" : dessiné à la fin de la réponse*` : '*Graphique : dessiné à la fin de la réponse*'
30}
31
32/** Le texte à afficher pour un lot de lignes reçues, et l'état pour le lot suivant. */
33export function displayOf(state: StreamState, delta: string): { state: StreamState; text: string } {
34 let open = state.open
35 const shown: string[] = []
36 // le dernier élément est vide quand le lot finit sur un saut de ligne : il ne se rejoint pas
37 const lines = delta.split('\n')
38 const isWhole = delta.endsWith('\n')
39 for (const [i, line] of lines.entries()) {
40 if (isWhole && i === lines.length - 1) break
41 if (open === null) {
42 const opening = fenceOpening(line)
43 if (opening) open = { ...opening, body: [] }
44 if (!opening?.kind) shown.push(line)
45 } else if (closesFence(line, open.fence)) {
46 shown.push(open.kind ? placeholderOf(open.kind, open.body.join('\n')) : line)
47 open = null
48 } else {
49 if (open.kind) open = { ...open, body: [...open.body, line] }
50 else shown.push(line)
51 }
52 }
53 const text = shown.length === 0 ? '' : shown.join('\n') + (isWhole ? '\n' : '')
54 return { state: { open }, text }
55}
56
57// « Graphique : ... » sans titre, « Graphique "Titre" : ... » avec
58const PLACEHOLDER = /^\*(?:Graphique|Schéma)(?: .*)? : dessiné à la fin de la réponse\*$/m
59
60/** Le texte vient du streaming : une ligne y tient la place d'un graphique. */
61export function hasPlaceholder(text: string): boolean {
62 return PLACEHOLDER.test(text)
63}
64
65const squeezed = (text: string) => text.replace(/\s+/g, ' ').trim()
66
67/**
68 * Le moteur garde le texte affiché au streaming pour dessiner le bloc fini : on
69 * remet la source à la place de chaque ligne de remplacement, prise dans le
70 * message enregistré (`stored`, du plus ancien au plus récent) qui a donné ce
71 * texte. null quand aucun ne l'a donné.
72 */
73export function restoredFrom(shown: string, stored: readonly string[]): string | null {
74 for (const original of stored.toReversed()) {
75 // ce message, transformé, contient le texte affiché : c'est le bon, même joint à d'autres blocs
76 if (!squeezed(displayOf(START, original).text).includes(squeezed(shown))) continue
77 const blocks = chartBlocksOf(original).map(block => ({
78 placeholder: placeholderOf(block.kind, block.source),
79 fenced: original.slice(block.start, block.end),
80 }))
81 let next = 0
82 const lines = shown.split('\n').map(line => {
83 if (!PLACEHOLDER.test(line)) return line
84 const at = blocks.findIndex((block, i) => i >= next && block.placeholder === line)
85 if (at < 0) return line
86 next = at + 1
87 return blocks[at]!.fenced
88 })
89 if (next > 0) return lines.join('\n')
90 }
91 return null
92}
93hooks/mermaid-charts.ts 229 lines1// Les camemberts (pie) et les diagrammes de Gantt mermaid, que beautiful-mermaid ne
2// dessine pas, traduits en specs vega-lite : même renderer et même thème que les graphiques.
3import type { ParsedSpec } from './fences'
4
5const SCHEMA = 'https://vega.github.io/schema/vega-lite/v6.json'
6/** la palette a 8 couleurs, jamais recyclées : au-delà, les plus petites parts sont regroupées */
7const MAX_SLICES = 8
8const PIE_PX = 220
9const GANTT_ROW_PX = 24
10/** l'axe des dates sous les barres, et la place d'un titre ou d'une légende au-dessus */
11const GANTT_AXIS_PX = 30
12const GANTT_BAND_PX = 30
13
14const SLICE = /^"([^"]+)"\s*:\s*(\d+(?:\.\d+)?)$/
15const DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
16const DURATION = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/
17const DAY_MS = 864e5
18const UNIT_MS: Record<string, number> = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: DAY_MS, w: 7 * DAY_MS }
19const TAGS = new Set(['done', 'active', 'crit', 'milestone'])
20// réglages mermaid sans effet ici : les dates sont lues en YYYY-MM-DD [HH:mm], sans jours exclus
21const IGNORED = /^(dateFormat|excludes|includes|todayMarker|tickInterval|weekday|weekend|inclusiveEndDates|topAxis|displayMode|accTitle|accDescr|click)\b/
22
23type Task = { name: string; section: string; start: number; end: number; tags: string[] }
24type Found = { at: number } | { error: string }
25
26/** Un camembert ou un gantt traduit, son erreur, ou null pour les autres types mermaid. */
27export function mermaidChart(source: string): ParsedSpec | null {
28 const lines = source
29 .split('\n')
30 .map(line => line.trim())
31 .filter(line => line !== '' && !line.startsWith('%%'))
32 const [first = '', ...rest] = lines
33 const type = first.split(/\s/)[0]
34 if (type === 'pie') return pieSpec(first.slice(3).trim(), rest)
35 if (type === 'gantt') return ganttSpec(rest)
36 return null
37}
38
39function pieSpec(header: string, lines: readonly string[]): ParsedSpec {
40 let title: string | undefined
41 let showData = false
42 const slices: { name: string; value: number }[] = []
43 // l'en-tête peut porter showData et le titre : `pie showData title Animaux`
44 for (const raw of [header, ...lines]) {
45 let line = raw
46 if (/^showData\b/.test(line)) {
47 showData = true
48 line = line.slice('showData'.length).trim()
49 }
50 if (line === '' || /^acc(Title|Descr)\b/.test(line)) continue
51 const titled = /^title\s+(.+)$/.exec(line)
52 if (titled) {
53 title = titled[1]
54 continue
55 }
56 const slice = SLICE.exec(line)
57 if (!slice) return { error: `pie : ligne illisible : ${line}` }
58 slices.push({ name: slice[1]!, value: Number(slice[2]) })
59 }
60 const total = slices.reduce((sum, s) => sum + s.value, 0)
61 if (total <= 0) return { error: 'pie : aucune part non nulle' }
62
63 const sorted = slices.toSorted((a, b) => b.value - a.value)
64 const kept =
65 sorted.length <= MAX_SLICES
66 ? sorted
67 : [...sorted.slice(0, MAX_SLICES - 1), { name: 'Autres', value: sorted.slice(MAX_SLICES - 1).reduce((sum, s) => sum + s.value, 0) }]
68 const values = kept.map((s, rank) => {
69 const share = (s.value / total) * 100
70 const percent = share > 0 && share < 1 ? '<1 %' : `${Math.round(share)} %`
71 return { rank, value: s.value, label: showData ? `${s.name} ${s.value} (${percent})` : `${s.name} ${percent}` }
72 })
73 return {
74 spec: {
75 $schema: SCHEMA,
76 ...(title ? { title } : {}),
77 description: title ?? 'camembert',
78 width: PIE_PX,
79 height: PIE_PX,
80 data: { values },
81 mark: { type: 'arc', padAngle: 0.012 },
82 encoding: {
83 theta: { field: 'value', type: 'quantitative', stack: true },
84 order: { field: 'rank', type: 'quantitative' },
85 color: { field: 'label', type: 'nominal', sort: null, legend: { title: null, orient: 'right', direction: 'vertical', labelLimit: 400 } },
86 },
87 },
88 }
89}
90
91function ganttSpec(lines: readonly string[]): ParsedSpec {
92 let title: string | undefined
93 let axisFormat: string | undefined
94 let section = ''
95 const tasks: Task[] = []
96 const byId = new Map<string, Task>()
97 for (const line of lines) {
98 const directive = /^(title|axisFormat|section)\s+(.+)$/.exec(line)
99 if (directive) {
100 const value = directive[2]!
101 if (directive[1] === 'title') title = value
102 else if (directive[1] === 'axisFormat') axisFormat = value
103 else section = value
104 continue
105 }
106 if (IGNORED.test(line)) continue
107 const colon = line.indexOf(':')
108 if (colon <= 0) return { error: `gantt : ligne illisible : ${line}` }
109 const name = line.slice(0, colon).trim()
110 const read = taskOf(name, line.slice(colon + 1), section, tasks.at(-1), byId)
111 if ('error' in read) return { error: `gantt : "${name}" : ${read.error}` }
112 tasks.push(read.task)
113 if (read.id) byId.set(read.id, read.task)
114 }
115 if (tasks.length === 0) return { error: 'gantt : aucune tâche' }
116
117 const sections = [...new Set(tasks.map(t => t.section))]
118 const values = tasks.map(t => ({
119 task: t.tags.includes('crit') ? `${t.name} (critique)` : t.name,
120 section: t.section || 'tâches',
121 start: new Date(t.start).toISOString(),
122 end: new Date(t.end).toISOString(),
123 isDone: t.tags.includes('done'),
124 isMilestone: t.tags.includes('milestone'),
125 }))
126 // l'heure mermaid est celle écrite : échelle UTC, sinon le fuseau décale les jours
127 // sans axisFormat, vega écrit les dates en anglais (Fri 02, Oct 04) : jour/mois, et l'heure s'il y en a
128 const hasTimes = tasks.some(t => t.start % DAY_MS !== 0 || t.end % DAY_MS !== 0)
129 const format = axisFormat ?? (hasTimes ? '%d/%m %Hh%M' : '%d/%m')
130 const x = { field: 'start', type: 'temporal', scale: { type: 'utc' }, axis: { title: null, format } }
131 const hasSections = sections.length > 1 || sections[0] !== ''
132 return {
133 spec: {
134 $schema: SCHEMA,
135 ...(title ? { title } : {}),
136 description: title ?? 'diagramme de Gantt',
137 // le renderer fait tenir le tout (titre, légende, axe) dans la hauteur : on les ajoute aux lignes
138 height: tasks.length * GANTT_ROW_PX + GANTT_AXIS_PX + (title ? GANTT_BAND_PX : 0) + (hasSections ? GANTT_BAND_PX : 0),
139 data: { values },
140 encoding: {
141 // l'ordre écrit, jalons compris : les deux couches ne le gardent pas seules
142 y: { field: 'task', type: 'nominal', sort: [...new Set(values.map(v => v.task))], axis: { title: null, labelOverlap: false, labelLimit: 240 } },
143 ...(hasSections ? { color: { field: 'section', type: 'nominal', sort: sections.map(s => s || 'tâches'), legend: { title: null } } } : {}),
144 },
145 layer: [
146 {
147 transform: [{ filter: '!datum.isMilestone' }],
148 mark: { type: 'bar', cornerRadius: 3 },
149 encoding: { x, x2: { field: 'end' }, opacity: { condition: { test: 'datum.isDone', value: 0.4 }, value: 1 } },
150 },
151 {
152 transform: [{ filter: 'datum.isMilestone' }],
153 mark: { type: 'point', shape: 'diamond', filled: true, size: 140, opacity: 1 },
154 encoding: { x },
155 },
156 ],
157 },
158 }
159}
160
161/**
162 * `nom : [tags,] [id,] [début,] fin` : la fin est une date, une durée (3d) ou
163 * `until id` ; sans début, la tâche suit la précédente ; un début peut être `after id`.
164 */
165function taskOf(
166 name: string,
167 meta: string,
168 section: string,
169 previous: Task | undefined,
170 byId: ReadonlyMap<string, Task>,
171): { task: Task; id?: string } | { error: string } {
172 const items = meta
173 .split(',')
174 .map(item => item.trim())
175 .filter(item => item !== '')
176 const firstPlain = items.findIndex(item => !TAGS.has(item))
177 const tags = firstPlain === -1 ? items : items.slice(0, firstPlain)
178 const rest = firstPlain === -1 ? [] : items.slice(firstPlain)
179 if (rest.length === 0 || rest.length > 3) return { error: 'il faut une fin ou une durée, et au plus id, début, fin' }
180
181 let id: string | undefined
182 let startText: string | undefined
183 if (rest.length === 3) [id, startText] = rest
184 else if (rest.length === 2) {
185 const head = rest[0]!
186 // une date impossible (2026-02-31) reste un début : son erreur le dira
187 if (head.startsWith('after ') || DATE.test(head)) startText = head
188 else id = head
189 }
190 const start: Found = startText !== undefined ? startOf(startText, byId) : previous ? { at: previous.end } : { error: 'ni date de début ni tâche avant' }
191 if ('error' in start) return start
192 const end = tags.includes('milestone') ? start : endOf(rest.at(-1)!, start.at, byId)
193 if ('error' in end) return end
194 return { task: { name, section, start: start.at, end: end.at, tags }, ...(id ? { id } : {}) }
195}
196
197function startOf(text: string, byId: ReadonlyMap<string, Task>): Found {
198 const after = /^after\s+(.+)$/.exec(text)
199 if (after) {
200 const ends = after[1]!.split(/\s+/).map(id => byId.get(id)?.end)
201 const known = ends.filter((end): end is number => end !== undefined)
202 return known.length === ends.length ? { at: Math.max(...known) } : { error: `tâche inconnue dans "${text}"` }
203 }
204 const at = dateOf(text)
205 return at === null ? { error: `date illisible "${text}" (format YYYY-MM-DD ou YYYY-MM-DD HH:mm)` } : { at }
206}
207
208function endOf(text: string, start: number, byId: ReadonlyMap<string, Task>): Found {
209 const duration = DURATION.exec(text)
210 if (duration) return { at: start + Number(duration[1]) * UNIT_MS[duration[2]!]! }
211 const until = /^until\s+(\S+)$/.exec(text)
212 if (until) {
213 const task = byId.get(until[1]!)
214 return task ? { at: task.start } : { error: `tâche inconnue dans "${text}"` }
215 }
216 const at = dateOf(text)
217 if (at === null) return { error: `fin illisible "${text}" (date YYYY-MM-DD ou durée : 3d, 2w, 4h)` }
218 return at < start ? { error: `la fin "${text}" est avant le début` } : { at }
219}
220
221function dateOf(text: string): number | null {
222 const m = DATE.exec(text)
223 if (!m) return null
224 const day = Number(m[3])
225 const at = Date.UTC(Number(m[1]), Number(m[2]) - 1, day, Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0))
226 // 2026-02-31 : Date.UTC déborde sur mars, la date est refusée
227 return new Date(at).getUTCDate() === day ? at : null
228}
229