From 0db9fadee953c0e4b9eb90f40b103f4a65e6f64c Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 7 Sep 2026 08:16:49 +0200 Subject: [PATCH] feat(ui): Cheat-Sheet-Fenster im Hilfe-Menue Sechs Markdown-Sheets (git, Conventional Commits, Claude CLI, Claude-Config, dotnet CLI, PowerShell) als EmbeddedResource, gerendert im modelosen, per Topmost anpinnbaren CheatSheetsWindow. MarkdownView bekommt dafuer das Opt-in CommandRows: `befehl` - beschreibung wird zu Mono-Chip + Copy-Button, jede ##-Sektion landet in einem Expander. Enthaelt ausserdem: Mission Control wird beim Oeffnen aus dem Minimiert-Zustand geholt, nicht nur aktiviert. --- .../Assets/CheatSheets/claude-cli.md | 63 +++++++ .../Assets/CheatSheets/claude-config.md | 48 ++++++ .../CheatSheets/conventional-commits.md | 34 ++++ .../Assets/CheatSheets/dotnet-cli.md | 49 ++++++ src/ClaudeDo.Ui/Assets/CheatSheets/git.md | 80 +++++++++ .../Assets/CheatSheets/powershell.md | 52 ++++++ src/ClaudeDo.Ui/CLAUDE.md | 17 +- src/ClaudeDo.Ui/ClaudeDo.Ui.csproj | 3 + src/ClaudeDo.Ui/Services/IDialogService.cs | 3 + .../ViewModels/IslandsShellViewModel.cs | 13 ++ .../ViewModels/Modals/CheatSheetsViewModel.cs | 80 +++++++++ .../Views/Controls/MarkdownView.axaml.cs | 156 +++++++++++++++++- src/ClaudeDo.Ui/Views/MainWindow.axaml | 14 ++ .../Views/Modals/CheatSheetsWindow.axaml | 45 +++++ .../Views/Modals/CheatSheetsWindow.axaml.cs | 10 ++ src/ClaudeDo.Ui/Views/WindowDialogService.cs | 21 +++ .../ViewModels/CheatSheetsViewModelTests.cs | 34 ++++ .../ListsIslandCreateListCancelTests.cs | 1 + .../ViewModels/TasksIslandMoveToListTests.cs | 1 + .../Views/MarkdownCommandRowTests.cs | 32 ++++ 20 files changed, 749 insertions(+), 7 deletions(-) create mode 100644 src/ClaudeDo.Ui/Assets/CheatSheets/claude-cli.md create mode 100644 src/ClaudeDo.Ui/Assets/CheatSheets/claude-config.md create mode 100644 src/ClaudeDo.Ui/Assets/CheatSheets/conventional-commits.md create mode 100644 src/ClaudeDo.Ui/Assets/CheatSheets/dotnet-cli.md create mode 100644 src/ClaudeDo.Ui/Assets/CheatSheets/git.md create mode 100644 src/ClaudeDo.Ui/Assets/CheatSheets/powershell.md create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/CheatSheetsViewModel.cs create mode 100644 src/ClaudeDo.Ui/Views/Modals/CheatSheetsWindow.axaml create mode 100644 src/ClaudeDo.Ui/Views/Modals/CheatSheetsWindow.axaml.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/CheatSheetsViewModelTests.cs create mode 100644 tests/ClaudeDo.Ui.Tests/Views/MarkdownCommandRowTests.cs diff --git a/src/ClaudeDo.Ui/Assets/CheatSheets/claude-cli.md b/src/ClaudeDo.Ui/Assets/CheatSheets/claude-cli.md new file mode 100644 index 00000000..0debbe7a --- /dev/null +++ b/src/ClaudeDo.Ui/Assets/CheatSheets/claude-cli.md @@ -0,0 +1,63 @@ +# Claude CLI + +## Session starten +- `claude` — interaktive Session im aktuellen Verzeichnis +- `claude ""` — Session mit erstem Prompt starten +- `claude -c` — letzte Session in diesem Verzeichnis fortsetzen +- `claude -r` — Session aus einer Auswahlliste fortsetzen +- `claude -r ` — bestimmte Session fortsetzen +- `claude --fork-session -r ` — Session unter neuer ID fortsetzen +- `claude -n ""` — Anzeigename für die Session setzen + +## Modell und Aufwand +- `claude --model sonnet` — Alias oder voller Name (`fable`, `opus`, `sonnet`, `claude-opus-5`) +- `claude --effort high` — Denkaufwand: `low`, `medium`, `high`, `xhigh`, `max` +- `claude --fallback-model sonnet` — Ausweichmodell bei Überlast (nur mit `-p`) + +## Nicht interaktiv +- `claude -p ""` — antworten und beenden +- `claude -p --output-format json` — ein JSON-Ergebnis +- `claude -p --output-format stream-json` — Streaming-JSON für Skripte +- `claude -p --include-partial-messages` — Teil-Chunks mitstreamen +- `claude -p --max-budget-usd 5` — Kostendeckel für den Lauf +- `claude -p --json-schema ''` — strukturierte Ausgabe validieren + +## Berechtigungen und Tools +- `claude --permission-mode ` — `acceptEdits`, `auto`, `plan`, `manual`, `dontAsk`, `bypassPermissions` +- `claude --allowed-tools "Bash(git *)" Edit` — nur diese Tools erlauben +- `claude --disallowed-tools "Bash(rm *)"` — diese Tools sperren +- `claude --tools "Bash,Edit,Read"` — Built-in-Toolset festlegen +- `claude --add-dir ` — weiteres Arbeitsverzeichnis freigeben +- `claude --restricted` — ohne befehlsausführende Tools und ohne lokale Settings + +## Kontext und Konfiguration +- `claude --append-system-prompt ""` — Systemprompt ergänzen +- `claude --system-prompt ""` — Systemprompt ersetzen +- `claude --settings ` — zusätzliche Settings laden +- `claude --agents ''` — eigene Subagents für diese Session +- `claude --mcp-config ` — MCP-Server aus JSON laden +- `claude --strict-mcp-config` — nur diese MCP-Server, alle anderen ignorieren +- `claude --safe-mode` — alle Anpassungen aus, zum Fehlersuchen +- `claude --bare` — Minimalmodus ohne Hooks, Plugins, CLAUDE.md + +## Hintergrund-Sessions +- `claude --bg ""` — im Hintergrund starten, gibt die Kurz-ID aus +- `claude agents` — laufende Hintergrund-Sessions auflisten +- `claude attach ` — Hintergrund-Session in dieses Terminal holen +- `claude logs ` — letzte Ausgabe einer Hintergrund-Session +- `claude stop ` — Session stoppen, Verlauf bleibt +- `claude rm ` — Session löschen + +## Worktree +- `claude -w` — neuen Git-Worktree für diese Session anlegen +- `claude -w ` — Worktree mit eigenem Namen + +## Verwaltung +- `claude doctor` — Installation prüfen +- `claude update` — auf neue Version aktualisieren +- `claude mcp` — MCP-Server konfigurieren +- `claude plugin` — Plugins verwalten +- `claude auth` — Authentifizierung verwalten +- `claude setup-token` — Langzeit-Token einrichten +- `claude -d api,hooks` — Debugmodus mit Kategoriefilter +- `claude -v` — Version ausgeben diff --git a/src/ClaudeDo.Ui/Assets/CheatSheets/claude-config.md b/src/ClaudeDo.Ui/Assets/CheatSheets/claude-config.md new file mode 100644 index 00000000..b38966dc --- /dev/null +++ b/src/ClaudeDo.Ui/Assets/CheatSheets/claude-config.md @@ -0,0 +1,48 @@ +# Claude Konfiguration + +## Dateien und Orte +- `~/.claude/settings.json` — globale Settings für alle Projekte +- `.claude/settings.json` — Projekt-Settings, eingecheckt fürs Team +- `.claude/settings.local.json` — persönliche Projekt-Settings, nicht eingecheckt +- `Rangfolge: managed > local > projekt > global` — spezifischer gewinnt +- `/config` — Settings interaktiv in der Session ansehen und ändern +- `claude doctor` — Konfiguration auf Fehler prüfen + +## CLAUDE.md +- `~/.claude/CLAUDE.md` — globale Anweisungen für alle Projekte +- `CLAUDE.md` — im Projekt-Root, wird bei Start automatisch geladen +- `/CLAUDE.md` — wird geladen, sobald dort gearbeitet wird +- `@pfad/zur/datei.md` — Import einer weiteren Datei in eine CLAUDE.md +- `# ` — in der Session: Notiz in die CLAUDE.md übernehmen +- `/init` — CLAUDE.md für ein Projekt generieren lassen + +## Berechtigungen +- `"permissions": { "allow": [...], "deny": [...], "ask": [...] }` — Struktur in settings.json +- `Bash(git *)` — Bash-Befehle per Muster erlauben oder sperren +- `Edit(src/**)` — Dateizugriff per Glob einschränken +- `WebFetch(domain:example.com)` — Webzugriff auf eine Domain begrenzen +- `mcp____` — Regel für ein einzelnes MCP-Tool +- `/permissions` — Regeln in der Session ansehen und ergänzen + +## MCP-Server +- `claude mcp add -- ` — lokalen stdio-Server anlegen +- `claude mcp add --transport http ` — HTTP-Server anlegen +- `claude mcp add --scope project ...` — in `.mcp.json` fürs Team +- `claude mcp list` — konfigurierte Server anzeigen +- `claude mcp remove ` — Server entfernen +- `/mcp` — Serverstatus in der Session, OAuth-Login + +## Hooks +- `"hooks": { "PreToolUse": [...] }` — Shell-Befehle an Lifecycle-Events hängen +- `PreToolUse / PostToolUse` — vor und nach jedem Tool-Aufruf +- `UserPromptSubmit` — beim Absenden eines Prompts +- `SessionStart / Stop` — bei Sessionbeginn und -ende +- `Exit-Code 2` — blockiert die Aktion, stderr geht an Claude +- `/hooks` — Hooks interaktiv verwalten + +## Erweiterungen +- `.claude/commands/.md` — eigener Slash-Command `/name` +- `.claude/skills//SKILL.md` — Skill, wird bei passender Aufgabe geladen +- `.claude/agents/.md` — eigener Subagent-Typ +- `~/.claude/...` — dieselben Ordner global statt pro Projekt +- `claude plugin` — Plugins installieren und verwalten diff --git a/src/ClaudeDo.Ui/Assets/CheatSheets/conventional-commits.md b/src/ClaudeDo.Ui/Assets/CheatSheets/conventional-commits.md new file mode 100644 index 00000000..c96c0d68 --- /dev/null +++ b/src/ClaudeDo.Ui/Assets/CheatSheets/conventional-commits.md @@ -0,0 +1,34 @@ +# Conventional Commits + +## Format +- `(): ` — Grundform, Scope optional +- `feat(auth): add login endpoint` — Beispiel mit Scope +- `fix: prevent crash on empty input` — Beispiel ohne Scope +- `imperativ, klein, kein Punkt am Ende` — Stil der Beschreibung + +## Types +- `feat` — neues Feature +- `fix` — Bugfix +- `refactor` — Umbau ohne Verhaltensänderung +- `perf` — Performance-Verbesserung +- `docs` — nur Dokumentation +- `test` — Tests hinzufügen oder korrigieren +- `style` — Formatierung, kein Code-Effekt +- `build` — Build-System oder Dependencies +- `ci` — CI-Konfiguration +- `chore` — Sonstiges ohne src- oder Test-Änderung + +## Breaking Changes +- `feat(api)!: remove legacy endpoint` — `!` markiert einen Breaking Change +- `BREAKING CHANGE: ` — alternativ als Footer im Commit-Body + +## Body und Footer +- `Leerzeile nach der Betreffzeile` — dann optionaler Body mit dem Warum +- `Refs: #123` — Footer für Ticket-Referenzen +- `Reverts: ` — Footer für Reverts + +## Warum das Ganze +- `feat` — löst ein Minor-Release aus (SemVer) +- `fix` — löst ein Patch-Release aus (SemVer) +- `!` oder `BREAKING CHANGE` — löst ein Major-Release aus +- `git log --oneline --grep "^feat"` — Verlauf wird filterbar diff --git a/src/ClaudeDo.Ui/Assets/CheatSheets/dotnet-cli.md b/src/ClaudeDo.Ui/Assets/CheatSheets/dotnet-cli.md new file mode 100644 index 00000000..37a7f018 --- /dev/null +++ b/src/ClaudeDo.Ui/Assets/CheatSheets/dotnet-cli.md @@ -0,0 +1,49 @@ +# dotnet CLI + +## Projekt anlegen +- `dotnet new list` — verfügbare Vorlagen anzeigen +- `dotnet new console -n ` — Projekt aus Vorlage anlegen +- `dotnet new sln` — Solution anlegen +- `dotnet sln add ` — Projekt in die Solution aufnehmen + +## Bauen +- `dotnet restore` — NuGet-Pakete auflösen +- `dotnet build -c Release` — Release bauen +- `dotnet build --no-restore` — ohne Restore, wenn Pakete aktuell sind +- `dotnet build -v minimal` — knappere Ausgabe +- `dotnet clean -c Release` — Build-Ausgabe löschen + +## Laufen lassen +- `dotnet run --project -c Release` — starten +- `dotnet run --project -- ` — Argumente an die App +- `dotnet watch --project run` — bei Dateiänderung neu starten + +## Testen +- `dotnet test -c Release --no-build` — Tests ohne Neubau +- `dotnet test --filter "FullyQualifiedName~"` — nur passende Tests +- `dotnet test --filter "FullyQualifiedName!~"` — Tests ausschließen +- `dotnet test --logger "console;verbosity=detailed"` — Details pro Test +- `dotnet test --blame-hang-timeout 60s` — hängende Tests aufspüren + +## Pakete +- `dotnet add package ` — Paket hinzufügen +- `dotnet add package --version 1.2.3` — feste Version +- `dotnet remove package ` — Paket entfernen +- `dotnet list package --outdated` — veraltete Pakete +- `dotnet nuget locals all --clear` — NuGet-Caches leeren + +## EF Core Migrationen +- `dotnet ef migrations add -p ` — Migration anlegen +- `dotnet ef migrations list -p ` — Migrationen anzeigen +- `dotnet ef migrations remove -p ` — letzte Migration zurücknehmen +- `dotnet ef database update -p ` — Schema anwenden + +## Veröffentlichen +- `dotnet publish -c Release -r win-x64 --self-contained` — eigenständiges Build +- `dotnet publish -c Release -p:PublishSingleFile=true` — eine Exe + +## Diagnose +- `dotnet --info` — SDKs, Runtimes, RID +- `dotnet --list-sdks` — installierte SDKs +- `dotnet format ` — Formatierung anwenden +- `dotnet build -p:TreatWarningsAsErrors=true` — Warnungen als Fehler diff --git a/src/ClaudeDo.Ui/Assets/CheatSheets/git.md b/src/ClaudeDo.Ui/Assets/CheatSheets/git.md new file mode 100644 index 00000000..842c1556 --- /dev/null +++ b/src/ClaudeDo.Ui/Assets/CheatSheets/git.md @@ -0,0 +1,80 @@ +# Git + +## Start +- `git clone ` — Repository klonen +- `git init` — neues Repository anlegen +- `git remote add origin ` — Remote verbinden + +## Stand ansehen +- `git status -sb` — Kurzstatus mit Branch-Zeile +- `git diff` — nicht gestagete Änderungen +- `git diff --staged` — gestagete Änderungen +- `git diff main...HEAD` — alles, was dieser Branch gegenüber main bringt +- `git diff --stat` — nur Datei- und Zeilenzähler +- `git log --oneline --graph --decorate -20` — kompakter Verlauf +- `git log -p -- ` — Verlauf einer Datei mit Diffs +- `git show ` — ein Commit im Detail + +## Branch +- `git switch ` — Branch wechseln +- `git switch -c ` — Branch anlegen und wechseln +- `git switch -` — zurück zum vorherigen Branch +- `git branch -vv` — Branches mit Upstream und letztem Commit +- `git branch -d ` — gemergten Branch löschen (`-D` erzwingt) + +## Committen +- `git add ` — Änderungen stagen +- `git add -p` — Änderungen hunk-weise stagen +- `git commit -m ""` — gestagete Änderungen committen +- `git commit -- ` — nur diese Pfade committen +- `git commit --amend --no-edit` — letzten Commit um die Staging-Area erweitern +- `git commit --fixup ` — Fixup für späteres Autosquash + +## Remote +- `git fetch --all --prune` — alles holen, gelöschte Remote-Branches aufräumen +- `git pull --rebase` — holen und eigene Commits obendrauf setzen +- `git push -u origin ` — pushen und Upstream setzen +- `git push --force-with-lease` — sicheres Force-Push, nie plain `--force` + +## Rebase und Merge +- `git rebase main` — Branch auf aktuellen main setzen +- `git rebase -i --autosquash main` — interaktiv aufräumen +- `git rebase --continue` — nach gelöstem Konflikt weiter +- `git rebase --abort` — Rebase abbrechen, Ausgangszustand zurück +- `git merge --no-ff ` — Merge-Commit erzwingen +- `git merge --abort` — laufenden Merge abbrechen +- `git cherry-pick ` — einzelnen Commit übernehmen + +## Konflikte +- `git diff --name-only --diff-filter=U` — konfliktbehaftete Dateien +- `git checkout --ours ` — eigene Seite übernehmen +- `git checkout --theirs ` — fremde Seite übernehmen +- `git mergetool` — konfiguriertes Merge-Tool starten + +## Rückgängig +- `git restore ` — Datei auf HEAD zurücksetzen, Änderungen weg +- `git restore --staged ` — unstagen, Änderungen behalten +- `git reset --soft HEAD~1` — letzten Commit auflösen, Änderungen gestaged behalten +- `git reset --hard ` — alles verwerfen, nicht rückholbar +- `git revert ` — Commit durch Gegen-Commit aufheben +- `git reflog` — Referenzverlauf, um verlorene Commits wiederzufinden + +## Stash +- `git stash push -m ""` — Änderungen wegpacken +- `git stash push -u` — inklusive unversionierter Dateien +- `git stash list` — Stashes anzeigen +- `git stash pop` — letzten Stash zurückholen und entfernen +- `git stash apply ` — anwenden, aber behalten + +## Worktrees +- `git worktree list` — alle Worktrees +- `git worktree add ` — Worktree für bestehenden Branch +- `git worktree add -b ` — Worktree mit neuem Branch +- `git worktree remove ` — Worktree entfernen +- `git worktree prune` — verwaiste Einträge aufräumen + +## Suchen +- `git grep -n ""` — im Arbeitsbaum suchen +- `git log -S""` — Commits, die den Text einfügen oder entfernen +- `git blame -L 40,60 ` — Zeilenbereich zuordnen +- `git bisect start` — binäre Suche nach dem Fehler-Commit diff --git a/src/ClaudeDo.Ui/Assets/CheatSheets/powershell.md b/src/ClaudeDo.Ui/Assets/CheatSheets/powershell.md new file mode 100644 index 00000000..372748be --- /dev/null +++ b/src/ClaudeDo.Ui/Assets/CheatSheets/powershell.md @@ -0,0 +1,52 @@ +# PowerShell + +## Navigieren und Dateien +- `Get-ChildItem` — Verzeichnis auflisten (`ls`, `-Force` zeigt versteckte) +- `Set-Location ` — Verzeichnis wechseln (`cd`) +- `New-Item -ItemType Directory ` — Ordner anlegen (`mkdir`) +- `Copy-Item -Recurse` — kopieren +- `Move-Item ` — verschieben oder umbenennen +- `Remove-Item -Recurse -Force` — löschen, endgültig +- `Test-Path ` — existiert die Datei oder der Ordner? + +## Ansehen und Suchen +- `Get-Content -Tail 50` — letzte Zeilen (`-Wait` folgt wie tail -f) +- `Select-String "" -List` — in Dateien suchen (grep) +- `Get-ChildItem -Recurse -Filter *.log` — Dateien rekursiv finden +- ` | Select-Object -First 10` — Ausgabe begrenzen (head) +- ` | Where-Object Name -like "*test*"` — Ausgabe filtern +- ` | Measure-Object` — zählen (wc) +- ` | Out-GridView` — Ausgabe in sortierbarem Fenster + +## Prozesse und Dienste +- `Get-Process | Sort-Object CPU -Descending | Select-Object -First 10` — Top-Prozesse +- `Stop-Process -Name ` — Prozess beenden (`-Id ` per PID) +- `Get-Service ` — Dienststatus +- `Restart-Service ` — Dienst neu starten (Admin nötig) + +## Netzwerk +- `Test-NetConnection -Port 443` — Erreichbarkeit und Port testen +- `Get-NetTCPConnection -LocalPort ` — wer belegt den Port (OwningProcess = PID) +- `Invoke-RestMethod ` — HTTP-Aufruf, JSON kommt als Objekt zurück +- `Resolve-DnsName ` — DNS-Auflösung prüfen + +## Umgebung +- `$env:PATH -split ";"` — PATH lesbar ausgeben +- `$env:NAME = "wert"` — Variable für diese Session setzen +- `[Environment]::SetEnvironmentVariable("NAME", "wert", "User")` — dauerhaft setzen +- `Get-Command ` — wo liegt ein Befehl (which) +- `Get-History` — bisherige Befehle dieser Session + +## Windows-Spezifisches +- `explorer .` — aktuellen Ordner im Explorer öffnen +- `Start-Process ` — mit Standardprogramm öffnen +- `Start-Process pwsh -Verb RunAs` — Terminal als Administrator +- `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` — lokale Skripte erlauben +- `winget search ` — Software im Paketmanager suchen +- `winget install ` — installieren +- `winget upgrade --all` — alles aktualisieren + +## Hilfe +- `Get-Help -Examples` — Beispiele zu einem Cmdlet +- `Get-Member` — Eigenschaften eines Pipeline-Objekts anzeigen +- `Get-Alias ` — welches Cmdlet steckt hinter einem Alias diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 39c7367d..988ab37b 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -58,13 +58,25 @@ Design/ — Tokens.axaml (design tokens; merged before styles) | `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. | | `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. | | `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). | -| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. | +| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. The modal is **display + threshold drag only** — the PowerUse switch/window/handoff % it used to host moved to Settings → Execution (`GeneralSettingsTabViewModel`), and "Refresh now" is an icon in the titlebar via `ModalShell.HeaderActions` rather than a body row. | Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`, `UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`. +`CheatSheetsViewModel` backs the modeless `CheatSheetsWindow` (Help → Cheat Sheets): content is +markdown under `Assets/CheatSheets/*.md` as **EmbeddedResource** (not `AvaloniaResource` — +`AssetLoader` throws without an initialized Avalonia app, which would make the VM unconstructible +in tests), rendered through `MarkdownView`, with a `Topmost` pin so it can sit docked beside the +shell. A new sheet = a new `.md` file + its key in `CheatSheetsViewModel.Keys` (+ a menu entry if +it should be directly reachable). `MarkdownView` has no table/fenced-code support, so sheets are +bullet lists with inline code — rendered via its opt-in `CommandRows` property, which turns a +`` `command` — description `` bullet into a mono chip + copy button + description and wraps each +`##` section in a collapsible `Expander` (blocks are added to the open section's panel, not to the +host). The flag is opt-in because the other three `MarkdownView` call sites (prompt-file viewer, +weekly report, task-description preview) render prose where copy buttons would be noise. + ## Diff & Conflicts `UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified @@ -106,6 +118,9 @@ There is no null→bool converter — bind Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog sets the result on save/cancel, the caller awaits the TCS. +`ModalShell.HeaderActions` is the titlebar slot left of maximise/close — for a window-level action +that should stay visible without costing a body row (currently only the usage monitor's refresh). + `ModalShell` also owns the window chrome for every modal: the titlebar drag goes through `Window.BeginMoveDrag` (so Windows snap works — drag to the top edge to maximise; the OS only snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by the window's diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index 5492f80a..4b4dc211 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -36,6 +36,9 @@ + + diff --git a/src/ClaudeDo.Ui/Services/IDialogService.cs b/src/ClaudeDo.Ui/Services/IDialogService.cs index 725197af..b047c529 100644 --- a/src/ClaudeDo.Ui/Services/IDialogService.cs +++ b/src/ClaudeDo.Ui/Services/IDialogService.cs @@ -36,4 +36,7 @@ public interface IDialogService /// Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close. void ShowMissionControl(MissionControlViewModel vm); + + /// Show (or re-show + focus) the modeless cheat-sheet window for the shell's shared VM. + void ShowCheatSheets(CheatSheetsViewModel vm); } diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 51dafa72..b1ef68c2 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -515,6 +515,19 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm); } + // One shared VM so the sheets are parsed once and the window keeps its selection/pin + // across reopens; the window itself is single-instance in the dialog service. + private CheatSheetsViewModel? _cheatSheets; + + [RelayCommand] + private void OpenCheatSheet(string? key) + { + if (Dialogs is null) return; + _cheatSheets ??= new CheatSheetsViewModel(); + if (!string.IsNullOrEmpty(key)) _cheatSheets.Select(key); + Dialogs.ShowCheatSheets(_cheatSheets); + } + [RelayCommand] private async Task OpenLogVisualizer() { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/CheatSheetsViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/CheatSheetsViewModel.cs new file mode 100644 index 00000000..be2b56f5 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/CheatSheetsViewModel.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using ClaudeDo.Ui.Localization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +/// One cheat sheet: title from the file's leading # heading, body is the rest. +public sealed record CheatSheet(string Key, string Title, string Body); + +/// +/// Backs the non-modal cheat-sheet window (Help → Cheat Sheets). Content lives as markdown +/// under Assets/CheatSheets/*.md (embedded resources) and is rendered by the existing +/// MarkdownView, +/// so a new sheet is a new file — but the key list here stays explicit to keep the order +/// deterministic and a missing resource visible instead of silently absent. +/// +public partial class CheatSheetsViewModel : ViewModelBase +{ + private static readonly string[] Keys = + ["git", "conventional-commits", "claude-cli", "claude-config", "dotnet-cli", "powershell"]; + + public ObservableCollection Sheets { get; } = []; + + [ObservableProperty] + private CheatSheet? _selectedSheet; + + /// Keeps the window above the shell so it can sit docked at a screen edge. + [ObservableProperty] + private bool _isPinned; + + public Action? CloseAction { get; set; } + + public CheatSheetsViewModel() + { + foreach (var key in Keys) Sheets.Add(Load(key)); + SelectedSheet = Sheets.FirstOrDefault(); + } + + /// Preselects a sheet by key; unknown keys keep the current selection. + public void Select(string key) + { + var sheet = Sheets.FirstOrDefault(s => s.Key == key); + if (sheet is not null) SelectedSheet = sheet; + } + + [RelayCommand] + private void Close() => CloseAction?.Invoke(); + + private static CheatSheet Load(string key) + { + var assembly = typeof(CheatSheetsViewModel).Assembly; + // Match by suffix rather than composing the logical name from RootNamespace — the + // prefix is a build detail, and a rename shouldn't silently blank the sheet. + var name = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith($".CheatSheets.{key}.md", StringComparison.Ordinal)); + var stream = name is null ? null : assembly.GetManifestResourceStream(name); + if (stream is null) + return new CheatSheet(key, key, Loc.T("modals.cheatSheets.missing", $"{key}.md")); + + using var reader = new StreamReader(stream); + var text = reader.ReadToEnd().Replace("\r\n", "\n"); + + // The window shows the title in its own selector, so strip the leading heading + // rather than rendering it a second time inside the body. + var lines = new List(text.Split('\n')); + var title = key; + var first = lines.FindIndex(l => !string.IsNullOrWhiteSpace(l)); + if (first >= 0 && lines[first].StartsWith("# ", StringComparison.Ordinal)) + { + title = lines[first][2..].Trim(); + lines.RemoveRange(0, first + 1); + } + return new CheatSheet(key, title, string.Join('\n', lines).Trim()); + } +} diff --git a/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs index 08a218d1..8932ee0f 100644 --- a/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs @@ -1,10 +1,14 @@ +using System; using System.Text.RegularExpressions; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Documents; +using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Markup.Xaml; using Avalonia.Media; +using Avalonia.Threading; +using ClaudeDo.Ui.Localization; namespace ClaudeDo.Ui.Views.Controls; @@ -13,12 +17,27 @@ public partial class MarkdownView : UserControl public static readonly StyledProperty MarkdownProperty = AvaloniaProperty.Register(nameof(Markdown)); + /// + /// Renders a bullet that starts with inline code as a command row: the command as a + /// monospace chip with a copy button, the rest as a description below it, and a rule under + /// each ## section. Opt-in — the prompt-file viewer and the weekly report render + /// prose where nearly every bullet carries inline code, and copy buttons there are noise. + /// + public static readonly StyledProperty CommandRowsProperty = + AvaloniaProperty.Register(nameof(CommandRows)); + public string? Markdown { get => GetValue(MarkdownProperty); set => SetValue(MarkdownProperty, value); } + public bool CommandRows + { + get => GetValue(CommandRowsProperty); + set => SetValue(CommandRowsProperty, value); + } + private StackPanel? _host; public MarkdownView() @@ -34,6 +53,8 @@ public partial class MarkdownView : UserControl base.OnPropertyChanged(change); if (change.Property == MarkdownProperty) Render(change.GetNewValue()); + else if (change.Property == CommandRowsProperty) + Render(Markdown); } private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled); @@ -48,6 +69,9 @@ public partial class MarkdownView : UserControl return; var lines = md.Replace("\r\n", "\n").Split('\n'); + // In CommandRows mode every "## " opens a collapsible section and blocks land inside it; + // otherwise this stays the host for the whole document. + var target = _host; int i = 0; while (i < lines.Length) { @@ -56,19 +80,22 @@ public partial class MarkdownView : UserControl if (line.StartsWith("### ")) { - _host.Children.Add(Heading(line[4..], 13)); + target.Children.Add(Heading(line[4..], 13)); i++; continue; } if (line.StartsWith("## ")) { - _host.Children.Add(Heading(line[3..], 15)); + if (CommandRows) + target = OpenSection(line[3..]); + else + target.Children.Add(Heading(line[3..], 15)); i++; continue; } if (line.StartsWith("# ")) { - _host.Children.Add(Heading(line[2..], 17)); + target.Children.Add(Heading(line[2..], 17)); i++; continue; } @@ -77,7 +104,10 @@ public partial class MarkdownView : UserControl { while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* "))) { - _host.Children.Add(Bullet(lines[i][2..])); + var item = lines[i][2..]; + target.Children.Add(CommandRows && TrySplitCommand(item, out var cmd, out var desc) + ? CommandRow(cmd, desc) + : Bullet(item)); i++; } continue; @@ -89,7 +119,7 @@ public partial class MarkdownView : UserControl while (i < lines.Length && NumberedItem.IsMatch(lines[i])) { var m = NumberedItem.Match(lines[i]); - _host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value)); + target.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value)); i++; } continue; @@ -104,7 +134,7 @@ public partial class MarkdownView : UserControl paragraph.Append(lines[i].Trim()); i++; } - _host.Children.Add(Paragraph(paragraph.ToString())); + target.Children.Add(Paragraph(paragraph.ToString())); } } @@ -126,6 +156,120 @@ public partial class MarkdownView : UserControl return tb; } + /// + /// Adds a collapsible section for a ## heading and returns the panel its blocks go + /// into. Native — it carries the chevron, keyboard toggle and + /// animation, and the app already uses it unstyled in the worktrees overview. + /// + private StackPanel OpenSection(string title) + { + var header = new TextBlock + { + FontSize = 15, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap, + }; + AppendInlines(header.Inlines!, title); + + var body = new StackPanel { Spacing = 6 }; + _host!.Children.Add(new Expander + { + Header = header, + Content = body, + IsExpanded = true, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + }); + return body; + } + + /// + /// Splits `command` — description into its two parts. False when the bullet doesn't + /// open with inline code, so prose bullets keep the plain renderer. + /// + internal static bool TrySplitCommand(string text, out string command, out string description) + { + command = ""; + description = ""; + if (text.Length < 3 || text[0] != '`') return false; + var close = text.IndexOf('`', 1); + if (close <= 1) return false; + command = text[1..close]; + description = text[(close + 1)..].TrimStart(' ', '—', '–', '-', ':').Trim(); + return true; + } + + private Control CommandRow(string command, string description) + { + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + Margin = new Thickness(0, 2, 0, 2), + }; + + var text = new SelectableTextBlock { Text = command, TextWrapping = TextWrapping.Wrap }; + // Bound, not resolved: Render can run while detached, where a resource lookup finds + // nothing and would freeze on the fallback. + text.Bind(TextBlock.FontFamilyProperty, this.GetResourceObservable("MonoFont")); + text.Bind(TextBlock.FontSizeProperty, this.GetResourceObservable("FontSizeMono")); + + var chip = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(7, 4), + HorizontalAlignment = HorizontalAlignment.Left, + Child = text, + }; + chip.Bind(Border.BackgroundProperty, this.GetResourceObservable("Surface2Brush")); + + var stack = new StackPanel { Spacing = 3, Children = { chip } }; + if (description.Length > 0) + { + var desc = new TextBlock + { + Classes = { "body" }, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(2, 0, 0, 0), + }; + AppendInlines(desc.Inlines!, description); + stack.Children.Add(desc); + } + Grid.SetColumn(stack, 0); + grid.Children.Add(stack); + + var copy = CopyButton(command); + Grid.SetColumn(copy, 1); + grid.Children.Add(copy); + return grid; + } + + private Button CopyButton(string command) + { + var copyIcon = new PathIcon { Width = 11, Height = 11 }; + copyIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Copy")); + var checkIcon = new PathIcon { Width = 11, Height = 11 }; + checkIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Check")); + + var button = new Button + { + Classes = { "icon-btn" }, + Content = copyIcon, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(6, 0, 0, 0), + }; + ToolTip.SetTip(button, Loc.T("controls.markdown.copy")); + + button.Click += async (_, _) => + { + if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return; + await clipboard.SetTextAsync(command); + // The clipboard gives no feedback of its own; a brief check mark is the confirmation. + button.Content = checkIcon; + DispatcherTimer.RunOnce(() => button.Content = copyIcon, TimeSpan.FromSeconds(1.5)); + }; + return button; + } + private static Control Paragraph(string text) { var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index 846ffd2f..dc9c3458 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -80,6 +80,20 @@ + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Modals/CheatSheetsWindow.axaml b/src/ClaudeDo.Ui/Views/Modals/CheatSheetsWindow.axaml new file mode 100644 index 00000000..686b4ab0 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Modals/CheatSheetsWindow.axaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Modals/CheatSheetsWindow.axaml.cs b/src/ClaudeDo.Ui/Views/Modals/CheatSheetsWindow.axaml.cs new file mode 100644 index 00000000..80b7f959 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Modals/CheatSheetsWindow.axaml.cs @@ -0,0 +1,10 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace ClaudeDo.Ui.Views.Modals; + +public partial class CheatSheetsWindow : Window +{ + public CheatSheetsWindow() => InitializeComponent(); + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/ClaudeDo.Ui/Views/WindowDialogService.cs b/src/ClaudeDo.Ui/Views/WindowDialogService.cs index 37278aa5..86df0296 100644 --- a/src/ClaudeDo.Ui/Views/WindowDialogService.cs +++ b/src/ClaudeDo.Ui/Views/WindowDialogService.cs @@ -28,6 +28,7 @@ public sealed class WindowDialogService : IDialogService private readonly Window _owner; private MissionControlWindow? _missionControl; private DiffViewerView? _diffViewerWindow; + private CheatSheetsWindow? _cheatSheets; public WindowDialogService(Window owner) => _owner = owner; @@ -182,9 +183,29 @@ public sealed class WindowDialogService : IDialogService _missionControl ??= new MissionControlWindow { DataContext = vm }; if (!_missionControl.IsVisible) _missionControl.Show(); // modeless, independent top-level window + // Activate() alone does not restore a minimised window — it stays in the taskbar and the + // button looks dead. Un-minimise first, then take focus. + if (_missionControl.WindowState == WindowState.Minimized) + _missionControl.WindowState = WindowState.Normal; _missionControl.Activate(); // bring to front / focus } + public void ShowCheatSheets(CheatSheetsViewModel vm) + { + // Modeless and single-instance, like Mission Control: the point is to park it beside the + // shell, so a second window (or a modal that blocks the shell) would defeat it. + if (_cheatSheets is null) + { + _cheatSheets = new CheatSheetsWindow { DataContext = vm }; + vm.CloseAction = () => _cheatSheets?.Close(); + _cheatSheets.Closed += (_, _) => _cheatSheets = null; + _cheatSheets.Show(_owner); + } + if (_cheatSheets.WindowState == WindowState.Minimized) + _cheatSheets.WindowState = WindowState.Normal; + _cheatSheets.Activate(); + } + public Task ConfirmAsync(string message) { var tcs = new TaskCompletionSource(); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/CheatSheetsViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/CheatSheetsViewModelTests.cs new file mode 100644 index 00000000..d088d589 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/CheatSheetsViewModelTests.cs @@ -0,0 +1,34 @@ +using System.Linq; +using ClaudeDo.Ui.ViewModels.Modals; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class CheatSheetsViewModelTests +{ + [Fact] + public void LoadsEverySheetWithTitleFromHeading() + { + var vm = new CheatSheetsViewModel(); + + Assert.Equal(6, vm.Sheets.Count); + Assert.Equal( + ["Git", "Conventional Commits", "Claude CLI", "Claude Konfiguration", "dotnet CLI", "PowerShell"], + vm.Sheets.Select(s => s.Title)); + // The heading is stripped so the window doesn't render the title twice. + Assert.All(vm.Sheets, s => Assert.StartsWith("## ", s.Body)); + } + + [Fact] + public void SelectSwitchesSheetAndIgnoresUnknownKeys() + { + var vm = new CheatSheetsViewModel(); + Assert.Equal("git", vm.SelectedSheet!.Key); + + vm.Select("dotnet-cli"); + Assert.Equal("dotnet-cli", vm.SelectedSheet!.Key); + + vm.Select("nope"); + Assert.Equal("dotnet-cli", vm.SelectedSheet!.Key); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs index 65957521..752b685d 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs @@ -65,6 +65,7 @@ public class ListsIslandCreateListCancelTests : IDisposable public Task ConfirmAsync(string message) => Task.FromResult(true); public Task ShowErrorAsync(string message) => Task.CompletedTask; public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); + public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException(); } private sealed class ListSettingsProvider(Func create) : IServiceProvider diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs index 1bd236a9..34699d4a 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs @@ -63,6 +63,7 @@ public class TasksIslandMoveToListTests : IDisposable public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException(); public Task ShowErrorAsync(string message) => Task.CompletedTask; public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); + public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException(); } private TasksIslandViewModel BuildViewModel() => new(new TestDbFactory(NewContext), worker: null); diff --git a/tests/ClaudeDo.Ui.Tests/Views/MarkdownCommandRowTests.cs b/tests/ClaudeDo.Ui.Tests/Views/MarkdownCommandRowTests.cs new file mode 100644 index 00000000..4f824030 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Views/MarkdownCommandRowTests.cs @@ -0,0 +1,32 @@ +using ClaudeDo.Ui.Views.Controls; +using Xunit; + +namespace ClaudeDo.Ui.Tests.Views; + +/// +/// Covers the command-row split only — rendering itself needs an Avalonia app, which this +/// project has no host for. +/// +public class MarkdownCommandRowTests +{ + [Theory] + [InlineData("`git status -sb` — Kurzstatus mit Branch-Zeile", "git status -sb", "Kurzstatus mit Branch-Zeile")] + [InlineData("`git branch -d ` — löschen (`-D` erzwingt)", "git branch -d ", "löschen (`-D` erzwingt)")] + [InlineData("`dotnet build`", "dotnet build", "")] + public void SplitsCommandFromDescription(string line, string cmd, string desc) + { + Assert.True(MarkdownView.TrySplitCommand(line, out var actualCmd, out var actualDesc)); + Assert.Equal(cmd, actualCmd); + Assert.Equal(desc, actualDesc); + } + + [Theory] + [InlineData("Prosa ohne Befehl")] + [InlineData("Text mit `code` in der Mitte")] + [InlineData("`unclosed backtick")] + [InlineData("``")] + public void LeavesNonCommandBulletsToThePlainRenderer(string line) + { + Assert.False(MarkdownView.TrySplitCommand(line, out _, out _)); + } +}