# Offene Punkte — bei jedem Start prüfen und abhaken

jq -Rs '{systemMessage: ., hookSpecificOutput:{hookEventName:"SessionStart", additionalContext: .}}' /Users/marcus.hinz/claude-projects/ai-control/OFFENE-PUNKTE.md


# Offene Punkte — bei jedem Start prüfen und abhaken


Hey
eine erweiterung der App
Man kann bei Claude code Text nicht richtig aus dem Terminal kopieren (TUI)
Können wir unsere Terminal Fenter um eine (dockbares) panel erweitern (mit Mögflichkeit zur Ansicht / wegschalten) das Plain Text / MD kann, einen copy Button zu übernahme hat und die Möglichkeit bietet Teile zu selektieren und NORMAL zu kopieren?


CronCreate CronDelete CronList DesignSync EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Monitor NotebookEdit PushNotification RemoteTrigger SendMessage TaskCreate TaskGet TaskList TaskOutput TaskStop TaskUpdate WebFetch WebSearch


claude claude-code-guide Explore general-purpose Plan statusline-setup

- claude: Catch-all for any task that doesn't fit a more specific agent. FleetView's default when no agent name is typed. (Tools: *)

- claude-code-guide: Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - Messages API for directly passing messages to Claude, Tool Runner (`client.beta.messages.tool_runner`) for running an agentic loop over your own tools, manual tool-use loops, Managed Agents for server-hosted agents with a managed sandbox, prompt caching, and general Anthropic SDK usage; (4) Claude Tag (Claude in Slack) - what it is, setting it up for a Slack workspace, `/install-slack-app`. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage. (Tools: Bash, Read, WebFetch, WebSearch)

- Explore: Read-only search agent for broad fan-out searches — when answering means sweeping many files, directories, or naming conventions and you only need the conclusion, not the file dumps. It reads excerpts rather than whole files, so it locates code; it doesn't review or audit it. Specify search breadth: "medium" for moderate exploration, "very thorough" for multiple locations and naming conventions. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)

- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)


- git-commit: Commit + Push des ai-control-App-Repos auf lintus (origin, volle Dev-Historie). Optional, nur auf ausdrückliche Ansage: kuratierter Squash-Publish + Tag auf github und GitHub-Release mit Linux-Binaries. Gedacht für die Linux-Box.
- git-sync: Commit + Push aller persistent konfigurierten Projekt-Repos
- deep-research: Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. - When the user wants a deep, multi-source, fact-checked research report on any topic. BEFORE invoking, check if the question is specific enough to research directly — if underspecified (e.g., "what car to buy" without budget/use-case/region), ask 2-3 clarifying questions to narrow scope. Then pass the refined question as args, weaving the answers in.
- dataviz: Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: "chart", "graph", "plot", "data viz", "visualization", "dashboard", "analytics", "visualize data", "categorical colors", "sequential / diverging palette", "stat tile", "sparkline", "heatmap", "legend", "axis", "tooltip", "chart colors", "color by series".
- artifact-design: Design guidance and fundamentals for Artifacts.
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- verify: Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.
- code-review: Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud). Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review.
- simplify: Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring cloud agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.
TRIGGER — read BEFORE opening the target file; don't skip because it "looks like a one-liner" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).
SKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).
- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a GitHub pull request; for your working diff use /code-review
- security-review: Complete a security review of the pending changes on the current branch

git-commit git-sync deep-research dataviz artifact-design update-config keybindings-help verify code-review simplify fewer-permission-prompts loop schedule claude-api run init review security-review


Add dockable text panel with copy functionality to terminal


Ja, machbar.

Kern des Problems: Claude Code ist ein Vollbild-TUI, das den Terminalpuffer ständig neu zeichnet — die sichtbaren Zeilen sind kein zusammenhängender selektierbarer Text.

Der gangbare Weg für das Panel:

- **Zweiter Lesepfad auf denselben PTY-Stream.** Das Panel bekommt nicht das gerenderte TUI, sondern den rohen stdout-Stream, aus dem ANSI-/Cursor-Sequenzen entfernt werden. Ergebnis ist fortlaufender Plain Text, den ein normales `<textarea>`/DOM-Element mit nativer Selektion und Copy-Button hält.
- Alternativ, wenn das Terminal xterm.js ist: dessen Buffer-API (`buffer.active`, `getLine`) liefert den Scrollback zeilenweise; das Panel spiegelt ihn.
- MD-Rendering ist ein zusätzlicher Umschalter auf demselben Textinhalt (roh ⇄ gerendert).
- Dockbar + wegschaltbar ist reines Layout (Splitter/Toggle).

Welcher der beiden Wege der richtige ist, hängt vom Terminal-Stack der App ab (xterm.js vs. native PTY-Anbindung). Das müsste ich im Code nachsehen — sag Bescheid, dann schaue ich.


Nein
Einfacher:
Wenn Text angefordert wird, kann der speziell ins PÜanel geschrieben und dort kopiert werden
das Pnale NUR in diesem explititen Fall


Ja.

Dann braucht es nur einen Trigger, der genau diesen Fall markiert: ein Marker im stdout-Stream (z. B. eine eindeutige Sequenz um den Textblock). Die App erkennt Anfang/Ende, schneidet den Block heraus, schreibt ihn ins Panel, blendet es ein. Kein Mitspiegeln des restlichen Terminals.

Offen ist nur, wie der Marker gesetzt wird — Claude Code müsste den angeforderten Text in eine erkennbare Hülle schreiben. Das geht am ehesten über eine Konvention (fixe Delimiter-Zeilen), die die App aus dem Stream filtert.


Nein Einfacher: Wenn Text angefordert wird, kann der speziell ins PÜanel geschrieben und dort kopiert werden das Pnale NUR in diesem explititen Fall


NEIN
Immer noch nicht. Eher interaktiv als Skill
Claude schreibe einen ADR / Entwerfe eine Mail
For the time Being kann man probieren
1) es immer zu nutzen, wenn Texte entworfen werden sollen
2) explizit aufzufodern es dort zu platzieren
aber nicht den normalen Chat fluss


Verstanden.

Ein Skill schreibt den Entwurf nicht in den TUI-Stream, sondern über einen eigenen Kanal ins Panel — z. B. eine Datei an fixem Ort, die die App per Watcher liest und im Panel anzeigt (Plain/MD, Selektion, Copy). Der normale Chatfluss bleibt unberührt.

Auslösung wie von dir genannt:
1) Der Skill triggert automatisch, sobald ein längerer Text entworfen wird (ADR, Mail, …).
2) Oder explizit: „schreib das ins Panel".

Der Skill ist damit der Marker — kein Stream-Parsing, kein Delimiter-Gefummel. Die App muss nur eine Quelle (Datei/Socket) beobachten und das Panel bei neuem Inhalt einblenden.


Genau
machbar?
Linux / Windows / Mac UND Schön?


Ja.

- **Panel schön:** Wenn das Frontend web-basiert ist (dein `cfg(target_os)` deutet auf Rust, plausibel Tauri), ist das Panel eine reguläre Web-View — Markdown-Rendering, saubere Selektion, Copy-Button sind damit trivial und optisch frei gestaltbar.
- **Datei-Watcher / Kanal:** plattformneutral (Rust `notify` o. ä.), keine OS-Sonderpfade nötig.
- **Skill:** reines Textfile-Schreiben, plattformunabhängig.

Einzige Einschränkung: **Windows** ist bisher kein Zielsystem (Memory: macOS → Linux). Der Panel-Teil selbst wäre plattformneutral, aber ob die Gesamt-App unter Windows baut/läuft, ist offen — das hängt am restlichen macOS/Linux-spezifischen Code, nicht am Panel.

Welcher Stack tatsächlich drunterliegt, müsste ich im Code verifizieren, bevor ich „ja" hart zusage.


Dann prüfen aktuellen Stand
Windows ist erstellt
lass ein git Update laufen


Bash

git fetch --all --prune 2>&1 && echo "---STATUS---" && git status && echo "---BRANCHES---" && git branch -avv

Git fetch und Status prüfen


echo "---ROOT---" && ls -la && echo "---CARGO---" && cat Cargo.toml 2>/dev/null | head -60

Repo-Root und Stack prüfen


---STATUS---
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
---BRANCHES---
* main                92ec59e [origin/main] Initial commit
  remotes/origin/HEAD -> origin/main
  remotes/origin/main 92ec59e Initial commit


---ROOT---
total 720
drwxr-xr-x  22 marcus.hinz  staff    704 Jul  9 10:26 .
drwxr-xr-x  11 marcus.hinz  staff    352 Jul 10 13:34 ..
-rw-r--r--@  1 marcus.hinz  staff   8196 Jul  5 15:39 .DS_Store
drwxr-xr-x   5 marcus.hinz  staff    160 Jul  7 09:56 .claude
drwxr-xr-x  13 marcus.hinz  staff    416 Jul 11 10:48 .git
-rw-r--r--   1 marcus.hinz  staff     20 Jul  3 20:24 .gitignore
-rw-r--r--   1 marcus.hinz  staff     56 Jul  5 11:07 OFFENE-PUNKTE.md
-rw-r--r--@  1 marcus.hinz  staff  58184 Jul  5 14:51 Screenshot 2026-07-05 at 14.32.05.png
-rw-r--r--@  1 marcus.hinz  staff  55393 Jul  5 14:37 Screenshot 2026-07-05 at 14.37.25.png
-rw-r--r--@  1 marcus.hinz  staff  60056 Jul  5 14:44 Screenshot 2026-07-05 at 14.37.55.png
-rw-r--r--@  1 marcus.hinz  staff  25629 Jul  5 14:38 Screenshot 2026-07-05 at 14.38.13.png
-rw-r--r--@  1 marcus.hinz  staff  25822 Jul  5 14:38 Screenshot 2026-07-05 at 14.38.27.png
-rw-r--r--@  1 marcus.hinz  staff  65740 Jul  5 14:46 Screenshot 2026-07-05 at 14.39.04.png
-rw-------   1 marcus.hinz  staff     98 Jul  5 15:04 ai-control.json
drwxr-xr-x   8 marcus.hinz  staff    256 Jul  3 17:09 bin
-rw-r--r--@  1 marcus.hinz  staff  17565 Jul  6 08:15 icon-aic-512.png
-rw-r--r--   1 marcus.hinz  staff    839 Jul  5 10:58 icon-aic.svg
drwxr-xr-x  10 marcus.hinz  staff    320 Jul  5 19:20 memory
-rwxr-xr-x   1 marcus.hinz  staff   1606 Jul  6 16:00 migrate-linux.sh
drwxr-xr-x   3 marcus.hinz  staff     96 Jul  7 12:01 release
-rwxr-xr-x   1 marcus.hinz  staff   1246 Jul  5 19:04 setup-config.sh
-rwxr-xr-x   1 marcus.hinz  staff    106 Jul  5 19:16 start-claude.sh
---CARGO---


echo "===== ~/projects/ai-control =====" && cd /Users/marcus.hinz/projects/ai-control && git fetch --all --prune 2>&1 && echo "---STATUS---" && git status && echo "---LOG---" && git log --oneline -10 && echo "---BRANCHES---" && git branch -avv

Git fetch/status im App-Repo


cd /Users/marcus.hinz/projects/ai-control && echo "---ROOT---" && ls -la && echo "---CARGO---" && cat Cargo.toml 2>/dev/null | head -80 && echo "---TAURI?---" && find . -maxdepth 3 -name "tauri.conf.json" -o -maxdepth 3 -name "package.json" 2>/dev/null | grep -v node_modules | head

Stack im App-Repo prüfen


===== ~/projects/ai-control =====
Fetching origin
Fetching github
---STATUS---
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
---LOG---
c39330b Tray auf HTML-Popup vereinheitlicht (macOS/Windows), Version 0.2.0
8434255 Hauptfenster: Header fest, nur Inhalt scrollt
d3231cc git-sync: 2026-07-06 22:34
1df0da4 Linux-Port: GNOME-Popup + Tray, Terminal-Schriftgröße global
85c18eb Version 0.1.1
c5fca16 Terminal-Header: Projekt-Icon vor dem Titel, Pool-Chip zeigt Anzeigenamen statt Pool-ID (neues Command pool_label); project_icon/pool_label im Command-Satz des Terminal-Prozesses registriert
63c8062 repository-URL: github.com/mHinz-hub/ai-control
fb7eab1 Buy-me-a-coffee-Link (marcusH) im Installationsabsatz + .github/FUNDING.yml für den GitHub-Sponsor-Button
f83231a DMG-Bundle im Build (CI=true wegen TCC-blockiertem Finder-AppleScript); README: Installation mit Quarantäne-Hinweis, xattr-Befehl und Sponsoring-Bemerkung zur fehlenden Signierung
438ee69 Version durchgängig 0.1.0
---BRANCHES---
* main                c39330b [origin/main] Tray auf HTML-Popup vereinheitlicht (macOS/Windows), Version 0.2.0
  remotes/github/HEAD -> github/main
  remotes/github/main 1f526e1 ai-control 0.2.0
  remotes/origin/HEAD -> origin/main
  remotes/origin/main c39330b Tray auf HTML-Popup vereinheitlicht (macOS/Windows), Version 0.2.0


Dann prüfen aktuellen Stand Windows ist erstellt lass ein git Update laufen


---ROOT---
total 264
drwxr-xr-x  27 marcus.hinz  staff    864 Jul  7 09:08 .
drwxr-xr-x  18 marcus.hinz  staff    576 Jul  4 14:50 ..
-rw-r--r--@  1 marcus.hinz  staff   8196 Jul  4 21:39 .DS_Store
drwxr-xr-x  14 marcus.hinz  staff    448 Jul 11 10:48 .git
drwxr-xr-x   3 marcus.hinz  staff     96 Jul  5 16:20 .github
-rw-r--r--   1 marcus.hinz  staff    253 Jul  3 08:48 .gitignore
drwxr-xr-x   8 marcus.hinz  staff    256 Jul  5 16:34 .idea
drwxr-xr-x   3 marcus.hinz  staff     96 Jul  3 08:48 .vscode
-rw-r--r--   1 marcus.hinz  staff   1068 Jul  5 14:21 LICENSE
-rw-r--r--   1 marcus.hinz  staff   6591 Jul  5 16:20 README.md
-rwxr-xr-x   1 marcus.hinz  staff   1216 Jul  5 16:01 build.sh
-rwxr-xr-x   1 marcus.hinz  staff    245 Jul  3 08:50 dev.sh
drwxr-xr-x   8 marcus.hinz  staff    256 Jul  7 09:23 dist
drwxr-xr-x   7 marcus.hinz  staff    224 Jul  5 14:49 docs
-rw-r--r--   1 marcus.hinz  staff    360 Jul  3 08:48 index.html
drwxr-xr-x  53 marcus.hinz  staff   1696 Jul  4 12:49 node_modules
-rw-r--r--   1 marcus.hinz  staff  58949 Jul  7 07:51 package-lock.json
-rw-r--r--   1 marcus.hinz  staff    900 Jul  7 09:08 package.json
-rw-r--r--   1 marcus.hinz  staff    327 Jul  7 07:51 popup.html
drwxr-xr-x   4 marcus.hinz  staff    128 Jul  3 08:48 public
drwxr-xr-x  10 marcus.hinz  staff    320 Jul  7 07:51 src
drwxr-xr-x  13 marcus.hinz  staff    416 Jul  7 09:09 src-tauri
-rw-r--r--   1 marcus.hinz  staff   5623 Jul  7 07:51 terminal.html
-rw-r--r--   1 marcus.hinz  staff    430 Jul  3 08:48 tsconfig.app.json
-rw-r--r--   1 marcus.hinz  staff    119 Jul  3 08:48 tsconfig.json
-rw-r--r--   1 marcus.hinz  staff    558 Jul  3 08:48 tsconfig.node.json
-rw-r--r--   1 marcus.hinz  staff   1088 Jul  7 07:51 vite.config.ts
---CARGO---
---TAURI?---
./package.json
./src-tauri/tauri.conf.json


Git-Update gelaufen, beide Repos (`origin` + `github`) sauber und aktuell — nichts zu pullen.

**Stand bestätigt:**
- Stack ist **Tauri** (`src-tauri/tauri.conf.json`) mit Web-Frontend (Vite, `terminal.html`, `popup.html`, `src/`).
- Letzter Commit `c39330b`: „Tray auf HTML-Popup vereinheitlicht (macOS/**Windows**)" — Windows ist als Ziel drin.

**Für dein Panel heißt das:**
- Machbar und schön: Das Panel ist ein weiteres DOM-Element/Web-View im bestehenden Frontend — MD-Rendering, native Selektion, Copy-Button frei gestaltbar.
- **Cross-platform inkl. Windows:** ja. Tauri-Frontend ist plattformneutral; der Datei-Watcher läuft über Rust (`notify`) ohne OS-Sonderpfade.
- Skill schreibt in ein Textfile, App beobachtet es, Panel blendet ein.

Hard bestätigt: der Panel-Teil ist auf allen drei Systemen gleich umsetzbar.


ok -- kann man den Dockbar machen (auch als eigenes Fenster und wieder dransetbar)?


Ja.

- **Angedockt:** Das Panel ist ein Split-Pane im Hauptfenster (CSS-Layout, Splitter zum Ziehen).
- **Als eigenes Fenster:** Tauri kann per `WebviewWindow` ein zweites Fenster öffnen, das denselben Panel-Inhalt lädt. Da die Quelle das beobachtete Textfile ist, rendern beide automatisch dasselbe — kein State-Syncing nötig.
- **Wieder dransetzen:** Zweitfenster schließen, Split-Pane wieder einblenden. Ein Toggle-Button „ablösen / andocken".

Eine Einschränkung zur Erwartung: **echtes Drag-and-Dock** (Fenster per Maus an eine Kante ziehen, das dann einschnappt) bringt Tauri nicht mit — das müsste man selbst bauen. Der gängige, robuste Weg ist der Toggle (Button statt Ziehen). Angedockt/abgelöst/wieder-angedockt als Zustände: problemlos.


dann los


Go verstanden. Erst den echten Code-Aufbau lesen (kein Starten der App — nur lesen/editieren), dann Panel + Skill bauen.


echo "===src===" && ls -la src && echo "===src-tauri===" && ls -la src-tauri && echo "===src-tauri/src===" && ls -la src-tauri/src && echo "===package.json===" && cat package.json && echo "===vite.config===" && cat vite.config.ts

Projektstruktur src und src-tauri lesen


===src===
total 112
drwxr-xr-x  10 marcus.hinz  staff    320 Jul  7 07:51 .
drwxr-xr-x  27 marcus.hinz  staff    864 Jul  7 09:08 ..
-rw-r--r--   1 marcus.hinz  staff   5689 Jul  7 07:51 App.vue
drwxr-xr-x   2 marcus.hinz  staff     64 Jul  3 11:18 assets
drwxr-xr-x   6 marcus.hinz  staff    192 Jul  7 07:51 components
-rw-r--r--   1 marcus.hinz  staff   9648 Jul  5 15:36 i18n.ts
-rw-r--r--   1 marcus.hinz  staff    246 Jul  4 12:49 main.ts
-rw-r--r--   1 marcus.hinz  staff    110 Jul  7 07:51 popup.ts
-rw-r--r--   1 marcus.hinz  staff  11601 Jul  7 07:51 style.css
-rw-r--r--   1 marcus.hinz  staff  15159 Jul  7 07:51 terminal.ts
===src-tauri===
total 328
drwxr-xr-x  13 marcus.hinz  staff     416 Jul  7 09:09 .
drwxr-xr-x  27 marcus.hinz  staff     864 Jul  7 09:08 ..
-rw-r--r--@  1 marcus.hinz  staff    8196 Jul  4 21:39 .DS_Store
-rw-r--r--   1 marcus.hinz  staff      86 Jul  3 08:49 .gitignore
-rw-r--r--   1 marcus.hinz  staff  137515 Jul  7 09:09 Cargo.lock
-rw-r--r--   1 marcus.hinz  staff    1267 Jul  7 09:08 Cargo.toml
-rw-r--r--   1 marcus.hinz  staff      37 Jul  3 08:49 build.rs
drwxr-xr-x   3 marcus.hinz  staff      96 Jul  7 07:51 capabilities
drwxr-xr-x   3 marcus.hinz  staff      96 Jul  3 08:52 gen
drwxr-xr-x  23 marcus.hinz  staff     736 Jul  7 07:51 icons
drwxr-xr-x   5 marcus.hinz  staff     160 Jul  7 08:46 src
drwxr-xr-x@  7 marcus.hinz  staff     224 Jul  4 21:39 target
-rw-r--r--   1 marcus.hinz  staff     713 Jul  7 09:08 tauri.conf.json
===src-tauri/src===
total 240
drwxr-xr-x   5 marcus.hinz  staff     160 Jul  7 08:46 .
drwxr-xr-x  13 marcus.hinz  staff     416 Jul  7 09:09 ..
-rw-r--r--   1 marcus.hinz  staff  105353 Jul  7 08:46 lib.rs
-rw-r--r--   1 marcus.hinz  staff     177 Jul  3 08:49 main.rs
-rw-r--r--   1 marcus.hinz  staff   10872 Jul  7 07:51 terminal.rs
===package.json===
{
  "name": "ai-control",
  "private": true,
  "version": "0.2.0",
  "license": "MIT",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview",
    "tauri": "tauri"
  },
  "dependencies": {
    "@fontsource/jetbrains-mono": "^5.2.8",
    "@tauri-apps/api": "^2.11.1",
    "@tauri-apps/plugin-autostart": "^2.5.1",
    "@tauri-apps/plugin-dialog": "^2.7.1",
    "@tauri-apps/plugin-opener": "^2.5.4",
    "@xterm/addon-fit": "^0.11.0",
    "@xterm/addon-ligatures": "^0.10.0",
    "@xterm/addon-webgl": "^0.19.0",
    "@xterm/xterm": "^6.0.0",
    "vue": "^3.5.39",
    "vue-i18n": "^11.4.6"
  },
  "devDependencies": {
    "@tauri-apps/cli": "^2.11.4",
    "@types/node": "^24.13.2",
    "@vitejs/plugin-vue": "^6.0.7",
    "@vue/tsconfig": "^0.9.1",
    "typescript": "~6.0.2",
    "vite": "^8.1.1",
    "vue-tsc": "^3.3.5"
  }
}
===vite.config===
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

const host = process.env.TAURI_DEV_HOST;

// https://vite.dev/config/
export default defineConfig(async () => ({
  plugins: [vue()],

  // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
  //
  // 1. prevent Vite from obscuring rust errors
  clearScreen: false,
  // Zweite Seite: Terminal-Fenster (terminal.html)
  build: {
    rollupOptions: {
      input: {
        main: new URL("./index.html", import.meta.url).pathname,
        terminal: new URL("./terminal.html", import.meta.url).pathname,
        popup: new URL("./popup.html", import.meta.url).pathname,
      },
    },
  },
  // 2. tauri expects a fixed port, fail if that port is not available
  server: {
    port: 1420,
    strictPort: true,
    host: host || false,
    hmr: host
      ? {
          protocol: "ws",
          host,
          port: 1421,
        }
      : undefined,
    watch: {
      // 3. tell Vite to ignore watching `src-tauri`
      ignored: ["**/src-tauri/**"],
    },
  },
}));


1	<!doctype html>
2
3
4	    <meta charset="UTF-8" />
5	    <title>Terminal</title>
6
187
188
189
190
191
192
193

194	        <button class="winbtn" id="win-min" aria-label="Minimieren">
195	          <svg width="12" height="12" viewBox="0 0 12 12"><line x1="2" y1="6.5" x2="10" y2="6.5" /></svg>
196	        </button>
197	        <button class="winbtn" id="win-max" aria-label="Maximieren">
198	          <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" rx="1" /></svg>
199	        </button>
200	        <button class="winbtn close" id="win-close" aria-label="Schließen">
201	          <svg width="12" height="12" viewBox="0 0 12 12"><line x1="2.7" y1="2.7" x2="9.3" y2="9.3" /><line x1="9.3" y1="2.7" x2="2.7" y2="9.3" /></svg>
202	        </button>
203

204
205

206

207

208

209

210

211

212

213

214

215

216
217
218
219

<!doctype html>

    <meta charset="UTF-8" />
    <title>Terminal</title>

        <button class="winbtn" id="win-min" aria-label="Minimieren">
          <svg width="12" height="12" viewBox="0 0 12 12"><line x1="2" y1="6.5" x2="10" y2="6.5" /></svg>
        </button>
        <button class="winbtn" id="win-max" aria-label="Maximieren">
          <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" rx="1" /></svg>
        </button>
        <button class="winbtn close" id="win-close" aria-label="Schließen">
          <svg width="12" height="12" viewBox="0 0 12 12"><line x1="2.7" y1="2.7" x2="9.3" y2="9.3" /><line x1="9.3" y1="2.7" x2="2.7" y2="9.3" /></svg>
        </button>


1	{
2	  "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
3	  "productName": "ai-control",
4	  "version": "0.2.0",
5	  "identifier": "com.aicontrol.app",
6	  "build": {
7	    "frontendDist": "../dist",
8	    "devUrl": "http://localhost:1420",
9	    "beforeDevCommand": "npm run dev",
10	    "beforeBuildCommand": "npm run build"
11	  },
12	  "app": {
13	    "macOSPrivateApi": true,
14	    "windows": [],
15	    "security": {
16	      "csp": null
17	    }
18	  },
19	  "bundle": {
20	    "active": true,
21	    "targets": "all",
22	    "icon": [
23	      "icons/32x32.png",
24	      "icons/128x128.png",
25	      "icons/128x128@2x.png",
26	      "icons/icon.icns",
27	      "icons/icon.ico"
28	    ],
29	    "android": {
30	      "debugApplicationIdSuffix": ".debug"
31	    }
32	  }
33	}
34


1	import { Terminal } from "@xterm/xterm";
2	import { FitAddon } from "@xterm/addon-fit";
3	import { WebglAddon } from "@xterm/addon-webgl";
4	import { LigaturesAddon } from "@xterm/addon-ligatures";
5	import "@xterm/xterm/css/xterm.css";
6	import "@fontsource/jetbrains-mono/400.css";
7	import "@fontsource/jetbrains-mono/500.css";
8	import "@fontsource/jetbrains-mono/600.css";
9	import "@fontsource/jetbrains-mono/700.css";
10	import { invoke } from "@tauri-apps/api/core";
11	import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
12
13	// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
14	function showError(msg: string) {
15	  const el = document.createElement("pre");
16	  el.style.cssText =
17	    "color:#f38ba8;padding:16px;font:12px Menlo,monospace;white-space:pre-wrap";
18	  el.textContent = msg;
19	  document.body.appendChild(el);
20	  invoke("term_log", { msg });
21	}
22	window.addEventListener("error", (e) =>
23	  showError(`error: ${e.message}\n${e.error?.stack ?? ""}`),
24	);
25	window.addEventListener("unhandledrejection", (e) =>
26	  showError(`rejection: ${e.reason}\n${e.reason?.stack ?? ""}`),
27	);
28
29	const project = new URLSearchParams(location.search).get("project")!;
30	const win = getCurrentWebviewWindow();
31
32	// macOS nutzt die native Ampel (Overlay-Titelbar); sonst eigene Fensterknöpfe
33	// im Header (Linux ist dekorationslos, siehe terminal.rs).
34	const isMac = /Mac|Macintosh/.test(navigator.userAgent);
35	document.documentElement.dataset.platform = isMac ? "mac" : "other";
36	if (!isMac) {
37	  document
38	    .getElementById("win-min")
39	    ?.addEventListener("click", () => win.minimize());
40	  document
41	    .getElementById("win-max")
42	    ?.addEventListener("click", () => win.toggleMaximize());
43	  document
44	    .getElementById("win-close")
45	    ?.addEventListener("click", () => win.close());
46	  // Resize-Zonen: dekorationslose Fenster haben keinen nativen Rand.
47	  for (const g of document.querySelectorAll<HTMLElement>(".grip")) {
48	    g.addEventListener("mousedown", (e) => {
49	      e.preventDefault();
50	      win.startResizeDragging(g.dataset.dir as never);
51	    });
52	  }
53	}
54
55	// Fensterhintergrund beim Öffnen kommt aus terminal.rs (theme_background);
56	// dort nicht gepflegte Themes fallen auf Mocha zurück (nur kurzer Moment
57	// bis die Webview lädt).
58	const THEMES: Record<
59	  string,
60	  { header: string; border: string; xterm: Record<string, string> }
61	> = {
62	  mocha: {
63	    header: "#181825",
64	    border: "#313244",
65	    xterm: {
66	      background: "#1e1e2e",
67	      foreground: "#cdd6f4",
68	      cursor: "#f5e0dc",
69	      cursorAccent: "#1e1e2e",
70	      selectionBackground: "#585b7080",
71	      black: "#45475a",
72	      red: "#f38ba8",
73	      green: "#a6e3a1",
74	      yellow: "#f9e2af",
75	      blue: "#89b4fa",
76	      magenta: "#f5c2e7",
77	      cyan: "#94e2d5",
78	      white: "#bac2de",
79	      brightBlack: "#585b70",
80	      brightRed: "#f38ba8",
81	      brightGreen: "#a6e3a1",
82	      brightYellow: "#f9e2af",
83	      brightBlue: "#89b4fa",
84	      brightMagenta: "#f5c2e7",
85	      brightCyan: "#94e2d5",
86	      brightWhite: "#a6adc8",
87	    },
88	  },
89	  dracula: {
90	    header: "#21222c",
91	    border: "#44475a",
92	    xterm: {
93	      background: "#282a36",
94	      foreground: "#f8f8f2",
95	      cursor: "#f8f8f2",
96	      cursorAccent: "#282a36",
97	      selectionBackground: "#44475a80",
98	      black: "#21222c",
99	      red: "#ff5555",
100	      green: "#50fa7b",
101	      yellow: "#f1fa8c",
102	      blue: "#bd93f9",
103	      magenta: "#ff79c6",
104	      cyan: "#8be9fd",
105	      white: "#f8f8f2",
106	      brightBlack: "#6272a4",
107	      brightRed: "#ff6e6e",
108	      brightGreen: "#69ff94",
109	      brightYellow: "#ffffa5",
110	      brightBlue: "#d6acff",
111	      brightMagenta: "#ff92df",
112	      brightCyan: "#a4ffff",
113	      brightWhite: "#ffffff",
114	    },
115	  },
116	  "solarized-dark": {
117	    header: "#073642",
118	    border: "#586e75",
119	    xterm: {
120	      background: "#002b36",
121	      foreground: "#839496",
122	      cursor: "#839496",
123	      cursorAccent: "#002b36",
124	      selectionBackground: "#07364280",
125	      black: "#073642",
126	      red: "#dc322f",
127	      green: "#859900",
128	      yellow: "#b58900",
129	      blue: "#268bd2",
130	      magenta: "#d33682",
131	      cyan: "#2aa198",
132	      white: "#eee8d5",
133	      brightBlack: "#586e75",
134	      brightRed: "#cb4b16",
135	      brightGreen: "#859900",
136	      brightYellow: "#657b83",
137	      brightBlue: "#839496",
138	      brightMagenta: "#6c71c4",
139	      brightCyan: "#93a1a1",
140	      brightWhite: "#fdf6e3",
141	    },
142	  },
143	  gruvbox: {
144	    header: "#1d2021",
145	    border: "#3c3836",
146	    xterm: {
147	      background: "#282828",
148	      foreground: "#ebdbb2",
149	      cursor: "#ebdbb2",
150	      cursorAccent: "#282828",
151	      selectionBackground: "#3c383680",
152	      black: "#282828",
153	      red: "#cc241d",
154	      green: "#98971a",
155	      yellow: "#d79921",
156	      blue: "#458588",
157	      magenta: "#b16286",
158	      cyan: "#689d6a",
159	      white: "#a89984",
160	      brightBlack: "#928374",
161	      brightRed: "#fb4934",
162	      brightGreen: "#b8bb26",
163	      brightYellow: "#fabd2f",
164	      brightBlue: "#83a598",
165	      brightMagenta: "#d3869b",
166	      brightCyan: "#8ec07c",
167	      brightWhite: "#ebdbb2",
168	    },
169	  },
170	  "one-dark": {
171	    header: "#21252b",
172	    border: "#3e4451",
173	    xterm: {
174	      background: "#282c34",
175	      foreground: "#abb2bf",
176	      cursor: "#abb2bf",
177	      cursorAccent: "#282c34",
178	      selectionBackground: "#3e445180",
179	      black: "#282c34",
180	      red: "#e06c75",
181	      green: "#98c379",
182	      yellow: "#e5c07b",
183	      blue: "#61afef",
184	      magenta: "#c678dd",
185	      cyan: "#56b6c2",
186	      white: "#abb2bf",
187	      brightBlack: "#5c6370",
188	      brightRed: "#e06c75",
189	      brightGreen: "#98c379",
190	      brightYellow: "#e5c07b",
191	      brightBlue: "#61afef",
192	      brightMagenta: "#c678dd",
193	      brightCyan: "#56b6c2",
194	      brightWhite: "#ffffff",
195	    },
196	  },
197	  "solarized-light": {
198	    header: "#eee8d5",
199	    border: "#93a1a1",
200	    xterm: {
201	      background: "#fdf6e3",
202	      foreground: "#657b83",
203	      cursor: "#657b83",
204	      cursorAccent: "#fdf6e3",
205	      selectionBackground: "#eee8d5b0",
206	      black: "#073642",
207	      red: "#dc322f",
208	      green: "#859900",
209	      yellow: "#b58900",
210	      blue: "#268bd2",
211	      magenta: "#d33682",
212	      cyan: "#2aa198",
213	      white: "#eee8d5",
214	      brightBlack: "#002b36",
215	      brightRed: "#cb4b16",
216	      brightGreen: "#586e75",
217	      brightYellow: "#657b83",
218	      brightBlue: "#839496",
219	      brightMagenta: "#6c71c4",
220	      brightCyan: "#93a1a1",
221	      brightWhite: "#fdf6e3",
222	    },
223	  },
224	  "catppuccin-latte": {
225	    header: "#e6e9ef",
226	    border: "#ccd0da",
227	    xterm: {
228	      background: "#eff1f5",
229	      foreground: "#4c4f69",
230	      cursor: "#dc8a78",
231	      cursorAccent: "#eff1f5",
232	      selectionBackground: "#acb0be80",
233	      black: "#5c5f77",
234	      red: "#d20f39",
235	      green: "#40a02b",
236	      yellow: "#df8e1d",
237	      blue: "#1e66f5",
238	      magenta: "#ea76cb",
239	      cyan: "#179299",
240	      white: "#acb0be",
241	      brightBlack: "#6c6f85",
242	      brightRed: "#d20f39",
243	      brightGreen: "#40a02b",
244	      brightYellow: "#df8e1d",
245	      brightBlue: "#1e66f5",
246	      brightMagenta: "#ea76cb",
247	      brightCyan: "#179299",
248	      brightWhite: "#bcc0cc",
249	    },
250	  },
251	  "one-light": {
252	    header: "#eaeaeb",
253	    border: "#d4d4d4",
254	    xterm: {
255	      background: "#fafafa",
256	      foreground: "#383a42",
257	      cursor: "#526eff",
258	      cursorAccent: "#fafafa",
259	      selectionBackground: "#e5e5e6b0",
260	      black: "#383a42",
261	      red: "#e45649",
262	      green: "#50a14f",
263	      yellow: "#c18401",
264	      blue: "#4078f2",
265	      magenta: "#a626a4",
266	      cyan: "#0184bc",
267	      white: "#a0a1a7",
268	      brightBlack: "#696c77",
269	      brightRed: "#e45649",
270	      brightGreen: "#50a14f",
271	      brightYellow: "#c18401",
272	      brightBlue: "#4078f2",
273	      brightMagenta: "#a626a4",
274	      brightCyan: "#0184bc",
275	      brightWhite: "#ffffff",
276	    },
277	  },
278	  nord: {
279	    header: "#3b4252",
280	    border: "#4c566a",
281	    xterm: {
282	      background: "#2e3440",
283	      foreground: "#d8dee9",
284	      cursor: "#d8dee9",
285	      cursorAccent: "#2e3440",
286	      selectionBackground: "#434c5e80",
287	      black: "#3b4252",
288	      red: "#bf616a",
289	      green: "#a3be8c",
290	      yellow: "#ebcb8b",
291	      blue: "#81a1c1",
292	      magenta: "#b48ead",
293	      cyan: "#88c0d0",
294	      white: "#e5e9f0",
295	      brightBlack: "#4c566a",
296	      brightRed: "#bf616a",
297	      brightGreen: "#a3be8c",
298	      brightYellow: "#ebcb8b",
299	      brightBlue: "#81a1c1",
300	      brightMagenta: "#b48ead",
301	      brightCyan: "#8fbcbb",
302	      brightWhite: "#eceff4",
303	    },
304	  },
305	  "tokyo-night": {
306	    header: "#16161e",
307	    border: "#292e42",
308	    xterm: {
309	      background: "#1a1b26",
310	      foreground: "#c0caf5",
311	      cursor: "#c0caf5",
312	      cursorAccent: "#1a1b26",
313	      selectionBackground: "#28345780",
314	      black: "#15161e",
315	      red: "#f7768e",
316	      green: "#9ece6a",
317	      yellow: "#e0af68",
318	      blue: "#7aa2f7",
319	      magenta: "#bb9af7",
320	      cyan: "#7dcfff",
321	      white: "#a9b1d6",
322	      brightBlack: "#414868",
323	      brightRed: "#f7768e",
324	      brightGreen: "#9ece6a",
325	      brightYellow: "#e0af68",
326	      brightBlue: "#7aa2f7",
327	      brightMagenta: "#bb9af7",
328	      brightCyan: "#7dcfff",
329	      brightWhite: "#c0caf5",
330	    },
331	  },
332	  monokai: {
333	    header: "#1e1f1c",
334	    border: "#49483e",
335	    xterm: {
336	      background: "#272822",
337	      foreground: "#f8f8f2",
338	      cursor: "#f8f8f2",
339	      cursorAccent: "#272822",
340	      selectionBackground: "#49483e80",
341	      black: "#272822",
342	      red: "#f92672",
343	      green: "#a6e22e",
344	      yellow: "#e6db74",
345	      blue: "#66d9ef",
346	      magenta: "#ae81ff",
347	      cyan: "#a1efe4",
348	      white: "#f8f8f2",
349	      brightBlack: "#75715e",
350	      brightRed: "#f92672",
351	      brightGreen: "#a6e22e",
352	      brightYellow: "#f4bf75",
353	      brightBlue: "#66d9ef",
354	      brightMagenta: "#ae81ff",
355	      brightCyan: "#a1efe4",
356	      brightWhite: "#f9f8f5",
357	    },
358	  },
359	  "rose-pine": {
360	    header: "#1f1d2e",
361	    border: "#26233a",
362	    xterm: {
363	      background: "#191724",
364	      foreground: "#e0def4",
365	      cursor: "#e0def4",
366	      cursorAccent: "#191724",
367	      selectionBackground: "#403d5280",
368	      black: "#26233a",
369	      red: "#eb6f92",
370	      green: "#31748f",
371	      yellow: "#f6c177",
372	      blue: "#9ccfd8",
373	      magenta: "#c4a7e7",
374	      cyan: "#ebbcba",
375	      white: "#e0def4",
376	      brightBlack: "#6e6a86",
377	      brightRed: "#eb6f92",
378	      brightGreen: "#31748f",
379	      brightYellow: "#f6c177",
380	      brightBlue: "#9ccfd8",
381	      brightMagenta: "#c4a7e7",
382	      brightCyan: "#ebbcba",
383	      brightWhite: "#e0def4",
384	    },
385	  },
386	  everforest: {
387	    header: "#232a2e",
388	    border: "#475258",
389	    xterm: {
390	      background: "#2d353b",
391	      foreground: "#d3c6aa",
392	      cursor: "#d3c6aa",
393	      cursorAccent: "#2d353b",
394	      selectionBackground: "#47525880",
395	      black: "#475258",
396	      red: "#e67e80",
397	      green: "#a7c080",
398	      yellow: "#dbbc7f",
399	      blue: "#7fbbb3",
400	      magenta: "#d699b6",
401	      cyan: "#83c092",
402	      white: "#d3c6aa",
403	      brightBlack: "#859289",
404	      brightRed: "#e67e80",
405	      brightGreen: "#a7c080",
406	      brightYellow: "#dbbc7f",
407	      brightBlue: "#7fbbb3",
408	      brightMagenta: "#d699b6",
409	      brightCyan: "#83c092",
410	      brightWhite: "#d3c6aa",
411	    },
412	  },
413	};
414
415	interface Project {
416	  name: string;
417	  pool: string | null;
418	  terminal: { theme: string | null; icon: string | null; title: string | null };
419	}
420	const projects = await invoke<Project[]>("list_projects");
421	const cfg = projects.find((p) => p.name === project);
422	if (cfg?.pool) {
423	  // Anzeigename statt Pool-ID (Ordnername).
424	  document.getElementById("pool")!.textContent = await invoke<string>(
425	    "pool_label",
426	    { pool: cfg.pool },
427	  );
428	}
429	document.getElementById("project-name")!.textContent =
430	  cfg?.terminal.title ?? project;
431
432	// Projekt-Icon vor den Titel — dieselbe data-URL wie in der Projektliste.
433	if (cfg?.terminal.icon) {
434	  const data = await invoke<string | null>("project_icon", { project });
435	  if (data) {
436	    const img = document.getElementById("project-icon") as HTMLImageElement;
437	    img.src = data;
438	    img.hidden = false;
439	  }
440	}
441
442	const picked = THEMES[cfg?.terminal.theme ?? "mocha"];
443	const theme = picked.xterm;
444
445	// Seitenfarben ans Theme anpassen (Defaults in terminal.html sind Mocha).
446	document.documentElement.style.background = theme.background;
447	document.body.style.background = theme.background;
448	const header = document.querySelector("header") as HTMLElement;
449	header.style.background = picked.header;
450	header.style.borderBottomColor = picked.border;
451	header.style.color = theme.foreground;
452
453	// Globale Schriftgröße (settings.json: terminalFontSize), ein Wert für alle.
454	const DEFAULT_FONT_SIZE = 13;
455	let fontSize = await invoke<number>("terminal_font_size");
456
457	const term = new Terminal({
458	  // Ligatur-Addon nutzt die Character-Joiner-API (Proposed API)
459	  allowProposedApi: true,
460	  fontFamily: '"JetBrains Mono", Menlo, monospace',
461	  fontSize,
462	  fontWeightBold: "600",
463	  lineHeight: 1.0,
464	  letterSpacing: 0,
465	  cursorBlink: true,
466	  cursorStyle: "bar",
467	  scrollback: 10000,
468	  theme,
469	});
470	const fit = new FitAddon();
471	term.loadAddon(fit);
472
473	// Ctrl/Cmd + +/-/0: Schriftgröße global anpassen, sofort im aktuellen Fenster.
474	function applyFontSize(px: number) {
475	  fontSize = Math.max(8, Math.min(24, px));
476	  term.options.fontSize = fontSize;
477	  fit.fit();
478	  invoke("set_terminal_font_size", { size: fontSize });
479	}
480
481	// Shift+Enter als CSI-u-Sequenz senden (Zeilenumbruch in Claude Code,
482	// auch bei nicht-leerer Zeile). Neben keydown muss auch keypress unter-
483	// drückt werden, sonst sendet xterm.js zusätzlich \r und submittet damit.
484	term.attachCustomKeyEventHandler((e) => {
485	  if (e.key === "Enter" && e.shiftKey) {
486	    if (e.type === "keydown") invoke("term_write", { data: "\x1b[13;2u" });
487	    return false;
488	  }
489	  if ((e.ctrlKey || e.metaKey) && !e.altKey) {
490	    if (e.key === "+" || e.key === "=") {
491	      if (e.type === "keydown") applyFontSize(fontSize + 1);
492	      return false;
493	    }
494	    if (e.key === "-" || e.key === "_") {
495	      if (e.type === "keydown") applyFontSize(fontSize - 1);
496	      return false;
497	    }
498	    if (e.key === "0") {
499	      if (e.type === "keydown") applyFontSize(DEFAULT_FONT_SIZE);
500	      return false;
501	    }
502	  }
503	  return true;
504	});
505
506	// Font muss geladen sein, bevor WebGL den Glyphen-Atlas aufbaut —
507	// beide verwendeten Gewichte (400 normal, 600 bold).
508	await document.fonts.load(`${fontSize}px "JetBrains Mono"`);
509	await document.fonts.load(`600 ${fontSize}px "JetBrains Mono"`);
510	term.open(document.getElementById("term")!);
511	// Reihenfolge laut Addon-Doku: Ligaturen vor WebGL aktivieren.
512	term.loadAddon(new LigaturesAddon());
513	term.loadAddon(new WebglAddon());
514	fit.fit();
515
516	function b64ToBytes(b64: string): Uint8Array {
517	  const bin = atob(b64);
518	  const bytes = new Uint8Array(bin.length);
519	  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
520	  return bytes;
521	}
522
523	await win.listen<string>("pty-output", (e) => term.write(b64ToBytes(e.payload)));
524	await win.listen("pty-exit", () => term.write("\r\n\x1b[90m[Prozess beendet]\x1b[0m\r\n"));
525
526	term.onData((data) => invoke("term_write", { data }));
527	term.onResize(({ rows, cols }) => invoke("term_resize", { rows, cols }));
528	window.addEventListener("resize", () => fit.fit());
529
530	await invoke("term_start", { project, rows: term.rows, cols: term.cols });
531	term.focus();
532

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures";
import "@xterm/xterm/css/xterm.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";

// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
function showError(msg: string) {
  const el = document.createElement("pre");
  el.style.cssText =
    "color:#f38ba8;padding:16px;font:12px Menlo,monospace;white-space:pre-wrap";
  el.textContent = msg;
  document.body.appendChild(el);
  invoke("term_log", { msg });
}
window.addEventListener("error", (e) =>
  showError(`error: ${e.message}\n${e.error?.stack ?? ""}`),
);
window.addEventListener("unhandledrejection", (e) =>
  showError(`rejection: ${e.reason}\n${e.reason?.stack ?? ""}`),
);

const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow();

// macOS nutzt die native Ampel (Overlay-Titelbar); sonst eigene Fensterknöpfe
// im Header (Linux ist dekorationslos, siehe terminal.rs).
const isMac = /Mac|Macintosh/.test(navigator.userAgent);
document.documentElement.dataset.platform = isMac ? "mac" : "other";
if (!isMac) {
  document
    .getElementById("win-min")
    ?.addEventListener("click", () => win.minimize());
  document
    .getElementById("win-max")
    ?.addEventListener("click", () => win.toggleMaximize());
  document
    .getElementById("win-close")
    ?.addEventListener("click", () => win.close());
  // Resize-Zonen: dekorationslose Fenster haben keinen nativen Rand.
  for (const g of document.querySelectorAll<HTMLElement>(".grip")) {
    g.addEventListener("mousedown", (e) => {
      e.preventDefault();
      win.startResizeDragging(g.dataset.dir as never);
    });
  }
}

// Fensterhintergrund beim Öffnen kommt aus terminal.rs (theme_background);
// dort nicht gepflegte Themes fallen auf Mocha zurück (nur kurzer Moment
// bis die Webview lädt).
const THEMES: Record<
  string,
  { header: string; border: string; xterm: Record<string, string> }
> = {
  mocha: {
    header: "#181825",
    border: "#313244",
    xterm: {
      background: "#1e1e2e",
      foreground: "#cdd6f4",
      cursor: "#f5e0dc",
      cursorAccent: "#1e1e2e",
      selectionBackground: "#585b7080",
      black: "#45475a",
      red: "#f38ba8",
      green: "#a6e3a1",
      yellow: "#f9e2af",
      blue: "#89b4fa",
      magenta: "#f5c2e7",
      cyan: "#94e2d5",
      white: "#bac2de",
      brightBlack: "#585b70",
      brightRed: "#f38ba8",
      brightGreen: "#a6e3a1",
      brightYellow: "#f9e2af",
      brightBlue: "#89b4fa",
      brightMagenta: "#f5c2e7",
      brightCyan: "#94e2d5",
      brightWhite: "#a6adc8",
    },
  },
  dracula: {
    header: "#21222c",
    border: "#44475a",
    xterm: {
      background: "#282a36",
      foreground: "#f8f8f2",
      cursor: "#f8f8f2",
      cursorAccent: "#282a36",
      selectionBackground: "#44475a80",
      black: "#21222c",
      red: "#ff5555",
      green: "#50fa7b",
      yellow: "#f1fa8c",
      blue: "#bd93f9",
      magenta: "#ff79c6",
      cyan: "#8be9fd",
      white: "#f8f8f2",
      brightBlack: "#6272a4",
      brightRed: "#ff6e6e",
      brightGreen: "#69ff94",
      brightYellow: "#ffffa5",
      brightBlue: "#d6acff",
      brightMagenta: "#ff92df",
      brightCyan: "#a4ffff",
      brightWhite: "#ffffff",
    },
  },
  "solarized-dark": {
    header: "#073642",
    border: "#586e75",
    xterm: {
      background: "#002b36",
      foreground: "#839496",
      cursor: "#839496",
      cursorAccent: "#002b36",
      selectionBackground: "#07364280",
      black: "#073642",
      red: "#dc322f",
      green: "#859900",
      yellow: "#b58900",
      blue: "#268bd2",
      magenta: "#d33682",
      cyan: "#2aa198",
      white: "#eee8d5",
      brightBlack: "#586e75",
      brightRed: "#cb4b16",
      brightGreen: "#859900",
      brightYellow: "#657b83",
      brightBlue: "#839496",
      brightMagenta: "#6c71c4",
      brightCyan: "#93a1a1",
      brightWhite: "#fdf6e3",
    },
  },
  gruvbox: {
    header: "#1d2021",
    border: "#3c3836",
    xterm: {
      background: "#282828",
      foreground: "#ebdbb2",
      cursor: "#ebdbb2",
      cursorAccent: "#282828",
      selectionBackground: "#3c383680",
      black: "#282828",
      red: "#cc241d",
      green: "#98971a",
      yellow: "#d79921",
      blue: "#458588",
      magenta: "#b16286",
      cyan: "#689d6a",
      white: "#a89984",
      brightBlack: "#928374",
      brightRed: "#fb4934",
      brightGreen: "#b8bb26",
      brightYellow: "#fabd2f",
      brightBlue: "#83a598",
      brightMagenta: "#d3869b",
      brightCyan: "#8ec07c",
      brightWhite: "#ebdbb2",
    },
  },
  "one-dark": {
    header: "#21252b",
    border: "#3e4451",
    xterm: {
      background: "#282c34",
      foreground: "#abb2bf",
      cursor: "#abb2bf",
      cursorAccent: "#282c34",
      selectionBackground: "#3e445180",
      black: "#282c34",
      red: "#e06c75",
      green: "#98c379",
      yellow: "#e5c07b",
      blue: "#61afef",
      magenta: "#c678dd",
      cyan: "#56b6c2",
      white: "#abb2bf",
      brightBlack: "#5c6370",
      brightRed: "#e06c75",
      brightGreen: "#98c379",
      brightYellow: "#e5c07b",
      brightBlue: "#61afef",
      brightMagenta: "#c678dd",
      brightCyan: "#56b6c2",
      brightWhite: "#ffffff",
    },
  },
  "solarized-light": {
    header: "#eee8d5",
    border: "#93a1a1",
    xterm: {
      background: "#fdf6e3",
      foreground: "#657b83",
      cursor: "#657b83",
      cursorAccent: "#fdf6e3",
      selectionBackground: "#eee8d5b0",
      black: "#073642",
      red: "#dc322f",
      green: "#859900",
      yellow: "#b58900",
      blue: "#268bd2",
      magenta: "#d33682",
      cyan: "#2aa198",
      white: "#eee8d5",
      brightBlack: "#002b36",
      brightRed: "#cb4b16",
      brightGreen: "#586e75",
      brightYellow: "#657b83",
      brightBlue: "#839496",
      brightMagenta: "#6c71c4",
      brightCyan: "#93a1a1",
      brightWhite: "#fdf6e3",
    },
  },
  "catppuccin-latte": {
    header: "#e6e9ef",
    border: "#ccd0da",
    xterm: {
      background: "#eff1f5",
      foreground: "#4c4f69",
      cursor: "#dc8a78",
      cursorAccent: "#eff1f5",
      selectionBackground: "#acb0be80",
      black: "#5c5f77",
      red: "#d20f39",
      green: "#40a02b",
      yellow: "#df8e1d",
      blue: "#1e66f5",
      magenta: "#ea76cb",
      cyan: "#179299",
      white: "#acb0be",
      brightBlack: "#6c6f85",
      brightRed: "#d20f39",
      brightGreen: "#40a02b",
      brightYellow: "#df8e1d",
      brightBlue: "#1e66f5",
      brightMagenta: "#ea76cb",
      brightCyan: "#179299",
      brightWhite: "#bcc0cc",
    },
  },
  "one-light": {
    header: "#eaeaeb",
    border: "#d4d4d4",
    xterm: {
      background: "#fafafa",
      foreground: "#383a42",
      cursor: "#526eff",
      cursorAccent: "#fafafa",
      selectionBackground: "#e5e5e6b0",
      black: "#383a42",
      red: "#e45649",
      green: "#50a14f",
      yellow: "#c18401",
      blue: "#4078f2",
      magenta: "#a626a4",
      cyan: "#0184bc",
      white: "#a0a1a7",
      brightBlack: "#696c77",
      brightRed: "#e45649",
      brightGreen: "#50a14f",
      brightYellow: "#c18401",
      brightBlue: "#4078f2",
      brightMagenta: "#a626a4",
      brightCyan: "#0184bc",
      brightWhite: "#ffffff",
    },
  },
  nord: {
    header: "#3b4252",
    border: "#4c566a",
    xterm: {
      background: "#2e3440",
      foreground: "#d8dee9",
      cursor: "#d8dee9",
      cursorAccent: "#2e3440",
      selectionBackground: "#434c5e80",
      black: "#3b4252",
      red: "#bf616a",
      green: "#a3be8c",
      yellow: "#ebcb8b",
      blue: "#81a1c1",
      magenta: "#b48ead",
      cyan: "#88c0d0",
      white: "#e5e9f0",
      brightBlack: "#4c566a",
      brightRed: "#bf616a",
      brightGreen: "#a3be8c",
      brightYellow: "#ebcb8b",
      brightBlue: "#81a1c1",
      brightMagenta: "#b48ead",
      brightCyan: "#8fbcbb",
      brightWhite: "#eceff4",
    },
  },
  "tokyo-night": {
    header: "#16161e",
    border: "#292e42",
    xterm: {
      background: "#1a1b26",
      foreground: "#c0caf5",
      cursor: "#c0caf5",
      cursorAccent: "#1a1b26",
      selectionBackground: "#28345780",
      black: "#15161e",
      red: "#f7768e",
      green: "#9ece6a",
      yellow: "#e0af68",
      blue: "#7aa2f7",
      magenta: "#bb9af7",
      cyan: "#7dcfff",
      white: "#a9b1d6",
      brightBlack: "#414868",
      brightRed: "#f7768e",
      brightGreen: "#9ece6a",
      brightYellow: "#e0af68",
      brightBlue: "#7aa2f7",
      brightMagenta: "#bb9af7",
      brightCyan: "#7dcfff",
      brightWhite: "#c0caf5",
    },
  },
  monokai: {
    header: "#1e1f1c",
    border: "#49483e",
    xterm: {
      background: "#272822",
      foreground: "#f8f8f2",
      cursor: "#f8f8f2",
      cursorAccent: "#272822",
      selectionBackground: "#49483e80",
      black: "#272822",
      red: "#f92672",
      green: "#a6e22e",
      yellow: "#e6db74",
      blue: "#66d9ef",
      magenta: "#ae81ff",
      cyan: "#a1efe4",
      white: "#f8f8f2",
      brightBlack: "#75715e",
      brightRed: "#f92672",
      brightGreen: "#a6e22e",
      brightYellow: "#f4bf75",
      brightBlue: "#66d9ef",
      brightMagenta: "#ae81ff",
      brightCyan: "#a1efe4",
      brightWhite: "#f9f8f5",
    },
  },
  "rose-pine": {
    header: "#1f1d2e",
    border: "#26233a",
    xterm: {
      background: "#191724",
      foreground: "#e0def4",
      cursor: "#e0def4",
      cursorAccent: "#191724",
      selectionBackground: "#403d5280",
      black: "#26233a",
      red: "#eb6f92",
      green: "#31748f",
      yellow: "#f6c177",
      blue: "#9ccfd8",
      magenta: "#c4a7e7",
      cyan: "#ebbcba",
      white: "#e0def4",
      brightBlack: "#6e6a86",
      brightRed: "#eb6f92",
      brightGreen: "#31748f",
      brightYellow: "#f6c177",
      brightBlue: "#9ccfd8",
      brightMagenta: "#c4a7e7",
      brightCyan: "#ebbcba",
      brightWhite: "#e0def4",
    },
  },
  everforest: {
    header: "#232a2e",
    border: "#475258",
    xterm: {
      background: "#2d353b",
      foreground: "#d3c6aa",
      cursor: "#d3c6aa",
      cursorAccent: "#2d353b",
      selectionBackground: "#47525880",
      black: "#475258",
      red: "#e67e80",
      green: "#a7c080",
      yellow: "#dbbc7f",
      blue: "#7fbbb3",
      magenta: "#d699b6",
      cyan: "#83c092",
      white: "#d3c6aa",
      brightBlack: "#859289",
      brightRed: "#e67e80",
      brightGreen: "#a7c080",
      brightYellow: "#dbbc7f",
      brightBlue: "#7fbbb3",
      brightMagenta: "#d699b6",
      brightCyan: "#83c092",
      brightWhite: "#d3c6aa",
    },
  },
};

interface Project {
  name: string;
  pool: string | null;
  terminal: { theme: string | null; icon: string | null; title: string | null };
}
const projects = await invoke<Project[]>("list_projects");
const cfg = projects.find((p) => p.name === project);
if (cfg?.pool) {
  // Anzeigename statt Pool-ID (Ordnername).
  document.getElementById("pool")!.textContent = await invoke<string>(
    "pool_label",
    { pool: cfg.pool },
  );
}
document.getElementById("project-name")!.textContent =
  cfg?.terminal.title ?? project;

// Projekt-Icon vor den Titel — dieselbe data-URL wie in der Projektliste.
if (cfg?.terminal.icon) {
  const data = await invoke<string | null>("project_icon", { project });
  if (data) {
    const img = document.getElementById("project-icon") as HTMLImageElement;
    img.src = data;
    img.hidden = false;
  }
}

const picked = THEMES[cfg?.terminal.theme ?? "mocha"];
const theme = picked.xterm;

// Seitenfarben ans Theme anpassen (Defaults in terminal.html sind Mocha).
document.documentElement.style.background = theme.background;
document.body.style.background = theme.background;
const header = document.querySelector("header") as HTMLElement;
header.style.background = picked.header;
header.style.borderBottomColor = picked.border;
header.style.color = theme.foreground;

// Globale Schriftgröße (settings.json: terminalFontSize), ein Wert für alle.
const DEFAULT_FONT_SIZE = 13;
let fontSize = await invoke<number>("terminal_font_size");

const term = new Terminal({
  // Ligatur-Addon nutzt die Character-Joiner-API (Proposed API)
  allowProposedApi: true,
  fontFamily: '"JetBrains Mono", Menlo, monospace',
  fontSize,
  fontWeightBold: "600",
  lineHeight: 1.0,
  letterSpacing: 0,
  cursorBlink: true,
  cursorStyle: "bar",
  scrollback: 10000,
  theme,
});
const fit = new FitAddon();
term.loadAddon(fit);

// Ctrl/Cmd + +/-/0: Schriftgröße global anpassen, sofort im aktuellen Fenster.
function applyFontSize(px: number) {
  fontSize = Math.max(8, Math.min(24, px));
  term.options.fontSize = fontSize;
  fit.fit();
  invoke("set_terminal_font_size", { size: fontSize });
}

// Shift+Enter als CSI-u-Sequenz senden (Zeilenumbruch in Claude Code,
// auch bei nicht-leerer Zeile). Neben keydown muss auch keypress unter-
// drückt werden, sonst sendet xterm.js zusätzlich \r und submittet damit.
term.attachCustomKeyEventHandler((e) => {
  if (e.key === "Enter" && e.shiftKey) {
    if (e.type === "keydown") invoke("term_write", { data: "\x1b[13;2u" });
    return false;
  }
  if ((e.ctrlKey || e.metaKey) && !e.altKey) {
    if (e.key === "+" || e.key === "=") {
      if (e.type === "keydown") applyFontSize(fontSize + 1);
      return false;
    }
    if (e.key === "-" || e.key === "_") {
      if (e.type === "keydown") applyFontSize(fontSize - 1);
      return false;
    }
    if (e.key === "0") {
      if (e.type === "keydown") applyFontSize(DEFAULT_FONT_SIZE);
      return false;
    }
  }
  return true;
});

// Font muss geladen sein, bevor WebGL den Glyphen-Atlas aufbaut —
// beide verwendeten Gewichte (400 normal, 600 bold).
await document.fonts.load(`${fontSize}px "JetBrains Mono"`);
await document.fonts.load(`600 ${fontSize}px "JetBrains Mono"`);
term.open(document.getElementById("term")!);
// Reihenfolge laut Addon-Doku: Ligaturen vor WebGL aktivieren.
term.loadAddon(new LigaturesAddon());
term.loadAddon(new WebglAddon());
fit.fit();

function b64ToBytes(b64: string): Uint8Array {
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

await win.listen<string>("pty-output", (e) => term.write(b64ToBytes(e.payload)));
await win.listen("pty-exit", () => term.write("\r\n\x1b[90m[Prozess beendet]\x1b[0m\r\n"));

term.onData((data) => invoke("term_write", { data }));
term.onResize(({ rows, cols }) => invoke("term_resize", { rows, cols }));
window.addEventListener("resize", () => fit.fit());

await invoke("term_start", { project, rows: term.rows, cols: term.cols });
term.focus();


1	use base64::{engine::general_purpose::STANDARD, Engine};
2	use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
3	use std::collections::HashMap;
4	use std::io::{Read, Write};
5	use std::sync::Mutex;
6	use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
7
8	/// Eine laufende PTY-Session, gekoppelt an ein Terminal-Fenster (Key = Fenster-Label).
9	pub struct Session {
10	  writer: Box<dyn Write + Send>,
11	  master: Box<dyn MasterPty + Send>,
12	  killer: Box<dyn ChildKiller + Send + Sync>,
13	}
14
15	#[derive(Default)]
16	pub struct Terminals(pub Mutex<HashMap<String, Session>>);
17
18	/// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als
19	/// Terminal-Prozess (`--terminal <projekt>`), damit jedes Terminal ein
20	/// eigenes Dock-Icon bekommt. Vorher tritt der rufende Prozess die
21	/// Aktivierung ab — Aktivierung ist seit macOS 14 kooperativ, sonst öffnet
22	/// das Terminal-Fenster hinter der aktiven App.
23	#[tauri::command]
24	pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {
25	  let dir = crate::project_dir(&crate::Paths::real(), &project)?;
26	  if !dir.is_dir() {
27	    return Err(format!("Projektordner fehlt: {}", dir.display()));
28	  }
29	  let bundle_id = app.config().identifier.clone();
30	  app
31	    .run_on_main_thread(move || yield_activation_to_bundle(&bundle_id))
32	    .map_err(|e| e.to_string())?;
33	  let exe = std::env::current_exe().map_err(|e| e.to_string())?;
34	  std::process::Command::new(exe)
35	    .args(["--terminal", &project])
36	    .spawn()
37	    .map_err(|e| e.to_string())?;
38	  Ok(())
39	}
40
41	/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser
42	/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben).
43	#[cfg(target_os = "macos")]
44	fn yield_activation_to_bundle(bundle_id: &str) {
45	  use objc2::MainThreadMarker;
46	  use objc2_app_kit::NSApplication;
47	  use objc2_foundation::NSString;
48	  let mtm =
49	    MainThreadMarker::new().expect("yield_activation_to_bundle läuft nicht auf dem Main-Thread");
50	  NSApplication::sharedApplication(mtm)
51	    .yieldActivationToApplicationWithBundleIdentifier(&NSString::from_str(bundle_id));
52	}
53
54	/// Linux kennt keine kooperative Aktivierungsabtretung.
55	#[cfg(target_os = "linux")]
56	fn yield_activation_to_bundle(_bundle_id: &str) {}
57
58	/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
59	/// die Gegenseite hat vorher per yield abgetreten.
60	#[cfg(target_os = "macos")]
61	pub fn activate_self(app: &AppHandle) {
62	  use objc2::MainThreadMarker;
63	  use objc2_app_kit::NSApplication;
64	  let mtm = MainThreadMarker::new().expect("activate_self läuft nicht auf dem Main-Thread");
65	  NSApplication::sharedApplication(mtm).activate();
66	  if let Some(window) = app.webview_windows().values().next() {
67	    window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
68	  }
69	}
70
71	/// Linux: Fenster ohne NSApplication über die Tauri-API fokussieren.
72	#[cfg(target_os = "linux")]
73	pub fn activate_self(app: &AppHandle) {
74	  if let Some(window) = app.webview_windows().values().next() {
75	    window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
76	  }
77	}
78
79	/// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in
80	/// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf.
81	fn theme_background(theme: &str) -> (u8, u8, u8) {
82	  match theme {
83	    "dracula" => (0x28, 0x2a, 0x36),
84	    "solarized-dark" => (0x00, 0x2b, 0x36),
85	    "gruvbox" => (0x28, 0x28, 0x28),
86	    "one-dark" => (0x28, 0x2c, 0x34),
87	    _ => (0x1e, 0x1e, 0x2e), // Catppuccin Mocha
88	  }
89	}
90
91	/// Setzt das Dock-Icon dieses Terminal-Prozesses aus einer PNG/ICNS-Datei.
92	#[cfg(target_os = "macos")]
93	pub fn set_dock_icon(path: &str) {
94	  use objc2::{AnyThread, MainThreadMarker};
95	  use objc2_app_kit::{NSApplication, NSImage};
96	  use objc2_foundation::NSString;
97
98	  let mtm = MainThreadMarker::new().expect("set_dock_icon läuft nicht auf dem Main-Thread");
99	  // Nicht ladbar (Datei weg, TCC-geschützter Ordner wie ~/Downloads): Terminal
100	  // ohne eigenes Dock-Icon starten statt den Prozess zu beenden.
101	  let Some(img) = NSImage::initWithContentsOfFile(NSImage::alloc(), &NSString::from_str(path))
102	  else {
103	    eprintln!("Dock-Icon nicht ladbar, Terminal startet ohne: {path}");
104	    return;
105	  };
106	  // unsafe laut objc2-Signatur; das NSImage stammt aus einer Datei und ist gültig.
107	  unsafe {
108	    NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&img));
109	  }
110	}
111
112	/// Linux kennt kein Dock-Icon pro Prozess.
113	#[cfg(target_os = "linux")]
114	pub fn set_dock_icon(_path: &str) {}
115
116	/// Holt das Terminal-Fenster eines laufenden Projekts in den Vordergrund:
117	/// aktiviert den Terminal-Prozess über seine PID. Aktivierung ist seit
118	/// macOS 14 kooperativ — die Tray-App tritt sie vorher ab.
119	#[cfg(target_os = "macos")]
120	pub fn focus_terminal(pid: u32) {
121	  use objc2::MainThreadMarker;
122	  use objc2_app_kit::{NSApplication, NSApplicationActivationOptions, NSRunningApplication};
123	  let Some(term) = NSRunningApplication::runningApplicationWithProcessIdentifier(pid as i32)
124	  else {
125	    eprintln!("Terminal-Prozess {pid} nicht gefunden");
126	    return;
127	  };
128	  let mtm = MainThreadMarker::new().expect("focus_terminal läuft nicht auf dem Main-Thread");
129	  NSApplication::sharedApplication(mtm).yieldActivationToApplication(&term);
130	  term.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows);
131	}
132
133	/// Linux: Fremdprozess-Fokus über die PID steht ohne NSRunningApplication
134	/// nicht zur Verfügung.
135	#[cfg(target_os = "linux")]
136	pub fn focus_terminal(_pid: u32) {}
137
138	/// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst,
139	/// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine
140	/// Ausgaben verloren, bevor der Event-Listener steht.
141	pub fn build_window(
142	  app: &AppHandle,
143	  project: &str,
144	  cfg: &crate::TerminalConfig,
145	) -> tauri::Result<()> {
146	  let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default());
147	  let title = cfg.title.as_deref().unwrap_or(project);
148	  let builder = WebviewWindowBuilder::new(
149	    app,
150	    format!("term-{project}"),
151	    WebviewUrl::App(format!("terminal.html?project={project}").into()),
152	  )
153	  .title(format!("{title} — Session"))
154	  .inner_size(980.0, 640.0)
155	  .background_color(tauri::window::Color(r, g, b, 0xff));
156
157	  // Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region.
158	  #[cfg(target_os = "macos")]
159	  let builder = builder
160	    .title_bar_style(tauri::TitleBarStyle::Overlay)
161	    .hidden_title(true);
162
163	  // Linux/GNOME: keine GTK-Deko — eigene Titelleiste im Header (terminal.html).
164	  #[cfg(target_os = "linux")]
165	  let builder = builder.decorations(false);
166
167	  builder.build()?;
168	  Ok(())
169	}
170
171	/// Startet die PTY für das rufende Fenster: das konfigurierte Claude-Kommando
172	/// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem
173	/// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. zsh -i lädt die .zshrc (PATH, fnm).
174	#[tauri::command]
175	pub fn term_start(
176	  window: tauri::WebviewWindow,
177	  terminals: State<Terminals>,
178	  project: String,
179	  rows: u16,
180	  cols: u16,
181	) -> Result<(), String> {
182	  let paths = crate::Paths::real();
183	  let cwd = crate::project_dir(&paths, &project)?;
184
185	  let pty = native_pty_system()
186	    .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
187	    .map_err(|e| e.to_string())?;
188
189	  let mut cmd = CommandBuilder::new("/bin/zsh");
190	  cmd.args(["-ic", &crate::claude_command(&paths)]);
191	  cmd.cwd(&cwd);
192	  cmd.env("TERM", "xterm-256color");
193	  if let Some(pool_dir) = crate::project_pool_dir(&project)? {
194	    cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
195	  }
196
197	  let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
198	  drop(pty.slave);
199
200	  let killer = child.clone_killer();
201	  let writer = pty.master.take_writer().map_err(|e| e.to_string())?;
202	  let mut reader = pty.master.try_clone_reader().map_err(|e| e.to_string())?;
203
204	  let label = window.label().to_string();
205	  let app = window.app_handle().clone();
206
207	  // Output gebündelt emittieren statt pro read(): der Reader schiebt Chunks
208	  // in einen Channel, der Emitter sammelt alles, was innerhalb von 8 ms
209	  // ankommt, zu einem Event zusammen. Bei Output-Fluten (Scrollen, TUI-
210	  // Redraws) sinkt die Zahl der IPC-Events um Größenordnungen; die 8 ms
211	  // Zusatzlatenz beim Tasten-Echo liegen unter einem Frame.
212	  let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
213	  std::thread::spawn(move || {
214	    let mut buf = [0u8; 8192];
215	    loop {
216	      match reader.read(&mut buf) {
217	        Ok(0) | Err(_) => break,
218	        Ok(n) => {
219	          if tx.send(buf[..n].to_vec()).is_err() {
220	            break;
221	          }
222	        }
223	      }
224	    }
225	    let _ = child.wait();
226	    // tx wird hier gedroppt — der Emitter sieht den geschlossenen Channel
227	    // erst, nachdem er alle Rest-Chunks emittiert hat.
228	  });
229	  std::thread::spawn(move || {
230	    const FLUSH: std::time::Duration = std::time::Duration::from_millis(8);
231	    while let Ok(first) = rx.recv() {
232	      let mut chunk = first;
233	      let deadline = std::time::Instant::now() + FLUSH;
234	      loop {
235	        let now = std::time::Instant::now();
236	        if now >= deadline {
237	          break;
238	        }
239	        match rx.recv_timeout(deadline - now) {
240	          Ok(more) => chunk.extend_from_slice(&more),
241	          Err(_) => break,
242	        }
243	      }
244	      let _ = app.emit_to(&label, "pty-output", STANDARD.encode(&chunk));
245	    }
246	    let _ = app.emit_to(&label, "pty-exit", ());
247	  });
248
249	  terminals.0.lock().unwrap().insert(
250	    window.label().to_string(),
251	    Session { writer, master: pty.master, killer },
252	  );
253	  Ok(())
254	}
255
256	/// Debug: JS-Fehler aus dem Terminal-Fenster ins Dev-Log.
257	#[tauri::command]
258	pub fn term_log(window: tauri::WebviewWindow, msg: String) {
259	  eprintln!("[{}] {msg}", window.label());
260	}
261
262	/// Tastatureingaben des Fensters in die PTY.
263	#[tauri::command]
264	pub fn term_write(
265	  window: tauri::WebviewWindow,
266	  terminals: State<Terminals>,
267	  data: String,
268	) -> Result<(), String> {
269	  let mut map = terminals.0.lock().unwrap();
270	  let s = map
271	    .get_mut(window.label())
272	    .ok_or("keine Session für dieses Fenster")?;
273	  s.writer.write_all(data.as_bytes()).map_err(|e| e.to_string())
274	}
275
276	#[tauri::command]
277	pub fn term_resize(
278	  window: tauri::WebviewWindow,
279	  terminals: State<Terminals>,
280	  rows: u16,
281	  cols: u16,
282	) -> Result<(), String> {
283	  let map = terminals.0.lock().unwrap();
284	  let s = map
285	    .get(window.label())
286	    .ok_or("keine Session für dieses Fenster")?;
287	  s.master
288	    .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
289	    .map_err(|e| e.to_string())
290	}
291
292	/// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen.
293	/// Das Droppen des Masters schließt die PTY — claude bekommt HUP.
294	pub fn close(window: &tauri::Window) {
295	  let terminals = window.state::<Terminals>();
296	  let session = terminals.0.lock().unwrap().remove(window.label());
297	  if let Some(mut s) = session {
298	    let _ = s.killer.kill();
299	  }
300	}
301

use base64::{engine::general_purpose::STANDARD, Engine};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};

/// Eine laufende PTY-Session, gekoppelt an ein Terminal-Fenster (Key = Fenster-Label).
pub struct Session {
  writer: Box<dyn Write + Send>,
  master: Box<dyn MasterPty + Send>,
  killer: Box<dyn ChildKiller + Send + Sync>,
}

#[derive(Default)]
pub struct Terminals(pub Mutex<HashMap<String, Session>>);

/// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als
/// Terminal-Prozess (`--terminal <projekt>`), damit jedes Terminal ein
/// eigenes Dock-Icon bekommt. Vorher tritt der rufende Prozess die
/// Aktivierung ab — Aktivierung ist seit macOS 14 kooperativ, sonst öffnet
/// das Terminal-Fenster hinter der aktiven App.
#[tauri::command]
pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {
  let dir = crate::project_dir(&crate::Paths::real(), &project)?;
  if !dir.is_dir() {
    return Err(format!("Projektordner fehlt: {}", dir.display()));
  }
  let bundle_id = app.config().identifier.clone();
  app
    .run_on_main_thread(move || yield_activation_to_bundle(&bundle_id))
    .map_err(|e| e.to_string())?;
  let exe = std::env::current_exe().map_err(|e| e.to_string())?;
  std::process::Command::new(exe)
    .args(["--terminal", &project])
    .spawn()
    .map_err(|e| e.to_string())?;
  Ok(())
}

/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser
/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben).
#[cfg(target_os = "macos")]
fn yield_activation_to_bundle(bundle_id: &str) {
  use objc2::MainThreadMarker;
  use objc2_app_kit::NSApplication;
  use objc2_foundation::NSString;
  let mtm =
    MainThreadMarker::new().expect("yield_activation_to_bundle läuft nicht auf dem Main-Thread");
  NSApplication::sharedApplication(mtm)
    .yieldActivationToApplicationWithBundleIdentifier(&NSString::from_str(bundle_id));
}

/// Linux kennt keine kooperative Aktivierungsabtretung.
#[cfg(target_os = "linux")]
fn yield_activation_to_bundle(_bundle_id: &str) {}

/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
/// die Gegenseite hat vorher per yield abgetreten.
#[cfg(target_os = "macos")]
pub fn activate_self(app: &AppHandle) {
  use objc2::MainThreadMarker;
  use objc2_app_kit::NSApplication;
  let mtm = MainThreadMarker::new().expect("activate_self läuft nicht auf dem Main-Thread");
  NSApplication::sharedApplication(mtm).activate();
  if let Some(window) = app.webview_windows().values().next() {
    window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
  }
}

/// Linux: Fenster ohne NSApplication über die Tauri-API fokussieren.
#[cfg(target_os = "linux")]
pub fn activate_self(app: &AppHandle) {
  if let Some(window) = app.webview_windows().values().next() {
    window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
  }
}

/// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in
/// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf.
fn theme_background(theme: &str) -> (u8, u8, u8) {
  match theme {
    "dracula" => (0x28, 0x2a, 0x36),
    "solarized-dark" => (0x00, 0x2b, 0x36),
    "gruvbox" => (0x28, 0x28, 0x28),
    "one-dark" => (0x28, 0x2c, 0x34),
    _ => (0x1e, 0x1e, 0x2e), // Catppuccin Mocha
  }
}

/// Setzt das Dock-Icon dieses Terminal-Prozesses aus einer PNG/ICNS-Datei.
#[cfg(target_os = "macos")]
pub fn set_dock_icon(path: &str) {
  use objc2::{AnyThread, MainThreadMarker};
  use objc2_app_kit::{NSApplication, NSImage};
  use objc2_foundation::NSString;

  let mtm = MainThreadMarker::new().expect("set_dock_icon läuft nicht auf dem Main-Thread");
  // Nicht ladbar (Datei weg, TCC-geschützter Ordner wie ~/Downloads): Terminal
  // ohne eigenes Dock-Icon starten statt den Prozess zu beenden.
  let Some(img) = NSImage::initWithContentsOfFile(NSImage::alloc(), &NSString::from_str(path))
  else {
    eprintln!("Dock-Icon nicht ladbar, Terminal startet ohne: {path}");
    return;
  };
  // unsafe laut objc2-Signatur; das NSImage stammt aus einer Datei und ist gültig.
  unsafe {
    NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&img));
  }
}

/// Linux kennt kein Dock-Icon pro Prozess.
#[cfg(target_os = "linux")]
pub fn set_dock_icon(_path: &str) {}

/// Holt das Terminal-Fenster eines laufenden Projekts in den Vordergrund:
/// aktiviert den Terminal-Prozess über seine PID. Aktivierung ist seit
/// macOS 14 kooperativ — die Tray-App tritt sie vorher ab.
#[cfg(target_os = "macos")]
pub fn focus_terminal(pid: u32) {
  use objc2::MainThreadMarker;
  use objc2_app_kit::{NSApplication, NSApplicationActivationOptions, NSRunningApplication};
  let Some(term) = NSRunningApplication::runningApplicationWithProcessIdentifier(pid as i32)
  else {
    eprintln!("Terminal-Prozess {pid} nicht gefunden");
    return;
  };
  let mtm = MainThreadMarker::new().expect("focus_terminal läuft nicht auf dem Main-Thread");
  NSApplication::sharedApplication(mtm).yieldActivationToApplication(&term);
  term.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows);
}

/// Linux: Fremdprozess-Fokus über die PID steht ohne NSRunningApplication
/// nicht zur Verfügung.
#[cfg(target_os = "linux")]
pub fn focus_terminal(_pid: u32) {}

/// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst,
/// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine
/// Ausgaben verloren, bevor der Event-Listener steht.
pub fn build_window(
  app: &AppHandle,
  project: &str,
  cfg: &crate::TerminalConfig,
) -> tauri::Result<()> {
  let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default());
  let title = cfg.title.as_deref().unwrap_or(project);
  let builder = WebviewWindowBuilder::new(
    app,
    format!("term-{project}"),
    WebviewUrl::App(format!("terminal.html?project={project}").into()),
  )
  .title(format!("{title} — Session"))
  .inner_size(980.0, 640.0)
  .background_color(tauri::window::Color(r, g, b, 0xff));

  // Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region.
  #[cfg(target_os = "macos")]
  let builder = builder
    .title_bar_style(tauri::TitleBarStyle::Overlay)
    .hidden_title(true);

  // Linux/GNOME: keine GTK-Deko — eigene Titelleiste im Header (terminal.html).
  #[cfg(target_os = "linux")]
  let builder = builder.decorations(false);

  builder.build()?;
  Ok(())
}

/// Startet die PTY für das rufende Fenster: das konfigurierte Claude-Kommando
/// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem
/// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. zsh -i lädt die .zshrc (PATH, fnm).
#[tauri::command]
pub fn term_start(
  window: tauri::WebviewWindow,
  terminals: State<Terminals>,
  project: String,
  rows: u16,
  cols: u16,
) -> Result<(), String> {
  let paths = crate::Paths::real();
  let cwd = crate::project_dir(&paths, &project)?;

  let pty = native_pty_system()
    .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
    .map_err(|e| e.to_string())?;

  let mut cmd = CommandBuilder::new("/bin/zsh");
  cmd.args(["-ic", &crate::claude_command(&paths)]);
  cmd.cwd(&cwd);
  cmd.env("TERM", "xterm-256color");
  if let Some(pool_dir) = crate::project_pool_dir(&project)? {
    cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
  }

  let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
  drop(pty.slave);

  let killer = child.clone_killer();
  let writer = pty.master.take_writer().map_err(|e| e.to_string())?;
  let mut reader = pty.master.try_clone_reader().map_err(|e| e.to_string())?;

  let label = window.label().to_string();
  let app = window.app_handle().clone();

  // Output gebündelt emittieren statt pro read(): der Reader schiebt Chunks
  // in einen Channel, der Emitter sammelt alles, was innerhalb von 8 ms
  // ankommt, zu einem Event zusammen. Bei Output-Fluten (Scrollen, TUI-
  // Redraws) sinkt die Zahl der IPC-Events um Größenordnungen; die 8 ms
  // Zusatzlatenz beim Tasten-Echo liegen unter einem Frame.
  let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
  std::thread::spawn(move || {
    let mut buf = [0u8; 8192];
    loop {
      match reader.read(&mut buf) {
        Ok(0) | Err(_) => break,
        Ok(n) => {
          if tx.send(buf[..n].to_vec()).is_err() {
            break;
          }
        }
      }
    }
    let _ = child.wait();
    // tx wird hier gedroppt — der Emitter sieht den geschlossenen Channel
    // erst, nachdem er alle Rest-Chunks emittiert hat.
  });
  std::thread::spawn(move || {
    const FLUSH: std::time::Duration = std::time::Duration::from_millis(8);
    while let Ok(first) = rx.recv() {
      let mut chunk = first;
      let deadline = std::time::Instant::now() + FLUSH;
      loop {
        let now = std::time::Instant::now();
        if now >= deadline {
          break;
        }
        match rx.recv_timeout(deadline - now) {
          Ok(more) => chunk.extend_from_slice(&more),
          Err(_) => break,
        }
      }
      let _ = app.emit_to(&label, "pty-output", STANDARD.encode(&chunk));
    }
    let _ = app.emit_to(&label, "pty-exit", ());
  });

  terminals.0.lock().unwrap().insert(
    window.label().to_string(),
    Session { writer, master: pty.master, killer },
  );
  Ok(())
}

/// Debug: JS-Fehler aus dem Terminal-Fenster ins Dev-Log.
#[tauri::command]
pub fn term_log(window: tauri::WebviewWindow, msg: String) {
  eprintln!("[{}] {msg}", window.label());
}

/// Tastatureingaben des Fensters in die PTY.
#[tauri::command]
pub fn term_write(
  window: tauri::WebviewWindow,
  terminals: State<Terminals>,
  data: String,
) -> Result<(), String> {
  let mut map = terminals.0.lock().unwrap();
  let s = map
    .get_mut(window.label())
    .ok_or("keine Session für dieses Fenster")?;
  s.writer.write_all(data.as_bytes()).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn term_resize(
  window: tauri::WebviewWindow,
  terminals: State<Terminals>,
  rows: u16,
  cols: u16,
) -> Result<(), String> {
  let map = terminals.0.lock().unwrap();
  let s = map
    .get(window.label())
    .ok_or("keine Session für dieses Fenster")?;
  s.master
    .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
    .map_err(|e| e.to_string())
}

/// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen.
/// Das Droppen des Masters schließt die PTY — claude bekommt HUP.
pub fn close(window: &tauri::Window) {
  let terminals = window.state::<Terminals>();
  let session = terminals.0.lock().unwrap().remove(window.label());
  if let Some(mut s) = session {
    let _ = s.killer.kill();
  }
}


Architektur verstanden: jedes Terminal ist ein eigener Prozess (`--terminal <projekt>`), xterm.js in einer Tauri-Webview, PTY in `terminal.rs`. Jetzt die Anker in `lib.rs` und `Cargo.toml` prüfen.


echo "===Cargo===" && cat src-tauri/Cargo.toml && echo "===notify?===" && grep -n "notify" src-tauri/Cargo.lock | head

Cargo.toml und notify-Abhängigkeit prüfen


===Cargo===
[package]
name = "ai-control"
version = "0.2.0"
description = "Pool- und Session-Verwaltung für Claude Code"
authors = ["marcus.hinz"]
license = "MIT"
repository = "https://github.com/mHinz-hub/ai-control"
edition = "2021"
rust-version = "1.77.2"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }

[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
base64 = "0.22"
sha2 = "0.10"
portable-pty = "0.9"
uuid = { version = "1", features = ["v4"] }
keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] }

[target.'cfg(target_os = "linux")'.dependencies]
zbus = "5"
glib = "0.18"

[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder", "NSRunningApplication"] }
objc2-foundation = { version = "0.3", features = ["NSString"] }
===notify?===


grep -n "invoke_handler\|generate_handler\|--terminal\|build_window\|on_window_event\|WindowEvent\|fn run\|app_data_dir\|app_config_dir\|dirs::\|fn project_pool_dir\|fn project_dir\|struct Paths\|impl Paths\|fn real\|TerminalConfig" src/../src-tauri/src/lib.rs | head -80

Anker in lib.rs finden


22:pub(crate) struct Paths {
26:impl Paths {
27:  pub(crate) fn real() -> Self {
106:pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
141:  terminal: TerminalConfig,
155:  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
156:  terminal: TerminalConfig,
160:pub struct TerminalConfig {
169:impl TerminalConfig {
181:fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
188:/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
191:    .args(["-f", "--", &format!("--terminal {project}$")])
228:pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
486:  terminal: TerminalConfig,
789:  mut terminal: TerminalConfig,
840:pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &TerminalConfig) {
858:    "[Desktop Entry]\nType=Application\nName={project}\nExec={exec} --terminal {project}\n\
942:pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
1591:  terminal: TerminalConfig,
1724:  set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title })
1871:pub fn run() {
1876:    // `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
1878:    Some("--terminal") => {
1879:      let project = args.next().expect("--terminal braucht einen Projektnamen");
2123:    .on_window_event(|window, event| {
2124:      if let tauri::WindowEvent::CloseRequested { api, .. } = event {
2129:      if let tauri::WindowEvent::Focused(false) = event {
2135:    .invoke_handler(tauri::generate_handler![
2182:      terminal::build_window(app.handle(), &project, &cfg)?;
2186:    .on_window_event(|window, event| {
2187:      if let tauri::WindowEvent::Destroyed = event {
2191:    .invoke_handler(tauri::generate_handler![
2542:      TerminalConfig {
2588:    create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
2625:    create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false).unwrap();
2643:      create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
2658:      TerminalConfig::default(),
2853:      TerminalConfig { theme: Some("dracula".into()), icon: None, title: None },
2959:    create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
2969:    create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
2979:    create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
2988:    create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
2999:    create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
3021:    create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)


1	mod terminal;
2
3	use serde::{Deserialize, Serialize};
4	use sha2::{Digest, Sha256};
5	use std::collections::{HashMap, HashSet};
6	use std::fs;
7	use std::io::{BufRead, BufReader};
8	use std::os::unix::fs::PermissionsExt;
9	use std::path::PathBuf;
10	use std::process::Command;
11	use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13	/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
14	/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
15	/// die App speichert keine Tokens.
16	const APIKEY_FILE: &str = "apikey";
17	const POOL_FILE: &str = "pool.json";
18	const PROJECT_FILE: &str = "ai-control.json";
19	const PROJECTS_FILE: &str = "projects.json";
20
21	/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
22	pub(crate) struct Paths {
23	  home: PathBuf,
24	}
25
26	impl Paths {
27	  pub(crate) fn real() -> Self {
28	    Paths {
29	      home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
30	    }
31	  }
32
33	  /// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
34	  /// Projekte ohne gewählten Zielordner.
35	  fn projects_dir(&self) -> PathBuf {
36	    self.home.join("claude-projects")
37	  }
38
39	  fn config_dir(&self) -> PathBuf {
40	    self.home.join(".config").join("ai-control")
41	  }
42
43	  fn projects_file(&self) -> PathBuf {
44	    self.config_dir().join(PROJECTS_FILE)
45	  }
46
47	  fn pools_dir(&self) -> PathBuf {
48	    self.config_dir().join("pools")
49	  }
50
51	  /// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
52	  /// unabhängig von Pool-Zuordnung und Quell-Repos.
53	  fn icons_dir(&self) -> PathBuf {
54	    self.config_dir().join("icons")
55	  }
56
57	  fn pool_dir(&self, pool: &str) -> PathBuf {
58	    self.pools_dir().join(pool)
59	  }
60	}
61
62	// ---------- Projekt-Registry ----------
63
64	/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
65	/// Home-Bereich bleiben damit maschinenübergreifend stabil.
66	fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
67	  match p.strip_prefix(&paths.home) {
68	    Ok(rest) => format!("~/{}", rest.display()),
69	    Err(_) => p.display().to_string(),
70	  }
71	}
72
73	/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.
74	fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
75	  let file = paths.projects_file();
76	  if !file.is_file() {
77	    return Ok(std::collections::BTreeMap::new());
78	  }
79	  let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
80	  let map: std::collections::BTreeMap<String, String> =
81	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
82	  Ok(
83	    map
84	      .into_iter()
85	      .map(|(name, p)| {
86	        let dir = expand_home(paths, &p);
87	        (name, dir)
88	      })
89	      .collect(),
90	  )
91	}
92
93	fn save_registry(
94	  paths: &Paths,
95	  reg: &std::collections::BTreeMap<String, PathBuf>,
96	) -> Result<(), String> {
97	  let contracted: std::collections::BTreeMap<&String, String> =
98	    reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
99	  let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
100	  fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
101	  let file = paths.projects_file();
102	  fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
103	}
104
105	/// Ordner eines registrierten Projekts.
106	pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
107	  load_registry(paths)?
108	    .remove(name)
109	    .ok_or_else(|| format!("Projekt nicht registriert: {name}"))
110	}
111
112	/// Nimmt ein Projekt in die Registry auf.
113	fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
114	  let mut reg = load_registry(paths)?;
115	  if reg.contains_key(name) {

mod terminal;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
/// die App speichert keine Tokens.
const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json";
const PROJECTS_FILE: &str = "projects.json";

/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
pub(crate) struct Paths {
  home: PathBuf,
}

impl Paths {
  pub(crate) fn real() -> Self {
    Paths {
      home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
    }
  }

  /// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
  /// Projekte ohne gewählten Zielordner.
  fn projects_dir(&self) -> PathBuf {
    self.home.join("claude-projects")
  }

  fn config_dir(&self) -> PathBuf {
    self.home.join(".config").join("ai-control")
  }

  fn projects_file(&self) -> PathBuf {
    self.config_dir().join(PROJECTS_FILE)
  }

  fn pools_dir(&self) -> PathBuf {
    self.config_dir().join("pools")
  }

  /// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
  /// unabhängig von Pool-Zuordnung und Quell-Repos.
  fn icons_dir(&self) -> PathBuf {
    self.config_dir().join("icons")
  }

  fn pool_dir(&self, pool: &str) -> PathBuf {
    self.pools_dir().join(pool)
  }
}

// ---------- Projekt-Registry ----------

/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
/// Home-Bereich bleiben damit maschinenübergreifend stabil.
fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
  match p.strip_prefix(&paths.home) {
    Ok(rest) => format!("~/{}", rest.display()),
    Err(_) => p.display().to_string(),
  }
}

/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
  let file = paths.projects_file();
  if !file.is_file() {
    return Ok(std::collections::BTreeMap::new());
  }
  let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
  let map: std::collections::BTreeMap<String, String> =
    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
  Ok(
    map
      .into_iter()
      .map(|(name, p)| {
        let dir = expand_home(paths, &p);
        (name, dir)
      })
      .collect(),
  )
}

fn save_registry(
  paths: &Paths,
  reg: &std::collections::BTreeMap<String, PathBuf>,
) -> Result<(), String> {
  let contracted: std::collections::BTreeMap<&String, String> =
    reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
  let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
  fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
  let file = paths.projects_file();
  fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
}

/// Ordner eines registrierten Projekts.
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
  load_registry(paths)?
    .remove(name)
    .ok_or_else(|| format!("Projekt nicht registriert: {name}"))
}

/// Nimmt ein Projekt in die Registry auf.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
  let mut reg = load_registry(paths)?;
  if reg.contains_key(name) {