SLOPSHOPPER

projektfarben

Gibt jedem Projekt eine Farbe: ein Farbpunkt vor jedem Session-Namen in der Seitenleiste, ein farbiges Band im Chat und /projektfarbe zum Aendern. Der…

newpanebandspinnercommandtoast
v0.12.1MITupdated 2026-10-26MaxeLBerger/ClaudeColours
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · projektfarben
│ ┃ uebersicht ✕ › fix the failing auth test and add an audit log call │ ┃ Nach Status ansicht aktualisieren schlies │ ┃ Lädt … ⏺ 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 │ │ › /projektfarbe │ ⎿ projektfarben: 🟦 app: blau │ ⎿ projektfarben: PR-Zahl nicht verfügbar: gh antwortet in einem un │ ⎿ projektfarben: Issue-Zahl nicht verfügbar: gh antwortet in einem │ ⎿ projektfarben: │ ⎿ projektfarben: /projektfarbe zeigt die Farbe dieses P │ ⎿ projektfarben: /projektfarbe <farbe> setzt sie und färbt alle │ │ ✻ 🟦 Thinking…    app PRs: ? Issues: ?      Kontext          49 % meinung review handoff merge uebersicht farbe ausblenden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
   app PRs: ? Issues: ?      Kontext          49 % meinung review handoff merge uebersicht farb
Pane · uebersicht
Nach Status ansicht aktualisieren schliessen Lädt …
README

ClaudeColours

Ein Mod für die Claude-Desktop-App (Code-Tab): Jedes Projekt bekommt eine Farbe, und jede Session zeigt sie als Farbpunkt vor ihrem Namen. So sieht man in der Seitenleiste sofort, welche Session zu welchem Projekt gehört.

🟦 Fix the login bug
🟦 Write the docs
🟩 Release notes
🟧 Plan the next version

English: a Claude Code mod that gives each project a color and puts it as an emoji in front of every session title in the desktop app's sidebar, adds a colored project band above the prompt, and a /projektfarbe command to change the color. A sleep mode (/projektfarbe schlaf) swaps the bright emoji for plain shapes and muted band colors. The interface is in German.

Was der Mod macht

  • Farbpunkt im Session-Namen: Nach der ersten Antwort einer Session setzt der Mod den Farbpunkt ihres Projekts vor den Namen, den die App vergeben hat.
  • Band über dem Eingabefeld: zeigt das Projekt mit einem Farbblock in seiner Farbe und dem Namen in der Schrift deines Themes. Dazu „Farbe ändern“ (Auswahl per Klick), „Opus-Meinung“ und „ausblenden“.
  • Unabhängige Opus-Meinung: Der Knopf „Opus-Meinung“ (im Band ✦, links vor der Übersicht) schickt der Session sofort die Bitte, die du sonst selbst tippst. Die Session entscheidet aus dem Gespräch, was gemeint ist (deine letzte Frage, ein Plan, den du überarbeitet haben willst, oder der Stand des Projekts), und startet einen Opus-Subagenten, dem sie nur die Sache selbst mitgibt, nicht ihre eigene Schlussfolgerung und keinen Gesprächsverlauf. Den Rest liest er selbst im Repository nach. Die Antwort steht unverändert im Chat, darunter sagt die Session kurz, wo sie anderer Meinung ist. Das kostet einen Opus-Lauf und dauert einige Minuten.
  • Review + PR: Der Knopf „Review + PR“ (im Band ↑) schickt der Session sofort die Bitte, die Änderungen bis zum Pull Request zu bringen: erst prüfen (Diff seit dem Basis-Branch, echte Befunde beheben, Tests laufen lassen), dann sinnvoll committen, dann den Branch pushen und den PR mit Beschreibung öffnen; am Ende nennt sie den Link. Der Druck auf den Knopf gilt als Freigabe für genau diese drei Schritte: gemergt wird nicht, auf den Basis-Branch wird nicht gepusht, nie mit force. Geht ein Schritt nicht (Tests rot, nichts zu committen, ein Befund, der deine Entscheidung braucht), hält die Session an und sagt, woran es liegt.
  • Handoff: Der Knopf „Handoff“ (im Band der fliegende Brief, im Terminal ✉) schickt der Session sofort die Bitte, eine Übergabe für eine frische Session zu schreiben: Ziel, was erledigt und entschieden ist (mit Grund), was offen ist und womit es weitergeht, Risiken und die betroffenen Dateien, Branches oder PRs. Was die neue Session im Repository selbst lesen kann, lässt sie weg. Der Text ist so geschrieben, dass du ihn unverändert als erste Nachricht in eine neue Session einfügen kannst; die Session ändert und startet dabei nichts.
  • Merge: Der Knopf „Merge“ (im Band das Merge-Zeichen mit grünem Haken, daneben klein hochgestellt die Zahl der offenen PRs) schickt der Session sofort die Bitte, die offenen PRs sinnvoll in den Hauptzweig zu bringen und passende Issues gleich mit zu lösen: erst die unabhängigen PRs, dann die, die darauf aufbauen; gemergt wird, was grüne Checks hat, ein eindeutiger Konflikt wird gelöst. Danach löst sie die Issues, die klar und klein genug sind oder die der neue Stand schon erledigt, jedes in einem eigenen PR, und mergt auch diese bei grünen Checks. Die Grenzen sind fest: kein Force-Push, keine Änderung an der Historie des Hauptzweigs, keine Entwürfe. Was rot ist oder deine Entscheidung braucht, bleibt liegen und wird mit dem Grund genannt; am Ende steht eine kurze Liste.
  • Das Band passt sich der Breite an: Wo der Platz reicht, steht alles in einer Zeile: links Name, PRs, Issues und Kontext-Anzeige, ganz rechts die sieben Aktionen als Symbole: Opus-Meinung (Doktorhut), Review + PR (Pull Request mit Lupe), Handoff (fliegender Brief), Merge (Merge-Zeichen mit grünem Haken und der Zahl offener PRs), Übersicht (drei Linien), Farbe ändern (halbe Scheibe) und ausblenden (Kreuz). Was ein Symbol tut, steht in der Karte, die beim Darüberfahren erscheint. Wird es enger, fällt zuerst das Wort „Kontext“ weg und der Balken wird kürzer, dann rücken die Elemente enger zusammen, und zuletzt bleibt von der Anzeige nur der Prozentwert. Alles bleibt dabei in einer Zeile und die Kontext-Anzeige in der ersten. Reicht auch das nicht, gibt es zwei Zeilen als Raster: oben der Name mit der Kontext-Anzeige rechts, darunter PRs und Issues, ganz rechts die Symbole; ein zu langer Projektname wird dann mit „…“ gekürzt. Das Band hat nie mehr als zwei Zeilen. /projektfarbe breite sagt, wie breit das Band gerade ist und in welchem Layout es gezeichnet wird.
  • Kontext-Anzeige im Band: ein Balken mit Prozentwert zeigt, wie voll das Kontextfenster nach der letzten Antwort ist: grau unter 70 %, in der Warnfarbe deines Themes ab 70 %, in der Fehlerfarbe und mit fettem Prozentwert ab 85 % (dann ist /compact fällig). Der Mod liest dafür nur die Werte, die Claude Code ohnehin hat; es entstehen keine Modellaufrufe und keine Kosten.
  • Farbpunkt am Spinner: Während Claude arbeitet, steht der Farbpunkt des Projekts vor der Statuszeile („🟦 Sauteing…“).
  • Offene PRs und Issues im Band: Direkt hinter dem Projektnamen, vor der Kontext-Anzeige, steht PRs: 2 Issues: 7, die Zahl der offenen Pull Requests und Issues des Repositories dieses Projekts (ab 100 als 100+). Der Mod fragt dafür im Hintergrund gh pr list und gh issue list ab: beim Start der Session und danach höchstens alle 5 Minuten nach einer Antwort. Ohne angemeldetes gh, ohne GitHub-Repository oder ohne eingeschaltete Issues zeigt das Band dort ein graues ? (unbekannt, nicht „keine“), damit es immer gleich aussieht; /projektfarbe sagt dann, woran es liegt.
  • Neue Einträge als „+x“: Sind seit deinem letzten Blick neue PRs oder Issues dazugekommen, steht ihre Zahl klein und farbig hochgestellt hinter der Zahl, etwa PRs: 4⁺². Neu ist, was eine höhere Nummer hat als die höchste, die du schon gesehen hast. Beim ersten Mal gilt alles Offene als gesehen. Ein Klick auf PRs oder Issues öffnet die Liste (in Bramble, siehe unten, sonst im Standardbrowser über gh pr list --web / gh issue list --web) und nimmt das „+x“ weg, in allen Sessions desselben Repositorys. Was du gesehen hast, merkt sich der Mod pro Repository-Ordner; zwei Repositorys mit gleichem Ordnernamen kommen sich also nicht in die Quere.
  • Klick auf den Projektnamen: Farbblock und Name sind ein Link auf das Repository bei GitHub. Die Adresse liest der Mod einmal pro Ordner mit gh repo view; kennt gh sie nicht, bleibt der Name ein normaler Text.
  • Links in Bramble: Gibt es einen Release-Build von Bramble (target\release\bramble.exe), öffnen Projektname, PRs und Issues ihre GitHub-Seite dort statt im Standardbrowser. Der Mod sucht bei jedem Klick den neuesten Build, nach Änderungsdatum, im Bramble-Repository und in allen seinen Worktrees (.claude\worktrees\*). Das Repository liegt unter Dokumente\Repositories\LightWeightBrowser; woanders zeigt die Umgebungsvariable BRAMBLE_REPO darauf. Bramble läuft dafür mit einem eigenen Profil projektfarben, damit ein Link nie deine gespeicherten Tabs überschreibt; dort musst du dich bei GitHub einmal eigens anmelden. Links aus dem Band starten Bramble ohne den eingebauten Werbeblocker (uBlock Origin Lite), damit es schlank bleibt. Ohne Build bleibt alles wie oben.
  • /projektfarbe: Farbe zeigen oder ändern. Eine Änderung benennt alle offenen Sessions des Projekts sofort mit um.
  • Stand aller Sessions (/projektfarbe status): listet alle offenen Sessions, nach Projekt gruppiert (mit Projektfarbe), das Dringendste zuerst. Pro Zeile: Zeichen, Name ohne Farbpunkt, Stand und wann zuletzt etwas passiert ist:
  🟦 Alpha
    ⚠ Login — abgebrochen · PR #11 offen, Review wartet · vor 2 Std
    🔀 Suche — PR #9 offen, CI rot · vor 20 Min
    ▶ Status bauen (diese) — läuft · gerade eben
    💤 Alte Idee — ruht seit 5 Tagen, kein PR · vor 5 Tagen
ZeichenStand
⚠letzte Antwort abgebrochen oder mit Fehler beendet (eine neue Antwort hebt das auf; nach 3 Tagen ohne Aktivität ruht die Session stattdessen, und ein gemergter oder geschlossener PR geht vor)
🔀PR offen, mit „CI rot“ (ein abgebrochener Lauf zählt nicht dazu), „Änderungen gewünscht“, „Review wartet“ oder „Entwurf“, wenn zutreffend
✎in Arbeit, noch kein PR
▶läuft gerade
✓ / ✖PR gemerged / geschlossen (selten: die App archiviert solche Sessions meist selbst)
💤seit mehr als 3 Tagen nichts passiert und kein PR: Kandidat zum Archivieren

Den PR nimmt der Mod zuerst von der App, die ihn der Session selbst zuordnet. gh pr list liefert dazu CI, Review und Entwurf und findet den PR über den Branch, wenn die App keinen kennt. Pro Repository sind das zwei Abfragen: die offenen PRs mit ihren Checks und alle PRs nur mit Nummer, Stand und Branch, damit GitHub die teuren Checks nur für die offenen liefern muss. Bei mehreren PRs auf einem Branch zählt der neueste. Jede Session merkt sich dafür nach jeder eigenen Antwort, wie sie endete (Antwort, abgebrochen, Fehler).

  • Übersicht als Seitenpanel (/projektfarbe übersicht, auch uebersicht, oder der Knopf Übersicht ☰ im Band): dieselben Daten wie /projektfarbe status, rechts neben dem Chat. Standardmäßig nach Status in fünf Bereichen: ⚠ Braucht dich: Problem (rot; abgebrochen, Fehler, PR mit roter CI oder gewünschten Änderungen), 💬 Du bist dran: Claude wartet (gelb; die Session steht, Claude ist mit seiner Antwort fertig), 🔀 PR wartet auf Review oder Merge, ▶ Läuft, nichts zu tun und 🧹 Aufräumen (gemergte oder geschlossene PRs, ruhende Sessions); leere Bereiche fehlen. Die beiden ersten sind das, was deine Hilfe braucht, und stehen oben; die Zusammenfassung zählt sie als „dringend“ und „warten auf dich“. Jede Session hat zwei Zeilen und links eine Leiste in der Farbe ihres Bereichs (rot, gelb): oben Zeichen, Projektfarbe, Name und Projekt, rechts die Knöpfe öffnen ↗ (öffnet die Session in der App) und archivieren; darunter der Stand in seiner Farbe (rot Problem, gelb du bist dran, grün läuft, violett gemergt) und das Alter. Ein Klick auf das Dreieck vor einer Überschrift klappt den Bereich zu oder auf; „Läuft, nichts zu tun“ ist zu Beginn zugeklappt. Oben stehen die Knöpfe (die Ansicht steht fett daneben) und die Zusammenfassung als farbige Zählmarken, etwa „12 Sessions · 2 dringend · 3 warten auf dich · 4 mit offenem PR · 2 ruhen“. Der Knopf nach Projekt gruppiert stattdessen je Projekt (Kopfzeile mit Farbblock, Emoji, Name, Anzahl; Projekte mit Dringendem zuerst). Weitere Knöpfe: aktualisieren, schließen und bei 💤 ruht sowie ✓ / ✖ (PR gemerged oder geschlossen) archivieren: Die App fragt dich dann selbst; sagst du nein, bleibt die Zeile und eine Meldung sagt es. Die Session, in der das Panel läuft, lässt sich nicht archivieren. Das Panel lädt beim Öffnen, per Knopf und, solange es offen ist, nach einer Antwort höchstens einmal pro Minute neu; gh fragt es dabei höchstens alle 5 Minuten pro Repository, nur aktualisieren fragt sofort. Im Schlafmodus steht in der Projekt-Kopfzeile nur die Form, denn sie ist dort schon der Farbblock. Wo kein Panel Platz hat (Terminal), druckt der Befehl stattdessen den Text von /projektfarbe status.
  • Schlafmodus: /projektfarbe schlaf ersetzt die bunten Punkte durch ruhige Formen und das Band durch gedämpfte Farben. /projektfarbe bunt stellt alles zurück.
  • Wechsel in der Farbwahl: Unter den Farben von „Farbe ändern“ steht, mit etwas Abstand, die Zeile „Modus“: ein Emoji und der Name je Modus (🎨 Bunt, 🌙 Dämmerung). Der aktive Modus ist fett, die anderen sind gedimmt; jeder weitere Modus bekommt ein eigenes Emoji und reiht sich in die Zeile ein. Ein Klick auf den anderen Modus schaltet um und benennt alle offenen Sessions um; ein Klick auf den aktiven Modus tut nichts.

Als Projekt zählt der Ordner, in dem die Session läuft; ein Worktree unter .claude/worktrees/ zählt zu seinem Repository.

Installieren

In einer Terminal-Session von Claude Code:

/plugin install projektfarben --marketplace MaxeLBerger/ClaudeColours

y bestätigt den neuen Marketplace, dann den Bereich user wählen.

Aus einer Shell:

claude plugin install projektfarben --marketplace MaxeLBerger/ClaudeColours

In der Desktop-App, wo /plugin nicht läuft: auf + neben dem Eingabefeld klicken, dann Plugins, Add marketplace mit MaxeLBerger/ClaudeColours, dann Add plugin.

Sessions, die schon laufen, laden den Mod bei ihrem nächsten Start.

Befehle

BefehlWirkung
/projektfarbezeigt die Farbe dieses Projekts, die offenen PRs und Issues mit den neuen (oder warum eine Zahl fehlt) und alle Farben
/projektfarbe <farbe>setzt sie (rot, grün, lila, kreis-blau, … oder das Emoji) und färbt alle Sessions des Projekts um
/projektfarbe standardgibt dem Projekt die nächste freie Farbe
/projektfarbe allefärbt alle offenen Sessions neu ein
/projektfarbe statuszeigt den Stand aller offenen Sessions nach Projekt: läuft, abgebrochen, PR offen, in Arbeit, ruht …
/projektfarbe übersichtöffnet die Übersicht aller Sessions als Seitenpanel (ohne Platz dafür: dieselbe Auskunft als Text)
/projektfarbe bandblendet Band, Kontext-Anzeige, PR- und Issue-Zahl und den Farbpunkt am Spinner ein oder aus
/projektfarbe breitezeigt, wie viele Zellen das Band hat, in welchem Layout es gezeichnet wird (icons, kompakt, dicht, minimal, zwei) und wie viele Zellen die Layouts in einer Zeile brauchen; ist das Band ausgeblendet, sagt der Befehl das und nennt /projektfarbe band zum Einblenden
/projektfarbe promptsgibt die vier Prompts hinter den Band-Knöpfen (Opus-Meinung, Review + PR, Handoff, Merge) als Text aus, mit je einer Zeile, was der Knopf tut, und den Punkten, die ein Review prüft; dieselbe Ausgabe steht in docs/PROMPTS.md, eine lesbare Seite dazu in docs/prompts.html
/projektfarbe schlafschaltet den Schlafmodus ein und benennt alle offenen Sessions um
/projektfarbe buntschaltet zurück zu den bunten Farben und benennt alle offenen Sessions um

Im Schlafmodus gelten die Namen des Schlafmodus (/projektfarbe moos), im bunten Modus die bunten.

18 Farben: 🟦 🟩 🟧 🟪 🟥 🟨 🟫 ⬛ ⬜ und 🔵 🟢 🟠 🟣 🔴 🟡 🟤 ⚫ ⚪.

Schlafmodus

Der Schlafmodus nimmt den Farben die Reizwirkung, ohne die Projekte schwerer unterscheidbar zu machen. Er nutzt den Satz „Dämmerung“ mit 10 Formen und gedämpften Farben:

FormNameFormName
⬤nebel◐petrol
■sand◑ton
▲altrosa⬢salbei
◆moos▣schiefer
▼flieder✚mauve
  • Seitenleiste: Ein Session-Name ist reiner Text und lässt sich nicht einfärben, und die Emoji-Farben bestimmt das Betriebssystem (Mondphasen und mehrere Natur-Emoji sind unter Windows kräftig bunt). Deshalb steht vor dem Namen eine einfarbige Form, und die Form trennt die Projekte.
  • Band und Farbwahl: zeigen dieselbe Form in der gedämpften Farbe, ohne Fettschrift.
  • Jedes Projekt merkt sich seine Farbe pro Modus. Beim Zurückschalten hat es wieder seine bunte Farbe.

Wie die Farben vergeben werden

  • Ein neues Projekt bekommt die nächste Farbe, die noch kein anderes Projekt hat (erst die hellen Quadrate, dann die hellen Kreise, dann der Rest). Sind alle 18 vergeben (im Schlafmodus alle 10), entscheidet der Projektname.
  • Beim ersten Start übernimmt der Mod die Farbpunkte, die schon in der Seitenleiste stehen: Pro Projekt gilt der Punkt, den die meisten seiner Sessions tragen.
  • Deine Farben speichert der Mod lokal in seinem eigenen Speicher unter deinem Claude-Code-Konfigurationsordner. Der Mod selbst spricht nur mit den lokalen Session-Werkzeugen der Desktop-App und startet gh; nur gh redet dabei mit GitHub, mit deiner eigenen Anmeldung.

Grenzen

  • Die Seitenleiste selbst färbt kein Mod: Die Projekt-Überschriften bleiben grau, die Farbe steht im Session-Namen.
  • Umbenennen geht nur in der Desktop-App. In einer Terminal-Session zeigen sich nur Band, Kontext-Anzeige und Spinner-Punkt.
  • Die Kontext-Anzeige aktualisiert sich nach jeder Antwort, nach /compact also erst mit der nächsten. Vor der ersten Antwort einer Session zeigt sie nichts. In einem Ordner ohne Projekt erscheint das Band und damit die Anzeige nicht.
  • Zieht man das Fenster schmaler oder breiter, zeichnet die App das Band erst nach einer kurzen Pause neu; bis dahin ist sein rechter Rand kurz abgeschnitten, statt dass es in zwei Zeilen umbricht. Wer einmal in einem engeren Layout ist, geht erst ein paar Zeichen später zum weiteren zurück, damit es an einer Schwelle nicht hin- und herspringt.
  • Die Breite des Bandes ist eine Schätzung: Die Schrift der Desktop-App hat keine feste Zeichenbreite, der Mod rechnet mit gemessenen Näherungswerten (im Terminal ist ein Zeichen genau eine Zelle, dort rechnet er entsprechend). Springt das Band zu früh oder zu spät auf das nächste Layout, zeigt /projektfarbe breite die Zellenzahl, mit der er rechnet. Die Symbole im Band zeigen beim Überfahren eine kleine Karte mit ihrem Namen links neben sich (die App zeichnet Karten nur innerhalb des Bandes); wo ein Zeiger fehlt, steht ihre Bedeutung in /projektfarbe.
  • PR- und Issue-Zahl zählen alle offenen Einträge des Repositories, nicht nur die dieser Session. Läuft gh in der Desktop-App nicht, steht dort 0; ob es wirklich keine gibt, zeigt /projektfarbe.
  • Das „+x“ zählt auch PRs und Issues, die du selbst angelegt hast. Es verschwindet nur über den Klick auf die Zahl; die Liste nur im Browser zu öffnen reicht nicht. In anderen Sessions geht es mit deren nächster Antwort weg.
  • /projektfarbe status sieht nicht, ob eine Session auf deine Eingabe wartet (eine Rückfrage, eine Freigabe): Die App verrät das dem Mod nicht. Eine solche Session erscheint als „in Arbeit“ oder mit ihrem PR.
  • Ob eine Antwort abgebrochen wurde, weiß der Mod erst für Antworten nach diesem Update; ältere Sessions zeigen ihren Stand ohne diese Angabe. Sessions, die gerade nicht geladen sind (etwa nach einem Neustart der App), merken sich ihren Stand erst mit ihrer nächsten Antwort.
  • /projektfarbe status geht nur in der Desktop-App. Ohne angemeldetes gh fehlen CI- und Review-Angaben; die Ausgabe sagt dann, für welches Projekt.
  • Bramble nimmt eine Adresse nur beim Start an: Jeder Klick öffnet ein neues Bramble-Fenster, kein Tab im offenen. Mit Bramble ist der Projektname ein Knopf, den die Desktop-App ohne Fettdruck zeichnet; der Farbblock steht davor und ist selbst nicht anklickbar.
  • öffnen ↗ im Panel übergibt die claude://-Adresse der Session an das Betriebssystem (Windows rundll32, sonst open oder xdg-open), weil open_session_in der App nur für die eigene und selbst gestartete Sessions wirkt und das Panel keine anderen Links als https: zeigt. Klappt das nicht, sagt eine Meldung es; dann bleibt die Seitenleiste.
  • „Du bist dran“ heißt nur: Die Session steht und ist in Arbeit. Ob Claude etwas gefragt hat oder einfach fertig ist, verrät die App dem Mod nicht.
  • Das Panel braucht eine Oberfläche, die Panels platziert (Desktop-App). Im Terminal gibt /projektfarbe übersicht den Text aus. Archivieren fragt die App bei jedem Aufruf selbst; im Modus „Bypass“ oder „Auto“ geht es dort ohne Nachfrage durch.
  • Der Speicher des Mods schreibt nicht atomar: Beenden zwei Sessions im selben Moment eine Antwort, kann der Stand der einen verloren gehen (sie meldet ihn mit der nächsten Antwort wieder). Dasselbe gilt für das „+x“.
  • Der Prozentwert ist der von Claude Code gemeldete Füllstand des Fensters; er kann von der Zahl in /context leicht abweichen.
  • Hast du einen Session-Namen selbst vergeben, fragt die App einmal nach, bevor der Mod den Farbpunkt davorsetzt. Sagst du nein, fragt sie für diesen Namen nicht wieder.
  • Sortierst du die Seitenleiste nach Namen, ordnet sie nach Farbe (im Schlafmodus nach Form).
  • Das Umschalten zwischen bunt und Schlafmodus benennt alle offenen Sessions um; bei Namen, die du selbst vergeben hast, fragt die App wieder nach.
  • Im Schlafmodus gibt es 10 Plätze statt 18, weil sich gedämpfte Farben weniger weit voneinander entfernen lassen. Mit mehr Projekten wiederholen sich Formen und Farben.
  • Die Bandfarben des Schlafmodus sind für dunkle Terminal-Hintergründe gewählt; auf hellem Grund wirken sie blass.
  • Ein Name, der selbst mit einer dieser Formen und einem Leerzeichen beginnt (zum Beispiel ■ Aufgaben), wird vom Mod wie eine Farbmarke behandelt.

Entwickeln

claude plugin validate .
claude plugin test .

Lizenz

MIT, siehe LICENSE.

Source 3 files
hooks/register.tsx 1455 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpContentBlock, Register } from 'claude-code'
3
4import type { ProjektfarbenPanel, ProjektfarbenView } from '../types'
5import {
6  archiveReason,
7  assignFarben,
8  BAND_FORMEN,
9  bandBreite,
10  brambleKandidaten,
11  brambleRoot,
12  brambleStart,
13  bandStufe,
14  EINZEILER,
15  bereichOf,
16  buildBereiche,
17  buildUebersicht,
18  BUNT,
19  COMPACT_CELLS,
20  contextFill,
21  contextLevel,
22  contextPercent,
23  farbeFor,
24  findFarbe,
25  findSatz,
26  ghNumbers,
27  ghPrs,
28  ICON_BOX,
29  ICON_GAP,
30  ICON_SVG,
31  MERGE_EXTRA,
32  mergePrompt,
33  ICON_SVG_SIZE,
34  raised,
35  learnPicks,
36  markSeen,
37  handoffPrompt,
38  meinungPrompt,
39  promptsMarkdown,
40  mergePrs,
41  modusLabel,
42  nameBudget,
43  neuesterBuild,
44  newCount,
45  reviewPrompt,
46  planTitle,
47  PR_LIMIT,
48  prLabel,
49  projectOf,
50  readPicks,
51  readSeen,
52  readStand,
53  readTried,
54  rememberStand,
55  rememberTry,
56  repoKeyOf,
57  seenFor,
58  SAETZE,
59  satzOf,
60  SCHLAFSAETZE,
61  shortName,
62  superscript,
63  toListed,
64  toRow,
65  uebersichtZaehler,
66  uebersichtText,
67  type Art,
68  type BandInhalt,
69  type BandStufe,
70  type BereichId,
71  type BereichZeile,
72  type Farbe,
73  type GhPr,
74  type Grund,
75  type ListedSession,
76  type Satz,
77  type Status,
78  type SessionRow,
79  type TitlePlan,
80  type Uebersicht,
81  type UebersichtZeile,
82} from './colors'
83
84type Engine = EngineInterface
85type Outcome = TitlePlan['kind'] | 'renamed' | 'failed' | 'skipped'
86
87// The desktop app's own session tools: the same ones Claude renames sessions with.
88const SERVER = 'ccd_session_mgmt'
89// A session the app had not named yet when its turn ended is looked at once more.
90const RETRY_MS = 20_000
91const EMPTY: ProjektfarbenView = {
92  project: null,
93  mark: null,
94  hex: null,
95  contextPercent: null,
96  openPrs: null,
97  prsNote: null,
98  prNumbers: [],
99  newPrs: 0,
100  openIssues: null,
101  issuesNote: null,
102  issueNumbers: [],
103  newIssues: 0,
104  repoUrl: null,
105  bramble: null,
106  satz: BUNT.id,
107  isPicking: false,
108  isHidden: false,
109}
110const view = atom({ plugin: 'projektfarben', key: 'view' } as const, EMPTY)
111
112// The side panel with the overview of all sessions; its data is the one `/projektfarbe status` prints.
113const PANEL = 'uebersicht'
114const PANEL_EVERY_MS = 60_000
115const EMPTY_PANEL: ProjektfarbenPanel = { daten: null, isLoading: false, fehler: null, um: null, isOpen: false, ansicht: 'status', zu: ['laeuft'] }
116// Only the sections that want the person are colored: a problem in the error color, a waiting session in the warning's.
117const BEREICH_FARBE: Partial<Record<BereichId, 'error' | 'warning'>> = { dringend: 'error', dudran: 'warning' }
118
119/** The theme color a status label is drawn in; none for what is only history. */
120function statusFarbe(status: Status): 'error' | 'warning' | 'success' | 'suggestion' | 'merged' | undefined {
121  switch (status.art) {
122    case 'abgebrochen':
123    case 'fehler':
124      return 'error'
125    case 'pr-offen':
126      return status.rang <= 1 ? 'error' : 'suggestion'
127    case 'in-arbeit':
128      return 'warning'
129    case 'laeuft':
130      return 'success'
131    case 'pr-gemerged':
132      return 'merged'
133    default:
134      return undefined
135  }
136}
137
138const panel = atom({ plugin: 'projektfarben', key: 'uebersicht' } as const, EMPTY_PANEL)
139
140// Theme keys, so the gauge follows the person's theme: a calm gray bar on a fainter track,
141// the warning and error colors from where the window is filling up.
142const CONTEXT_FILL = { ok: 'inactive', warn: 'warning', full: 'error' } as const
143const CONTEXT_TRACK = 'subtle'
144// The theme color of the raised "+x" behind a count: new pull requests or issues.
145const NEW_COLOR = 'suggestion'
146
147// The color block and the gauge are runs of these on a colored background: on the desktop plain
148// runs of spaces collapse, and empty Boxes sat a few pixels off the line of the text beside them.
149const NBSP = String.fromCharCode(0xa0)
150
151const textOf = (content: readonly McpContentBlock[]): string =>
152  content.map(block => (block.type === 'text' ? (block.text ?? '') : '')).join('')
153
154async function callJson($: Engine, tool: string, args: Record<string, unknown>): Promise<unknown> {
155  const result = await $.mcp.call(SERVER, tool, args)
156  const text = textOf(result.content)
157  if (result.isError) {
158    throw new Error(text === '' ? `${tool} failed` : text)
159  }
160
161  return JSON.parse(text) as unknown
162}
163
164/** The color set in use: the bright one unless the person turned sleep mode on. */
165const satzNow = async ($: Engine): Promise<Satz> => satzOf(await $.store.get('modus'))
166
167const picksOf = async ($: Engine, satz: Satz): Promise<Record<string, string>> =>
168  readPicks(await $.store.get(satz.storeKey), satz)
169
170/** The picks, with a color handed to each of these projects that has none yet (and stored). */
171async function ensurePicks(
172  $: Engine,
173  projects: readonly string[],
174  satz: Satz,
175): Promise<Record<string, string>> {
176  const picks = await picksOf($, satz)
177  const next = assignFarben(projects, picks, satz)
178  if (Object.keys(next).length !== Object.keys(picks).length) {
179    await $.store.set(satz.storeKey, next)
180  }
181
182  return next
183}
184
185/** Every open session but this one, from the app. */
186async function listRows($: Engine): Promise<SessionRow[]> {
187  const listed = await callJson($, 'list_sessions', { limit: 500 })
188
189  return (Array.isArray(listed) ? listed : [])
190    .map(entry => toRow(entry))
191    .filter((row): row is SessionRow => row !== null)
192}
193
194/**
195 * Once per person: takes the colors the sidebar already shows into the store,
196 * so marks set before this mod (or by an earlier version) keep their project's color.
197 * Only the bright set is learned: sleep mode starts after the mod, with nothing to keep.
198 * Left undone when the app cannot be asked, and tried again at the next start.
199 */
200async function learnOnce($: Engine): Promise<void> {
201  if ((await $.store.get('gelernt')) === true) {
202    return
203  }
204  const self = toRow(await callJson($, 'get_session', { session_id: 'self' }), 'self')
205  const rows = await listRows($)
206  const learned = learnPicks(self === null ? rows : [self, ...rows], BUNT)
207  await $.store.set(BUNT.storeKey, { ...(await picksOf($, BUNT)), ...learned })
208  await $.store.set('gelernt', true)
209}
210
211async function rename($: Engine, sessionId: string, title: string): Promise<Outcome> {
212  const result = await $.mcp.call(SERVER, 'set_session_title', { session_id: sessionId, title })
213
214  return result.isError ? 'failed' : 'renamed'
215}
216
217/** Puts the project's color mark in front of one session's title. */
218async function recolor(
219  $: Engine,
220  row: SessionRow,
221  picks: Record<string, string>,
222  satz: Satz,
223): Promise<Outcome> {
224  const plan = planTitle(row, picks, satz)
225
226  return plan.kind === 'rename' ? rename($, row.sessionId, plan.title) : plan.kind
227}
228
229/**
230 * Recolors this session's own title. A title it already tried is left alone, even
231 * after a restart: a title the person set makes the app ask them, and one no is enough.
232 */
233async function recolorSelf($: Engine, force: boolean, known?: SessionRow | null): Promise<Outcome> {
234  const row = known !== undefined ? known : toRow(await callJson($, 'get_session', { session_id: 'self' }), 'self')
235  if (row === null) {
236    return 'failed'
237  }
238  const satz = await satzNow($)
239  const project = projectOf(row.cwd)
240  const plan = planTitle(row, await ensurePicks($, project === null ? [] : [project], satz), satz)
241  if (plan.kind !== 'rename') {
242    return plan.kind
243  }
244  const tried = readTried(await $.store.get('versucht'))
245  if (!force && tried[row.sessionId] === row.title) {
246    return 'skipped'
247  }
248  // Written before the rename, which may wait on the person's answer in the app.
249  await $.store.set('versucht', rememberTry(tried, row.sessionId, row.title))
250
251  return rename($, 'self', plan.title)
252}
253
254/** Stores why this session's turn ended and when, for `/projektfarbe status` in every session. */
255async function recordStand($: Engine, sessionId: string, grund: Grund): Promise<void> {
256  const um = await $.clock.now()
257  const stand = readStand(await $.store.get('stand'))
258  await $.store.set('stand', rememberStand(stand, sessionId, { grund, um }))
259}
260
261/**
262 * After a turn: the session's last turn is stored, the band follows a color picked
263 * in another session, and the title gets its mark.
264 */
265async function afterTurn($: Engine, grund: Grund): Promise<void> {
266  // Asked first and stored on its own, so a band that cannot be refreshed still leaves the stand.
267  const self = callJson($, 'get_session', { session_id: 'self' }).then(raw => toRow(raw, 'self'))
268  void self
269    .then(row => (row === null ? undefined : recordStand($, row.sessionId, grund)))
270    .catch(() => undefined)
271  const cwd = await $.session.cwd()
272  await refreshView($, cwd)
273  void refreshPrs($, cwd, false).catch(() => undefined)
274  void refreshPanel($).catch(() => undefined)
275  if ((await recolorSelf($, false, await self)) === 'unnamed') {
276    $.clock.after(RETRY_MS, () => {
277      void recolorSelf($, false).catch(() => undefined)
278    })
279  }
280}
281
282/** Recolors every open session, or those of one project. Returns how many it renamed. */
283async function recolorAll($: Engine, onlyProject: string | null): Promise<number> {
284  const satz = await satzNow($)
285  const rows = await listRows($)
286  const projects = rows.map(row => projectOf(row.cwd)).filter((p): p is string => p !== null)
287  const picks = await ensurePicks($, projects, satz)
288  const wanted = onlyProject?.toLowerCase()
289  let renamed = (await recolorSelf($, true)) === 'renamed' ? 1 : 0
290  for (const row of rows) {
291    if (wanted !== undefined && projectOf(row.cwd)?.toLowerCase() !== wanted) {
292      continue
293    }
294    try {
295      renamed += (await recolor($, row, picks, satz)) === 'renamed' ? 1 : 0
296    } catch {
297      // One session that cannot be renamed does not stop the others.
298    }
299  }
300
301  return renamed
302}
303
304/** How full the context window is: a local read of the figures the status line has, no model call. */
305async function readContext($: Engine): Promise<number | null> {
306  try {
307    return contextPercent(await $.session.usage())
308  } catch {
309    return null
310  }
311}
312
313/** Reads the project, its color and the context fill into the band's state. */
314async function refreshView($: Engine, cwd: string): Promise<void> {
315  const satz = await satzNow($)
316  const project = projectOf(cwd)
317  const farbe =
318    project === null ? null : farbeFor(project, await ensurePicks($, [project], satz), satz)
319  const isHidden = (await $.store.get('bandAus')) === true
320  const percent = await readContext($)
321  await update($, view, v => ({
322    ...v,
323    project,
324    mark: farbe?.mark ?? null,
325    hex: farbe?.hex ?? null,
326    contextPercent: percent,
327    satz: satz.id,
328    isHidden,
329  }))
330  await refreshNew($, cwd)
331}
332
333/**
334 * How many of the open pull requests and issues are new to the person. The highest numbers seen
335 * are kept per repository and shared by all sessions: one that opened the list in the browser
336 * clears the "+x" in the others.
337 */
338async function refreshNew($: Engine, cwd: string): Promise<void> {
339  const repo = repoKeyOf(cwd)
340  const project = projectOf(cwd)
341  const seen =
342    repo === null || project === null ? undefined : seenFor(readSeen(await $.store.get('gesehen')), repo, project)
343  await update($, view, v => ({
344    ...v,
345    newPrs: newCount(v.prNumbers, seen?.pr),
346    newIssues: newCount(v.issueNumbers, seen?.issue),
347  }))
348}
349
350const PR_EVERY_MS = 5 * 60_000
351// When, and in which folder, the pull requests and issues were last counted; starts over with a reload.
352let prsCheckedAt: number | null = null
353let prsFor: string | null = null
354
355const firstLine = (text: string): string => text.trim().split(/\r?\n/)[0]?.slice(0, 120) ?? ''
356
357/** The open pull requests or issues of the session's repository, listed by `gh` with the person's own login. */
358async function readOpen($: Engine, art: Art): Promise<{ numbers: number[] | null; note: string | null }> {
359  try {
360    const { exitCode, stdout, stderr } = await $.process.run(
361      // One more than the band shows: that is how "100" and "more than 100" are told apart.
362      ['gh', art, 'list', '--state', 'open', '--limit', String(PR_LIMIT + 1), '--json', 'number'],
363      { timeoutMs: 20_000 },
364    )
365    if (exitCode !== 0) {
366      return { numbers: null, note: `gh meldet: ${firstLine(stderr)}` }
367    }
368    const numbers = ghNumbers(stdout)
369
370    return { numbers, note: numbers === null ? 'gh antwortet in einem unerwarteten Format' : null }
371  } catch (error) {
372    return { numbers: null, note: `gh lässt sich hier nicht starten (${firstLine(String(error))})` }
373  }
374}
375
376/** The web address of the session's repository, from `gh`; null when it cannot tell. */
377async function readRepoUrl($: Engine): Promise<string | null> {
378  try {
379    const { exitCode, stdout } = await $.process.run(
380      ['gh', 'repo', 'view', '--json', 'url', '--jq', '.url'],
381      { timeoutMs: 20_000 },
382    )
383    const url = stdout.trim()
384
385    return exitCode === 0 && /^https:\/\/\S+$/.test(url) ? url : null
386  } catch {
387    return null
388  }
389}
390
391// The folder whose repository address the band holds; read once per folder, again after a failure.
392let urlFor: string | null = null
393
394/** The newest release build of Bramble, the person's own browser; null where there is none. */
395async function findBramble($: Engine): Promise<string | null> {
396  const root = await Promise.all([$.env.get('BRAMBLE_REPO'), $.env.get('USERPROFILE')])
397    .then(([own, home]) => brambleRoot(own, home))
398    .catch(() => null)
399  if (root === null) {
400    return null
401  }
402  const worktrees = await $.fs
403    .list(`${root}\\.claude\\worktrees`)
404    .then(entries => entries.filter(entry => entry.kind === 'dir').map(entry => entry.name))
405    .catch(() => [] as string[])
406  const builds = await Promise.all(
407    brambleKandidaten(root, worktrees).map(path =>
408      $.fs
409        .stat(path)
410        .then(stat => (stat.kind === 'file' ? { path, mtimeMs: stat.mtimeMs } : null))
411        .catch(() => null),
412    ),
413  )
414
415  return neuesterBuild(builds.filter(build => build !== null))
416}
417
418/**
419 * Opens an address in the newest Bramble build, looked up again on every press: a build made since
420 * the band last looked is the one that opens. False where there is no build or it would not start.
421 */
422async function openInBramble($: Engine, url: string): Promise<boolean> {
423  const exe = await findBramble($)
424  if (exe === null) {
425    return false
426  }
427  const { argv, env } = brambleStart(exe, url)
428  try {
429    const { exitCode } = await $.process.run(argv, { env, timeoutMs: 15_000 })
430
431    return exitCode === 0
432  } catch {
433    return false
434  }
435}
436
437/** Opens the repository's page in Bramble, or, without it, where the system opens web addresses. */
438async function openRepo($: Engine, url: string): Promise<void> {
439  if (await openInBramble($, url)) {
440    return
441  }
442  for (const opener of OPENERS) {
443    try {
444      if ((await $.process.run([...opener, url], { timeoutMs: 10_000 })).exitCode === 0) {
445        return
446      }
447    } catch {
448      // This system has no such opener: the next one is tried.
449    }
450  }
451  $.ui.toast(`Das Repository lässt sich nicht öffnen: ${url}`)
452}
453
454/**
455 * Opens the list of open pull requests or issues in the person's browser and, once it opened,
456 * marks every one open now as seen: the "+x" goes, in every session of the project.
457 */
458async function openList($: Engine, art: Art): Promise<void> {
459  const what = art === 'pr' ? 'PR-Liste' : 'Issue-Liste'
460  const { repoUrl } = await read($, view)
461  // In Bramble when there is a build and the address is known; else gh opens the default browser.
462  const isInBramble = repoUrl !== null && (await openInBramble($, `${repoUrl}/${art === 'pr' ? 'pulls' : 'issues'}`))
463  if (!isInBramble) {
464    try {
465      const { exitCode, stderr } = await $.process.run(['gh', art, 'list', '--web'], {
466        timeoutMs: 15_000,
467      })
468      if (exitCode !== 0) {
469        $.ui.toast(`${what} lässt sich nicht öffnen: ${firstLine(stderr)}`)
470
471        return
472      }
473    } catch (error) {
474      $.ui.toast(`${what} lässt sich nicht öffnen: ${firstLine(String(error))}`)
475
476      return
477    }
478  }
479  // Read again: the list in the browser can hold entries newer than the band's last reading.
480  const fresh = (await readOpen($, art)).numbers
481  if (fresh !== null) {
482    await update($, view, v =>
483      art === 'pr'
484        ? { ...v, openPrs: fresh.length, prNumbers: fresh, prsNote: null }
485        : { ...v, openIssues: fresh.length, issueNumbers: fresh, issuesNote: null },
486    )
487  }
488  const state = await read($, view)
489  if (state.project === null) {
490    return
491  }
492  const cwd = await $.session.cwd()
493  const repo = repoKeyOf(cwd)
494  if (repo === null) {
495    return
496  }
497  const numbers = art === 'pr' ? state.prNumbers : state.issueNumbers
498  const seen = readSeen(await $.store.get('gesehen'))
499  const next = markSeen(seen, repo, art, numbers, false, state.project)
500  if (next !== seen) {
501    await $.store.set('gesehen', next)
502  }
503  await refreshNew($, cwd)
504}
505
506/**
507 * Counts the open pull requests and issues into the band's state: at most every five minutes for
508 * one folder, at once for another, unless forced. A folder without a project has no band: no count.
509 * The first reading of a project is its baseline: what is open then is not new.
510 */
511async function refreshPrs($: Engine, cwd: string, isForced: boolean): Promise<void> {
512  const project = projectOf(cwd)
513  if (project === null) {
514    return
515  }
516  const now = await $.clock.now()
517  const isSameFolder = cwd === prsFor
518  if (!isForced && isSameFolder && prsCheckedAt !== null && now - prsCheckedAt < PR_EVERY_MS) {
519    return
520  }
521  prsCheckedAt = now
522  prsFor = cwd
523  const needsUrl = urlFor !== cwd
524  const [prs, issues, url, bramble] = await Promise.all([
525    readOpen($, 'pr'),
526    readOpen($, 'issue'),
527    needsUrl ? readRepoUrl($) : Promise.resolve(undefined),
528    findBramble($),
529  ])
530  if (url !== undefined && url !== null) {
531    urlFor = cwd
532  }
533  // A reading that fails keeps the last good one, but only of the same folder.
534  await update($, view, v => ({
535    ...v,
536    repoUrl: url === undefined ? v.repoUrl : (url ?? (isSameFolder ? v.repoUrl : null)),
537    bramble,
538    openPrs: prs.numbers?.length ?? (isSameFolder ? v.openPrs : null),
539    prNumbers: prs.numbers ?? (isSameFolder ? v.prNumbers : []),
540    prsNote: prs.note,
541    openIssues: issues.numbers?.length ?? (isSameFolder ? v.openIssues : null),
542    issueNumbers: issues.numbers ?? (isSameFolder ? v.issueNumbers : []),
543    issuesNote: issues.note,
544  }))
545  const repo = repoKeyOf(cwd) ?? project
546  const seen = readSeen(await $.store.get('gesehen'))
547  let next = seen
548  if (prs.numbers !== null) {
549    next = markSeen(next, repo, 'pr', prs.numbers, true, project)
550  }
551  if (issues.numbers !== null) {
552    next = markSeen(next, repo, 'issue', issues.numbers, true, project)
553  }
554  if (next !== seen) {
555    await $.store.set('gesehen', next)
556  }
557  await refreshNew($, cwd)
558}
559
560// Newest first, as gh lists them: enough to reach the pull requests of every open session.
561const STATUS_PR_LIMIT = 200
562// The open ones carry their checks, the costly part of the query: fewer of them.
563const STATUS_OPEN_LIMIT = 100
564const STATUS_GH_MS = 20_000
565
566type RepoPrs = { prs: GhPr[] | null; note: string | null }
567
568/** One `gh pr list` in a folder of the repository; null and why when gh could not list them. */
569async function ghPrList($: Engine, cwd: string, args: readonly string[]): Promise<RepoPrs> {
570  try {
571    const { exitCode, stdout, stderr } = await $.process.run(['gh', 'pr', 'list', ...args], {
572      cwd,
573      timeoutMs: STATUS_GH_MS,
574    })
575    if (exitCode !== 0) {
576      return { prs: null, note: `gh meldet: ${firstLine(stderr)}` }
577    }
578    const prs = ghPrs(stdout)
579
580    return { prs, note: prs === null ? 'gh antwortet in einem unerwarteten Format' : null }
581  } catch (error) {
582    return { prs: null, note: `gh lässt sich nicht starten (${firstLine(String(error))})` }
583  }
584}
585
586/**
587 * Every pull request of one repository: the open ones with checks, review and draft, the rest
588 * with number, state and branch only, so the costly checks are fetched for the few that are open.
589 */
590async function readRepoPrs($: Engine, cwd: string): Promise<RepoPrs> {
591  const [open, all] = await Promise.all([
592    ghPrList($, cwd, [
593      '--state', 'open', '--limit', String(STATUS_OPEN_LIMIT),
594      '--json', 'number,state,headRefName,isDraft,reviewDecision,statusCheckRollup',
595    ]),
596    ghPrList($, cwd, ['--state', 'all', '--limit', String(STATUS_PR_LIMIT), '--json', 'number,state,headRefName']),
597  ])
598  const prs = mergePrs(open.prs, all.prs)
599
600  return { prs, note: prs === null ? open.note : null }
601}
602
603// Each repository's pull requests, by `repoKeyOf`, with when they were read: the panel reloads the
604// sessions every minute, but asks gh at most every five minutes, like the band.
605const prCache = new Map<string, RepoPrs & { um: number }>()
606
607/** A repository's pull requests from the cache while fresh, else from gh; `isForced` always asks gh. */
608async function repoPrs($: Engine, repo: string, cwd: string, isForced: boolean): Promise<RepoPrs> {
609  const now = await $.clock.now()
610  const cached = prCache.get(repo)
611  if (!isForced && cached !== undefined && now - cached.um < PR_EVERY_MS) {
612    return cached
613  }
614  const fresh = await readRepoPrs($, cwd)
615  prCache.set(repo, { ...fresh, um: now })
616
617  return fresh
618}
619
620/**
621 * Where every open session stands, by project: the data `/projektfarbe status` prints and the side
622 * panel draws. Asks the app for the sessions, and gh per repository (see `repoPrs`), all
623 * repositories at once, each in the folder of one of its sessions.
624 */
625async function readUebersicht($: Engine, isForced: boolean): Promise<Uebersicht> {
626  const [selfRaw, listed] = await Promise.all([
627    callJson($, 'get_session', { session_id: 'self' }),
628    callJson($, 'list_sessions', { limit: 500 }),
629  ])
630  const self = toListed(selfRaw, 'self')
631  const others = (Array.isArray(listed) ? listed : [])
632    .map(entry => toListed(entry))
633    .filter((row): row is ListedSession => row !== null && row.sessionId !== self?.sessionId)
634  const sessions = self === null ? others : [self, ...others]
635
636  const folders = new Map<string, { cwd: string; project: string }>()
637  for (const session of sessions) {
638    const repo = repoKeyOf(session.cwd)
639    const project = projectOf(session.cwd)
640    if (repo !== null && project !== null && !session.isArchived && !folders.has(repo)) {
641      folders.set(repo, { cwd: session.cwd, project })
642    }
643  }
644  const gelesen = await Promise.all(
645    [...folders].map(async ([repo, { cwd, project }]) => ({
646      repo,
647      project,
648      ...(await repoPrs($, repo, cwd, isForced)),
649    })),
650  )
651  const prs = new Map(gelesen.map(entry => [entry.repo, entry.prs] as const))
652  const hinweise = gelesen
653    .filter(entry => entry.note !== null)
654    .map(entry => `PR-Stand für ${entry.project} nicht verfügbar: ${entry.note}`)
655
656  const satz = await satzNow($)
657  const picks = await picksOf($, satz)
658
659  return buildUebersicht({
660    sessions,
661    selfId: self?.sessionId ?? null,
662    stand: readStand(await $.store.get('stand')),
663    prs,
664    markOf: project => farbeFor(project, picks, satz).mark,
665    now: await $.clock.now(),
666    hinweise,
667  })
668}
669
670/**
671 * Reads the overview into the panel's state; a reading already under way is not started twice.
672 * `isForced` (the `aktualisieren` button) asks gh even when its last answer is fresh.
673 */
674async function loadPanel($: Engine, isForced = false): Promise<void> {
675  if ((await read($, panel)).isLoading) {
676    return
677  }
678  await update($, panel, p => ({ ...p, isLoading: true }))
679  try {
680    const daten = await readUebersicht($, isForced)
681    const um = await $.clock.now()
682    await update($, panel, p => ({ ...p, daten, um, fehler: null, isLoading: false }))
683  } catch (error) {
684    await update($, panel, p => ({
685      ...p,
686      isLoading: false,
687      fehler: `Der Stand der Sessions lässt sich nicht lesen (nur in der Desktop-App): ${firstLine(String(error))}`,
688    }))
689  }
690}
691
692/** After a turn: an open panel is read again, at most once a minute (gh at most every five). */
693async function refreshPanel($: Engine): Promise<void> {
694  const { isOpen, um } = await read($, panel)
695  if (isOpen && (um === null || (await $.clock.now()) - um >= PANEL_EVERY_MS)) {
696    await loadPanel($)
697  }
698}
699
700/** Opens the panel and reads the overview; false where no surface places it (the terminal, mostly). */
701async function openPanel($: Engine): Promise<boolean> {
702  const opened = await $.ui.open({ id: PANEL, title: 'Übersicht' })
703  if (!opened.isPlaced) {
704    return false
705  }
706  await update($, panel, p => ({ ...p, isOpen: true }))
707  void loadPanel($).catch(() => undefined)
708
709  return true
710}
711
712/**
713 * Sends the session a prompt of the person's own (`asUser`), a turn of its own once the session is idle:
714 * the band's two session actions (the Opus opinion, review + commit + PR) are such prompts, and the answer
715 * is a chat turn the person sees and can reply to. A refused or failing submission is a toast.
716 */
717async function bitteSenden($: Engine, failure: string, text: (projekt: string) => string): Promise<void> {
718  try {
719    const projekt = projectOf(await $.session.cwd()) ?? 'dieses Projekt'
720    const sent = await $.prompt.submit({ text: text(projekt), asUser: true })
721    if (sent.drop !== undefined) {
722      $.ui.toast(`${failure}: ${sent.drop}`)
723    }
724  } catch (error) {
725    $.ui.toast(`${failure}: ${firstLine(String(error))}`)
726  }
727}
728
729// What opens a `claude://` address, tried in turn until one starts: Windows, macOS, Linux.
730const OPENERS: readonly (readonly string[])[] = [['rundll32', 'url.dll,FileProtocolHandler'], ['open'], ['xdg-open']]
731
732/** Opens a session in the app through the system's handler for its `claude://` address. */
733async function openSession($: Engine, zeile: UebersichtZeile): Promise<void> {
734  if (zeile.link !== null) {
735    for (const opener of OPENERS) {
736      try {
737        const { exitCode } = await $.process.run([...opener, zeile.link], { timeoutMs: 10_000 })
738        if (exitCode === 0) {
739          return
740        }
741      } catch {
742        // This system has no such opener: the next one is tried.
743      }
744    }
745  }
746  $.ui.toast(`„${zeile.title}“ lässt sich von hier nicht öffnen: bitte in der Seitenleiste wählen.`)
747}
748
749/** Archives one session on the person's yes in the app; a no or an error is a toast, the row stays. */
750async function archive($: Engine, zeile: UebersichtZeile): Promise<void> {
751  const reason = archiveReason(zeile)
752  if (reason === null) {
753    return
754  }
755  try {
756    const result = await $.mcp.call(SERVER, 'archive_session', { session_id: zeile.sessionId, reason })
757    if (result.isError) {
758      $.ui.toast(`„${zeile.title}“ bleibt: ${firstLine(textOf(result.content)) || 'nicht archiviert'}`)
759
760      return
761    }
762  } catch (error) {
763    $.ui.toast(`„${zeile.title}“ bleibt: ${firstLine(String(error))}`)
764
765    return
766  }
767  $.ui.toast(`„${zeile.title}“ archiviert.`)
768  await loadPanel($)
769}
770
771/** Stores the person's color for this project (null: the next free one) and recolors its sessions. */
772async function choose($: Engine, farbe: Farbe | null): Promise<string> {
773  const { project } = await read($, view)
774  if (project === null) {
775    return 'Diese Session liegt in keinem Projektordner, sie hat keine Projektfarbe.'
776  }
777  const satz = await satzNow($)
778  const picks = await picksOf($, satz)
779  const key = project.toLowerCase()
780  delete picks[key]
781  const next = farbe === null ? assignFarben([project], picks, satz) : { ...picks, [key]: farbe.mark }
782  await $.store.set(satz.storeKey, next)
783  await update($, view, v => ({ ...v, isPicking: false }))
784  await refreshView($, await $.session.cwd())
785  const now = farbeFor(project, next, satz)
786  const renamed = await recolorAll($, project).catch(() => 0)
787
788  return `${now.mark} ${project} ist jetzt ${now.name}; ${renamed} Session(s) umbenannt.`
789}
790
791/** Switches the color set (sleep mode or back) and recolors every open session to it. */
792async function switchSatz($: Engine, satz: Satz): Promise<string> {
793  await $.store.set('modus', satz.id)
794  await update($, view, v => ({ ...v, isPicking: false }))
795  await refreshView($, await $.session.cwd())
796  const renamed = await recolorAll($, null).catch(() => 0)
797
798  return satz === BUNT
799    ? `Bunte Farben wieder an; ${renamed} Session(s) umbenannt.`
800    : `Schlafmodus (${satz.label}) an; ${renamed} Session(s) umbenannt.`
801}
802
803/** At the session's start: learn the sidebar's colors once, draw the band, mark the title. */
804async function startUp($: Engine, cwd: string): Promise<void> {
805  await learnOnce($).catch(() => undefined)
806  await refreshView($, cwd)
807  void refreshPrs($, cwd, true).catch(() => undefined)
808  await recolorSelf($, false)
809}
810
811const hilfe = (satz: Satz): string =>
812  [
813    '/projektfarbe            zeigt die Farbe dieses Projekts',
814    '/projektfarbe <farbe>    setzt sie und färbt alle Sessions des Projekts um',
815    '/projektfarbe standard   nächste freie Farbe automatisch',
816    '/projektfarbe alle       färbt alle offenen Sessions neu ein',
817    '/projektfarbe status     Stand aller offenen Sessions nach Projekt (läuft, abgebrochen, PR, ruht …)',
818    '/projektfarbe übersicht  Stand aller Sessions im Seitenpanel (im Terminal als Text)',
819    '/projektfarbe band       Band, Kontext-Anzeige, PR- und Issue-Zahl und Farbpunkt am Spinner ein- oder ausblenden',
820    '/projektfarbe breite     wie breit das Band gerade ist und in welchem Layout es gezeichnet wird',
821    '                         Symbole im Band: ✦ Opus-Meinung, ↑ Review + PR, ✉ Handoff, ⇉ Merge + Issues, ☰ Übersicht, ◐ Farbe ändern, ✕ ausblenden',
822    '/projektfarbe prompts    die vier Prompts hinter den Band-Knöpfen als Text, zum Prüfen',
823    '/projektfarbe schlaf     Schlafmodus: ruhige Farben, Formen statt bunter Punkte',
824    '/projektfarbe bunt       zurück zu den bunten Farben',
825    '',
826    `Farben (${satz.label}): ${satz.farben.map(f => `${f.mark} ${f.name}`).join(', ')}`,
827  ].join('\n')
828
829export const register: Register = on => {
830  // What the band was last drawn in, for `/projektfarbe breite`; a plain variable, as drawing must not write state.
831  let lastBand: { columns: number; stufe: BandStufe; inhalt: BandInhalt; isMono: boolean } | null = null
832
833  on('session.start', async ($, e, next) => {
834    await $.command.register({
835      name: 'projektfarbe',
836      description: 'Farbe dieses Projekts zeigen oder ändern (Farbpunkt vor den Session-Namen)',
837      argumentHint: '[farbe | standard | alle | status | übersicht | band | breite | schlaf | bunt]',
838    })
839    // In the background, so the session start never waits on the app.
840    void startUp($, e.cwd).catch(() => undefined)
841
842    return next(e)
843  })
844
845  // After each of the session's own turns (a subagent's are skipped). In the
846  // background: the turn never waits on the app or on the person's answer there.
847  on('turn.complete', async ($, e, next) => {
848    if (e.agentId === undefined) {
849      void afterTurn($, e.reason).catch(() => undefined)
850    }
851
852    return next(e)
853  })
854
855  on('command.run', { command: 'projektfarbe' }, async ($, e) => {
856    const arg = e.args.trim().toLowerCase()
857    const state = await read($, view)
858    const satz = await satzNow($)
859
860    if (arg === '') {
861      const name = satz.farben.find(f => f.mark === state.mark)?.name ?? 'keine'
862      const head =
863        state.project === null
864          ? 'Diese Session liegt in keinem Projektordner.'
865          : `${state.mark ?? ''} ${state.project}: ${name}${satz === BUNT ? '' : ` (Schlafmodus, ${satz.label})`}`
866
867      const neu = (count: number): string => (count > 0 ? ` (${count} neu)` : '')
868      const prs =
869        state.openPrs !== null
870          ? `\nOffene PRs: ${prLabel(state.openPrs)}${neu(state.newPrs)}`
871          : state.prsNote === null
872            ? ''
873            : `\nPR-Zahl nicht verfügbar: ${state.prsNote}`
874      const issues =
875        state.openIssues !== null
876          ? `\nOffene Issues: ${prLabel(state.openIssues)}${neu(state.newIssues)}`
877          : state.issuesNote === null
878            ? ''
879            : `\nIssue-Zahl nicht verfügbar: ${state.issuesNote}`
880
881      return { text: `${head}${prs}${issues}\n\n${hilfe(satz)}` }
882    }
883    if (arg === 'alle') {
884      const renamed = await recolorAll($, null)
885
886      return { text: `${renamed} Session(s) mit ihrer Projektfarbe umbenannt.` }
887    }
888    if (arg === 'status') {
889      try {
890        return { text: uebersichtText(await readUebersicht($, true)) }
891      } catch (error) {
892        return {
893          text: `Der Stand der Sessions lässt sich nicht lesen (nur in der Desktop-App): ${firstLine(String(error))}`,
894        }
895      }
896    }
897    if (arg === 'übersicht' || arg === 'uebersicht') {
898      if (await openPanel($)) {
899        return { text: 'Übersicht geöffnet.' }
900      }
901      // No surface places the panel: the same data as text.
902      try {
903        return { text: uebersichtText(await readUebersicht($, true)) }
904      } catch (error) {
905        return {
906          text: `Der Stand der Sessions lässt sich nicht lesen (nur in der Desktop-App): ${firstLine(String(error))}`,
907        }
908      }
909    }
910    if (arg === 'band') {
911      const isHidden = !state.isHidden
912      await $.store.set('bandAus', isHidden)
913      await update($, view, v => ({ ...v, isHidden }))
914
915      return {
916        text: isHidden
917          ? 'Band ausgeblendet (mit Kontext-Anzeige, PR- und Issue-Zahl und Farbpunkt am Spinner).'
918          : 'Band eingeblendet.',
919      }
920    }
921    if (arg === 'breite') {
922      // A hidden band is not drawn at all, in every session: say so rather than "not drawn yet".
923      if (state.isHidden) {
924        return { text: 'Das Band ist ausgeblendet; /projektfarbe band blendet es wieder ein.' }
925      }
926
927      return {
928        text:
929          lastBand === null
930            ? 'Das Band wurde in dieser Session noch nicht gezeichnet.'
931            : `Das Band hat ${lastBand.columns} Zellen und ist im Layout „${lastBand.stufe}“ gezeichnet (Name: ${lastBand.inhalt.name} Zeichen). In einer Zeile braucht es: ${EINZEILER.map(stufe => `${stufe} ${Math.round(bandBreite(stufe, lastBand!.inhalt, lastBand!.isMono))}`).join(', ')} Zellen.`,
932      }
933    }
934    if (arg === 'prompts') {
935      return { text: promptsMarkdown() }
936    }
937    if (arg === 'bunt') {
938      return { text: await switchSatz($, BUNT) }
939    }
940    if (arg === 'schlaf' || arg.startsWith('schlaf ')) {
941      const wanted = arg.slice('schlaf'.length).trim()
942      const sleep = wanted === '' ? SCHLAFSAETZE[0] : findSatz(wanted)
943      if (sleep === undefined) {
944        return {
945          text: `Unbekannter Schlafmodus „${wanted}“. Verfügbar: ${SCHLAFSAETZE.map(s => s.label).join(', ')}.`,
946        }
947      }
948
949      return { text: await switchSatz($, sleep) }
950    }
951    if (arg === 'standard') {
952      return { text: await choose($, null) }
953    }
954    const farbe = findFarbe(arg, satz)
955    if (farbe === undefined) {
956      return { text: `Unbekannte Farbe „${e.args.trim()}“.\n\n${hilfe(satz)}` }
957    }
958
959    return { text: await choose($, farbe) }
960  })
961
962  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
963    if (e.props.hasSurvey) {
964      return next(e)
965    }
966    const state = await read($, view)
967    const { hex, contextPercent: percent } = state
968    // A count that could not be read shows as a dimmed "?", not 0 (which would read as "none open"),
969    // so the band keeps one shape; /projektfarbe says why it could not be read.
970    const { openPrs, openIssues } = state
971    if (state.isHidden || state.project === null || state.mark === null || hex === null) {
972      return next(e)
973    }
974    const { Box, Button, Link, Text } = $.ui.resolve(e)
975    const satz = satzOf(state.satz)
976    const isCalm = satz !== BUNT
977    const pick = async (farbe: Farbe): Promise<void> => {
978      $.ui.toast(await choose($, farbe))
979    }
980    // The set in use stays as it is; any other one recolors every open session. What counts is
981    // the store: another session may have switched since this band last read it.
982    const changeMode = async (wanted: Satz): Promise<void> => {
983      if (wanted !== (await satzNow($))) {
984        $.ui.toast(await switchSatz($, wanted))
985      }
986    }
987
988    if (state.isPicking) {
989      return (
990        <Box flexDirection="column">
991          <Text dimColor>Farbe für {state.project}:</Text>
992          <Box flexWrap="wrap" columnGap={1}>
993            {satz.farben.map(farbe => (
994              <Button key={`farbe-${farbe.name}`} plain onPress={() => pick(farbe)}>
995                {isCalm ? <Text color={farbe.hex}>{farbe.mark}</Text> : farbe.mark}
996              </Button>
997            ))}
998            <Button
999              key="abbrechen"
1000              plain
1001              onPress={() => update($, view, v => ({ ...v, isPicking: false }))}
1002            >
1003              <Text dimColor>abbrechen</Text>
1004            </Button>
1005          </Box>
1006          <Box columnGap={2} marginTop="lg" flexWrap="wrap">
1007            <Text dimColor>Modus:</Text>
1008            {SAETZE.map(wanted => (
1009              <Button key={`modus-${wanted.id}`} plain onPress={() => changeMode(wanted)}>
1010                <Text bold={wanted === satz} dimColor={wanted !== satz}>
1011                  {modusLabel(wanted)}
1012                </Text>
1013              </Button>
1014            ))}
1015            <Text dimColor>benennt alle Sessions um</Text>
1016          </Box>
1017        </Box>
1018      )
1019    }
1020
1021    const level = percent === null ? 'ok' : contextLevel(percent)
1022    const tone =
1023      level === 'ok' ? { dimColor: true } : { color: CONTEXT_FILL[level], bold: level === 'full' }
1024    // Subagent views get no gauge: the number is the main session's.
1025    const showsGauge = percent !== null && e.props.view.agentId === undefined
1026    // How the band is laid out follows the cells its tree has (`bandStufe`); the counts are measured
1027    // by the text they will show.
1028    const countRest = (open: number | null, fresh: number): string =>
1029      `${open === null ? ' ?' : ` ${prLabel(open)}`}${superscript(fresh)}`
1030    const inhalt: BandInhalt = {
1031      name: [...state.project].length,
1032      prs: countRest(openPrs, state.newPrs).length,
1033      issues: countRest(openIssues, state.newIssues).length,
1034      percent: showsGauge ? String(percent).length : null,
1035    }
1036    // A surface that does not say how wide the band is (an older Claude Code) gets a roomy one-row band.
1037    const columns = Number.isFinite(e.props.bodyColumns) ? e.props.bodyColumns : 120
1038    // On the terminal a character is one cell, on the desktop it is less: the widths differ.
1039    const isMono = e.surface === 'terminal'
1040    // Only the session's own view is remembered and held in its layout: a subagent's has other content.
1041    const isMain = e.props.view.agentId === undefined
1042    const stufe = bandStufe(columns, inhalt, isMain ? (lastBand?.stufe ?? null) : null, isMono)
1043    if (isMain) {
1044      lastBand = { columns, stufe, inhalt, isMono }
1045    }
1046    const form = stufe === 'zwei' ? null : BAND_FORMEN[stufe]
1047    const block = isCalm ? (
1048      <Text color={hex}>{state.mark}</Text>
1049    ) : (
1050      <Text mono backgroundColor={hex}>
1051        {NBSP.repeat(2)}
1052      </Text>
1053    )
1054    // No break inside the name: a row squeezed by a resize would split "My Project" in two lines.
1055    const nameText = ` ${(stufe === 'zwei' ? shortName(state.project, nameBudget(columns, inhalt, isMono)) : state.project).split(' ').join(NBSP)}`
1056    const name = (
1057      <Text>
1058        {block}
1059        {/* The theme's text color, so the link around it does not paint the name in its own. */}
1060        <Text color="text" bold={!isCalm}>
1061          {nameText}
1062        </Text>
1063      </Text>
1064    )
1065    // Only the word is the button: the desktop draws a Button natively and drops the bold count
1066    // and the colored "+x" inside it, so those stand beside it.
1067    const count = (art: Art, label: string, open: number | null, fresh: number) => {
1068      const key = art === 'pr' ? 'prs' : 'issues'
1069
1070      return (
1071        <Box key={`${key}-zahl`}>
1072          <Button key={key} plain dimColor onPress={() => openList($, art)}>
1073            {label}
1074          </Button>
1075          <Text>
1076            {open === null ? <Text dimColor>{' ?'}</Text> : <Text bold>{` ${prLabel(open)}`}</Text>}
1077            {fresh === 0 ? null : (
1078              <Text color={NEW_COLOR} bold>
1079                {superscript(fresh)}
1080              </Text>
1081            )}
1082          </Text>
1083        </Box>
1084      )
1085    }
1086    // The gauge: the word, a bar of `bar` cells (none: the percentage alone) and the percentage.
1087    const gauge = (bar: number | null, hasLabel: boolean) => {
1088      const filled = percent === null || bar === null ? 0 : contextFill(percent, bar)
1089
1090      return (
1091        <Text>
1092          {hasLabel ? <Text dimColor>{`Kontext${NBSP}`}</Text> : null}
1093          {bar === null || filled === 0 ? null : (
1094            <Text mono backgroundColor={CONTEXT_FILL[level]}>
1095              {NBSP.repeat(filled)}
1096            </Text>
1097          )}
1098          {bar === null || filled === bar ? null : (
1099            <Text mono backgroundColor={CONTEXT_TRACK}>
1100              {NBSP.repeat(bar - filled)}
1101            </Text>
1102          )}
1103          {/* The percent sign never breaks away from its number, however tight the row gets. */}
1104          <Text {...tone}>{`${bar === null ? '' : ' '}${percent}${NBSP}%`}</Text>
1105        </Text>
1106      )
1107    }
1108    // Written out while there is room, else the icon alone (✦ opinion, ↑ review + PR, ☰ overview, ◐ color, ✕ hide), each in a
1109    // field of one width so the five are evenly spaced. The two session actions stand left of the overview.
1110    const mergeBadge = openPrs !== null && openPrs > 0 ? raised(prLabel(openPrs)) : ''
1111    const defs = [
1112      {
1113        key: 'meinung',
1114        hint: 'Opus-Meinung im Chat anfordern',
1115        icon: '✦',
1116        svg: ICON_SVG.meinung,
1117        press: () => bitteSenden($, 'Die Opus-Meinung wurde nicht angefordert', meinungPrompt),
1118      },
1119      {
1120        key: 'review',
1121        hint: 'Review, Commit und Pull Request anfordern',
1122        icon: '↑',
1123        svg: ICON_SVG.review,
1124        press: () => bitteSenden($, 'Review und PR wurden nicht gestartet', reviewPrompt),
1125      },
1126      {
1127        key: 'handoff',
1128        hint: 'Übergabe für eine frische Session schreiben lassen',
1129        icon: '✉',
1130        svg: ICON_SVG.handoff,
1131        press: () => bitteSenden($, 'Die Übergabe wurde nicht angefordert', handoffPrompt),
1132      },
1133      {
1134        key: 'merge',
1135        hint: 'Offene PRs mergen und passende Issues lösen',
1136        icon: '⇉',
1137        svg: ICON_SVG.merge,
1138        // The open pull requests as small raised digits at the icon: what it will merge.
1139        badge: mergeBadge,
1140        press: () => bitteSenden($, 'Merge und Issues wurden nicht gestartet', mergePrompt),
1141      },
1142      {
1143        key: 'uebersicht',
1144        hint: 'Übersicht',
1145        icon: '☰',
1146        svg: ICON_SVG.uebersicht,
1147        press: async () => {
1148          if (!(await openPanel($))) {
1149            $.ui.toast('Hier ist kein Platz für das Panel; /projektfarbe status zeigt den Stand als Text.')
1150          }
1151        },
1152      },
1153      {
1154        key: 'farbe',
1155        hint: 'Farbe ändern',
1156        icon: '◐',
1157        svg: ICON_SVG.farbe,
1158        press: async () => {
1159          // The picker offers the colors of the set in the store, not of the last turn's reading.
1160          const current = await satzNow($)
1161          await update($, view, v => ({ ...v, satz: current.id, isPicking: true }))
1162        },
1163      },
1164      {
1165        key: 'ausblenden',
1166        hint: 'Band ausblenden',
1167        icon: '✕',
1168        svg: ICON_SVG.ausblenden,
1169        press: async () => {
1170          await $.store.set('bandAus', true)
1171          await update($, view, v => ({ ...v, isHidden: true }))
1172        },
1173      },
1174    ]
1175    // Where the surface has `Svg` (not the terminal) the actions are drawn as vector icons, all of one
1176    // family so they stand on one line at one height. A Button takes only text, so the icon stands in the
1177    // field, centered in the row, and a Button of blanks lies over it, in the same cells; the press lands
1178    // on the Button. The terminal keeps its text glyphs.
1179    const elements = $.ui.resolve(e)
1180    const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : null
1181    const actions = defs.map(def => {
1182      const hasSvg = Svg !== null && def.svg !== undefined
1183      const width = def.key === 'merge' ? ICON_BOX + MERGE_EXTRA : ICON_BOX
1184      const badge = def.badge === undefined || def.badge === '' ? null : <Text dimColor>{def.badge}</Text>
1185      const button = (
1186        <Button key={def.key} plain onPress={def.press}>
1187          <Text dimColor>{hasSvg ? NBSP.repeat(width) : def.icon}</Text>
1188        </Button>
1189      )
1190
1191      return (
1192        <Box key={`${def.key}-feld`} width={width} justifyContent="center" alignItems="center">
1193          {hasSvg && Svg !== null && def.svg !== undefined ? (
1194            <>
1195              <Box alignItems="center">
1196                <Svg source={def.svg.source} alt={def.svg.alt} width={ICON_SVG_SIZE} height={ICON_SVG_SIZE} />
1197                {badge}
1198              </Box>
1199              <Box position="absolute" top={0} left={0}>
1200                {button}
hooks/colors.ts 1479 lines
1// Pure helpers: which project a folder belongs to, which color it has,
2// and what a session title looks like with the color mark in front.
3// No project is named here: colors live in the mod's own store, per person.
4
5export type Farbe = { name: string; mark: string; hex: string }
6
7/**
8 * A set of colors a project can have. The bright default and the calm sleep
9 * set are both data: nothing below names one, so a further set is one more entry.
10 */
11export type Satz = {
12  id: string
13  label: string
14  /** Stands for the set in the color picker's mode switch; none is shared by two sets. */
15  emoji: string
16  /** Where the person's picks for this set live in the store. */
17  storeKey: string
18  farben: readonly Farbe[]
19  /** The order a new project is handed a color in (names from `farben`). */
20  reihenfolge: readonly string[]
21  /** Marks are plain text, not emoji: one only counts when a space or the end follows it. */
22  strikt: boolean
23}
24
25export const FARBEN: readonly Farbe[] = [
26  { name: 'blau', mark: '🟦', hex: '#3b82f6' },
27  { name: 'gruen', mark: '🟩', hex: '#22c55e' },
28  { name: 'orange', mark: '🟧', hex: '#f97316' },
29  { name: 'lila', mark: '🟪', hex: '#a855f7' },
30  { name: 'rot', mark: '🟥', hex: '#ef4444' },
31  { name: 'gelb', mark: '🟨', hex: '#eab308' },
32  { name: 'braun', mark: '🟫', hex: '#b45309' },
33  { name: 'schwarz', mark: '⬛', hex: '#6b7280' },
34  { name: 'weiss', mark: '⬜', hex: '#94a3b8' },
35  { name: 'kreis-blau', mark: '🔵', hex: '#3b82f6' },
36  { name: 'kreis-gruen', mark: '🟢', hex: '#22c55e' },
37  { name: 'kreis-orange', mark: '🟠', hex: '#f97316' },
38  { name: 'kreis-lila', mark: '🟣', hex: '#a855f7' },
39  { name: 'kreis-rot', mark: '🔴', hex: '#ef4444' },
40  { name: 'kreis-gelb', mark: '🟡', hex: '#eab308' },
41  { name: 'kreis-braun', mark: '🟤', hex: '#b45309' },
42  { name: 'kreis-schwarz', mark: '⚫', hex: '#6b7280' },
43  { name: 'kreis-weiss', mark: '⚪', hex: '#94a3b8' },
44]
45
46/** The order a new project is handed a color in: bright squares, bright circles, then the dull ones. */
47const REIHENFOLGE = [
48  'blau', 'gruen', 'orange', 'lila', 'rot', 'gelb',
49  'kreis-blau', 'kreis-gruen', 'kreis-orange', 'kreis-lila', 'kreis-rot', 'kreis-gelb',
50  'braun', 'kreis-braun', 'schwarz', 'kreis-schwarz', 'weiss', 'kreis-weiss',
51]
52
53/**
54 * Calm colors for the evening. A session title is plain text and cannot be tinted,
55 * and emoji colors are the system's own (the moon phases and several nature emoji
56 * come out vivid on Windows), so the sidebar gets a monochrome shape and only
57 * the band gets the color: every hex is below 0.06 chroma (OKLCH), at least 5 apart (OKLab x100).
58 */
59const DAEMMERUNG_FARBEN: readonly Farbe[] = [
60  { name: 'nebel', mark: '⬤', hex: '#93aeca' },
61  { name: 'sand', mark: '■', hex: '#d7caa6' },
62  { name: 'altrosa', mark: '▲', hex: '#c59b9e' },
63  { name: 'moos', mark: '◆', hex: '#8a9974' },
64  { name: 'flieder', mark: '▼', hex: '#a597b8' },
65  { name: 'petrol', mark: '◐', hex: '#6e9ba1' },
66  { name: 'ton', mark: '◑', hex: '#b49078' },
67  { name: 'salbei', mark: '⬢', hex: '#a8c7ad' },
68  { name: 'schiefer', mark: '▣', hex: '#797f99' },
69  { name: 'mauve', mark: '✚', hex: '#9d7a8d' },
70]
71
72export const BUNT: Satz = {
73  id: 'bunt',
74  label: 'bunt',
75  emoji: '🎨',
76  storeKey: 'farben',
77  farben: FARBEN,
78  reihenfolge: REIHENFOLGE,
79  strikt: false,
80}
81
82export const DAEMMERUNG: Satz = {
83  id: 'daemmerung',
84  label: 'Dämmerung',
85  emoji: '🌙',
86  storeKey: 'farben-daemmerung',
87  farben: DAEMMERUNG_FARBEN,
88  reihenfolge: DAEMMERUNG_FARBEN.map(f => f.name),
89  strikt: true,
90}
91
92export const SAETZE: readonly Satz[] = [BUNT, DAEMMERUNG]
93/** The sets sleep mode can use; the first is what `/projektfarbe schlaf` picks. */
94export const SCHLAFSAETZE: readonly Satz[] = [DAEMMERUNG]
95
96/** What the color picker's mode switch shows for a set: its emoji and its name, capitalized. */
97export const modusLabel = (satz: Satz): string =>
98  `${satz.emoji} ${satz.label.charAt(0).toUpperCase()}${satz.label.slice(1)}`
99
100const PLATZHALTER = new Set(['', 'new session', 'neue session', 'untitled', 'unbenannt'])
101
102const byName = (satz: Satz, name: string): Farbe | undefined => satz.farben.find(f => f.name === name)
103const byMark = (satz: Satz, mark: string): Farbe | undefined => satz.farben.find(f => f.mark === mark)
104
105/** A name as the person types it: lowercase, umlauts spelled out, spaces as dashes. */
106const asciiName = (input: string): string =>
107  input
108    .trim()
109    .toLowerCase()
110    .replace(/ü/g, 'ue')
111    .replace(/ö/g, 'oe')
112    .replace(/ä/g, 'ae')
113    .replace(/ß/g, 'ss')
114    .replace(/\s+/g, '-')
115
116/** The color set stored under `modus`; the bright one when none is stored. */
117export function satzOf(id: unknown): Satz {
118  return SAETZE.find(s => s.id === id) ?? BUNT
119}
120
121/** A sleep set from what the person typed (dämmerung, Daemmerung, …). */
122export function findSatz(input: string): Satz | undefined {
123  const name = asciiName(input)
124
125  return SCHLAFSAETZE.find(s => asciiName(s.label) === name || s.id === name)
126}
127
128/** The project folder of a session directory; a worktree counts as its repository. */
129export function projectOf(cwd: string): string | null {
130  const path = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
131  if (/\/scratch-workspaces\//i.test(path)) {
132    return null
133  }
134  const cut = path.search(/\/\.claude\/worktrees\//i)
135  const root = cut >= 0 ? path.slice(0, cut) : path
136  const name = root.split('/').pop() ?? ''
137
138  return name === '' || /^[a-z]:$/i.test(name) ? null : name
139}
140
141/**
142 * What the opinion button sends the session, as the person's own words (they would type the same). It
143 * states goals, not a form: the session decides from the conversation what the opinion is about (the last
144 * question, a plan to rework, the state of the project) and builds the subagent's task itself, handing
145 * over the matter and only what it needs, never its own conclusion, so the view stays independent and
146 * the subagent's context small. The subagent reads the rest of the repository itself.
147 */
148export function meinungPrompt(projekt: string): string {
149  return [
150    `Hol eine unabhängige zweite Meinung von Opus ein zu dem, worum es in diesem Gespräch gerade geht: meine letzte Frage, der Plan, den wir haben, oder der Stand von „${projekt}“. Du entscheidest, was gemeint ist.`,
151    [
152      'Starte dafür mit dem Agent-Tool einen Subagenten (subagent_type general-purpose, model opus) und baue seinen Auftrag selbst zusammen:',
153      '- Gib ihm die Sache selbst mit, über die er urteilen soll: die Frage, den Plan im Wortlaut oder die Dateien und Stellen, um die es geht. Wenn ich den Plan überarbeitet haben will, gehört der Plan hinein, mit der Bitte, ihn zu verbessern.',
154      '- Gib ihm nicht deine eigene Schlussfolgerung oder Empfehlung mit, sonst urteilt er nur nach; es sei denn, genau die soll er prüfen.',
155      '- Nimm nur auf, was er wirklich braucht: keinen Gesprächsverlauf, keine Nebenpfade. Alles Weitere soll er selbst im Repository nachsehen (CLAUDE.md, README.md, git log, gh pr list, gh issue list). Er ändert nichts.',
156      '- Er soll direkt sein, jede Aussage mit einer Stelle belegen (Datei, Commit oder Nummer) und sagen, wo er anderer Meinung ist und warum. So lang wie nötig, ohne Füllstoff.',
157    ].join('\n'),
158    'Gib seine Antwort danach unverändert wieder und sage darunter kurz, wo du anderer Meinung bist und was du übernimmst.',
159  ].join('\n\n')
160}
161
162/**
163 * What the review button sends the session, as the person's own words: check the work, commit it, open
164 * the pull request. Pressing the button is the go-ahead for those three steps, and for nothing beyond
165 * them (no merge, no push to the main branch, no force). Like `meinungPrompt` it states goals and leaves
166 * the how to the session, which knows the repository; where a step cannot be done it stops and says why.
167 */
168export function reviewPrompt(projekt: string): string {
169  return [
170    `Bring die Änderungen in „${projekt}“ bis zum Pull Request: erst prüfen, dann committen, dann den PR öffnen.`,
171    [
172      '1. Review: Sieh dir an, was seit dem Abzweig vom Basis-Branch geändert ist (git status, git diff, bei Bedarf der code-review-Skill). Behebe echte Befunde, bevor es weitergeht, und lass die Tests und Prüfungen des Projekts laufen (siehe CLAUDE.md oder README). Sag mir kurz, was du gefunden und geändert hast.',
173      '2. Commit: Fasse zusammen, was zusammengehört, mit Nachrichten in der Art der letzten Commits. Nimm nur Dateien dieser Arbeit mit, keine Nebenprodukte und keine Geheimnisse.',
174      '3. PR: Pushe den Branch (nie auf den Basis-Branch, nie mit force) und öffne einen Pull Request dagegen, mit einer Beschreibung, die sagt, was sich ändert, warum und was geprüft ist. Mergen tust du nicht.',
175    ].join('\n'),
176    'Wenn einer der Schritte nicht geht (Tests rot, nichts zu committen, kein Remote, ein Befund, der meine Entscheidung braucht), halte an und sag mir, woran es liegt, statt es zu umgehen. Am Ende nennst du mir den Link zum PR.',
177  ].join('\n\n')
178}
179
180/**
181 * What the handoff button sends the session, as the person's own words: a message for a fresh session
182 * that carries on where this one stands. It holds what the repository cannot tell, and leaves out what
183 * the new session reads there itself; it is written so it can be pasted as the first message. Nothing is
184 * started or changed: the person decides what to do with the text.
185 */
186export function handoffPrompt(projekt: string): string {
187  return [
188    `Schreib mir eine Übergabe für eine frische Session, die an „${projekt}“ da weitermacht, wo wir gerade stehen.`,
189    'Sie soll enthalten, was die neue Session braucht und nicht aus dem Repository erfährt: das Ziel, was schon erledigt und entschieden ist (mit dem Grund), was offen ist und womit es weitergeht, wo es hakt oder riskant ist, und welche Dateien, Branches oder PRs betroffen sind. Was sie selbst lesen kann (Code, git log, CLAUDE.md), lass weg und nenne nur, wo sie nachsehen soll. Keine Gesprächsgeschichte, keine Füllsätze.',
190    'Schreib sie so, dass ich sie unverändert als erste Nachricht in eine neue Session einfügen kann. Ändere nichts und starte nichts.',
191  ].join('\n\n')
192}
193
194/**
195 * What the merge button sends the session, as the person's own words: bring the open pull requests into
196 * the main branch in a sensible order, and solve the issues that fit, merging those too. Pressing the
197 * button is the go-ahead for merging; the limits keep it safe (green checks, no force, no drafts) and
198 * what needs a decision stays open and is named.
199 */
200export function mergePrompt(projekt: string): string {
201  return [
202    `Bring die offenen PRs in „${projekt}“ sinnvoll in den Hauptzweig und löse passende Issues gleich mit.`,
203    [
204      '1. PRs: Sieh dir alle offenen PRs an (gh pr list) und lege eine sinnvolle Reihenfolge fest: erst die unabhängigen, dann die, die darauf aufbauen oder dieselben Dateien ändern. Merge jeden PR, dessen Checks grün sind und der keine offenen Befunde hat. Bei einem Konflikt: löse ihn, wenn er eindeutig ist, und lass die Checks neu laufen. Prüfe nach jedem Merge den nächsten gegen den neuen Stand.',
205      '2. Issues: Suche danach die offenen Issues, die klar und klein genug sind oder die der neue Stand schon löst (gh issue list). Löse jedes in einem eigenen Branch mit eigenem PR („Closes #n“), lass die Tests laufen und merge ihn, wenn die Checks grün sind. Was der Stand ohnehin schon erledigt, schließt du mit einem Hinweis.',
206    ].join('\n'),
207    'Grenzen: kein Force-Push, nichts an der Historie des Hauptzweigs ändern, keine Entwürfe mergen. Was rot ist, eine Entscheidung von mir braucht oder unklar bleibt, lässt du liegen und nennst es mir mit dem Grund. Am Ende gibst du mir eine kurze Liste: was gemerged ist, was gelöst wurde und was offen blieb.',
208  ].join('\n\n')
209}
210
211/** What each prompt button does and what a review of it checks, for `promptsMarkdown`. */
212export const PROMPT_INFO = [
213  {
214    key: 'meinung',
215    name: 'Opus-Meinung',
216    does: 'Holt eine unabhängige zweite Meinung von Opus ein: zur letzten Frage, zu einem Plan oder zum Stand des Projekts.',
217    checks: [
218      'Der Subagent bekommt die Sache selbst, nicht die Schlussfolgerung der Session und keinen Gesprächsverlauf.',
219      'Das Modell ist der Alias `opus` (eine volle Modell-ID wird vom Agent-Werkzeug abgelehnt).',
220      'Keine Zeilengrenze, keine feste Gliederung, kein Leseverbot.',
221    ],
222    text: meinungPrompt,
223  },
224  {
225    key: 'review',
226    name: 'Review + PR',
227    does: 'Prüft die Änderungen, committet sie und öffnet den Pull Request.',
228    checks: [
229      'Die Reihenfolge ist Review, Commit, PR.',
230      'Kein Merge, kein Push auf den Basis-Branch, nie mit force.',
231      'Geht ein Schritt nicht, hält die Session an und sagt, woran es liegt.',
232    ],
233    text: reviewPrompt,
234  },
235  {
236    key: 'handoff',
237    name: 'Handoff',
238    does: 'Schreibt eine Übergabe für eine frische Session, zum Einfügen als erste Nachricht.',
239    checks: [
240      'Verlangt, was das Repository nicht verrät, und lässt Lesbares (Code, git log, CLAUDE.md) weg.',
241      'Ändert und startet nichts.',
242    ],
243    text: handoffPrompt,
244  },
245  {
246    key: 'merge',
247    name: 'Merge',
248    does: 'Mergt die offenen PRs in sinnvoller Reihenfolge und löst passende Issues mit, auch diese werden gemergt.',
249    checks: [
250      'Gemergt wird nur bei grünen Checks, auch bei den neuen Issue-PRs.',
251      'Kein Force-Push, keine Änderung an der Historie des Hauptzweigs, keine Entwürfe.',
252      'Was rot ist, eine Entscheidung braucht oder unklar bleibt, bleibt liegen und wird mit Grund genannt.',
253    ],
254    text: mergePrompt,
255  },
256] as const
257
258/**
259 * The four prompts as one Markdown document, for a reviewer or an agent to read at a glance: what each
260 * button does, what to check, and the text itself. `/projektfarbe prompts` prints it, and `docs/PROMPTS.md`
261 * is its output, so the code stays the one source.
262 */
263export function promptsMarkdown(projekt = '{Projekt}'): string {
264  const parts = PROMPT_INFO.map(info =>
265    [
266      `## ${info.name} (\`${info.key}\`)`,
267      '',
268      info.does,
269      '',
270      'Prüfen:',
271      ...info.checks.map(check => `- ${check}`),
272      '',
273      '```text',
274      info.text(projekt),
275      '```',
276    ].join('\n'),
277  )
278
279  return [
280    '# Prompts hinter den Band-Knöpfen',
281    '',
282    'Jeder Knopf schickt der Session den Text als eigene Nachricht (`$.prompt.submit` mit `asUser`). `{Projekt}` steht für den Projektnamen. Die Texte stehen in `hooks/colors.ts`; diese Ausgabe kommt von `/projektfarbe prompts`.',
283    '',
284    parts.join('\n\n'),
285    '',
286  ].join('\n')
287}
288
289/** A color from a project's name, the same every time: used once every color of the set is taken. */
290function hashFarbe(project: string, satz: Satz): Farbe {
291  let hash = 0
292  for (const ch of project.toLowerCase()) {
293    hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0
294  }
295
296  return satz.farben[hash % satz.farben.length] ?? satz.farben[0]!
297}
298
299/** The color a project with none gets: the first no other project has, else one from its name. */
300export function nextFarbe(
301  project: string,
302  picks: Readonly<Record<string, string>>,
303  satz: Satz = BUNT,
304): Farbe {
305  const taken = new Set(Object.values(picks))
306  const free = satz.reihenfolge
307    .map(name => byName(satz, name))
308    .find(f => f !== undefined && !taken.has(f.mark))
309
310  return free ?? hashFarbe(project, satz)
311}
312
313/** The project's color: the stored one, else the one it would be handed now. */
314export function farbeFor(
315  project: string,
316  picks: Readonly<Record<string, string>>,
317  satz: Satz = BUNT,
318): Farbe {
319  const picked = picks[project.toLowerCase()]
320
321  return (picked === undefined ? undefined : byMark(satz, picked)) ?? nextFarbe(project, picks, satz)
322}
323
324/** The picks with a color recorded for each project that has none yet, in the order given. */
325export function assignFarben(
326  projects: readonly string[],
327  picks: Readonly<Record<string, string>>,
328  satz: Satz = BUNT,
329): Record<string, string> {
330  const next: Record<string, string> = { ...picks }
331  for (const project of projects) {
332    const key = project.toLowerCase()
333    if (next[key] === undefined || byMark(satz, next[key]) === undefined) {
334      next[key] = nextFarbe(project, next, satz).mark
335    }
336  }
337
338  return next
339}
340
341/** A color of the set from what the person typed: its name (grün, weiß and kreis-… work) or its mark. */
342export function findFarbe(input: string, satz: Satz = BUNT): Farbe | undefined {
343  const text = input.trim()
344
345  return byName(satz, asciiName(text)) ?? satz.farben.find(f => text.startsWith(f.mark))
346}
347
348/** True when the title starts with this mark (a plain-text mark needs a space or the end after it). */
349function carries(text: string, farbe: Farbe, satz: Satz): boolean {
350  if (!text.startsWith(farbe.mark)) {
351    return false
352  }
353  const after = text.charAt(farbe.mark.length)
354
355  return !satz.strikt || after === '' || /\s/.test(after)
356}
357
358/** The color mark a title starts with, if any: of one set, or of any set when none is given. */
359export function markOf(title: string, only?: Satz): Farbe | undefined {
360  const text = title.trim()
361  for (const satz of only === undefined ? SAETZE : [only]) {
362    const found = satz.farben.find(f => carries(text, f, satz))
363    if (found !== undefined) {
364      return found
365    }
366  }
367
368  return undefined
369}
370
371/** The title without color marks in front, whichever set they come from. */
372export function stripMark(title: string): string {
373  let text = title.trim()
374  for (let farbe = markOf(text); farbe !== undefined; farbe = markOf(text)) {
375    text = text.slice(farbe.mark.length).replace(/^️/, '').trim()
376  }
377
378  return text
379}
380
381/** The title with `mark` in front, or null while the app has not named the session yet. */
382export function desiredTitle(title: string, mark: string): string | null {
383  const bare = stripMark(title)
384  if (PLATZHALTER.has(bare.toLowerCase())) {
385    return null
386  }
387
388  return `${mark} ${bare}`
389}
390
391/** The person's picks for one set from the store, keeping only well-formed entries. */
392export function readPicks(raw: unknown, satz: Satz = BUNT): Record<string, string> {
393  const picks: Record<string, string> = {}
394  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
395    return picks
396  }
397  for (const [key, value] of Object.entries(raw)) {
398    if (typeof value === 'string' && byMark(satz, value) !== undefined) {
399      picks[key.toLowerCase()] = value
400    }
401  }
402
403  return picks
404}
405
406export type SessionRow = { sessionId: string; title: string; cwd: string }
407
408/** One session from a get_session or list_sessions entry, or null when a field is missing. */
409export function toRow(raw: unknown, fallbackId?: string): SessionRow | null {
410  if (raw === null || typeof raw !== 'object') {
411    return null
412  }
413  const entry = raw as Record<string, unknown>
414  const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : fallbackId
415  if (sessionId === undefined || typeof entry.title !== 'string' || typeof entry.cwd !== 'string') {
416    return null
417  }
418
419  return { sessionId, title: entry.title, cwd: entry.cwd }
420}
421
422/**
423 * The colors the sidebar already shows: per project, the mark of this set most of
424 * its sessions carry (the first seen on a tie). Sessions without one count for nothing.
425 */
426export function learnPicks(rows: readonly SessionRow[], satz: Satz = BUNT): Record<string, string> {
427  const tally = new Map<string, Map<string, number>>()
428  for (const row of rows) {
429    const project = projectOf(row.cwd)
430    const farbe = markOf(row.title, satz)
431    if (project === null || farbe === undefined) {
432      continue
433    }
434    const counts = tally.get(project.toLowerCase()) ?? new Map<string, number>()
435    counts.set(farbe.mark, (counts.get(farbe.mark) ?? 0) + 1)
436    tally.set(project.toLowerCase(), counts)
437  }
438  const learned: Record<string, string> = {}
439  for (const [key, counts] of tally) {
440    let best: [string, number] | undefined
441    for (const entry of counts) {
442      if (best === undefined || entry[1] > best[1]) {
443        best = entry
444      }
445    }
446    if (best !== undefined) {
447      learned[key] = best[0]
448    }
449  }
450
451  return learned
452}
453
454/** What a session's title needs: a rename, or why none. */
455export type TitlePlan =
456  | { kind: 'rename'; title: string }
457  | { kind: 'same' | 'unnamed' | 'no-project' }
458
459export function planTitle(
460  row: SessionRow,
461  picks: Readonly<Record<string, string>>,
462  satz: Satz = BUNT,
463): TitlePlan {
464  const project = projectOf(row.cwd)
465  if (project === null) {
466    return { kind: 'no-project' }
467  }
468  const wanted = desiredTitle(row.title, farbeFor(project, picks, satz).mark)
469  if (wanted === null) {
470    return { kind: 'unnamed' }
471  }
472
473  return wanted === row.title ? { kind: 'same' } : { kind: 'rename', title: wanted }
474}
475
476export type ContextLevel = 'ok' | 'warn' | 'full'
477
478const CONTEXT_WARN = 70
479const CONTEXT_FULL = 85
480
481/** The width of the gauge, in cells. */
482export const CONTEXT_CELLS = 8
483
484/** How full the context window is, 0 to 100, from `$.session.usage()`; null before the first answer. */
485export function contextPercent(usage: unknown): number | null {
486  const context = (usage as { context?: unknown } | null)?.context
487  if (context === null || typeof context !== 'object') {
488    return null
489  }
490  const { percent, tokens, window } = context as Record<string, unknown>
491  const value =
492    typeof percent === 'number'
493      ? percent
494      : typeof tokens === 'number' && typeof window === 'number' && window > 0
495        ? (tokens / window) * 100
496        : null
497
498  return value === null || !Number.isFinite(value) ? null : Math.min(100, Math.max(0, Math.round(value)))
499}
500
501/** From where the window is getting full (70 %) and where `/compact` is due (85 %). */
502export function contextLevel(percent: number): ContextLevel {
503  return percent >= CONTEXT_FULL ? 'full' : percent >= CONTEXT_WARN ? 'warn' : 'ok'
504}
505
506/** How many of the gauge's `width` cells are filled: in proportion to the percentage, at least one once there is any use. */
507export function contextFill(percent: number, width: number = CONTEXT_CELLS): number {
508  const cells = Math.round((percent / 100) * width)
509
510  return Math.min(width, Math.max(percent > 0 ? 1 : 0, cells))
511}
512
513/**
514 * The band's layouts, from the roomiest down. The actions are icons in all of them (what one does is
515 * the hover card's to say). The one-row layouts: `icons` has the gauge with its word and full bar,
516 * `kompakt` drops the word and shortens the bar, `dicht` moves the items closer together, `minimal`
517 * keeps only the percentage of the gauge. `zwei` is the last resort: two rows, name and gauge above,
518 * counts and icons below.
519 */
520export type BandStufe = 'icons' | 'kompakt' | 'dicht' | 'minimal' | 'zwei'
521
522/** The gauge's bar in the compact layouts; the roomier ones keep the full `CONTEXT_CELLS`. */
523export const COMPACT_CELLS = 4
524
525/** What a one-row layout draws. */
526export type BandForm = {
527  /** The `columnGap` of its rows, in cells. */
528  gap: number
529  /** The gauge's bar in cells; null: the percentage alone. */
530  bar: number | null
531  /** The word "Kontext" before the bar. */
532  hasWord: boolean
533  /** A blank node before the gauge, which sets the gauge apart by a wider gap. */
534  hasSpacer: boolean
535}
536
537export const BAND_FORMEN: Record<Exclude<BandStufe, 'zwei'>, BandForm> = {
538  icons: { gap: 2, bar: CONTEXT_CELLS, hasWord: true, hasSpacer: true },
539  kompakt: { gap: 2, bar: COMPACT_CELLS, hasWord: false, hasSpacer: true },
540  dicht: { gap: 1, bar: COMPACT_CELLS, hasWord: false, hasSpacer: false },
541  minimal: { gap: 1, bar: null, hasWord: false, hasSpacer: false },
542}
543
544/** What the band's width depends on, in characters. */
545export type BandInhalt = {
546  /** The project's name. */
547  name: number
548  /** What stands after `PRs:`: the blank, the number and any raised "+x" (` 12⁺¹`, ` ?`). */
549  prs: number
550  /** The same after `Issues:`. */
551  issues: number
552  /** The digits of the context percentage; null where there is no gauge. */
553  percent: number | null
554}
555
556// The band's five actions are drawn as vector icons where the surface has `Svg` (the desktop, not the
557// terminal, which keeps the text glyphs). All five are one family, so they share height, weight and
558// color: text glyphs next to them sat lower, smaller and darker. An `Svg` is an image and does not take
559// the theme's text color, so the stroke is one mid gray that reads on the light and the dark theme alike.
560// Line icons on a 24 grid.
561export const ICON_SVG_COLOR = '#666c78'
562/** The green of the check on the merge icon: mid-tone, like the gray, so it reads on both themes. */
563export const ICON_SVG_OK = '#2ea35e'
564/** CSS pixels an icon is drawn at; the field around it is `ICON_BOX` cells. */
565export const ICON_SVG_SIZE = 14
566
567const svgIcon = (inner: string): string =>
568  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ICON_SVG_COLOR}" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
569
570export const ICON_SVG = {
571  // A doctoral cap with a spark: the independent second opinion.
572  meinung: {
573    alt: 'Opus-Meinung',
574    source: svgIcon(
575      '<path d="M2.5 11 11 7 19.5 11 11 15Z"/><path d="M6 13v4c0 1.2 2.2 2.5 5 2.5s5-1.3 5-2.5v-4"/><path d="M19.5 11v4"/><path d="M19 2.5v3.5M17.2 4.2h3.6"/>',
576    ),
577  },
578  // A pull request under a magnifying glass: review the work, then open it.
579  review: {
580    alt: 'Review und Pull Request',
581    source: svgIcon(
582      '<circle cx="5" cy="5" r="2"/><circle cx="5" cy="14" r="2"/><path d="M5 7v5"/><path d="M13 10V8a3 3 0 0 0-3-3H8"/><path d="m9.6 3.4-1.6 1.6 1.6 1.6"/><circle cx="16" cy="16" r="4.5"/><path d="M19.3 19.3 22 22"/>',
583    ),
584  },
585  // A letter in flight, speed lines behind it: the handoff to a fresh session.
586  handoff: {
587    alt: 'Handoff',
588    source: svgIcon(
589      '<g transform="rotate(-9 14.5 12)"><rect x="7.5" y="6.5" width="14.5" height="11" rx="2"/><path d="m8 8.5 6.8 5.2L21.5 8.5"/></g><path d="M2 9.5h3M1 12.5h4M2.5 15.5h2.5"/>',
590    ),
591  },
592  // A merge (two commits flowing into one) with a green check: the open pull requests, merged.
593  merge: {
594    alt: 'Offene PRs mergen',
595    source: svgIcon(
596      `<circle cx="5" cy="4.5" r="2"/><circle cx="5" cy="16.5" r="2"/><circle cx="14" cy="10.5" r="2"/><path d="M5 6.5v8"/><path d="M5 6.5v.5a3.5 3.5 0 0 0 3.5 3.5h3.5"/><path d="m12.5 17.5 3.2 3.2 6-6.7" stroke="${ICON_SVG_OK}" stroke-width="2.25"/>`,
597    ),
598  },
599  // Three lines: the overview of all sessions.
600  uebersicht: {
601    alt: 'Übersicht',
602    source: svgIcon('<path d="M3 3.5h18M3 12h18M3 20.5h18"/>'),
603  },
604  // A half-filled disc: the color.
605  farbe: {
606    alt: 'Farbe ändern',
607    source: svgIcon(`<circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 1 0 17Z" fill="${ICON_SVG_COLOR}"/>`),
608  },
609  // A cross: hide the band.
610  ausblenden: {
611    alt: 'Band ausblenden',
612    source: svgIcon('<path d="M3.5 3.5l17 17M20.5 3.5l-17 17"/>'),
613  },
614} as const
615
616// An icon's field and the gap between two: three icons take as many cells as `ICON_BOX` and `ICON_GAP` say.
617export const ICON_BOX = 3
618export const ICON_GAP = 1
619const BLOCK_CELLS = 2
620// The band's actions, in the order it draws them: opinion, review + PR, handoff, merge, overview, color, hide.
621export const ACTION_COUNT = 7
622// The merge action also shows the number of open pull requests next to its icon: its field is this many
623// cells wider.
624export const MERGE_EXTRA = 1
625// The shortest a name is cut to before the band lets the row run over instead.
626const NAME_MIN = 8
627
628/** What the band's widths are measured in: the cells a character, a gap and a node take on a surface. */
629type Metrics = {
630  /** Cells per character of plain text and of the bold name. */
631  text: number
632  name: number
633  /** Cells of the gap `columnGap: 2` and `columnGap: 1` draw, and of the gap between two icon fields. */
634  gap: number
635  gapTight: number
636  iconGap: number
637  /** The blank node before the gauge. */
638  spacer: number
639  /** How far the room a row really has reaches past `bodyColumns`. */
640  slack: number
641}
642
643// Measured on the desktop (the one-row band, in cells of 7.75 px): the text is set in a proportional
644// font, so a character takes less than a cell, the bold name a little more; the gap between the items
645// is about 22 px (`gap: 2`) or 15 px (`gap: 1`), and the blank
646// spacer before the gauge takes no room of its own. The gauge's bar is a run of blanks on a background:
647// exact cells. The band's padding is less than the engine's marks at its right end, hence the slack.
648const DESKTOP: Metrics = {
649  text: 0.83,
650  name: 0.89,
651  gap: 2.85,
652  gapTight: 1.85,
653  iconGap: 1.5,
654  spacer: 0,
655  slack: 0.5,
656}
657
658// On the terminal a character is one cell and a gap is what `columnGap` says; the spacer is its four blanks.
659const TERMINAL: Metrics = {
660  text: 1,
661  name: 1,
662  gap: 2,
663  gapTight: 1,
664  iconGap: ICON_GAP,
665  spacer: 4,
666  slack: 0,
667}
668
669/**
670 * The cells a layout takes in one row (`isMono`: on the terminal, not the desktop); `zwei` has no
671 * one-row width and answers Infinity. The row has two parts, spread to its two ends: the project with
672 * the counts and the gauge at the left, the actions at the right; the gap between them is the least it
673 * keeps.
674 */
675export function bandBreite(stufe: BandStufe, inhalt: BandInhalt, isMono = false): number {
676  if (stufe === 'zwei') {
677    return Number.POSITIVE_INFINITY
678  }
679  const m = isMono ? TERMINAL : DESKTOP
680  const form = BAND_FORMEN[stufe]
681  const gapCells = form.gap === 1 ? m.gapTight : m.gap
682  const items = [
683    BLOCK_CELLS + (1 + inhalt.name) * m.name,
684    (4 + inhalt.prs) * m.text,
685    (7 + inhalt.issues) * m.text,
686  ]
687  if (inhalt.percent !== null) {
688    // The blank spacer before the gauge is a node of its own, with a gap on each side.
689    if (form.hasSpacer) {
690      items.push(m.spacer)
691    }
692    // " 8 %" after a bar, "8 %" alone.
693    const percent = (inhalt.percent + (form.bar === null ? 2 : 3)) * m.text
694    items.push((form.hasWord ? 8 * m.text : 0) + (form.bar ?? 0) + percent)
695  }
696  const actions = ACTION_COUNT * ICON_BOX + MERGE_EXTRA + (ACTION_COUNT - 1) * m.iconGap
697
698  return items.reduce((sum, cells) => sum + cells, 0) + (items.length - 1) * gapCells + gapCells + actions
699}
700
701// Going back to a roomier layout than the one drawn last takes this many cells more than fitting does,
702// so a window settled again and again along a threshold does not flip the band back and forth. Small: a
703// window at rest must show the roomiest layout that fits.
704const HYSTERESIS_CELLS = 1
705
706/** The one-row layouts, the roomiest first. */
707export const EINZEILER: readonly Exclude<BandStufe, 'zwei'>[] = ['icons', 'kompakt', 'dicht', 'minimal']
708
709const STUFEN: readonly BandStufe[] = [...EINZEILER, 'zwei']
710
711/**
712 * The roomiest layout that fits `columns` cells in one row; `zwei` when none does. `previous`, the
713 * layout drawn last, holds the band in it until the room for a roomier one is clear.
714 */
715export function bandStufe(
716  columns: number,
717  inhalt: BandInhalt,
718  previous: BandStufe | null = null,
719  isMono = false,
720): BandStufe {
721  const last = previous === null ? -1 : STUFEN.indexOf(previous)
722  const room = columns + (isMono ? TERMINAL : DESKTOP).slack
723
724  return (
725    EINZEILER.find((stufe, rank) => {
726      const bias = last > rank ? HYSTERESIS_CELLS : 0
727
728      return bandBreite(stufe, inhalt, isMono) <= room - bias
729    }) ?? 'zwei'
730  )
731}
732
733/** How many characters of the name fit the first row of `zwei`, beside the block and the gauge. */
734export function nameBudget(columns: number, inhalt: BandInhalt, isMono = false): number {
735  const m = isMono ? TERMINAL : DESKTOP
736  const gauge = inhalt.percent === null ? 0 : m.gapTight + COMPACT_CELLS + (inhalt.percent + 3) * m.text
737
738  return Math.max(NAME_MIN, Math.floor((columns - BLOCK_CELLS - m.name - gauge) / m.name))
739}
740
741/** The name cut to `max` characters with an ellipsis; as it is when it fits. */
742export function shortName(name: string, max: number): string {
743  const chars = [...name]
744
745  return chars.length <= max ? name : `${chars.slice(0, Math.max(1, max - 1)).join('')}…`
746}
747
748/** The band counts open pull requests up to this number; more than that shows as "100+". */
749export const PR_LIMIT = 100
750
751/** The numbers in the JSON `gh pr list` / `gh issue list --json number` prints; null when it is no list. */
752export function ghNumbers(stdout: string): number[] | null {
753  try {
754    const list: unknown = JSON.parse(stdout)
755    if (!Array.isArray(list)) {
756      return null
757    }
758
759    return list
760      .map(entry => (entry !== null && typeof entry === 'object' ? (entry as { number?: unknown }).number : undefined))
761      .filter((n): n is number => typeof n === 'number' && Number.isFinite(n))
762  } catch {
763    return null
764  }
765}
766
767/** The count as the band shows it. */
768export function prLabel(count: number): string {
769  return count > PR_LIMIT ? `${PR_LIMIT}+` : String(count)
770}
771
772/** What the band counts for a repository: its pull requests and its issues. */
773export type Art = 'pr' | 'issue'
774
775/**
776 * The highest number seen of each kind, per repository folder (`repoKeyOf`), from the store key
777 * `gesehen`; entries of older versions are keyed by project name (see `seenFor`).
778 */
779export type Gesehen = Record<string, Partial<Record<Art, number>>>
780
781const SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹'
782
783/** How many new entries there are, as small raised digits (`⁺²`); empty for none. */
784export function superscript(count: number): string {
785  if (count <= 0) {
786    return ''
787  }
788  const digits = [...String(Math.floor(count))].map(d => SUPERSCRIPT[Number(d)]).join('')
789
790  return `⁺${digits}`
791}
792
793/** A count label (`3`, `100+`) as small raised characters (`³`, `¹⁰⁰⁺`). */
794export function raised(label: string): string {
795  return [...label].map(c => (c === '+' ? '⁺' : /\d/.test(c) ? SUPERSCRIPT[Number(c)] : c)).join('')
796}
797
798/** The highest seen numbers from the store; anything that is not a number is left out. */
799export function readSeen(raw: unknown): Gesehen {
800  const seen: Gesehen = {}
801  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
802    return seen
803  }
804  for (const [project, value] of Object.entries(raw)) {
805    if (value === null || typeof value !== 'object' || Array.isArray(value)) {
806      continue
807    }
808    const entry: Partial<Record<Art, number>> = {}
809    for (const art of ['pr', 'issue'] as const) {
810      const n = (value as Record<string, unknown>)[art]
811      if (typeof n === 'number' && Number.isFinite(n)) {
812        entry[art] = n
813      }
814    }
815    seen[project.toLowerCase()] = entry
816  }
817
818  return seen
819}
820
821/** How many of these open numbers are newer than the highest one seen; none while nothing was seen. */
822export function newCount(numbers: readonly number[], seenMax: number | undefined): number {
823  return seenMax === undefined ? 0 : numbers.filter(n => n > seenMax).length
824}
825
826/**
827 * What was seen in one repository. `key` is the repository's folder (`repoKeyOf`); `legacyKey`, its
828 * project name, is where versions before it kept the entry: it still counts until the repository
829 * has one of its own, so updating shows no "+x" that was already seen.
830 */
831export function seenFor(seen: Gesehen, key: string, legacyKey?: string): Partial<Record<Art, number>> {
832  const legacy = legacyKey === undefined ? undefined : seen[legacyKey.toLowerCase()]
833
834  return { ...legacy, ...seen[key.toLowerCase()] }
835}
836
837/**
838 * Marks these open numbers as seen for a repository (`key`, see `seenFor`). With `isFirstOnly`,
839 * only where nothing was seen yet: the first reading is the baseline, so it shows no "+x". The
840 * highest seen never goes down. Returns the same object when nothing changed.
841 */
842export function markSeen(
843  seen: Gesehen,
844  key: string,
845  art: Art,
846  numbers: readonly number[],
847  isFirstOnly: boolean,
848  legacyKey?: string,
849): Gesehen {
850  const own = key.toLowerCase()
851  const known = seenFor(seen, own, legacyKey)
852  const before = known[art]
853  if (isFirstOnly && before !== undefined) {
854    return seen
855  }
856  const after = Math.max(before ?? 0, ...numbers)
857  if (after === seen[own]?.[art]) {
858    return seen
859  }
860
861  return { ...seen, [own]: { ...known, [art]: after } }
862}
863
864const TRIED_LIMIT = 300
865
866/** The title each session last tried to recolor, by session id, from the store. */
867export function readTried(raw: unknown): Record<string, string> {
868  const tried: Record<string, string> = {}
869  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
870    return tried
871  }
872  for (const [id, title] of Object.entries(raw)) {
873    if (typeof title === 'string') {
874      tried[id] = title
875    }
876  }
877
878  return tried
879}
880
881/** Records one try, newest last, keeping the newest 300 sessions. */
882export function rememberTry(
883  tried: Readonly<Record<string, string>>,
884  sessionId: string,
885  title: string,
886): Record<string, string> {
887  const entries = Object.entries(tried).filter(([id]) => id !== sessionId)
888  entries.push([sessionId, title])
889
890  return Object.fromEntries(entries.slice(-TRIED_LIMIT))
891}
892
893/** Why a session's last own turn ended, as `turn.complete` reports it. */
894export type Grund = 'answer' | 'aborted' | 'refusal' | 'error'
895
896/** A session's last own turn: why it ended and when (`$.clock.now()`), from the store key `stand`. */
897export type StandEintrag = { grund: Grund; um: number }
898
899const GRUENDE: readonly Grund[] = ['answer', 'aborted', 'refusal', 'error']
900const STAND_LIMIT = 300
901
902/** Each session's last turn, by session id, from the store; malformed entries are left out. */
903export function readStand(raw: unknown): Record<string, StandEintrag> {
904  const stand: Record<string, StandEintrag> = {}
905  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
906    return stand
907  }
908  for (const [id, value] of Object.entries(raw)) {
909    if (value === null || typeof value !== 'object') {
910      continue
911    }
912    const { grund, um } = value as Record<string, unknown>
913    if (GRUENDE.includes(grund as Grund) && typeof um === 'number' && Number.isFinite(um)) {
914      stand[id] = { grund: grund as Grund, um }
915    }
916  }
917
918  return stand
919}
920
921/** Records one session's last turn, newest last, keeping the newest 300 sessions. */
922export function rememberStand(
923  stand: Readonly<Record<string, StandEintrag>>,
924  sessionId: string,
925  eintrag: StandEintrag,
926): Record<string, StandEintrag> {
927  const entries = Object.entries(stand).filter(([id]) => id !== sessionId)
928  entries.push([sessionId, eintrag])
929
930  return Object.fromEntries(entries.slice(-STAND_LIMIT))
931}
932
933export type PrState = 'OPEN' | 'MERGED' | 'CLOSED'
934
935const isPrState = (value: unknown): value is PrState =>
936  value === 'OPEN' || value === 'MERGED' || value === 'CLOSED'
937
938/** A session as `list_sessions` lists it, with what its status is read from. */
939export type ListedSession = SessionRow & {
940  branch: string | null
941  isRunning: boolean
942  isArchived: boolean
943  /** Milliseconds since the epoch; null when the app gave none. */
944  lastActivityAt: number | null
945  /** The pull request the app itself links to the session, when it names one (`prNumber`, `prState`). */
946  appPr: { number: number; state: PrState } | null
947  /** The app's `claude://` address of the session, which opens it; null when the app gave none. */
948  link: string | null
949}
950
951/** One session from a list_sessions or get_session entry, or null when a needed field is missing. */
952export function toListed(raw: unknown, fallbackId?: string): ListedSession | null {
953  const row = toRow(raw, fallbackId)
954  if (row === null) {
955    return null
956  }
957  const entry = raw as Record<string, unknown>
958  const at = typeof entry.lastActivityAt === 'string' ? Date.parse(entry.lastActivityAt) : Number.NaN
959
960  return {
961    ...row,
962    branch: typeof entry.branch === 'string' && entry.branch !== '' ? entry.branch : null,
963    isRunning: entry.isRunning === true,
964    isArchived: entry.isArchived === true,
965    lastActivityAt: Number.isFinite(at) ? at : null,
966    appPr:
967      typeof entry.prNumber === 'number' && isPrState(entry.prState)
968        ? { number: entry.prNumber, state: entry.prState }
969        : null,
970    link: typeof entry.link === 'string' && /^claude:\/\/[\w.~\/-]+$/.test(entry.link) ? entry.link : null,
971  }
972}
973
974/** A pull request as `gh pr list --json number,state,headRefName,isDraft,reviewDecision,statusCheckRollup` prints it. */
975export type GhPr = {
976  number: number
977  state: PrState
978  headRefName: string
979  isDraft: boolean
980  /** `REVIEW_REQUIRED`, `CHANGES_REQUESTED`, `APPROVED`, or null when the repository asks for none. */
981  reviewDecision: string | null
982  /** True when a check failed. */
983  isCiRed: boolean
984}
985
986/**
987 * Conclusions (check runs) and states (commit statuses) that make the CI red. Not CANCELLED: a
988 * workflow that cancels its older run on a new push leaves that run in the rollup while CI is green.
989 */
990const CI_ROT = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
991
992function ciRed(rollup: unknown): boolean {
993  if (!Array.isArray(rollup)) {
994    return false
995  }
996
997  return rollup.some(check => {
998    if (check === null || typeof check !== 'object') {
999      return false
1000    }
1001    const { conclusion, state } = check as Record<string, unknown>
1002
1003    return CI_ROT.has(String(conclusion ?? '')) || CI_ROT.has(String(state ?? ''))
1004  })
1005}
1006
1007/** The pull requests in what `gh pr list --json …` printed; null when it is no list. */
1008export function ghPrs(stdout: string): GhPr[] | null {
1009  let list: unknown
1010  try {
1011    list = JSON.parse(stdout)
1012  } catch {
1013    return null
1014  }
1015  if (!Array.isArray(list)) {
1016    return null
1017  }
1018  const prs: GhPr[] = []
1019  for (const raw of list) {
1020    if (raw === null || typeof raw !== 'object') {
1021      continue
1022    }
1023    const entry = raw as Record<string, unknown>
1024    if (typeof entry.number !== 'number' || !isPrState(entry.state) || typeof entry.headRefName !== 'string') {
1025      continue
1026    }
1027    prs.push({
1028      number: entry.number,
1029      state: entry.state,
1030      headRefName: entry.headRefName,
1031      isDraft: entry.isDraft === true,
1032      reviewDecision:
1033        typeof entry.reviewDecision === 'string' && entry.reviewDecision !== '' ? entry.reviewDecision : null,
1034      isCiRed: ciRed(entry.statusCheckRollup),
1035    })
1036  }
1037
1038  return prs
1039}
1040
1041/**
1042 * One list from two gh readings: the open pull requests with their checks, review and draft, and
1043 * all pull requests with number, state and branch only (cheap). An open one's full entry wins;
1044 * null when neither could be read.
1045 */
1046export function mergePrs(open: readonly GhPr[] | null, all: readonly GhPr[] | null): GhPr[] | null {
1047  if (open === null && all === null) {
1048    return null
1049  }
1050  const byNumber = new Map<number, GhPr>()
1051  for (const pr of all ?? []) {
1052    byNumber.set(pr.number, pr)
1053  }
1054  for (const pr of open ?? []) {
1055    byNumber.set(pr.number, pr)
1056  }
1057
1058  return [...byNumber.values()].sort((a, b) => b.number - a.number)
1059}
1060
1061export type StatusArt =
1062  | 'laeuft'
1063  | 'abgebrochen'
1064  | 'fehler'
1065  | 'pr-offen'
1066  | 'pr-gemerged'
1067  | 'pr-geschlossen'
1068  | 'in-arbeit'
1069  | 'ruht'
1070
1071/** Where a session stands: its sign, a German label, and `rang` (lower first: what needs the person). */
1072export type Status = {
1073  art: StatusArt
1074  mark: string
1075  label: string
1076  rang: number
1077  /** The pull request the status names, if any. */
1078  pr: number | null
1079  /** For a resting session: the whole days since it was last active; null otherwise. */
1080  tage: number | null
1081}
1082
1083/** A session with no activity for longer than this rests: a candidate to archive. */
1084export const RUHT_AB_MS = 3 * 24 * 60 * 60_000
1085const TAG_MS = 24 * 60 * 60_000
1086
1087type SessionPr = { number: number; state: PrState; zusatz: string[]; isUrgent: boolean }
1088
1089/**
1090 * The session's pull request. The one the app names comes first, with its number and state;
1091 * `gh` adds what it waits for (CI, review, draft). Without one from the app, the newest
1092 * `gh` lists for the session's branch.
1093 */
1094function prFor(session: ListedSession, prs: readonly GhPr[] | null): SessionPr | null {
1095  const listed = prs ?? []
1096  const app = session.appPr
1097  const own =
1098    app !== null
1099      ? listed.find(pr => pr.number === app.number)
1100      : listed
1101          .filter(pr => session.branch !== null && pr.headRefName === session.branch)
1102          .sort((a, b) => b.number - a.number)[0]
1103  const number = app?.number ?? own?.number
1104  const state = app?.state ?? own?.state
1105  if (number === undefined || state === undefined) {
1106    return null
1107  }
1108  if (own === undefined || state !== 'OPEN') {
1109    return { number, state, zusatz: [], isUrgent: false }
1110  }
1111  const zusatz = [
1112    own.isCiRed ? 'CI rot' : null,
1113    own.reviewDecision === 'CHANGES_REQUESTED' ? 'Änderungen gewünscht' : null,
1114    own.reviewDecision === 'REVIEW_REQUIRED' ? 'Review wartet' : null,
1115    own.isDraft ? 'Entwurf' : null,
1116  ].filter((z): z is string => z !== null)
1117
1118  return { number, state, zusatz, isUrgent: own.isCiRed || own.reviewDecision === 'CHANGES_REQUESTED' }
1119}
1120
1121function prText(pr: SessionPr): string {
1122  const what = pr.state === 'OPEN' ? 'offen' : pr.state === 'MERGED' ? 'gemerged' : 'geschlossen'
1123
1124  return `PR #${pr.number} ${what}${pr.zusatz.length === 0 ? '' : `, ${pr.zusatz.join(', ')}`}`
1125}
1126
1127/**
1128 * Where a session stands, from its list_sessions row, its last turn (`stand`; absent for a
1129 * session from before this version), its repository's pull requests (null when `gh` could not
1130 * list them) and the time now. Running comes first; then a merged or closed pull request (the
1131 * work is done, whatever the last turn was); then an aborted or failed turn, but only while the
1132 * session is recent: one left alone for more than three days rests like any other; then an open
1133 * pull request; without one the session is in work or rests.
1134 */
1135export function statusOf(
1136  session: ListedSession,
1137  stand: StandEintrag | undefined,
1138  prs: readonly GhPr[] | null,
1139  now: number,
1140): Status {
1141  const pr = prFor(session, prs)
1142  const id = pr?.number ?? null
1143  const withPr = (text: string): string => (pr === null ? text : `${text} · ${prText(pr)}`)
1144  const status = (art: StatusArt, mark: string, label: string, rang: number): Status => ({
1145    art,
1146    mark,
1147    label,
1148    rang,
1149    pr: id,
1150    tage: null,
1151  })
1152  if (session.isRunning) {
1153    return status('laeuft', '▶', withPr('läuft'), 5)
1154  }
1155  if (pr !== null && pr.state !== 'OPEN') {
1156    return pr.state === 'MERGED'
1157      ? status('pr-gemerged', '✓', prText(pr), 6)
1158      : status('pr-geschlossen', '✖', prText(pr), 6)
1159  }
1160  const last = lastActive(session, stand)
1161  const rests = last !== null && now - last > RUHT_AB_MS
1162  const isFailed = stand?.grund === 'error' || stand?.grund === 'refusal'
1163  if (!rests && stand?.grund === 'aborted') {
1164    return status('abgebrochen', '⚠', withPr('abgebrochen'), 0)
1165  }
1166  if (!rests && isFailed) {
1167    return status('fehler', '⚠', withPr(stand?.grund === 'error' ? 'Fehler' : 'Fehler (Modell lehnte ab)'), 0)
1168  }
1169  if (pr !== null) {
1170    return status('pr-offen', '🔀', prText(pr), pr.isUrgent ? 1 : 2)
1171  }
1172  if (last !== null && rests) {
1173    const tage = Math.floor((now - last) / TAG_MS)
1174    const zuletzt = stand?.grund === 'aborted' ? ', zuletzt abgebrochen' : isFailed ? ', zuletzt Fehler' : ''
1175
1176    return { ...status('ruht', '💤', `ruht seit ${tage} Tagen, kein PR${zuletzt}`, 7), tage }
1177  }
1178
1179  return status('in-arbeit', '✎', 'in Arbeit, kein PR', 4)
1180}
1181
1182/** When the session was last active: the later of the app's figure and its last turn; null when neither is known. */
1183export function lastActive(session: ListedSession, stand: StandEintrag | undefined): number | null {
1184  const times = [session.lastActivityAt, stand?.um].filter((t): t is number => t !== null && t !== undefined)
1185
1186  return times.length === 0 ? null : Math.max(...times)
1187}
1188
1189/** How long ago, in short German: "gerade eben", "vor 5 Min", "vor 2 Std", "vor 3 Tagen". */
1190export function alterText(ms: number): string {
1191  const min = Math.floor(Math.max(0, ms) / 60_000)
1192  if (min < 1) {
1193    return 'gerade eben'
1194  }
1195  if (min < 60) {
1196    return `vor ${min} Min`
1197  }
1198  const std = Math.floor(min / 60)
1199  if (std < 24) {
1200    return `vor ${std} Std`
types/index.d.ts 92 lines
1export type ProjektfarbenView = {
2  /** The project this session belongs to, from its folder; null in a scratch folder. */
3  project: string | null
4  /** The project's color mark, an emoji such as a blue square or a plain shape in sleep mode; null without a project. */
5  mark: string | null
6  /** The color the band paints the project name in. */
7  hex: string | null
8  /** How full the context window was after the last answer, 0 to 100; null before the first one. */
9  contextPercent: number | null
10  /** The open pull requests of the project's repository, counted by `gh`; null while unknown. */
11  openPrs: number | null
12  /** Why `openPrs` could not be read, for `/projektfarbe`; null when it could. */
13  prsNote: string | null
14  /** The numbers of the open pull requests last read (at most 101), to tell the new ones. */
15  prNumbers: number[]
16  /** How many open pull requests are newer than the highest one the person has seen. */
17  newPrs: number
18  /** The open issues of the project's repository, counted by `gh`; null while unknown. */
19  openIssues: number | null
20  /** Why `openIssues` could not be read, for `/projektfarbe`; null when it could. */
21  issuesNote: string | null
22  /** The numbers of the open issues last read (at most 101), to tell the new ones. */
23  issueNumbers: number[]
24  /** How many open issues are newer than the highest one the person has seen. */
25  newIssues: number
26  /**
27   * The repository's web address, read by `gh repo view` once per folder; the project's color and
28   * name link to it. A Link, not a Button: the desktop draws a Button natively, without the colors.
29   */
30  repoUrl: string | null
31  /**
32   * The newest release build of Bramble (`bramble.exe`), the person's own browser, found with the
33   * counts; null without one. With it the name, `PRs:` and `Issues:` open their pages there.
34   */
35  bramble: string | null
36  /** Id of the color set in use: `bunt`, or a calm sleep set such as `daemmerung`. */
37  satz: string
38  /** True while the band shows the color buttons. */
39  isPicking: boolean
40  /** True when the person hid the band (kept across sessions in the store). */
41  isHidden: boolean
42}
43
44// The contract must be self-contained, so these mirror `Uebersicht` in hooks/colors.ts: change them together.
45export type PanelStatus = {
46  art: 'laeuft' | 'abgebrochen' | 'fehler' | 'pr-offen' | 'pr-gemerged' | 'pr-geschlossen' | 'in-arbeit' | 'ruht'
47  mark: string
48  label: string
49  rang: number
50  pr: number | null
51  tage: number | null
52}
53
54export type PanelZeile = {
55  sessionId: string
56  title: string
57  status: PanelStatus
58  alter: string
59  lastActive: number | null
60  isSelf: boolean
61  link: string | null
62}
63
64export type PanelUebersicht = {
65  gruppen: { project: string | null; mark: string | null; zeilen: PanelZeile[] }[]
66  hinweise: string[]
67}
68
69/** What the side panel draws: the overview of all sessions and how fresh it is. */
70export type ProjektfarbenPanel = {
71  /** The last overview read; null before the first one. */
72  daten: PanelUebersicht | null
73  /** True while the overview is being read. */
74  isLoading: boolean
75  /** Why the overview could not be read; null when it could. */
76  fehler: string | null
77  /** When the overview was read (`$.clock.now()`); null before the first reading. */
78  um: number | null
79  /** True while the panel is open: only then a finished turn refreshes it. */
80  isOpen: boolean
81  /** `status`: sections by what to do (default); `projekt`: grouped by project. */
82  ansicht: 'status' | 'projekt'
83  /** Sections and projects the person folded: `laeuft` (default), a section id, or `projekt-<name>`. */
84  zu: string[]
85}
86
87declare module 'claude-code' {
88  interface PluginState {
89    projektfarben: { view: ProjektfarbenView; uebersicht: ProjektfarbenPanel }
90  }
91}
92