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

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.
✦, 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.↑) 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.✉) 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./projektfarbe breite sagt, wie breit das Band gerade ist und in welchem Layout es gezeichnet wird./compact fällig). Der Mod liest dafür nur die Werte, die Claude Code ohnehin hat; es entstehen keine Modellaufrufe und keine Kosten.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.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.gh repo view; kennt gh sie nicht, bleibt der Name ein normaler Text.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./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
| Zeichen | Stand |
|---|---|
| ⚠ | 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).
/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./projektfarbe schlaf ersetzt die bunten Punkte durch ruhige Formen und das Band durch gedämpfte Farben. /projektfarbe bunt stellt alles zurück.🎨 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.
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.
| Befehl | Wirkung |
|---|---|
/projektfarbe | zeigt 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 standard | gibt dem Projekt die nächste freie Farbe |
/projektfarbe alle | färbt alle offenen Sessions neu ein |
/projektfarbe status | zeigt 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 band | blendet Band, Kontext-Anzeige, PR- und Issue-Zahl und den Farbpunkt am Spinner ein oder aus |
/projektfarbe breite | zeigt, 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 prompts | gibt 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 schlaf | schaltet den Schlafmodus ein und benennt alle offenen Sessions um |
/projektfarbe bunt | schaltet 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 🔵 🟢 🟠 🟣 🔴 🟡 🟤 ⚫ ⚪.
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:
| Form | Name | Form | Name |
|---|---|---|---|
| ⬤ | nebel | ◐ | petrol |
| ■ | sand | ◑ | ton |
| ▲ | altrosa | ⬢ | salbei |
| ◆ | moos | ▣ | schiefer |
| ▼ | flieder | ✚ | mauve |
gh; nur gh redet dabei mit GitHub, mit deiner eigenen Anmeldung./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./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.gh in der Desktop-App nicht, steht dort 0; ob es wirklich keine gibt, zeigt /projektfarbe./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./projektfarbe status geht nur in der Desktop-App. Ohne angemeldetes gh fehlen CI- und Review-Angaben; die Ausgabe sagt dann, für welches Projekt.ö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./projektfarbe übersicht den Text aus. Archivieren fragt die App bei jedem Aufruf selbst; im Modus „Bypass“ oder „Auto“ geht es dort ohne Nachfrage durch./context leicht abweichen.■ Aufgaben), wird vom Mod wie eine Farbmarke behandelt.claude plugin validate .
claude plugin test .
MIT, siehe LICENSE.
hooks/register.tsx 1455 lines1import { 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 lines1// 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 lines1export 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