    unlink: "Remove from list only",
    unlinkHint:
      "Remove from list only drops the entry; the folder stays untouched.",

import { createI18n } from "vue-i18n";

const de = {
  app: {
    projects: "Projekte",
    pools: "Pools",
    usage: "Verbrauch",
    autostart: "Autostart",
  },
  usage: {
    days7: "7 Tage",
    days30: "30 Tage",
    pool: "Pool",
    project: "Projekt",
    input: "Input",
    output: "Output",
    cacheWrite: "Cache ↑",
    cacheRead: "Cache ↓",
    cost: "≈ Kosten",
    total: "Summe",
    empty: "Keine Verbrauchsdaten im Zeitraum.",
    estimateNote:
      "API-Gegenwert aus den lokalen Transcripts — keine Abrechnung, Historie nur solange claude sie vorhält.",
  },
  projects: {
    project: "Projekt",
    pool: "Pool",
    noPool: "– kein Pool –",
    start: "Starten",
    stop: "Beenden",
    terminalSettings: "Terminal-Einstellungen",
    stillRunning: "{name} läuft noch",
    poolChangeSaved:
      "Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber weiter im alten Pool — der neue gilt erst ab dem nächsten Start.",
    restartHint:
      "Der Neustart beendet die laufende Session. Der automatische commit+push von claude-sync nach Sessionende entfällt dabei.",
    keepRunning: "Weiterlaufen lassen",
    restartNow: "Jetzt neu starten",
    restarting: "Starte neu …",
    terminal: "Terminal — {name}",
    title: "Titel",
    theme: "Theme",
    dockIcon: "Dock-Icon",
    chooseFile: "Datei wählen …",
    remove: "Entfernen",
    defaultIcon: "Standard-Icon",
    appliesNextStart: "Gilt ab dem nächsten Terminal-Start.",
    cancel: "Abbrechen",
    save: "Speichern",
    noPoolAssigned: "kein Pool",
    needsPool: "Kein Pool zugeordnet",
    needsKey: "Pool hat keinen API-Key",
    newProject: "+ Projekt",
    addFolder: "+ Ordner",
    addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
    wizardTitle: "Neues Projekt",
    name: "Name",
    projectDir: "Projektordner",
    projectDirCustom: "Ordner wählen …",
    workDir: "Arbeitsordner",
    workDirNone: "keiner",
    workDirCustom: "vorhandenen Ordner verknüpfen",
    chooseFolder: "Ordner wählen …",
    create: "Anlegen",
    wizardHint:
      "Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json (Memory-Verzeichnis + Berechtigungen) und die Pool-/Terminal-Zuordnung.",
    delete: "Projekt löschen",
    deleteRunning: "Läuft noch — erst beenden",
    deleteTitle: "{name} löschen",
    deleteWarning:
      "Der Projektordner (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
    deleteWorkDirs: "Arbeitsordner mitlöschen:",
    workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
    unlink: "Nur aus Liste nehmen",
    unlinkHint:
      "Nur aus Liste nehmen entfernt den Eintrag; der Ordner bleibt unangetastet.",
    todo: "Todoliste",
    todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
  },
  pools: {
    pool: "Pool",
    type: "Typ",
    actions: "Aktionen",
    newOauth: "+ oAuth",
    newApikey: "+ apiKey",
    relogin: "Zurücksetzen",
    rename: "Umbenennen",
    renameTitle: "{name} umbenennen",
    changeKey: "Key ändern",
    insertKey: "Key eintragen",
    delete: "Löschen",
    assigned: "kein Projekt | 1 Projekt | {count} Projekte",
    empty: "Noch keine Pools angelegt.",
    newOauthPool: "Neuen oAuth-Pool anlegen",
    newApikeyPool: "Neuen API-Key-Pool anlegen",
    editKey: "API-Key ändern – {name}",
    name: "Name",
    apiKey: "API-Key",
    oauthHint:
      "Der Pool wird angelegt; die Anmeldung macht claude beim ersten Start selbst per /login (Browser gegen dein Abo).",
    cancel: "Abbrechen",
    createPool: "Anlegen",
    reset: "Zurücksetzen",
    save: "Speichern",
    deletePool: "Pool löschen",
    deleteWarning:
      "Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.",
    deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:",
    deleteBlockedTooltip:
      "Löschen gesperrt — {projects} läuft gerade. Erst beenden.",
    reloginTitle: "{name} zurücksetzen",
    reloginWarning:
      "Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Beim nächsten Start verlangt claude erneut /login.",
    reloginNoEntry:
      "Kein Schlüsselbund-Eintrag vorhanden — beim nächsten Start meldet sich claude ohnehin per /login an.",
    reloginBlocked:
      "Zurücksetzen ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:",
    keychainUnavailableTitle: "Keine sichere Ablage",
    keychainUnavailable:
      "Keychain/Keyring ist nicht verfügbar. Der Key kann stattdessen ungesichert als Datei im Pool-Ordner liegen (0600).",
    storeAsFile: "Als Datei ablegen",
  },
};

const en: typeof de = {
  app: {
    projects: "Projects",
    pools: "Pools",
    usage: "Usage",
    autostart: "Autostart",
  },
  usage: {
    days7: "7 days",
    days30: "30 days",
    pool: "Pool",
    project: "Project",
    input: "Input",
    output: "Output",
    cacheWrite: "Cache ↑",
    cacheRead: "Cache ↓",
    cost: "≈ cost",
    total: "Total",
    empty: "No usage data in this period.",
    estimateNote:
      "API equivalent from local transcripts — not a bill; history only as long as claude keeps it.",
  },
  projects: {
    project: "Project",
    pool: "Pool",
    noPool: "– no pool –",
    start: "Start",
    stop: "Stop",
    terminalSettings: "Terminal settings",
    stillRunning: "{name} is still running",
    poolChangeSaved:
      "The pool change is saved. The running session keeps working in the old pool — the new one applies from the next start.",
    restartHint:
      "Restarting ends the running session. The automatic commit+push by claude-sync after the session is skipped.",
    keepRunning: "Keep running",
    restartNow: "Restart now",
    restarting: "Restarting …",
    terminal: "Terminal — {name}",
    title: "Title",
    theme: "Theme",
    dockIcon: "Dock icon",
    chooseFile: "Choose file …",
    remove: "Remove",
    defaultIcon: "Default icon",
    appliesNextStart: "Applies from the next terminal start.",
    cancel: "Cancel",
    save: "Save",
    noPoolAssigned: "no pool",
    needsPool: "No pool assigned",
    needsKey: "Pool has no API key",
    newProject: "+ Project",
    addFolder: "+ Folder",
    addFolderTitle: "Add an existing folder as a project",
    wizardTitle: "New project",
    name: "Name",
    projectDir: "Project folder",
    projectDirCustom: "Choose folder …",
    workDir: "Working folder",
    workDirNone: "none",
    workDirCustom: "link existing folder",
    chooseFolder: "Choose folder …",
    create: "Create",
    wizardHint:
      "Creates the project folder with memory/, sentinel .gitignore, settings.json (memory dir + permissions), and the pool/terminal assignment.",
    delete: "Delete project",
    deleteRunning: "Still running — stop it first",
    deleteTitle: "Delete {name}",
    deleteWarning:
      "The project folder (incl. memory/, settings and pool assignment) will be deleted permanently.",
    deleteWorkDirs: "Also delete working folders:",
    workDirsStay: "The working folders stay untouched.",
    unlink: "Remove from list only",
    unlinkHint:
      "\"Remove from list only\" drops the entry; the folder stays untouched.",
    todo: "Todo list",
    todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
  },
  pools: {
    pool: "Pool",
    type: "Type",
    actions: "Actions",
    newOauth: "+ oAuth",
    newApikey: "+ apiKey",
    relogin: "Reset",
    rename: "Rename",
    renameTitle: "Rename {name}",
    changeKey: "Change key",
    insertKey: "Insert key",
    delete: "Delete",
    assigned: "no projects | 1 project | {count} projects",
    empty: "No pools yet.",
    newOauthPool: "Create oAuth pool",
    newApikeyPool: "Create API key pool",
    editKey: "Change API key – {name}",
    name: "Name",
    apiKey: "API key",
    oauthHint:
      "The pool is created; claude signs in itself on first start via /login (browser against your subscription).",
    cancel: "Cancel",
    createPool: "Create",
    reset: "Reset",
    save: "Save",
    deletePool: "Delete pool",
    deleteWarning:
      "Pool {name} and its credential file will be deleted. This cannot be undone.",
    deleteUnassigns: "These projects lose their pool assignment:",
    deleteBlockedTooltip:
      "Deletion blocked — active: {projects}. Stop the session first.",
    reloginTitle: "Reset {name}",
    reloginWarning:
      "The access token for {name} stored in the keychain will be deleted. On the next start claude asks for /login again.",
    reloginNoEntry:
      "No keychain entry present — claude signs in via /login on the next start anyway.",
    reloginBlocked:
      "Resetting requires an unused pool. These projects are still running:",
    keychainUnavailableTitle: "No secure storage",
    keychainUnavailable:
      "Keychain/keyring is unavailable. The key can instead be stored unprotected as a file in the pool folder (0600).",
    storeAsFile: "Store as file",
  },
};

const stored = localStorage.getItem("lang");
const initial =
  stored === "de" || stored === "en"
    ? stored
    : navigator.language.startsWith("de")
      ? "de"
      : "en";

export const i18n = createI18n({
  legacy: false,
  globalInjection: true,
  locale: initial,
  fallbackLocale: "en",
  messages: { de, en },
});

export function setLocale(lang: "de" | "en") {
  i18n.global.locale.value = lang;
  localStorage.setItem("lang", lang);
}

-      "\"Remove from list only\" drops the entry; the folder stays untouched.",

+      "Remove from list only drops the entry; the folder stays untouched.",


TryMenü für Projektliste mit Start/Stop-Button


Bash

Rebuild frontend


dist/assets/terminal-DRQoelm4.js                                678.64 kB │ gzip: 186.29 kB

✓ built in 830ms
[plugin builtin:vite-reporter]
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.


Frontend baut. Noch das README nachziehen:


Edit

- **Projekte** — Verzeichnisse unter `~/claude-projects/<name>` (erkennbar am `.claude`-Ordner). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).

- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).


The file /home/marcuh/projects/ai-control/README.md has been updated successfully. (file state is current in your context — no need to Read it back)

# ai-control

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

## Zweck

Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:

- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — Verzeichnisse unter `~/claude-projects/<name>` (erkennbar am `.claude`-Ordner). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und kann dann (opt-in) den Projektkontext per git-sync committen und pushen.

## Projektaufbau

```
├── index.html            Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html         Terminal-Fenster (xterm.js)
├── src/                  Frontend: Vue 3 + TypeScript
├── src-tauri/
│   ├── src/lib.rs        Tauri-Commands: Projekte, Pools, Keychain,
│   │                     OAuth-Login, Tray-Menü, Session-Watcher
│   ├── src/terminal.rs   PTY-Sessions (portable-pty), Terminal-Fenster,
│   │                     Dock-Icon, Fokussieren laufender Terminals
│   └── icons/            App- und Tray-Icons
├── dev.sh                Entwicklungsmodus (tauri dev)
└── build.sh              Release-Build (.app-Bundle)
```

Zwei Prozessrollen aus einem Binary:

- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.

Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.

## Tooling

| Bereich   | Stack |
|-----------|-------|
| Shell     | Tauri 2 (Rust) |
| Frontend  | Vue 3, TypeScript, Vite |
| Terminal  | xterm.js, portable-pty |
| macOS     | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets   | keyring (macOS Keychain) |

## Entwicklung

```sh
./dev.sh      # tauri dev mit Hot-Reload
./build.sh    # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```

`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.

Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.

## Konfigurationslayout

```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

~/claude-projects/<projekt>/
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```

-- **Projekte** — Verzeichnisse unter `~/claude-projects/<name>` (erkennbar am `.claude`-Ordner). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).

+- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).


- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und kann dann (opt-in) den Projektkontext per git-sync committen und pushen.

- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und synct dann (opt-in) das Git-Repo, in dem das Projekt liegt: add → commit → pull --rebase → push.


# ai-control

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

## Zweck

Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:

- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und kann dann (opt-in) den Projektkontext per git-sync committen und pushen.

## Projektaufbau

```
├── index.html            Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html         Terminal-Fenster (xterm.js)
├── src/                  Frontend: Vue 3 + TypeScript
├── src-tauri/
│   ├── src/lib.rs        Tauri-Commands: Projekte, Pools, Keychain,
│   │                     OAuth-Login, Tray-Menü, Session-Watcher
│   ├── src/terminal.rs   PTY-Sessions (portable-pty), Terminal-Fenster,
│   │                     Dock-Icon, Fokussieren laufender Terminals
│   └── icons/            App- und Tray-Icons
├── dev.sh                Entwicklungsmodus (tauri dev)
└── build.sh              Release-Build (.app-Bundle)
```

Zwei Prozessrollen aus einem Binary:

- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.

Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.

## Tooling

| Bereich   | Stack |
|-----------|-------|
| Shell     | Tauri 2 (Rust) |
| Frontend  | Vue 3, TypeScript, Vite |
| Terminal  | xterm.js, portable-pty |
| macOS     | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets   | keyring (macOS Keychain) |

## Entwicklung

```sh
./dev.sh      # tauri dev mit Hot-Reload
./build.sh    # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```

`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.

Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.

## Konfigurationslayout

```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

~/claude-projects/<projekt>/
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```

-- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und kann dann (opt-in) den Projektkontext per git-sync committen und pushen.

+- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und synct dann (opt-in) das Git-Repo, in dem das Projekt liegt: add → commit → pull --rebase → push.


```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

~/claude-projects/<projekt>/
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```

```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
├── projects.json                 Projekt-Registry: Name → Ordner
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

<projektordner>/                  beliebiger Pfad, per Registry gemappt
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```


```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
├── projects.json                 Projekt-Registry: Name → Ordner
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

<projektordner>/                  beliebiger Pfad, per Registry gemappt
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```

# ai-control

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

## Zweck

Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:

- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und synct dann (opt-in) das Git-Repo, in dem das Projekt liegt: add → commit → pull --rebase → push.

## Projektaufbau

```
├── index.html            Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html         Terminal-Fenster (xterm.js)
├── src/                  Frontend: Vue 3 + TypeScript
├── src-tauri/
│   ├── src/lib.rs        Tauri-Commands: Projekte, Pools, Keychain,
│   │                     OAuth-Login, Tray-Menü, Session-Watcher
│   ├── src/terminal.rs   PTY-Sessions (portable-pty), Terminal-Fenster,
│   │                     Dock-Icon, Fokussieren laufender Terminals
│   └── icons/            App- und Tray-Icons
├── dev.sh                Entwicklungsmodus (tauri dev)
└── build.sh              Release-Build (.app-Bundle)
```

Zwei Prozessrollen aus einem Binary:

- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.

Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.

## Tooling

| Bereich   | Stack |
|-----------|-------|
| Shell     | Tauri 2 (Rust) |
| Frontend  | Vue 3, TypeScript, Vite |
| Terminal  | xterm.js, portable-pty |
| macOS     | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets   | keyring (macOS Keychain) |

## Entwicklung

```sh
./dev.sh      # tauri dev mit Hot-Reload
./build.sh    # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```

`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.

Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.

## Konfigurationslayout

```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

~/claude-projects/<projekt>/
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```

+├── projects.json                 Projekt-Registry: Name → Ordner

└── pool.json                 Name + Credential-Typ

+<projektordner>/                  beliebiger Pfad, per Registry gemappt


Umbau fertig. Backend-Tests grün (48/48), Frontend baut.

**Was sich geändert hat:**

- **Registry** `~/.config/ai-control/projects.json` (Name → Ordner, Home-Pfade als `~/…` gespeichert). Ohne die Datei greift der Alt-Layout-Scan über `~/claude-projects` — dein Setup läuft also unverändert weiter, bis die Registry beim ersten Anlegen/Aufnehmen/Entfernen materialisiert wird und dabei die bestehenden Projekte übernimmt.
- **Alle Pfad-Lookups** (Projektliste, Terminal-Start/-CWD, ai-control.json, Todo-Hook, Icons, Verbrauchsstatistik, Pool-Nutzung) laufen über die Registry; die Hartkodierungen in terminal.rs sind raus.
- **Neue Commands:** `add_project` (bestehenden Ordner aufnehmen, Name = Ordnername) und `remove_project` (nur Listeneintrag, Ordner bleibt).
- **UI:** Toolbar-Button „+ Ordner" mit Ordner-Picker; im Wizard wählbarer Ablageort (Default `~/claude-projects/<name>` oder eigener Ordner, Projekt entsteht als `<ordner>/<name>`); im Lösch-Dialog zusätzlich „Nur aus Liste nehmen". i18n de/en ergänzt.
- **Sync:** statt des `pool/bin/git-sync`-Skripts synct der Watcher jetzt das Git-Repo, in dem das jeweilige Projekt liegt (aufwärts gesucht): add → commit → pull --rebase → push; nichts zu committen beendet still. Funktioniert damit an beliebigen Pfaden.
- **README** entsprechend aktualisiert.

Nicht gebaut/committet — sag Bescheid, dann Build + Deploy + Commit wie gehabt.


ok --
migriesrt du mein setup?


Bash

for d in ~/claude-projects/*/; do [ -d "$d.claude" ] && basename "$d"; done

List legacy-layout projects with .claude


08-07
ai-control
limbach
misc
robotunits
wittgenstein
wuerth-plato


Write

{
  "08-07": "~/claude-projects/08-07",
  "ai-control": "~/claude-projects/ai-control",
  "limbach": "~/claude-projects/limbach",
  "misc": "~/claude-projects/misc",
  "robotunits": "~/claude-projects/robotunits",
  "wittgenstein": "~/claude-projects/wittgenstein",
  "wuerth-plato": "~/claude-projects/wuerth-plato"
}


File created successfully at: /home/marcuh/.config/ai-control/projects.json (file state is current in your context — no need to Read it back)


Migriert: `~/.config/ai-control/projects.json` enthält jetzt alle 7 Projekte (08-07, ai-control, limbach, misc, robotunits, wittgenstein, wuerth-plato) mit `~/…`-Pfaden — identisch zu dem, was der Alt-Layout-Scan geliefert hätte. Die laufende App (altes Binary) ignoriert die Datei; nach Deploy und Neustart liest die neue Version direkt daraus.


comit / oush / build / deloy


Bash

Show working tree status


 M README.md
 M src-tauri/src/lib.rs
 M src-tauri/src/terminal.rs
 M src/components/ProjectList.vue
 M src/i18n.ts


git add -A && git commit -m 'Projekt-Registry statt festem ~/claude-projects-Root: projects.json (Name → Pfad, ~-relativ) mit Alt-Layout-Fallback; Ordner frei mappbar (add/remove_project, Wizard-Ablageort); Session-Sync per Projekt-Repo (add/commit/pull --rebase/push) statt git-sync-Skript; README + GitHub-Release-Fassung' && git push 2>&1 | tail -2

Commit and push registry refactor


[main ec3d2f4] Projekt-Registry statt festem ~/claude-projects-Root: projects.json (Name → Pfad, ~-relativ) mit Alt-Layout-Fallback; Ordner frei mappbar (add/remove_project, Wizard-Ablageort); Session-Sync per Projekt-Repo (add/commit/pull --rebase/push) statt git-sync-Skript; README + GitHub-Release-Fassung
 5 files changed, 419 insertions(+), 111 deletions(-)
To ssh://git.lintus.art:2222/marcus.hinz/ai-control.git
   ddd8984..ec3d2f4  main -> main


Build release bundle


failed to scan /home/marcuh/projects/ai-control/src-tauri/target/release/bundle/macos/ai-control.app: -10814
 from spotlightfailed to scan /home/marcuh/projects/ai-control/src-tauri/target/release/bundle/macos.noindex/ai-control.app: -10814
 from spotlight


rm -rf ~/Applications/ai-control.app && cp -R /home/marcuh/projects/ai-control/src-tauri/target/release/bundle/macos.noindex/ai-control.app ~/Applications/ && ls -d ~/Applications/ai-control.app

Deploy bundle to ~/Applications


Erledigt: Commit `ec3d2f4` gepusht, Release gebaut, `~/Applications/ai-control.app` ersetzt. Die Registry ist bereits migriert — nach dem Neustart der Tray-App (machst du) läuft alles über `projects.json`.


können wir die Altlogik rausnehmen?
macht keinen sin und ist nur komisch und zu pflegen


- 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-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


Edit

/// Registry Name → Projektordner. Ohne projects.json gilt das Alt-Layout:
/// Unterordner von ~/claude-projects mit .claude-Ordner.
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
  let file = paths.projects_file();
  if file.is_file() {
    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()))?;
    return Ok(
      map
        .into_iter()
        .map(|(name, p)| {
          let dir = expand_home(paths, &p);
          (name, dir)
        })
        .collect(),
    );
  }
  let mut map = std::collections::BTreeMap::new();
  let Ok(entries) = fs::read_dir(paths.projects_dir()) else {
    return Ok(map); // kein Alt-Layout → keine Projekte
  };
  for entry in entries {
    let entry = entry.map_err(|e| e.to_string())?;
    if entry.path().join(".claude").is_dir() {
      map.insert(entry.file_name().to_string_lossy().into_owned(), entry.path());
    }
  }
  Ok(map)
}

/// 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(),
  )
}


The file /home/marcuh/projects/ai-control/src-tauri/src/lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// Registry Name → Projektordner. Ohne projects.json gilt das Alt-Layout:
/// Unterordner von ~/claude-projects mit .claude-Ordner.
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
  let file = paths.projects_file();
  if file.is_file() {
    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()))?;
    return Ok(
      map
        .into_iter()
        .map(|(name, p)| {
          let dir = expand_home(paths, &p);
          (name, dir)
        })
        .collect(),
    );
  }
  let mut map = std::collections::BTreeMap::new();
  let Ok(entries) = fs::read_dir(paths.projects_dir()) else {
    return Ok(map); // kein Alt-Layout → keine Projekte
  };
  for entry in entries {
    let entry = entry.map_err(|e| e.to_string())?;
    if entry.path().join(".claude").is_dir() {
      map.insert(entry.file_name().to_string_lossy().into_owned(), entry.path());
    }
  }
  Ok(map)
}

/// 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(),
  )
}

-/// Registry Name → Projektordner. Ohne projects.json gilt das Alt-Layout:

-/// Unterordner von ~/claude-projects mit .claude-Ordner.

+/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.

-    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()))?;

-          let dir = expand_home(paths, &p);

+    return Ok(std::collections::BTreeMap::new());

-  let mut map = std::collections::BTreeMap::new();

-  let Ok(entries) = fs::read_dir(paths.projects_dir()) else {

-    return Ok(map); // kein Alt-Layout → keine Projekte

-    let entry = entry.map_err(|e| e.to_string())?;

-    if entry.path().join(".claude").is_dir() {

-      map.insert(entry.file_name().to_string_lossy().into_owned(), entry.path());

+  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()))?;

+        let dir = expand_home(paths, &p);


/// Nimmt ein Projekt in die Registry auf; materialisiert dabei auch die
/// per Alt-Layout gefundenen Einträge in projects.json.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {

/// Nimmt ein Projekt in die Registry auf.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {


/// Nimmt ein Projekt in die Registry auf; materialisiert dabei auch die
/// per Alt-Layout gefundenen Einträge in projects.json.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {

/// Nimmt ein Projekt in die Registry auf.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {

.ok_or_else(|| format!("Projekt nicht registriert: {name}"))

-/// Nimmt ein Projekt in die Registry auf; materialisiert dabei auch die

-/// per Alt-Layout gefundenen Einträge in projects.json.

+/// Nimmt ein Projekt in die Registry auf.


  // Registry-Snapshot vor dem Anlegen — der Alt-Layout-Scan würde den frisch
  // angelegten Ordner sonst schon als bestehendes Projekt sehen.
  let mut reg = load_registry(paths)?;
  if reg.contains_key(name) {
    return Err(format!("Projekt existiert bereits: {name}"));
  }

  let mut reg = load_registry(paths)?;
  if reg.contains_key(name) {
    return Err(format!("Projekt existiert bereits: {name}"));
  }


  // Registry-Snapshot vor dem Anlegen — der Alt-Layout-Scan würde den frisch
  // angelegten Ordner sonst schon als bestehendes Projekt sehen.
  let mut reg = load_registry(paths)?;
  if reg.contains_key(name) {
    return Err(format!("Projekt existiert bereits: {name}"));
  }

-  // Registry-Snapshot vor dem Anlegen — der Alt-Layout-Scan würde den frisch

-  // angelegten Ordner sonst schon als bestehendes Projekt sehen.


  fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
  // Vor der Materialisierung gab es keinen Eintrag zu entfernen.
  if paths.projects_file().is_file() {
    unregister_project(paths, name)?;
  }
  Ok(())
}

  fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
  unregister_project(paths, name)
}


  fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
  // Vor der Materialisierung gab es keinen Eintrag zu entfernen.
  if paths.projects_file().is_file() {
    unregister_project(paths, name)?;
  }
  Ok(())
}

  fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
  unregister_project(paths, name)
}

-  // Vor der Materialisierung gab es keinen Eintrag zu entfernen.

fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {


  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
  // Registrieren nur, wenn die Registry schon materialisiert ist — sonst
  // greift der Alt-Layout-Scan.
  if paths.projects_file().is_file() {
    register_project(paths, name, &dir)?;
  }
  Ok(())
}

  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
  register_project(paths, name, &dir)
}


  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
  // Registrieren nur, wenn die Registry schon materialisiert ist — sonst
  // greift der Alt-Layout-Scan.
  if paths.projects_file().is_file() {
    register_project(paths, name, &dir)?;
  }
  Ok(())
}

  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
  register_project(paths, name, &dir)
}

-  // Registrieren nur, wenn die Registry schon materialisiert ist — sonst

-    register_project(paths, name, &dir)?;

/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.


/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
fn remove_project(name: String) -> Result<(), String> {
  if is_running(&name) {
    return Err(format!("{name} läuft noch — erst beenden"));
  }
  let paths = Paths::real();
  // Alt-Layout erst materialisieren, sonst taucht das Projekt beim
  // nächsten Scan wieder auf.
  if !paths.projects_file().is_file() {
    save_registry(&paths, &load_registry(&paths)?)?;
  }
  unregister_project(&paths, &name)
}

/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
fn remove_project(name: String) -> Result<(), String> {
  if is_running(&name) {
    return Err(format!("{name} läuft noch — erst beenden"));
  }
  unregister_project(&Paths::real(), &name)
}


/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
fn remove_project(name: String) -> Result<(), String> {
  if is_running(&name) {
    return Err(format!("{name} läuft noch — erst beenden"));
  }
  let paths = Paths::real();
  // Alt-Layout erst materialisieren, sonst taucht das Projekt beim
  // nächsten Scan wieder auf.
  if !paths.projects_file().is_file() {
    save_registry(&paths, &load_registry(&paths)?)?;
  }
  unregister_project(&paths, &name)
}

/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
fn remove_project(name: String) -> Result<(), String> {
  if is_running(&name) {
    return Err(format!("{name} läuft noch — erst beenden"));
  }
  unregister_project(&Paths::real(), &name)
}

-  // Alt-Layout erst materialisieren, sonst taucht das Projekt beim

-  if !paths.projects_file().is_file() {

-    save_registry(&paths, &load_registry(&paths)?)?;

+  unregister_project(&Paths::real(), &name)


können wir die Altlogik rausnehmen? macht keinen sin und ist nur komisch und zu pflegen


- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).

- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).


# ai-control

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

## Zweck

Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:

- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und synct dann (opt-in) das Git-Repo, in dem das Projekt liegt: add → commit → pull --rebase → push.

## Projektaufbau

```
├── index.html            Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html         Terminal-Fenster (xterm.js)
├── src/                  Frontend: Vue 3 + TypeScript
├── src-tauri/
│   ├── src/lib.rs        Tauri-Commands: Projekte, Pools, Keychain,
│   │                     OAuth-Login, Tray-Menü, Session-Watcher
│   ├── src/terminal.rs   PTY-Sessions (portable-pty), Terminal-Fenster,
│   │                     Dock-Icon, Fokussieren laufender Terminals
│   └── icons/            App- und Tray-Icons
├── dev.sh                Entwicklungsmodus (tauri dev)
└── build.sh              Release-Build (.app-Bundle)
```

Zwei Prozessrollen aus einem Binary:

- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.

Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.

## Tooling

| Bereich   | Stack |
|-----------|-------|
| Shell     | Tauri 2 (Rust) |
| Frontend  | Vue 3, TypeScript, Vite |
| Terminal  | xterm.js, portable-pty |
| macOS     | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets   | keyring (macOS Keychain) |

## Entwicklung

```sh
./dev.sh      # tauri dev mit Hot-Reload
./build.sh    # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```

`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.

Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.

## Konfigurationslayout

```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
├── projects.json                 Projekt-Registry: Name → Ordner
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

<projektordner>/                  beliebiger Pfad, per Registry gemappt
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```

-- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).

+- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).


Bash

export CARGO_HOME="$HOME/tools/.cargo" RUSTUP_HOME="$HOME/tools/.rustup" PATH="$HOME/tools/.cargo/bin:$PATH"; cargo test --manifest-path /home/marcuh/projects/ai-control/src-tauri/Cargo.toml 2>&1 | grep -E "FAILED|test result|panicked at|^error" -A 2 | head -40

Run test suite after removing legacy scan


test result: ok. 48 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.59s

     Running unittests src/main.rs (/home/marcuh/projects/ai-control/src-tauri/target/debug/deps/ai_control-cb387f5a798b9c01)
--
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests app_lib
--
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Alt-Logik entfernt, Tests grün (48/48):

- `load_registry`: ohne `projects.json` leere Liste — kein Scan über `~/claude-projects` mehr.
- Die Materialisierungs-Sonderfälle in `delete_project_in`, `remove_project` und dem Test-Helper sind raus; registrieren/deregistrieren läuft jetzt überall bedingungslos.
- README-Satz zum Alt-Layout gestrichen.

`~/claude-projects` bleibt nur noch als Default-Ablageort für neue Projekte ohne gewählten Zielordner. Dein Setup ist unbetroffen — die Registry ist ja bereits migriert. Commit/Build/Deploy auf Zuruf.


Ein kleiner bug noch -- kannst die Terminals nach dem öffnen aktivieren? Ich hatte gerade ein pop under


Ursache: Die Terminal-Prozesse werden per `Command::spawn` gestartet (ohne Launch Services) — seit macOS 14 ist Aktivierung kooperativ, der neue Prozess darf sich nicht selbst nach vorn bringen, solange die aktive App nicht abtritt. Fix: der startende Prozess tritt die Aktivierung an die nächste Instanz der eigenen Bundle-ID ab, der Terminal-Prozess aktiviert sich beim Ready-Event.


Edit

/// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als
/// Terminal-Prozess (`--terminal <projekt>`), damit jedes Terminal ein
/// eigenes Dock-Icon bekommt.
#[tauri::command]
pub fn open_terminal(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 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(())
}

/// Ö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).
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));
}

/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
/// die Gegenseite hat vorher per yield abgetreten.
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");
  }
}


The file /home/marcuh/projects/ai-control/src-tauri/src/terminal.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// Ö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).
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));
}

/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
/// die Gegenseite hat vorher per yield abgetreten.
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");
  }
}

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.
#[tauri::command]
pub fn open_terminal(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 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(())
}

/// 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));
  }
}

/// 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.
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);
}

/// 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);

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

/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst.
#[tauri::command]
pub fn term_start(
  window: tauri::WebviewWindow,
  terminals: State<Terminals>,
  project: String,
  rows: u16,
  cols: u16,
) -> Result<(), String> {
  let cwd = crate::project_dir(&crate::Paths::real(), &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", "claude-sync"]);
  cmd.cwd(&cwd);
  cmd.env("TERM", "xterm-256color");

  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();
  }
}

+/// 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.

-pub fn open_terminal(project: String) -> Result<(), String> {

+pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {

+  let bundle_id = app.config().identifier.clone();

+    .run_on_main_thread(move || yield_activation_to_bundle(&bundle_id))

+/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser

+/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben).

+fn yield_activation_to_bundle(bundle_id: &str) {

+    MainThreadMarker::new().expect("yield_activation_to_bundle läuft nicht auf dem Main-Thread");

+    .yieldActivationToApplicationWithBundleIdentifier(&NSString::from_str(bundle_id));

+/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);

+/// die Gegenseite hat vorher per yield abgetreten.

+pub fn activate_self(app: &AppHandle) {

+  let mtm = MainThreadMarker::new().expect("activate_self läuft nicht auf dem Main-Thread");

+  if let Some(window) = app.webview_windows().values().next() {

+    window.set_focus().expect("Terminal-Fenster nicht fokussierbar");


/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
/// interne Terminal neu.
#[tauri::command]
fn restart_project(project: String) -> Result<(), String> {
  kill_terminals(&project)?;
  let deadline = Instant::now() + Duration::from_secs(30);
  while is_running(&project) {
    if Instant::now() > deadline {
      return Err(format!("{project} hat sich nach 30 s nicht beendet"));
    }
    std::thread::sleep(Duration::from_millis(250));
  }
  terminal::open_terminal(project)
}

/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
/// interne Terminal neu.
#[tauri::command]
fn restart_project(app: tauri::AppHandle, project: String) -> Result<(), String> {
  kill_terminals(&project)?;
  let deadline = Instant::now() + Duration::from_secs(30);
  while is_running(&project) {
    if Instant::now() > deadline {
      return Err(format!("{project} hat sich nach 30 s nicht beendet"));
    }
    std::thread::sleep(Duration::from_millis(250));
  }
  terminal::open_terminal(app, project)
}


/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
/// interne Terminal neu.
#[tauri::command]
fn restart_project(project: String) -> Result<(), String> {
  kill_terminals(&project)?;
  let deadline = Instant::now() + Duration::from_secs(30);
  while is_running(&project) {
    if Instant::now() > deadline {
      return Err(format!("{project} hat sich nach 30 s nicht beendet"));
    }
    std::thread::sleep(Duration::from_millis(250));
  }
  terminal::open_terminal(project)
}

/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
/// interne Terminal neu.
#[tauri::command]
fn restart_project(app: tauri::AppHandle, project: String) -> Result<(), String> {
  kill_terminals(&project)?;
  let deadline = Instant::now() + Duration::from_secs(30);
  while is_running(&project) {
    if Instant::now() > deadline {
      return Err(format!("{project} hat sich nach 30 s nicht beendet"));
    }
    std::thread::sleep(Duration::from_millis(250));
  }
  terminal::open_terminal(app, project)
}

-fn restart_project(project: String) -> Result<(), String> {

+fn restart_project(app: tauri::AppHandle, project: String) -> Result<(), String> {

+  terminal::open_terminal(app, project)


/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es. Das Label wechselt, sobald der Watcher den neuen Status sieht.
fn start_or_focus(project: &str) {
  match terminal_pids(project).first() {
    Some(pid) => terminal::focus_terminal(*pid),
    None => {
      if let Err(e) = terminal::open_terminal(project.to_string()) {
        eprintln!("{project} starten: {e}");
      }
    }
  }
}

/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es.
fn start_or_focus(app: &tauri::AppHandle, project: &str) {
  match terminal_pids(project).first() {
    Some(pid) => terminal::focus_terminal(*pid),
    None => {
      if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) {
        eprintln!("{project} starten: {e}");
      }
    }
  }
}


/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es. Das Label wechselt, sobald der Watcher den neuen Status sieht.
fn start_or_focus(project: &str) {
  match terminal_pids(project).first() {
    Some(pid) => terminal::focus_terminal(*pid),
    None => {
      if let Err(e) = terminal::open_terminal(project.to_string()) {
        eprintln!("{project} starten: {e}");
      }
    }
  }
}

/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es.
fn start_or_focus(app: &tauri::AppHandle, project: &str) {
  match terminal_pids(project).first() {
    Some(pid) => terminal::focus_terminal(*pid),
    None => {
      if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) {
        eprintln!("{project} starten: {e}");
      }
    }
  }
}

-/// sonst startet es. Das Label wechselt, sobald der Watcher den neuen Status sieht.

+fn start_or_focus(app: &tauri::AppHandle, project: &str) {

-      if let Err(e) = terminal::open_terminal(project.to_string()) {

+      if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) {


          id => {
            if let Some(project) = id.strip_prefix("project:") {
              start_or_focus(project);
            }
          }

          id => {
            if let Some(project) = id.strip_prefix("project:") {
              start_or_focus(app, project);
            }
          }


          id => {
            if let Some(project) = id.strip_prefix("project:") {
              start_or_focus(project);
            }
          }

          id => {
            if let Some(project) = id.strip_prefix("project:") {
              start_or_focus(app, project);
            }
          }

+              start_or_focus(app, project);


        .run(move |_app, event| {
          if let tauri::RunEvent::Ready = event {
            if let Some(icon) = icon.as_deref() {
              terminal::set_dock_icon(icon);
            }
          }
        });

        .run(move |app, event| {
          if let tauri::RunEvent::Ready = event {
            if let Some(icon) = icon.as_deref() {
              terminal::set_dock_icon(icon);
            }
            terminal::activate_self(app);
          }
        });


        .run(move |_app, event| {
          if let tauri::RunEvent::Ready = event {
            if let Some(icon) = icon.as_deref() {
              terminal::set_dock_icon(icon);
            }
          }
        });

        .run(move |app, event| {
          if let tauri::RunEvent::Ready = event {
            if let Some(icon) = icon.as_deref() {
              terminal::set_dock_icon(icon);
            }
            terminal::activate_self(app);
          }
        });

.expect("error while building tauri application")

// Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt

// überschreibt macOS es beim Anlegen des Dock-Tiles wieder.

+            terminal::activate_self(app);


Bash

export CARGO_HOME="$HOME/tools/.cargo" RUSTUP_HOME="$HOME/tools/.rustup" PATH="$HOME/tools/.cargo/bin:$PATH"; cargo check --manifest-path /home/marcuh/projects/ai-control/src-tauri/Cargo.toml 2>&1 | grep -E "^(error|warning)" -A 6 | head -40

Type-check activation fix


(Bash completed with no output)


Kompiliert sauber. Umsetzung:

- **Starterseite** (`open_terminal`): tritt vor dem Spawn per `yieldActivationToApplicationWithBundleIdentifier` die Aktivierung an den nächsten startenden Prozess der eigenen Bundle-ID ab — das deckt Tray-Klick, Hauptfenster-Start und Restart ab.
- **Terminalseite**: beim Ready-Event aktiviert sich der Prozess (`NSApplication.activate()`) und fokussiert sein Fenster.

Der Grund fürs Pop-under: `Command::spawn` läuft an Launch Services vorbei, und seit macOS 14 darf ein Prozess sich ohne Abtretung der aktiven App nicht selbst nach vorn bringen.

Bereit für Commit/Build/Deploy auf dein Go.


los


git add -A && git commit -m 'Terminal öffnet als aktives Fenster: Starter tritt Aktivierung per yieldActivationToApplicationWithBundleIdentifier ab, Terminal-Prozess aktiviert sich beim Ready-Event (kooperative Aktivierung seit macOS 14)' && git push 2>&1 | tail -2 && ./build.sh 2>&1 | tail -2 && rm -rf ~/Applications/ai-control.app && cp -R src-tauri/target/release/bundle/macos.noindex/ai-control.app ~/Applications/ && echo DEPLOYED

Commit, push, build and deploy


Error: Exit code 1
[main b4ca889] Terminal öffnet als aktives Fenster: Starter tritt Aktivierung per yieldActivationToApplicationWithBundleIdentifier ab, Terminal-Prozess aktiviert sich beim Ready-Event (kooperative Aktivierung seit macOS 14)
 3 files changed, 727 insertions(+)
 create mode 100644 pool/private/projects/-Users-marcus-hinz-claude-projects-ai-control/7fa72046-ab7a-4b41-a496-f0f6b9a4729d.jsonl
To ssh://git.lintus.art:2222/marcus.hinz/claude-projects.git
   e274dd4..b4ca889  main -> main
(eval):1: no such file or directory: ./build.sh
cp: src-tauri/target/release/bundle/macos.noindex/ai-control.app: No such file or directory


cd /home/marcuh/claude-projects && git log --oneline -1 && git commit --amend -m 'claude sync: 2026-07-05' && git push --force-with-lease 2>&1 | tail -2

Fix wrong commit message in claude-projects repo


b4ca889 Terminal öffnet als aktives Fenster: Starter tritt Aktivierung per yieldActivationToApplicationWithBundleIdentifier ab, Terminal-Prozess aktiviert sich beim Ready-Event (kooperative Aktivierung seit macOS 14)
[main b25172d] claude sync: 2026-07-05
 Date: Sun Jul 5 14:08:34 2026 +0200
 3 files changed, 727 insertions(+)
 create mode 100644 pool/private/projects/-Users-marcus-hinz-claude-projects-ai-control/7fa72046-ab7a-4b41-a496-f0f6b9a4729d.jsonl
To ssh://git.lintus.art:2222/marcus.hinz/claude-projects.git
 + b4ca889...b25172d main -> main (forced update)
Shell cwd was reset to /home/marcuh/claude-projects/ai-control


cd /home/marcuh/projects/ai-control && git status --short && git add src-tauri/src/lib.rs src-tauri/src/terminal.rs && git commit -m 'Terminal öffnet als aktives Fenster: Starter tritt Aktivierung per yieldActivationToApplicationWithBundleIdentifier ab, Terminal-Prozess aktiviert sich beim Ready-Event (kooperative Aktivierung seit macOS 14)' && git push 2>&1 | tail -2

Commit and push in the app repo


 M README.md
 M src-tauri/src/lib.rs
 M src-tauri/src/terminal.rs
[main 65e9df1] Terminal öffnet als aktives Fenster: Starter tritt Aktivierung per yieldActivationToApplicationWithBundleIdentifier ab, Terminal-Prozess aktiviert sich beim Ready-Event (kooperative Aktivierung seit macOS 14)
 2 files changed, 59 insertions(+), 57 deletions(-)
To ssh://git.lintus.art:2222/marcus.hinz/ai-control.git
   ec3d2f4..65e9df1  main -> main


git add README.md && git commit -m 'Alt-Layout-Fallback entfernt: Projekte kommen ausschließlich aus projects.json; README angepasst' && git push 2>&1 | tail -2 && ./build.sh 2>&1 | tail -2 && rm -rf ~/Applications/ai-control.app && cp -R src-tauri/target/release/bundle/macos.noindex/ai-control.app ~/Applications/ && echo DEPLOYED

Commit README, build and deploy


[main b5995da] Alt-Layout-Fallback entfernt: Projekte kommen ausschließlich aus projects.json; README angepasst
 1 file changed, 1 insertion(+), 1 deletion(-)
To ssh://git.lintus.art:2222/marcus.hinz/ai-control.git
   65e9df1..b5995da  main -> main
 from spotlightfailed to scan /home/marcuh/projects/ai-control/src-tauri/target/release/bundle/macos.noindex/ai-control.app: -10814
 from spotlightDEPLOYED


Erledigt — mit einem Zwischenfall, der behoben ist:

- **Fehlgriff:** Der erste Commit lief im falschen Verzeichnis (die Shell hatte das Arbeitsverzeichnis auf `~/claude-projects/ai-control` zurückgesetzt) und hat im claude-projects-Repo Session-Transkripte mit der Fix-Message committet und gepusht. Korrigiert: Message per amend auf „claude sync: 2026-07-05" geändert und mit `--force-with-lease` gepusht — Inhalt entspricht damit einem normalen Sync-Commit.
- **App-Repo:** `65e9df1` (Aktivierungs-Fix; enthält auch die noch nicht committete Altlogik-Entfernung in lib.rs — die Message nennt nur den Fix) und `b5995da` (README-Anpassung zur Altlogik) gepusht.
- **Build + Deploy:** neues Bundle liegt unter `~/Applications/ai-control.app`.

Nach dem App-Neustart: Tray/UI-Start eines Projekts sollte das Terminal-Fenster jetzt aktiv nach vorn bringen.


dann weiter GitHub
ReaMe ok für Dich?


Inhaltlich ja — Purpose, Aufbau, Tooling, Konfiguration stimmen mit dem Code überein und sind auf Stand (Registry, Sync, Tray). Für einen GitHub-Release sehe ich vier offene Punkte:

1. **Sprache:** Das README ist deutsch. Für ein öffentliches Repo ist Englisch die Konvention — deutsch geht, schränkt das Publikum aber deutlich ein.
2. **Lizenz:** Es gibt keine LICENSE-Datei (Cargo.toml: `license = ""`). Ohne Lizenz ist der Code formal „all rights reserved" — für einen Release der wichtigste fehlende Baustein.
3. **`claude-sync`-Hardcode:** `term_start` startet `zsh -ic claude-sync` — dein persönliches Skript. Bei jedem anderen Nutzer scheitert der Terminal-Start; das README verspricht aber „startet Claude Code". Entweder Code auf `claude` umstellen/konfigurierbar machen oder im README als Voraussetzung nennen.
4. **Screenshot:** üblich und für eine Tray-App mit Terminal quasi Pflicht, fehlt.

Punkt 3 ist der einzige, der den Release funktional betrifft; 1, 2 und 4 sind Form.


englisch -> bin ich bei dir
claude executable machen wir konfugurierbar und setzen es im default auf claude
Lizenz --> was würdest du vorschlagen?
Und Screenshots -> kan ich die machen, oder kriegst Du das hin


Lizenz: **MIT** — für ein kleines Tool die Standardwahl: maximal permissiv, ein File, jeder kennt sie, kompatibel mit allem. (Apache-2.0 nur, falls dir explizite Patentklauseln wichtig sind — hier kein Bedarf.)

Screenshots: machst besser du — die App läuft bei dir, und deine echten Projekt-/Poolnamen sollten ohnehin nicht ins öffentliche Repo; leg dir dafür kurz Demo-Projekte an. Sinnvolle Motive: Projektliste (Hauptfenster), Tray-Menü offen, ein Terminal-Fenster.

Ich setze jetzt die zwei beschlossenen Punkte um: konfigurierbares Claude-Kommando (Default `claude`) und englisches README. Dabei fällt mir auf: `term_start` verlässt sich komplett auf dein `claude-sync` — auch für das Setzen von `CLAUDE_CONFIG_DIR`. Damit die App standalone funktioniert, setzt sie das Pool-Verzeichnis jetzt selbst als Env; dein Setup bekommt `claudeCommand: claude-sync` in die settings.json, verhält sich also unverändert.


Edit

/// Setzt das Opt-in; erhält übrige App-settings.

/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
pub(crate) fn claude_command(paths: &Paths) -> String {
  fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
    .ok()
    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
    .and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
    .unwrap_or_else(|| "claude".into())
}

/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben.
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
  let paths = Paths::real();
  Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
}

/// Setzt das Opt-in; erhält übrige App-settings.


/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
pub(crate) fn claude_command(paths: &Paths) -> String {
  fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
    .ok()
    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
    .and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
    .unwrap_or_else(|| "claude".into())
}

/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben.
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
  let paths = Paths::real();
  Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
}

/// Setzt das Opt-in; erhält übrige App-settings.

+/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).

+pub(crate) fn claude_command(paths: &Paths) -> String {

+    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())

+    .and_then(|v| v["claudeCommand"].as_str().map(str::to_string))

+    .unwrap_or_else(|| "claude".into())

+/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als

+pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {

+  Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))

fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> {

let path = paths.config_dir().join(APP_SETTINGS_FILE);


/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst.
#[tauri::command]
pub fn term_start(
  window: tauri::WebviewWindow,
  terminals: State<Terminals>,
  project: String,
  rows: u16,
  cols: u16,
) -> Result<(), String> {
  let cwd = crate::project_dir(&crate::Paths::real(), &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", "claude-sync"]);
  cmd.cwd(&cwd);
  cmd.env("TERM", "xterm-256color");

/// 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);
  }


/// 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);
  }

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).
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));
}

/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
/// die Gegenseite hat vorher per yield abgetreten.
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");
  }
}

/// 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));
  }
}

/// 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.
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);
}

/// 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);

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

/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst.
#[tauri::command]
pub fn term_start(
  window: tauri::WebviewWindow,
  terminals: State<Terminals>,
  project: String,
  rows: u16,
  cols: u16,
) -> Result<(), String> {
  let cwd = crate::project_dir(&crate::Paths::real(), &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", "claude-sync"]);
  cmd.cwd(&cwd);
  cmd.env("TERM", "xterm-256color");

  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();
  }
}

-/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.

-/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht

+/// 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).

-  let cwd = crate::project_dir(&crate::Paths::real(), &project)?;

+  let cwd = crate::project_dir(&paths, &project)?;

+  cmd.args(["-ic", &crate::claude_command(&paths)]);

+  if let Some(pool_dir) = crate::project_pool_dir(&project)? {

+    cmd.env("CLAUDE_CONFIG_DIR", pool_dir);


englisch -> bin ich bei dir claude executable machen wir konfugurierbar und setzen es im default auf claude Lizenz --> was würdest du vorschlagen? Und Screenshots -> kan ich die machen, oder kriegst D…


Bash

export CARGO_HOME="$HOME/tools/.cargo" RUSTUP_HOME="$HOME/tools/.rustup" PATH="$HOME/tools/.cargo/bin:$PATH"; cargo check --manifest-path /home/marcuh/projects/ai-control/src-tauri/Cargo.toml 2>&1 | grep -E "^(error|warning)" -A 5 | head -30; cat ~/.config/ai-control/settings.json

Type-check and read current app settings


Error: Exit code 1
cat: /home/marcuh/.config/ai-control/settings.json: No such file or directory


Kompiliert sauber. Die settings.json existiert noch nicht — ich lege sie mit deinem Kommando an:


Write

{
  "claudeCommand": "claude-sync"
}


File created successfully at: /home/marcuh/.config/ai-control/settings.json (file state is current in your context — no need to Read it back)