SLOPSHOPPER

charts

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

newbandrowsguardcommandtoast
v0.2.0MITupdated 2026-10-03ThomasTartrau/charts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · charts
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /charts ⎿ charts: charts on | thème dark | largeur max 100 col | hauteur max 22 lignes | ratio cellule 0.5 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img alt="charts" src="assets/logo/charts-banner.png" width="520">

License: MIT Claude Code Node

Charts and diagrams in Claude Code, drawn as images instead of code blocks.

Install | What it draws | Usage | How it works | Development


What is charts?

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">

Install

You need:

  • Claude Code 2.1.280 or newer
  • Node 20 or newer on your PATH
  • For images in the terminal, a terminal with the kitty graphics protocol, such as Ghostty or kitty. Other terminals show the chart's text description instead.

1. Turn on function hooks

Mods 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" }
}

2. Add the marketplace and install

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.

Updating

claude plugin update charts

What it draws

Charts in replies

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">

Diagrams

`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">

Options you can compare

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.

Usage

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.

CommandEffect
/chartsShow the current settings
/charts on / offTurn the mod on or off
/charts theme dark / lightMatch 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 resetBack to the defaults

How it works

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.
  • Rendered images are cached in ~/.cache/claude-charts, keyed by a hash of the spec. Scrolling back over an old chart doesn't start Node again.
  • Claude Code draws a block of text only once the block is finished, so a chart shows up when the reply ends. While the reply streams, the chart's source doesn't scroll by: one italic line holds its place, with the chart's title, until the image replaces it.

When a block can't be drawn, usually an invalid spec, you get the source back with the error under it.

Development

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

License

MIT

Source 9 files
hooks/register.tsx 539 lines
1// 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}
539
hooks/ask.ts 167 lines
1// 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}
167
hooks/compare.ts 24 lines
1// 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}
24
hooks/drawings.ts 33 lines
1// 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}
33
hooks/fences.ts 112 lines
1// 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}
112
hooks/prefs.ts 83 lines
1// 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')
83
hooks/render.ts 132 lines
1// 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}
132
hooks/streaming.ts 93 lines
1// 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}
93
hooks/mermaid-charts.ts 229 lines
1// 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