SLOPSHOPPER

tracker-diff

Zeigt bei Tracker-Schreibbefehlen den Diff in der Zeile des Tool-Aufrufs (Versuch)

paneguardprocess
v0.0.1no licenseupdated 2026-10-23causa-prima/mahl/.claude/mods/tracker-diff
A shopper browsing a rack in a slop shop
Source 1 files
hooks/register.js 113 lines
1// Zeigt bei Tracker-Schreibbefehlen (obs|lessons|td|oq add|set|remove) den Diff der Datei in
2// einem Pane, solange der Aufruf auf Freigabe und Ausführung wartet.
3//
4// Nur Anzeige: Die Freigabe bleibt beim PreToolUse-Hook und seinem Prompt, den kein Mod
5// gestalten kann. Berechnet wird der Diff in Python (prozesscode/tracker_diff.py), aus dem
6// echten Werkzeug-Code mit abgefangenem Schreiben – hier wird er nur gezeichnet.
7const TRACKER = /^python3 -m prozesscode\.(obs|lessons|td|oq) (add|set|remove)\b/
8const PANE = 'tracker-diff'
9
10// Je wartendem Aufruf sein Befund; das Pane zeigt den jüngsten.
11const offen = new Map()
12
13async function berechne($, command) {
14  try {
15    const lauf = await $.process.run(['python3', '-m', 'prozesscode.tracker_diff'],
16      { stdin: command, cwd: await $.session.root(), timeoutMs: 10000 })
17    if (lauf.exitCode !== 0) {
18      return { dateien: [], fehler: 'tracker_diff endete mit Exit ' + lauf.exitCode + ': ' + lauf.stderr.trim().slice(-400) }
19    }
20    return JSON.parse(lauf.stdout)
21  } catch (fehler) {
22    return { dateien: [], fehler: 'tracker_diff nicht aufrufbar: ' + fehler }
23  }
24}
25
26// Stile aus tracker_diff.py → Text-Props. ANSI-Farben: Im Pane erscheinen alle Farben gedämpft
27// (Hex, Theme-Schlüssel, ANSI und invers am echten Terminal verglichen); ANSI hatte dort den
28// meisten Kontrast.
29const STIL = {
30  fett: { weight: 'semibold' },
31  code: { color: 'suggestion' },
32  kursiv: { italic: true },
33  weg: { backgroundColor: 'red', color: 'white', strikethrough: true },
34  neu: { backgroundColor: 'green', color: 'white' },
35}
36// Ganze neue oder gelöschte Zeilen: Marke vorn, die ganze Zeile in der Farbe.
37const ZEILE = {
38  neu: { marke: '+ ', props: STIL.neu },
39  weg: { marke: '− ', props: STIL.weg },
40}
41
42// Grob, wie viele Zeilen der Inhalt im Pane braucht, aus der Geometrie, die die Engine meldet
43// (ein Zeichen = eine Zelle, Umbruch an Wortgrenzen nicht nachgebildet). Scrollen geht nicht,
44// solange der Prompt die Tastatur hat – darum der Hinweis, wenn es nicht passt.
45function geschaetzteZeilen(befund, spalten) {
46  let zeilen = 2 * befund.dateien.length
47  for (const datei of befund.dateien) {
48    for (const block of datei.bloecke) {
49      zeilen += 1
50      for (const zeile of block.zeilen) {
51        const laenge = zeile.reduce((n, s) => n + s.text.length, 2)
52        zeilen += Math.max(1, Math.ceil(laenge / Math.max(spalten, 20)))
53      }
54    }
55  }
56  return zeilen
57}
58
59
60function zeichneZeile(Text, art, zeile, key) {
61  const stuecke = zeile.map((s) => Text(Object.assign({ children: [s.text] }, ...s.stil.map((n) => STIL[n] || {}))))
62  const rahmen = ZEILE[art]
63  if (!rahmen) return Text({ key, children: stuecke.length ? stuecke : [' '] })
64  return Text({ key, ...rahmen.props, children: [rahmen.marke, ...stuecke] })
65}
66
67export function register(on) {
68  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
69    if (!TRACKER.test(e.command)) return next(e)
70    const befund = await berechne($, e.command)
71    if (!befund) return next(e)
72    offen.set(e.tool_use_id, befund)
73    await $.ui.open({ id: PANE, title: 'Tracker-Änderung' })
74    $.ui.invalidate('ui.render')
75    try {
76      // Das Warten auf next zählt nicht gegen das Zeitlimit des Hooks: Prompt und Ausführung laufen darin.
77      return await next(e)
78    } finally {
79      offen.delete(e.tool_use_id)
80      if (offen.size === 0) await $.ui.close({ id: PANE })
81      else $.ui.invalidate('ui.render')
82    }
83  }).catch(async (_$, e, next) => next(e))
84
85  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
86    const befund = [...offen.values()].pop()
87    if (!befund) return next(e)
88    const { Box, Text } = $.ui.resolve(e)
89    const kinder = []
90    if (befund.fehler) kinder.push(Text({ key: 'fehler', color: 'error', children: ['Der Befehl würde scheitern: ' + befund.fehler] }))
91    if (befund.warnung) kinder.push(Text({ key: 'warnung', color: 'warning', children: ['⚠ ' + befund.warnung] }))
92    for (const datei of befund.dateien) {
93      kinder.push(Text({ key: 'datei:' + datei.datei, weight: 'semibold', underline: true, children: [datei.datei] }))
94      datei.bloecke.forEach((block, b) => {
95        const zeilen = block.zeilen.map((zeile, z) => zeichneZeile(Text, block.art, zeile, datei.datei + ':' + b + ':' + z))
96        kinder.push(Box({ key: 'block:' + datei.datei + ':' + b, flexDirection: 'column', children: zeilen }))
97      })
98    }
99    if (kinder.length === 0) kinder.push(Text({ key: 'leer', children: ['Der Befehl ändert keine Datei.'] }))
100    const zeilen = e.props.scroll && e.props.scroll.bodyRows
101    if (!zeilen || geschaetzteZeilen(befund, e.props.bodyColumns) <= zeilen) {
102      return Box({ flexDirection: 'column', gap: 1, children: kinder })
103    }
104    // Passt es nicht: das Pane genau so hoch wie gemeldet, die Engine schneidet den Inhalt ab,
105    // und die letzte Zeile sagt es – dort, wo der Inhalt weiterginge.
106    return Box({ flexDirection: 'column', height: zeilen, children: [
107      Box({ key: 'inhalt', flexDirection: 'column', gap: 1, flexGrow: 1, overflow: 'hidden', children: kinder }),
108      Text({ key: 'zuviel', dimColor: true,
109        children: ['↓ Mehr, als hier Platz hat – Fenster vergrößern (scrollen geht bei offenem Prompt nicht)'] }),
110    ] })
111  })
112}
113