Compare commits
36 Commits
v0.1.79
...
v0.2.14-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| bab1df1c57 | |||
| b952b8e8de | |||
| d7d7a83aec | |||
| 879322bc9a | |||
| ecaa42fa77 | |||
| 280358166a | |||
| 4732feb33e | |||
| 5977024953 | |||
| 27007b90e3 | |||
| 38e65619e9 | |||
| d2c1c2108a | |||
| cc163e6650 | |||
| 38082059a5 | |||
| beae0942a1 | |||
| 6b49981b3a | |||
| b46b392a9a | |||
| 4889dd974f | |||
| b6fd8a557e | |||
| 93deab68a7 | |||
| 2dce2993cc | |||
| e482452ffd | |||
| 8c710fc7bf | |||
| b7585420ef | |||
| bf8ef3dba1 | |||
| 418afe00ed | |||
| ab16ac11e7 | |||
| 429acd2fb5 | |||
| c853f2676d | |||
| 090aad6bc6 | |||
| c023d80c86 | |||
| 33f02e65c0 | |||
| c5e28f9caa | |||
| 86176d8830 | |||
| 58a10c65e9 | |||
| d56c6e3845 | |||
| 574fca633a |
@@ -5,11 +5,14 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -17,8 +20,43 @@ env:
|
||||
REPO: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
compute-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch all tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Compute version from VERSION file and tags
|
||||
id: version
|
||||
run: |
|
||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||
|
||||
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1)
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
echo "Latest matching tag: ${LATEST_TAG}"
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
else
|
||||
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
|
||||
PATCH=$(git rev-list --count HEAD)
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Computed version: ${VERSION}"
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version]
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
@@ -51,17 +89,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION="0.1.${COMMIT_COUNT}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Computed version: ${VERSION}"
|
||||
|
||||
- name: Set app version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||
@@ -130,7 +160,7 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
TAG="v${{ steps.version.outputs.VERSION }}"
|
||||
TAG="v${{ needs.compute-version.outputs.version }}"
|
||||
# Create release
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
@@ -153,6 +183,7 @@ jobs:
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
needs: [compute-version]
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
@@ -180,17 +211,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION="0.1.${COMMIT_COUNT}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Computed version: ${VERSION}"
|
||||
|
||||
- name: Set app version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||
@@ -240,12 +263,12 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
TAG="v${{ steps.version.outputs.VERSION }}-mac"
|
||||
TAG="v${{ needs.compute-version.outputs.version }}-mac"
|
||||
# Create release
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
@@ -263,6 +286,7 @@ jobs:
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
needs: [compute-version]
|
||||
defaults:
|
||||
run:
|
||||
shell: cmd
|
||||
@@ -272,18 +296,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
|
||||
set "VERSION=0.1.%COMMIT_COUNT%"
|
||||
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
|
||||
echo Computed version: %VERSION%
|
||||
|
||||
- name: Set app version
|
||||
shell: powershell
|
||||
run: |
|
||||
$version = "${{ steps.version.outputs.VERSION }}"
|
||||
$version = "${{ needs.compute-version.outputs.version }}"
|
||||
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
||||
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
||||
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||
@@ -364,9 +380,9 @@ jobs:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
|
||||
set "TAG=v${{ needs.compute-version.outputs.version }}-win"
|
||||
echo Creating release %TAG%...
|
||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
||||
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
||||
:found
|
||||
echo Release ID: %RELEASE_ID%
|
||||
@@ -374,3 +390,123 @@ jobs:
|
||||
echo Uploading %%~nxf...
|
||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
|
||||
)
|
||||
|
||||
create-tag:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create version tag
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
TAG="v${VERSION}"
|
||||
echo "Creating tag ${TAG}..."
|
||||
|
||||
# Create annotated tag via Gitea API
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
|
||||
|
||||
echo "Tag ${TAG} created successfully"
|
||||
|
||||
sync-to-github:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
steps:
|
||||
- name: Download artifacts from Gitea releases
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p artifacts
|
||||
|
||||
# Download assets from all 3 platform releases
|
||||
for TAG_SUFFIX in "" "-mac" "-win"; do
|
||||
TAG="v${VERSION}${TAG_SUFFIX}"
|
||||
echo "==> Fetching assets for release ${TAG}..."
|
||||
|
||||
RELEASE_JSON=$(curl -sf \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
|
||||
|
||||
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.name) \(.browser_download_url)"' | while read -r NAME URL; do
|
||||
[ -z "$NAME" ] && continue
|
||||
echo " Downloading ${NAME}..."
|
||||
curl -sfL \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-o "artifacts/${NAME}" \
|
||||
"$URL"
|
||||
done
|
||||
done
|
||||
|
||||
echo "==> All downloaded artifacts:"
|
||||
ls -la artifacts/
|
||||
|
||||
- name: Create GitHub release and upload artifacts
|
||||
env:
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -e
|
||||
TAG="v${VERSION}"
|
||||
|
||||
echo "==> Creating unified release ${TAG} on GitHub..."
|
||||
|
||||
# Delete existing release if present (idempotent re-runs)
|
||||
EXISTING=$(curl -sf \
|
||||
-H "Authorization: Bearer ${GH_PAT}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
|
||||
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo " Deleting existing GitHub release ${TAG} (id: ${EXISTING_ID})..."
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: Bearer ${GH_PAT}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/${EXISTING_ID}"
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer ${GH_PAT}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"${TAG}\",
|
||||
\"name\": \"Triple-C ${TAG}\",
|
||||
\"body\": \"Automated build from commit ${COMMIT_SHA}\n\nIncludes Linux, macOS, and Windows artifacts.\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
echo "==> Upload URL: ${UPLOAD_URL}"
|
||||
|
||||
for file in artifacts/*; do
|
||||
[ -f "$file" ] || continue
|
||||
FILENAME=$(basename "$file")
|
||||
MIME="application/octet-stream"
|
||||
echo "==> Uploading ${FILENAME}..."
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer ${GH_PAT}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: ${MIME}" \
|
||||
--data-binary "@${file}" \
|
||||
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${FILENAME}")"
|
||||
done
|
||||
|
||||
echo "==> GitHub release sync complete."
|
||||
|
||||
@@ -5,10 +5,12 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "container/**"
|
||||
- ".gitea/workflows/build.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "container/**"
|
||||
- ".gitea/workflows/build.yml"
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
@@ -34,6 +36,13 @@ jobs:
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: shadowdao
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Build and push container image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -44,5 +53,7 @@ jobs:
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-release:
|
||||
|
||||
@@ -72,7 +72,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
|
||||
- `image.rs` — Image build/pull with progress streaming
|
||||
- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
|
||||
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
|
||||
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||
|
||||
### Container (`container/`)
|
||||
@@ -90,6 +90,8 @@ Containers use a **stop/start** model (not create/destroy). Installed packages p
|
||||
Per-project, independently configured:
|
||||
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
||||
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
||||
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
|
||||
- **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
307
HOW-TO-USE.md
307
HOW-TO-USE.md
@@ -4,6 +4,25 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [First Launch](#first-launch)
|
||||
- [The Interface](#the-interface)
|
||||
- [Project Management](#project-management)
|
||||
- [Project Configuration](#project-configuration)
|
||||
- [MCP Servers (Beta)](#mcp-servers-beta)
|
||||
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
|
||||
- [Ollama Configuration](#ollama-configuration)
|
||||
- [OpenAI Compatible Configuration](#openai-compatible-configuration)
|
||||
- [Settings](#settings)
|
||||
- [Terminal Features](#terminal-features)
|
||||
- [Scheduled Tasks (Inside the Container)](#scheduled-tasks-inside-the-container)
|
||||
- [What's Inside the Container](#whats-inside-the-container)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
@@ -33,6 +52,8 @@ You need access to Claude Code through one of:
|
||||
|
||||
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
|
||||
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
|
||||
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
|
||||
- **OpenAI Compatible** — Any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) (best-effort support)
|
||||
|
||||
---
|
||||
|
||||
@@ -65,13 +86,13 @@ Switch to the **Projects** tab in the sidebar and click the **+** button.
|
||||
|
||||
### 3. Start the Container
|
||||
|
||||
Select your project in the sidebar and click **Start**. The status dot changes from gray (stopped) to orange (starting) to green (running).
|
||||
Select your project in the sidebar and click **Start**. A progress modal appears showing real-time status as the container starts. The status dot changes from gray (stopped) to orange (starting) to green (running). The modal auto-closes on success.
|
||||
|
||||
### 4. Open a Terminal
|
||||
|
||||
Click the **Terminal** button (highlighted in accent color) to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
|
||||
Click the **Terminal** button to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
|
||||
|
||||
Claude Code launches automatically with `--dangerously-skip-permissions` inside the sandboxed container.
|
||||
Claude Code launches automatically. By default, it runs in standard permission mode and will ask for your approval before executing commands or editing files. To enable auto-approval of all actions within the sandbox, enable **Full Permissions** in the project configuration.
|
||||
|
||||
### 5. Authenticate
|
||||
|
||||
@@ -84,10 +105,25 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
|
||||
**AWS Bedrock:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the auth mode to **Bedrock**.
|
||||
2. In the project card, switch the backend to **Bedrock**.
|
||||
3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below).
|
||||
4. Start the container again.
|
||||
|
||||
**Ollama:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the backend to **Ollama**.
|
||||
3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Set the **Model ID** to the model you want to use (required).
|
||||
4. Make sure the model has been pulled in Ollama (e.g., `ollama pull qwen3.5:27b`) or used via Ollama cloud before starting.
|
||||
5. Start the container again.
|
||||
|
||||
**OpenAI Compatible:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the backend to **OpenAI Compatible**.
|
||||
3. Expand the **Config** panel and set the base URL of your OpenAI-compatible endpoint (defaults to `http://host.docker.internal:4000` as an example). Optionally set an API key and model ID.
|
||||
4. Start the container again.
|
||||
|
||||
---
|
||||
|
||||
## The Interface
|
||||
@@ -99,16 +135,16 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
|
||||
│ Sidebar │ │
|
||||
│ │ Terminal View │
|
||||
│ Projects │ (xterm.js) │
|
||||
│ MCP │ │
|
||||
│ Settings │ │
|
||||
│ │ │
|
||||
├────────────┴────────────────────────────────────────┤
|
||||
│ StatusBar X projects · X running · X terminals │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **TopBar** — Terminal tabs for switching between sessions. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
|
||||
- **Sidebar** — Toggle between the **Projects** list and **Settings** panel.
|
||||
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering.
|
||||
- **TopBar** — Terminal tabs for switching between sessions. Bash shell tabs show a "(bash)" suffix. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
|
||||
- **Sidebar** — Toggle between the **Projects** list, **MCP** server configuration, and **Settings** panel.
|
||||
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering. Includes a **Jump to Current** button that appears when you scroll up, so you can quickly return to the latest output.
|
||||
- **StatusBar** — Counts of total projects, running containers, and open terminal sessions.
|
||||
|
||||
---
|
||||
@@ -134,11 +170,17 @@ Select a project in the sidebar to see its action buttons:
|
||||
|--------|---------------|--------------|
|
||||
| **Start** | Stopped | Creates (if needed) and starts the container |
|
||||
| **Stop** | Running | Stops the container but preserves its state |
|
||||
| **Terminal** | Running | Opens a new terminal session in this container |
|
||||
| **Terminal** | Running | Opens a new Claude Code terminal session |
|
||||
| **Shell** | Running | Opens a bash login shell in the container (no Claude Code) |
|
||||
| **Files** | Running | Opens the file manager to browse, download, and upload files |
|
||||
| **Reset** | Stopped | Destroys and recreates the container from scratch |
|
||||
| **Config** | Always | Toggles the configuration panel |
|
||||
| **Remove** | Stopped | Deletes the project and its container (with confirmation) |
|
||||
|
||||
### Renaming a Project
|
||||
|
||||
Double-click the project name in the sidebar to rename it inline. Press **Enter** to confirm or **Escape** to cancel.
|
||||
|
||||
### Container Lifecycle
|
||||
|
||||
Containers use a **stop/start** model. When you stop a container, everything inside it is preserved — installed packages, modified files, downloaded tools. Starting it again resumes where you left off.
|
||||
@@ -147,6 +189,10 @@ Containers use a **stop/start** model. When you stop a container, everything ins
|
||||
|
||||
Only **Remove** deletes everything, including the config volume and any stored credentials.
|
||||
|
||||
### Container Progress Feedback
|
||||
|
||||
When starting, stopping, or resetting a container, a progress modal shows real-time status messages (e.g., "Setting up MCP network...", "Starting MCP containers...", "Creating container..."). If an error occurs, the modal displays the error with a **Close** button. A **Force Stop** option is available if the operation stalls. The modal auto-closes on success.
|
||||
|
||||
---
|
||||
|
||||
## Project Configuration
|
||||
@@ -177,6 +223,31 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
|
||||
|
||||
> Toggling this requires stopping and restarting the container to take effect.
|
||||
|
||||
### Mission Control
|
||||
|
||||
Toggle **Mission Control** to integrate [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology — into the project. When enabled:
|
||||
|
||||
- The Flight Control repository is automatically cloned into the container
|
||||
- Flight Control skills are installed to Claude Code's skill directory (`~/.claude/skills/`)
|
||||
- Project instructions are appended with Flight Control workflow guidance
|
||||
- The repository is symlinked at `/workspace/mission-control`
|
||||
|
||||
Available skills include `/mission`, `/flight`, `/leg`, `/agentic-workflow`, `/flight-debrief`, `/mission-debrief`, `/daily-briefing`, and `/init-project`.
|
||||
|
||||
> This setting can only be changed when the container is stopped. Toggling it triggers a container recreation on the next start.
|
||||
|
||||
### Full Permissions
|
||||
|
||||
Toggle **Full Permissions** to allow Claude Code to run with `--dangerously-skip-permissions` inside the container. This is **off by default**.
|
||||
|
||||
When **enabled**, Claude auto-approves all tool calls (file edits, shell commands, etc.) without prompting you. This is the fastest workflow since you won't be interrupted for approvals, and the Docker container provides isolation.
|
||||
|
||||
When **disabled** (default), Claude prompts you for approval before executing each action, giving you fine-grained control over what it does.
|
||||
|
||||
> **CAUTION:** Enabling full permissions means Claude can execute any command inside the container without asking. While the container sandbox limits the blast radius, make sure you understand the implications — especially if the container has Docker socket access or network connectivity.
|
||||
|
||||
> This setting can only be changed when the container is stopped. It takes effect the next time you open a terminal session.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key.
|
||||
@@ -188,8 +259,8 @@ Click **Edit** to open the environment variables modal. Add key-value pairs that
|
||||
Click **Edit** to map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser.
|
||||
|
||||
Each mapping specifies:
|
||||
- **Host Port** — The port on your machine (1–65535)
|
||||
- **Container Port** — The port inside the container (1–65535)
|
||||
- **Host Port** — The port on your machine (1-65535)
|
||||
- **Container Port** — The port inside the container (1-65535)
|
||||
- **Protocol** — TCP (default) or UDP
|
||||
|
||||
### Claude Instructions
|
||||
@@ -198,9 +269,131 @@ Click **Edit** to write per-project instructions for Claude Code. These are writ
|
||||
|
||||
---
|
||||
|
||||
## MCP Servers (Beta)
|
||||
|
||||
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, which extend Claude Code with access to external tools and data sources. MCP servers are configured in a **global library** and **enabled per-project**.
|
||||
|
||||
### How It Works
|
||||
|
||||
There are two dimensions to MCP server configuration:
|
||||
|
||||
| | **Manual** (no Docker image) | **Docker** (Docker image specified) |
|
||||
|---|---|---|
|
||||
| **Stdio** | Command runs inside the project container | Command runs in a separate MCP container via `docker exec` |
|
||||
| **HTTP** | Connects to a URL you provide | Runs in a separate container, reached by hostname on a shared Docker network |
|
||||
|
||||
**Docker images are pulled automatically** if not already present when the project starts.
|
||||
|
||||
### Accessing MCP Configuration
|
||||
|
||||
Click the **MCP** tab in the sidebar to open the MCP server library. This is where you define all available MCP servers.
|
||||
|
||||
### Adding an MCP Server
|
||||
|
||||
1. Type a name in the input field and click **Add**.
|
||||
2. Expand the server card and configure it.
|
||||
|
||||
The key decision is whether to set a **Docker Image**:
|
||||
- **With Docker image** — The MCP server runs in its own isolated container. Best for servers that need specific dependencies or system-level packages.
|
||||
- **Without Docker image** (manual) — The command runs directly inside your project container. Best for lightweight npx-based servers that just need Node.js.
|
||||
|
||||
Then choose the **Transport Type**:
|
||||
- **Stdio** — The MCP server communicates over stdin/stdout. This is the most common type.
|
||||
- **HTTP** — The MCP server exposes an HTTP endpoint (streamable HTTP transport).
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
#### Example 1: Filesystem Server (Stdio, Manual)
|
||||
|
||||
A simple npx-based server that runs inside the project container. No Docker image needed since Node.js is already installed.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Docker Image** | *(empty)* |
|
||||
| **Transport** | Stdio |
|
||||
| **Command** | `npx` |
|
||||
| **Arguments** | `-y @modelcontextprotocol/server-filesystem /workspace` |
|
||||
|
||||
This gives Claude Code access to browse and read files via MCP. The command runs directly inside the project container using the pre-installed Node.js.
|
||||
|
||||
#### Example 2: GitHub Server (Stdio, Manual)
|
||||
|
||||
Another npx-based server, with an environment variable for authentication.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Docker Image** | *(empty)* |
|
||||
| **Transport** | Stdio |
|
||||
| **Command** | `npx` |
|
||||
| **Arguments** | `-y @modelcontextprotocol/server-github` |
|
||||
| **Environment Variables** | `GITHUB_PERSONAL_ACCESS_TOKEN` = `ghp_your_token` |
|
||||
|
||||
#### Example 3: Custom MCP Server (HTTP, Docker)
|
||||
|
||||
An MCP server packaged as a Docker image that exposes an HTTP endpoint.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Docker Image** | `myregistry/my-mcp-server:latest` |
|
||||
| **Transport** | HTTP |
|
||||
| **Container Port** | `8080` |
|
||||
| **Environment Variables** | `API_KEY` = `your_key` |
|
||||
|
||||
Triple-C will:
|
||||
1. Pull the image automatically if not present
|
||||
2. Start the container on the project's bridge network
|
||||
3. Configure Claude Code to reach it at `http://triple-c-mcp-{id}:8080/mcp`
|
||||
|
||||
The hostname is the MCP container's name on the Docker network — **not** `localhost`.
|
||||
|
||||
#### Example 4: Database Server (Stdio, Docker)
|
||||
|
||||
An MCP server that needs its own runtime environment, communicating over stdio.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Docker Image** | `mcp/postgres-server:latest` |
|
||||
| **Transport** | Stdio |
|
||||
| **Command** | `node` |
|
||||
| **Arguments** | `dist/index.js` |
|
||||
| **Environment Variables** | `DATABASE_URL` = `postgresql://user:pass@host:5432/db` |
|
||||
|
||||
Triple-C will:
|
||||
1. Pull the image and start it on the project network
|
||||
2. Configure Claude Code to communicate via `docker exec -i triple-c-mcp-{id} node dist/index.js`
|
||||
3. Automatically enable Docker socket access on the project container (required for `docker exec`)
|
||||
|
||||
### Enabling MCP Servers Per-Project
|
||||
|
||||
In a project's configuration panel (click **Config**), the **MCP Servers** section shows checkboxes for all globally defined servers. Toggle each server on or off for that project. Changes take effect on the next container start.
|
||||
|
||||
### How Docker-Based MCP Works
|
||||
|
||||
When a project with Docker-based MCP servers starts:
|
||||
|
||||
1. Missing Docker images are **automatically pulled** (progress shown in the progress modal)
|
||||
2. A dedicated **bridge network** is created for the project (`triple-c-net-{projectId}`)
|
||||
3. Each enabled Docker MCP server gets its own container on that network
|
||||
4. The main project container is connected to the same network
|
||||
5. MCP server configuration is written to `~/.claude.json` inside the container
|
||||
|
||||
**Networking**: Docker-based MCP containers are reached by their container name as a hostname (e.g., `triple-c-mcp-{serverId}`), not by `localhost`. Docker DNS resolves these names automatically on the shared bridge network.
|
||||
|
||||
**Stdio + Docker**: The project container uses `docker exec` to communicate with the MCP container over stdin/stdout. This automatically enables Docker socket access on the project container.
|
||||
|
||||
**HTTP + Docker**: The project container connects to the MCP container's HTTP endpoint using the container hostname and port (e.g., `http://triple-c-mcp-{serverId}:3000/mcp`).
|
||||
|
||||
**Manual (no Docker image)**: Stdio commands run directly inside the project container. HTTP URLs connect to wherever you point them (could be an external service or something running on the host).
|
||||
|
||||
### Configuration Change Detection
|
||||
|
||||
MCP server configuration is tracked via SHA-256 fingerprints stored as Docker labels. If you add, remove, or modify MCP servers for a project, the container is automatically recreated on the next start to apply the new configuration. The container filesystem is snapshotted first, so installed packages are preserved.
|
||||
|
||||
---
|
||||
|
||||
## AWS Bedrock Configuration
|
||||
|
||||
To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode to **Bedrock** on the project card.
|
||||
To use Claude via AWS Bedrock instead of Anthropic's API, switch the backend to **Bedrock** on the project card.
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
@@ -227,6 +420,43 @@ Per-project settings always override these global defaults.
|
||||
|
||||
---
|
||||
|
||||
## Ollama Configuration
|
||||
|
||||
To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
|
||||
- **Model ID** — **Required.** The model to use (e.g., `qwen3.5:27b`). The model must be pulled in Ollama before use — run `ollama pull <model>` or use it via Ollama cloud so it is available when the container starts.
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server instead of Anthropic's API. The `ANTHROPIC_AUTH_TOKEN` is set to `ollama` (required by Claude Code but not used for actual authentication).
|
||||
|
||||
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
|
||||
|
||||
> **Important:** The model must already be available in Ollama before starting the container. If using a local Ollama instance, pull the model first with `ollama pull <model-name>`. If using Ollama's cloud service, ensure the model has been used at least once so it is cached.
|
||||
|
||||
---
|
||||
|
||||
## OpenAI Compatible Configuration
|
||||
|
||||
To use Claude Code through any OpenAI API-compatible endpoint, switch the backend to **OpenAI Compatible** on the project card. This works with any server that exposes an OpenAI-compatible API, including LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, and others.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Base URL** — The URL of your OpenAI-compatible endpoint. Defaults to `http://host.docker.internal:4000` as an example (adjust to match your server's address and port).
|
||||
- **API Key** — Optional. The API key for your endpoint, if authentication is required. Stored securely in your OS keychain.
|
||||
- **Model ID** — Optional. Override the model to use.
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your OpenAI-compatible endpoint. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
> **Note:** OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected when routing to non-Anthropic models through the endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Access global settings via the **Settings** tab in the sidebar.
|
||||
@@ -264,7 +494,11 @@ When an update is available, a pulsing **Update** button appears in the top bar.
|
||||
|
||||
### Multiple Sessions
|
||||
|
||||
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it.
|
||||
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name, with a "(bash)" suffix for shell sessions.
|
||||
|
||||
### Bash Shell Sessions
|
||||
|
||||
In addition to Claude Code terminals, you can open a plain **bash login shell** in any running container by clicking the **Shell** button. This is useful for manual inspection, package installation, debugging, or running commands that don't need Claude Code.
|
||||
|
||||
### URL Detection
|
||||
|
||||
@@ -272,9 +506,32 @@ When Claude Code prints a long URL (e.g., during `claude login`), Triple-C detec
|
||||
|
||||
Shorter URLs in terminal output are also clickable directly.
|
||||
|
||||
### Copying and Pasting
|
||||
|
||||
Use **Ctrl+Shift+C** (or **Cmd+C** on macOS) to copy selected text from the terminal, and **Ctrl+Shift+V** (or **Cmd+V** on macOS) to paste. This follows standard terminal emulator conventions since Ctrl+C is reserved for sending SIGINT.
|
||||
|
||||
### Clipboard Support (OSC 52)
|
||||
|
||||
Programs inside the container can copy text to your host clipboard. When a container program uses `xclip`, `xsel`, or `pbcopy`, the text is transparently forwarded to your host clipboard via OSC 52 escape sequences. No additional configuration is required — this works out of the box.
|
||||
|
||||
### Image Paste
|
||||
|
||||
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container and the file path is injected into the terminal input so Claude Code can reference it.
|
||||
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
|
||||
|
||||
### Jump to Current
|
||||
|
||||
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
|
||||
|
||||
### File Manager
|
||||
|
||||
Click the **Files** button on a running project to open the file manager modal. You can:
|
||||
|
||||
- **Browse** the container filesystem starting from `/workspace`, with breadcrumb navigation
|
||||
- **Download** any file to your host machine via the download button on each file entry
|
||||
- **Upload** files from your host into the current container directory
|
||||
- **Refresh** the directory listing at any time
|
||||
|
||||
The file manager shows file names, sizes, and modification dates.
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
@@ -356,6 +613,8 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||
| build-essential | — | C/C++ compiler toolchain |
|
||||
| openssh-client | — | SSH for git and remote access |
|
||||
|
||||
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, and an **audio shim** (`rec`, `arecord`) for future voice mode support.
|
||||
|
||||
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
||||
|
||||
---
|
||||
@@ -378,7 +637,7 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
|
||||
|
||||
- Check that the Docker image is "Ready" in Settings.
|
||||
- Verify that the mounted folder paths exist on your host.
|
||||
- Look at the error message displayed in red below the project card.
|
||||
- Look at the error message displayed in the progress modal.
|
||||
|
||||
### OAuth Login URL Not Opening
|
||||
|
||||
@@ -394,4 +653,20 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
|
||||
### Settings Won't Save
|
||||
|
||||
- Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again.
|
||||
- Some changes (like toggling Docker access or changing mounted folders) trigger an automatic container recreation on the next start.
|
||||
- Some changes (like toggling Docker access, Mission Control, or changing mounted folders) trigger an automatic container recreation on the next start.
|
||||
|
||||
### MCP Containers Not Starting
|
||||
|
||||
- Ensure the Docker image for the MCP server exists (pull it first if needed).
|
||||
- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this).
|
||||
- Try resetting the project container to force a clean recreation.
|
||||
|
||||
### "Failed to install Anthropic marketplace" Error
|
||||
|
||||
If Claude Code shows **"Failed to install Anthropic marketplace - Will retry on next startup"** repeatedly, the marketplace metadata in `~/.claude.json` may be corrupted. To fix this, open a **Shell** session in the project and run:
|
||||
|
||||
```bash
|
||||
cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswith("officialMarketplace") | not))' ~/.claude.json.bak > ~/.claude.json
|
||||
```
|
||||
|
||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||
|
||||
82
README.md
82
README.md
@@ -1,6 +1,6 @@
|
||||
# Triple-C (Claude-Code-Container)
|
||||
|
||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. When running with `--dangerously-skip-permissions`, Claude only has access to the files and projects you explicitly provide to it.
|
||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project can optionally enable full permissions mode (`--dangerously-skip-permissions`), giving Claude unrestricted access within the sandbox.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,10 +27,10 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
||||
### Container Lifecycle
|
||||
|
||||
1. **Create**: New container created with bind mounts, env vars, and labels
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group
|
||||
3. **Terminal**: `docker exec` launches Claude Code with a PTY
|
||||
4. **Stop**: Container halted (filesystem persists in named volume)
|
||||
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers
|
||||
3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY
|
||||
4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped
|
||||
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
|
||||
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
|
||||
|
||||
### Mounts
|
||||
@@ -41,14 +41,18 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
|
||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
||||
| `/var/run/docker.sock` | Host Docker socket | Bind | Only if "Allow container spawning" is ON |
|
||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers |
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
Each project can independently use one of:
|
||||
|
||||
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||
- **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
||||
|
||||
> **Note:** Ollama and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
@@ -56,6 +60,31 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i
|
||||
|
||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||
|
||||
### MCP Server Architecture
|
||||
|
||||
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources.
|
||||
|
||||
**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified:
|
||||
|
||||
| Mode | Where It Runs | How It Communicates |
|
||||
|------|--------------|---------------------|
|
||||
| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) |
|
||||
| Stdio + Docker | Separate MCP container | `docker exec -i <mcp-container> <command>` from the project container |
|
||||
| HTTP + Manual | External / user-provided | Connects to the URL you specify |
|
||||
| HTTP + Docker | Separate MCP container | `http://<mcp-container>:<port>/mcp` via Docker DNS on a shared bridge network |
|
||||
|
||||
**Key behaviors**:
|
||||
- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json`
|
||||
- **Per-project toggles**: Each project enables/disables individual servers via checkboxes
|
||||
- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts
|
||||
- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost
|
||||
- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation
|
||||
- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq`
|
||||
|
||||
### Mission Control Integration
|
||||
|
||||
Optional per-project integration with [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology. When enabled, the repo is cloned into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
@@ -73,19 +102,34 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
|
||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
||||
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
|
||||
| `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons |
|
||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
|
||||
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
|
||||
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
|
||||
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
||||
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
||||
| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) |
|
||||
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, and global settings |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
|
||||
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||
| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations |
|
||||
| `app/src/hooks/useVoice.ts` | Voice mode audio capture (currently hidden) |
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting |
|
||||
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar |
|
||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||
| `app/src-tauri/src/docker/network.rs` | Per-project bridge networks for MCP containers |
|
||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, MCP servers, Mission Control) |
|
||||
| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) |
|
||||
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Mission Control setup |
|
||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||
|
||||
## CSS / Styling Notes
|
||||
|
||||
@@ -100,4 +144,6 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
||||
|
||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `rec`/`arecord` (audio FIFO for voice mode)
|
||||
|
||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||
|
||||
130
TECHNICAL.md
130
TECHNICAL.md
@@ -154,13 +154,12 @@ The `.claude` configuration directory uses a **named Docker volume** (`triple-c-
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
Each project independently chooses one of three authentication methods:
|
||||
Each project independently chooses one of two authentication methods:
|
||||
|
||||
| Mode | How It Works | When to Use |
|
||||
|------|-------------|-------------|
|
||||
| **Login (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via the web links addon. Token persists in the `.claude` config volume. | Personal use, interactive sessions |
|
||||
| **API Key** | Key stored in OS keychain, injected as `ANTHROPIC_API_KEY` env var at container creation. | Automated workflows, team-shared keys |
|
||||
| **AWS Bedrock** | Per-project AWS credentials (static, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
|
||||
| **Anthropic (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via URL detection. Token persists in the `.claude` config volume. | Default — personal and team use |
|
||||
| **AWS Bedrock** | Per-project AWS credentials (static keys, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
|
||||
|
||||
### UID/GID Remapping
|
||||
|
||||
@@ -213,66 +212,93 @@ The `TerminalView` component works around this with a **URL accumulator**:
|
||||
|
||||
```
|
||||
triple-c/
|
||||
├── LICENSE # MIT
|
||||
├── TECHNICAL.md # This document
|
||||
├── Triple-C.md # Project overview
|
||||
├── README.md # Architecture overview
|
||||
├── TECHNICAL.md # This document
|
||||
├── HOW-TO-USE.md # User guide
|
||||
├── BUILDING.md # Build instructions
|
||||
├── CLAUDE.md # Claude Code instructions
|
||||
│
|
||||
├── container/
|
||||
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
||||
│ └── entrypoint.sh # UID/GID remap, SSH setup, git config
|
||||
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
||||
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, MCP injection
|
||||
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
|
||||
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
|
||||
│ ├── triple-c-scheduler # Bash-based cron task system
|
||||
│ └── triple-c-task-runner # Task execution runner for scheduler
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
├── vite.config.ts # Vite bundler config
|
||||
├── index.html # HTML entry point
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||
│ └── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
├── vite.config.ts # Vite bundler config
|
||||
├── index.html # HTML entry point
|
||||
│
|
||||
├── src/ # React frontend
|
||||
│ ├── main.tsx # React DOM root
|
||||
│ ├── App.tsx # Top-level layout
|
||||
│ ├── index.css # CSS variables, dark theme, scrollbars
|
||||
├── src/ # React frontend
|
||||
│ ├── main.tsx # React DOM root
|
||||
│ ├── App.tsx # Top-level layout
|
||||
│ ├── index.css # CSS variables, dark theme, scrollbars
|
||||
│ ├── store/
|
||||
│ │ └── appState.ts # Zustand store (projects, sessions, UI)
|
||||
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI)
|
||||
│ ├── hooks/
|
||||
│ │ ├── useDocker.ts # Docker status, image build
|
||||
│ │ ├── useProjects.ts # Project CRUD operations
|
||||
│ │ ├── useSettings.ts # API key, app settings
|
||||
│ │ └── useTerminal.ts # Terminal I/O, resize, session events
|
||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||
│ │ ├── useFileManager.ts # File manager operations
|
||||
│ │ ├── useMcpServers.ts # MCP server CRUD
|
||||
│ │ ├── useProjects.ts # Project CRUD operations
|
||||
│ │ ├── useSettings.ts # App settings
|
||||
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
|
||||
│ │ ├── useUpdates.ts # App update checking
|
||||
│ │ └── useVoice.ts # Voice mode audio capture
|
||||
│ ├── lib/
|
||||
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
||||
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
||||
│ │ └── constants.ts # App-wide constants
|
||||
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
||||
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
||||
│ │ └── constants.ts # App-wide constants
|
||||
│ └── components/
|
||||
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
||||
│ ├── projects/ # ProjectList, ProjectCard, AddProjectDialog
|
||||
│ ├── terminal/ # TerminalView (xterm.js), TerminalTabs
|
||||
│ ├── settings/ # ApiKeyInput, DockerSettings, AwsSettings
|
||||
│ └── containers/ # SiblingContainers
|
||||
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
||||
│ ├── mcp/ # McpPanel, McpServerCard
|
||||
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
|
||||
│ │ # FileManagerModal, ContainerProgressModal, modals
|
||||
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||
│ │ # UpdateDialog
|
||||
│ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
|
||||
│
|
||||
└── src-tauri/ # Rust backend
|
||||
├── Cargo.toml # Rust dependencies
|
||||
├── tauri.conf.json # Tauri app configuration
|
||||
└── src-tauri/ # Rust backend
|
||||
├── Cargo.toml # Rust dependencies
|
||||
├── tauri.conf.json # Tauri app configuration
|
||||
├── capabilities/
|
||||
│ └── default.json # Tauri v2 permission grants
|
||||
│ └── default.json # Tauri v2 permission grants
|
||||
└── src/
|
||||
├── lib.rs # App builder, plugin + command registration
|
||||
├── main.rs # Entry point
|
||||
├── commands/ # Tauri command handlers
|
||||
│ ├── docker_commands.rs
|
||||
│ ├── project_commands.rs
|
||||
│ ├── settings_commands.rs
|
||||
│ └── terminal_commands.rs
|
||||
├── docker/ # Docker API layer
|
||||
│ ├── client.rs # bollard singleton connection
|
||||
│ ├── container.rs # Create, start, stop, remove, inspect
|
||||
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
||||
│ ├── image.rs # Build from embedded Dockerfile, pull from registry
|
||||
│ └── sibling.rs # List non-Triple-C containers
|
||||
├── models/ # Data structures
|
||||
│ ├── project.rs # Project, AuthMode, BedrockConfig
|
||||
│ └── container_config.rs
|
||||
└── storage/ # Persistence
|
||||
├── lib.rs # App builder, plugin + command registration
|
||||
├── main.rs # Entry point
|
||||
├── logging.rs # Log configuration
|
||||
├── commands/ # Tauri command handlers
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File manager (list/download/upload)
|
||||
│ ├── mcp_commands.rs # MCP server CRUD
|
||||
│ ├── project_commands.rs # Start/stop/rebuild containers
|
||||
│ ├── settings_commands.rs # Settings CRUD
|
||||
│ ├── terminal_commands.rs # Terminal I/O, resize
|
||||
│ └── update_commands.rs # App update checking
|
||||
├── docker/ # Docker API layer
|
||||
│ ├── client.rs # bollard singleton connection
|
||||
│ ├── container.rs # Create, start, stop, remove, fingerprinting
|
||||
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
||||
│ ├── image.rs # Build from Dockerfile, pull from registry
|
||||
│ └── network.rs # Per-project bridge networks for MCP
|
||||
├── models/ # Data structures
|
||||
│ ├── project.rs # Project, Backend, BedrockConfig
|
||||
│ ├── mcp_server.rs # MCP server configuration
|
||||
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
|
||||
│ ├── container_config.rs # Image name resolution
|
||||
│ └── update_info.rs # Update metadata
|
||||
└── storage/ # Persistence
|
||||
├── projects_store.rs # JSON file with atomic writes
|
||||
├── settings_store.rs # App settings
|
||||
├── mcp_store.rs # MCP server persistence
|
||||
├── settings_store.rs # App settings (Tauri plugin-store)
|
||||
└── secure.rs # OS keychain via keyring
|
||||
```
|
||||
|
||||
|
||||
68
app/package-lock.json
generated
68
app/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "triple-c",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
@@ -1643,6 +1643,70 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
17
app/public/audio-capture-processor.js
Normal file
17
app/public/audio-capture-processor.js
Normal file
@@ -0,0 +1,17 @@
|
||||
class AudioCaptureProcessor extends AudioWorkletProcessor {
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (input && input.length > 0 && input[0].length > 0) {
|
||||
const samples = input[0]; // Float32Array, mono channel
|
||||
const int16 = new Int16Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
this.port.postMessage(int16.buffer, [int16.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('audio-capture-processor', AudioCaptureProcessor);
|
||||
2
app/src-tauri/Cargo.lock
generated
2
app/src-tauri/Cargo.lock
generated
@@ -4668,7 +4668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "triple-c"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bollard",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "triple-c"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
30
app/src-tauri/src/commands/aws_commands.rs
Normal file
30
app/src-tauri/src/commands/aws_commands.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use tauri::State;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aws_sso_refresh(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state.projects_store.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let profile = project.bedrock_config.as_ref()
|
||||
.and_then(|b| b.aws_profile.clone())
|
||||
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
|
||||
|
||||
let status = tokio::process::Command::new("aws")
|
||||
.args(["sso", "login", "--profile", &profile])
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("SSO login failed or was cancelled".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
211
app/src-tauri/src/commands/file_commands.rs
Normal file
211
app/src-tauri/src/commands/file_commands.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_directory: bool,
|
||||
pub size: u64,
|
||||
pub modified: String,
|
||||
pub permissions: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_container_files(
|
||||
project_id: String,
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = vec![
|
||||
"find".to_string(),
|
||||
path.clone(),
|
||||
"-mindepth".to_string(),
|
||||
"1".to_string(),
|
||||
"-maxdepth".to_string(),
|
||||
"1".to_string(),
|
||||
"-printf".to_string(),
|
||||
"%f\t%y\t%s\t%T@\t%m\n".to_string(),
|
||||
];
|
||||
|
||||
let output = exec_oneshot(container_id, cmd).await?;
|
||||
|
||||
let mut entries: Vec<FileEntry> = output
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let name = parts[0].to_string();
|
||||
let is_directory = parts[1] == "d";
|
||||
let size = parts[2].parse::<u64>().unwrap_or(0);
|
||||
let modified_epoch = parts[3].parse::<f64>().unwrap_or(0.0);
|
||||
let permissions = parts[4].to_string();
|
||||
|
||||
// Convert epoch to ISO-ish string
|
||||
let modified = {
|
||||
let secs = modified_epoch as i64;
|
||||
let dt = chrono::DateTime::from_timestamp(secs, 0)
|
||||
.unwrap_or_default();
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
};
|
||||
|
||||
let entry_path = if path.ends_with('/') {
|
||||
format!("{}{}", path, name)
|
||||
} else {
|
||||
format!("{}/{}", path, name)
|
||||
};
|
||||
|
||||
Some(FileEntry {
|
||||
name,
|
||||
path: entry_path,
|
||||
is_directory,
|
||||
size,
|
||||
modified,
|
||||
permissions,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort: directories first, then alphabetical
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_directory
|
||||
.cmp(&a.is_directory)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_container_file(
|
||||
project_id: String,
|
||||
container_path: String,
|
||||
host_path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut stream = docker.download_from_container(
|
||||
container_id,
|
||||
Some(DownloadFromContainerOptions {
|
||||
path: container_path.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
let mut tar_bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Failed to download file: {}", e))?;
|
||||
tar_bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// Extract single file from tar archive
|
||||
let mut archive = tar::Archive::new(&tar_bytes[..]);
|
||||
let mut found = false;
|
||||
for entry in archive
|
||||
.entries()
|
||||
.map_err(|e| format!("Failed to read tar entries: {}", e))?
|
||||
{
|
||||
let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {}", e))?;
|
||||
let mut contents = Vec::new();
|
||||
std::io::Read::read_to_end(&mut entry, &mut contents)
|
||||
.map_err(|e| format!("Failed to read file contents: {}", e))?;
|
||||
std::fs::write(&host_path, &contents)
|
||||
.map_err(|e| format!("Failed to write file to host: {}", e))?;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Err("File not found in tar archive".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_file_to_container(
|
||||
project_id: String,
|
||||
host_path: String,
|
||||
container_dir: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
let file_data = std::fs::read(&host_path)
|
||||
.map_err(|e| format!("Failed to read host file: {}", e))?;
|
||||
|
||||
let file_name = std::path::Path::new(&host_path)
|
||||
.file_name()
|
||||
.ok_or_else(|| "Invalid file path".to_string())?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Build tar archive in memory
|
||||
let mut tar_buf = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(file_data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, &file_name, &file_data[..])
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: container_dir,
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
60
app/src-tauri/src/commands/help_commands.rs
Normal file
60
app/src-tauri/src/commands/help_commands.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const HELP_URL: &str =
|
||||
"https://repo.anhonesthost.net/cybercovellc/triple-c/raw/branch/main/HOW-TO-USE.md";
|
||||
|
||||
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");
|
||||
|
||||
/// Cached help content fetched from the remote repo (or `None` if not yet fetched).
|
||||
static CACHED_HELP: OnceLock<Mutex<Option<String>>> = OnceLock::new();
|
||||
|
||||
/// Return the help markdown content.
|
||||
///
|
||||
/// On the first call, tries to fetch the latest version from the gitea repo.
|
||||
/// If that fails (network error, timeout, etc.), falls back to the version
|
||||
/// embedded at compile time. The result is cached for the rest of the session.
|
||||
#[tauri::command]
|
||||
pub async fn get_help_content() -> Result<String, String> {
|
||||
let mutex = CACHED_HELP.get_or_init(|| Mutex::new(None));
|
||||
let mut guard = mutex.lock().await;
|
||||
|
||||
if let Some(ref cached) = *guard {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
|
||||
let content = match fetch_remote_help().await {
|
||||
Ok(md) => {
|
||||
log::info!("Loaded help content from remote repo");
|
||||
md
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("Using embedded help content (remote fetch failed: {})", e);
|
||||
EMBEDDED_HELP.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
*guard = Some(content.clone());
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
async fn fetch_remote_help() -> Result<String, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let resp = client
|
||||
.get(HELP_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch help content: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Remote returned status {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
pub mod aws_commands;
|
||||
pub mod docker_commands;
|
||||
pub mod file_commands;
|
||||
pub mod help_commands;
|
||||
pub mod mcp_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, Backend, McpServer, Project, ProjectPath, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -34,6 +34,11 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
||||
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
|
||||
}
|
||||
}
|
||||
if let Some(ref oai_config) = project.openai_compatible_config {
|
||||
if let Some(ref v) = oai_config.api_key {
|
||||
secure::store_project_secret(&project.id, "openai-compatible-api-key", v)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -51,6 +56,10 @@ fn load_secrets_for_project(project: &mut Project) {
|
||||
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
|
||||
.unwrap_or(None);
|
||||
}
|
||||
if let Some(ref mut oai_config) = project.openai_compatible_config {
|
||||
oai_config.api_key = secure::get_project_secret(&project.id, "openai-compatible-api-key")
|
||||
.unwrap_or(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve enabled MCP servers and filter to Docker-only ones.
|
||||
@@ -170,13 +179,29 @@ pub async fn start_project_container(
|
||||
// Resolve enabled MCP servers for this project
|
||||
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
||||
|
||||
// Validate auth mode requirements
|
||||
if project.auth_mode == AuthMode::Bedrock {
|
||||
// Validate backend requirements
|
||||
if project.backend == Backend::Bedrock {
|
||||
let bedrock = project.bedrock_config.as_ref()
|
||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||
.ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?;
|
||||
// Region can come from per-project or global
|
||||
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
||||
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
||||
return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if project.backend == Backend::Ollama {
|
||||
let ollama = project.ollama_config.as_ref()
|
||||
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
|
||||
if ollama.base_url.is_empty() {
|
||||
return Err("Ollama base URL is required.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if project.backend == Backend::OpenAiCompatible {
|
||||
let oai_config = project.openai_compatible_config.as_ref()
|
||||
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
|
||||
if oai_config.base_url.is_empty() {
|
||||
return Err("OpenAI Compatible base URL is required.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +227,28 @@ pub async fn start_project_container(
|
||||
|
||||
// Set up Docker network and MCP containers if needed
|
||||
let network_name = if !docker_mcp.is_empty() {
|
||||
// Pull any missing MCP Docker images before starting containers
|
||||
for server in &docker_mcp {
|
||||
if let Some(ref image) = server.docker_image {
|
||||
if !docker::image_exists(image).await.unwrap_or(false) {
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
&format!("Pulling MCP image for '{}'...", server.name),
|
||||
);
|
||||
let image_clone = image.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let pid_clone = project_id.clone();
|
||||
let sname = server.name.clone();
|
||||
docker::pull_image(&image_clone, move |msg| {
|
||||
emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg));
|
||||
}).await.map_err(|e| {
|
||||
format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Setting up MCP network...");
|
||||
let net = docker::ensure_project_network(&project.id).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
|
||||
@@ -386,6 +433,46 @@ pub async fn rebuild_project_container(
|
||||
start_project_container(project_id, app_handle, state).await
|
||||
}
|
||||
|
||||
/// Reconcile project statuses against actual Docker container state.
|
||||
/// Called by the frontend after Docker is confirmed available. Projects
|
||||
/// marked as Running whose containers are no longer running get reset
|
||||
/// to Stopped.
|
||||
#[tauri::command]
|
||||
pub async fn reconcile_project_statuses(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<Project>, String> {
|
||||
let projects = state.projects_store.list();
|
||||
|
||||
for project in &projects {
|
||||
if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_running = if let Some(ref container_id) = project.container_id {
|
||||
docker::is_container_running(container_id).await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_running {
|
||||
log::info!(
|
||||
"Project '{}' ({}) container is still running — keeping Running status",
|
||||
project.name,
|
||||
project.id
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Project '{}' ({}) container is not running — setting to Stopped",
|
||||
project.name,
|
||||
project.id
|
||||
);
|
||||
let _ = state.projects_store.update_status(&project.id, ProjectStatus::Stopped);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(state.projects_store.list())
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
|
||||
@@ -1,11 +1,88 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||
use crate::AppState;
|
||||
|
||||
/// Build the command to run in the container terminal.
|
||||
///
|
||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
let mut cmd = vec!["claude".to_string()];
|
||||
if project.full_permissions {
|
||||
cmd.push("--dangerously-skip-permissions".to_string());
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Resolve AWS profile: project-level → global settings → "default"
|
||||
let profile = project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
.and_then(|b| b.aws_profile.clone())
|
||||
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||
// then exec's into claude.
|
||||
let claude_cmd = if project.full_permissions {
|
||||
"exec claude --dangerously-skip-permissions"
|
||||
} else {
|
||||
"exec claude"
|
||||
};
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
echo "Validating AWS session for profile '{profile}'..."
|
||||
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "AWS session valid."
|
||||
else
|
||||
echo "AWS session expired or invalid."
|
||||
# Check if this profile uses SSO (has sso_start_url or sso_session configured)
|
||||
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
|
||||
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "Starting SSO login..."
|
||||
echo ""
|
||||
triple-c-sso-refresh
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
||||
echo "You may see authentication errors."
|
||||
echo ""
|
||||
fi
|
||||
else
|
||||
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
|
||||
echo "Starting Claude anyway..."
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
script,
|
||||
]
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_terminal_session(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
session_type: Option<String>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@@ -19,10 +96,10 @@ pub async fn open_terminal_session(
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = vec![
|
||||
"claude".to_string(),
|
||||
"--dangerously-skip-permissions".to_string(),
|
||||
];
|
||||
let cmd = match session_type.as_deref() {
|
||||
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||
_ => build_terminal_cmd(&project, &state),
|
||||
};
|
||||
|
||||
let output_event = format!("terminal-output-{}", session_id);
|
||||
let exit_event = format!("terminal-exit-{}", session_id);
|
||||
@@ -69,6 +146,10 @@ pub async fn close_terminal_session(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Close audio bridge if it exists
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
// Close terminal session
|
||||
state.exec_manager.close_session(&session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -92,3 +173,53 @@ pub async fn paste_image_to_terminal(
|
||||
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Get container_id from the terminal session
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
|
||||
// Create audio bridge exec session with ID "audio-{session_id}"
|
||||
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
let cmd = vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
|
||||
];
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.create_session_with_tty(
|
||||
&container_id,
|
||||
&audio_session_id,
|
||||
cmd,
|
||||
false,
|
||||
|_data| { /* ignore output from the audio bridge */ },
|
||||
Box::new(|| { /* no exit handler needed */ }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_audio_data(
|
||||
session_id: String,
|
||||
data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.send_input(&audio_session_id, data).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
|
||||
use serde::Deserialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, GitHubRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
|
||||
use crate::AppState;
|
||||
|
||||
const RELEASES_URL: &str =
|
||||
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
|
||||
"https://api.github.com/repos/shadowdao/triple-c/releases";
|
||||
|
||||
/// GHCR container-registry API base (OCI distribution spec).
|
||||
const REGISTRY_API_BASE: &str =
|
||||
"https://ghcr.io/v2/shadowdao/triple-c-sandbox";
|
||||
|
||||
/// GHCR token endpoint for anonymous pull access.
|
||||
const GHCR_TOKEN_URL: &str =
|
||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
@@ -15,9 +28,10 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let releases: Vec<GiteaRelease> = client
|
||||
let releases: Vec<GitHubRelease> = client
|
||||
.get(RELEASES_URL)
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "triple-c-updater")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch releases: {}", e))?
|
||||
@@ -26,30 +40,34 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
|
||||
|
||||
// Filter releases by platform tag suffix
|
||||
let platform_releases: Vec<&GiteaRelease> = releases
|
||||
// Determine platform-specific asset extensions
|
||||
let platform_extensions: &[&str] = if cfg!(target_os = "windows") {
|
||||
&[".msi", ".exe"]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[".dmg", ".app.tar.gz"]
|
||||
} else {
|
||||
&[".AppImage", ".deb", ".rpm"]
|
||||
};
|
||||
|
||||
// Filter releases that have at least one asset matching the current platform
|
||||
let platform_releases: Vec<&GitHubRelease> = releases
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
if is_windows {
|
||||
r.tag_name.ends_with("-win")
|
||||
} else {
|
||||
!r.tag_name.ends_with("-win")
|
||||
}
|
||||
r.assets.iter().any(|a| {
|
||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Find the latest release with a higher patch version
|
||||
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
|
||||
let current_patch = parse_patch_version(current_version).unwrap_or(0);
|
||||
|
||||
let mut best: Option<(&GiteaRelease, u32)> = None;
|
||||
// Find the latest release with a higher semver version
|
||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||
for release in &platform_releases {
|
||||
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
|
||||
if patch > current_patch {
|
||||
if best.is_none() || patch > best.unwrap().1 {
|
||||
best = Some((release, patch));
|
||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||
if ver > current_semver {
|
||||
if best.is_none() || ver > best.unwrap().1 {
|
||||
best = Some((release, ver));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,9 +75,13 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
|
||||
match best {
|
||||
Some((release, _)) => {
|
||||
// Only include assets matching the current platform
|
||||
let assets = release
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||
})
|
||||
.map(|a| ReleaseAsset {
|
||||
name: a.name.clone(),
|
||||
browser_download_url: a.browser_download_url.clone(),
|
||||
@@ -67,7 +89,6 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Reconstruct version string from tag
|
||||
let version = extract_version_from_tag(&release.tag_name)
|
||||
.unwrap_or_else(|| release.tag_name.clone());
|
||||
|
||||
@@ -84,34 +105,152 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse patch version from a semver string like "0.1.5" -> 5
|
||||
fn parse_patch_version(version: &str) -> Option<u32> {
|
||||
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||
let clean = version.trim_start_matches('v');
|
||||
let parts: Vec<&str> = clean.split('.').collect();
|
||||
if parts.len() >= 3 {
|
||||
parts[2].parse().ok()
|
||||
let major = parts[0].parse().ok()?;
|
||||
let minor = parts[1].parse().ok()?;
|
||||
let patch = parts[2].parse().ok()?;
|
||||
Some((major, minor, patch))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
|
||||
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
|
||||
/// Parse semver from a tag like "v0.2.5" -> (0, 2, 5)
|
||||
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
|
||||
let clean = tag.trim_start_matches('v');
|
||||
// Remove platform suffix
|
||||
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
||||
parse_patch_version(clean)
|
||||
parse_semver(clean)
|
||||
}
|
||||
|
||||
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
|
||||
/// Extract a clean version string from a tag like "v0.2.5" -> "0.2.5"
|
||||
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||
let clean = tag.trim_start_matches('v');
|
||||
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
||||
// Validate it looks like a version
|
||||
let parts: Vec<&str> = clean.split('.').collect();
|
||||
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
|
||||
Some(clean.to_string())
|
||||
} else {
|
||||
None
|
||||
let (major, minor, patch) = parse_semver_from_tag(tag)?;
|
||||
Some(format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
/// Check whether a newer container image is available in the registry.
|
||||
///
|
||||
/// Compares the local image digest with the remote registry digest using the
|
||||
/// Docker Registry HTTP API v2. Only applies when the image source is
|
||||
/// "registry" (the default); for local builds or custom images we cannot
|
||||
/// meaningfully check for remote updates.
|
||||
#[tauri::command]
|
||||
pub async fn check_image_update(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<ImageUpdateInfo>, String> {
|
||||
let settings = state.settings_store.get();
|
||||
|
||||
// Only check for registry images
|
||||
if settings.image_source != crate::models::app_settings::ImageSource::Registry {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let image_name =
|
||||
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// 1. Get local image digest via Docker
|
||||
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
|
||||
|
||||
// 2. Get remote digest from the GHCR container registry (OCI distribution spec)
|
||||
let remote_digest = fetch_remote_digest("latest").await?;
|
||||
|
||||
// No remote digest available — nothing to compare
|
||||
let remote_digest = match remote_digest {
|
||||
Some(d) => d,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// If local digest matches remote, no update
|
||||
if let Some(ref local) = local_digest {
|
||||
if *local == remote_digest {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// There's a difference (or no local image at all)
|
||||
Ok(Some(ImageUpdateInfo {
|
||||
remote_digest,
|
||||
local_digest,
|
||||
remote_updated_at: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch the digest of a tag from GHCR using the OCI / Docker Registry HTTP API v2.
|
||||
///
|
||||
/// GHCR requires authentication even for public images, so we first obtain an
|
||||
/// anonymous token, then issue a HEAD request to /v2/<repo>/manifests/<tag>
|
||||
/// and read the `Docker-Content-Digest` header.
|
||||
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// 1. Obtain anonymous bearer token from GHCR
|
||||
let token = match fetch_ghcr_token(&client).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to obtain GHCR token: {}", e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. HEAD the manifest with the token
|
||||
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
|
||||
|
||||
let response = client
|
||||
.head(&url)
|
||||
.header(
|
||||
"Accept",
|
||||
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
|
||||
)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
log::warn!(
|
||||
"Registry returned status {} when checking image digest",
|
||||
resp.status()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
// The digest is returned in the Docker-Content-Digest header
|
||||
if let Some(digest) = resp.headers().get("docker-content-digest") {
|
||||
if let Ok(val) = digest.to_str() {
|
||||
return Ok(Some(val.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to check registry for image update: {}", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch an anonymous bearer token from GHCR for pulling public images.
|
||||
async fn fetch_ghcr_token(client: &reqwest::Client) -> Result<String, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
token: String,
|
||||
}
|
||||
|
||||
let resp: TokenResponse = client
|
||||
.get(GHCR_TOKEN_URL)
|
||||
.header("User-Agent", "triple-c-updater")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GHCR token request failed: {}", e))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GHCR token response: {}", e))?;
|
||||
|
||||
Ok(resp.token)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||
use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
|
||||
@@ -40,6 +40,54 @@ After tasks run, check notifications with `triple-c-scheduler notifications` and
|
||||
### Timezone
|
||||
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
|
||||
|
||||
const MISSION_CONTROL_GLOBAL_INSTRUCTIONS: &str = r#"## Mission Control
|
||||
|
||||
The `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.
|
||||
- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`
|
||||
- The methodology docs and project registry live in `/workspace/mission-control/`
|
||||
|
||||
### When to Use
|
||||
|
||||
When working on any project that has a `.flightops/` directory, follow the Flight Control methodology:
|
||||
1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage
|
||||
2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow
|
||||
3. Use Mission Control skills for planning and execution
|
||||
|
||||
### Available Skills
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
| `/init-project` | Setting up a new project for Flight Control |
|
||||
| `/mission` | Defining new work outcomes (days-to-weeks scope) |
|
||||
| `/flight` | Creating technical specs from missions (hours-to-days scope) |
|
||||
| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |
|
||||
| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |
|
||||
| `/flight-debrief` | Post-flight analysis after a flight lands |
|
||||
| `/mission-debrief` | Post-mission retrospective after completion |
|
||||
| `/daily-briefing` | Cross-project status report |
|
||||
|
||||
### Key Rules
|
||||
|
||||
- **Planning skills produce artifacts only** — never modify source code directly
|
||||
- **Phase gates require human confirmation** — missions before flights, flights before legs
|
||||
- **Legs are immutable once in-flight** — create new ones instead of modifying
|
||||
- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents
|
||||
- **Artifacts live in the target project** — not in mission-control"#;
|
||||
|
||||
const MISSION_CONTROL_PROJECT_INSTRUCTIONS: &str = r#"## Flight Operations
|
||||
|
||||
This project uses [Flight Control](https://github.com/msieurthenardier/mission-control) for structured development.
|
||||
|
||||
**Before any mission/flight/leg work, read these files in order:**
|
||||
1. `.flightops/README.md` — What the flightops directory contains
|
||||
2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**
|
||||
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
|
||||
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
|
||||
|
||||
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||
@@ -48,8 +96,13 @@ fn build_claude_instructions(
|
||||
global_instructions: Option<&str>,
|
||||
project_instructions: Option<&str>,
|
||||
port_mappings: &[PortMapping],
|
||||
mission_control_enabled: bool,
|
||||
) -> Option<String> {
|
||||
let mut combined = merge_claude_instructions(global_instructions, project_instructions);
|
||||
let mut combined = merge_claude_instructions(
|
||||
global_instructions,
|
||||
project_instructions,
|
||||
mission_control_enabled,
|
||||
);
|
||||
|
||||
if !port_mappings.is_empty() {
|
||||
let mut port_lines: Vec<String> = Vec::new();
|
||||
@@ -116,14 +169,37 @@ fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
||||
}
|
||||
|
||||
/// Merge global and per-project Claude instructions into a single string.
|
||||
/// When mission_control_enabled is true, appends Mission Control global
|
||||
/// instructions after global and project instructions after project.
|
||||
fn merge_claude_instructions(
|
||||
global_instructions: Option<&str>,
|
||||
project_instructions: Option<&str>,
|
||||
mission_control_enabled: bool,
|
||||
) -> Option<String> {
|
||||
match (global_instructions, project_instructions) {
|
||||
// Build the global portion (user global + optional MC global)
|
||||
let global_part = if mission_control_enabled {
|
||||
match global_instructions {
|
||||
Some(g) => Some(format!("{}\n\n{}", g, MISSION_CONTROL_GLOBAL_INSTRUCTIONS)),
|
||||
None => Some(MISSION_CONTROL_GLOBAL_INSTRUCTIONS.to_string()),
|
||||
}
|
||||
} else {
|
||||
global_instructions.map(|g| g.to_string())
|
||||
};
|
||||
|
||||
// Build the project portion (user project + optional MC project)
|
||||
let project_part = if mission_control_enabled {
|
||||
match project_instructions {
|
||||
Some(p) => Some(format!("{}\n\n{}", p, MISSION_CONTROL_PROJECT_INSTRUCTIONS)),
|
||||
None => Some(MISSION_CONTROL_PROJECT_INSTRUCTIONS.to_string()),
|
||||
}
|
||||
} else {
|
||||
project_instructions.map(|p| p.to_string())
|
||||
};
|
||||
|
||||
match (global_part, project_part) {
|
||||
(Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)),
|
||||
(Some(g), None) => Some(g.to_string()),
|
||||
(None, Some(p)) => Some(p.to_string()),
|
||||
(Some(g), None) => Some(g),
|
||||
(None, Some(p)) => Some(p),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
@@ -155,6 +231,33 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for the Ollama configuration so we can detect changes.
|
||||
fn compute_ollama_fingerprint(project: &Project) -> String {
|
||||
if let Some(ref ollama) = project.ollama_config {
|
||||
let parts = vec![
|
||||
ollama.base_url.clone(),
|
||||
ollama.model_id.as_deref().unwrap_or("").to_string(),
|
||||
];
|
||||
sha256_hex(&parts.join("|"))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for the OpenAI Compatible configuration so we can detect changes.
|
||||
fn compute_openai_compatible_fingerprint(project: &Project) -> String {
|
||||
if let Some(ref config) = project.openai_compatible_config {
|
||||
let parts = vec![
|
||||
config.base_url.clone(),
|
||||
config.api_key.as_deref().unwrap_or("").to_string(),
|
||||
config.model_id.as_deref().unwrap_or("").to_string(),
|
||||
];
|
||||
sha256_hex(&parts.join("|"))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for the project paths so we can detect changes.
|
||||
/// Sorted by mount_name so order changes don't cause spurious recreation.
|
||||
fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
|
||||
@@ -350,7 +453,7 @@ pub async fn create_container(
|
||||
}
|
||||
|
||||
// Bedrock configuration
|
||||
if project.auth_mode == AuthMode::Bedrock {
|
||||
if project.backend == Backend::Bedrock {
|
||||
if let Some(ref bedrock) = project.bedrock_config {
|
||||
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
|
||||
|
||||
@@ -383,6 +486,7 @@ pub async fn create_container(
|
||||
if let Some(p) = profile {
|
||||
env_vars.push(format!("AWS_PROFILE={}", p));
|
||||
}
|
||||
env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string());
|
||||
}
|
||||
BedrockAuthMethod::BearerToken => {
|
||||
if let Some(ref token) = bedrock.aws_bearer_token {
|
||||
@@ -401,6 +505,30 @@ pub async fn create_container(
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama configuration
|
||||
if project.backend == Backend::Ollama {
|
||||
if let Some(ref ollama) = project.ollama_config {
|
||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url));
|
||||
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
|
||||
if let Some(ref model) = ollama.model_id {
|
||||
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI Compatible configuration
|
||||
if project.backend == Backend::OpenAiCompatible {
|
||||
if let Some(ref config) = project.openai_compatible_config {
|
||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", config.base_url));
|
||||
if let Some(ref key) = config.api_key {
|
||||
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
|
||||
}
|
||||
if let Some(ref model) = config.model_id {
|
||||
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||
@@ -426,11 +554,17 @@ pub async fn create_container(
|
||||
}
|
||||
}
|
||||
|
||||
// Mission Control env var
|
||||
if project.mission_control_enabled {
|
||||
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
|
||||
}
|
||||
|
||||
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||
let combined_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
project.mission_control_enabled,
|
||||
);
|
||||
|
||||
if let Some(ref instructions) = combined_instructions {
|
||||
@@ -490,7 +624,7 @@ pub async fn create_container(
|
||||
|
||||
// AWS config mount (read-only)
|
||||
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
|
||||
let should_mount_aws = if project.auth_mode == AuthMode::Bedrock {
|
||||
let should_mount_aws = if project.backend == Backend::Bedrock {
|
||||
if let Some(ref bedrock) = project.bedrock_config {
|
||||
bedrock.auth_method == BedrockAuthMethod::Profile
|
||||
} else {
|
||||
@@ -508,7 +642,7 @@ pub async fn create_container(
|
||||
if let Some(ref aws_path) = aws_dir {
|
||||
if aws_path.exists() {
|
||||
mounts.push(Mount {
|
||||
target: Some("/home/claude/.aws".to_string()),
|
||||
target: Some("/tmp/.host-aws".to_string()),
|
||||
source: Some(aws_path.to_string_lossy().to_string()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(true),
|
||||
@@ -560,13 +694,16 @@ pub async fn create_container(
|
||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
||||
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
|
||||
labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend));
|
||||
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
|
||||
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project));
|
||||
labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project));
|
||||
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
||||
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
||||
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
||||
|
||||
let host_config = HostConfig {
|
||||
mounts: Some(mounts),
|
||||
@@ -760,11 +897,13 @@ pub async fn container_needs_recreation(
|
||||
// Code settings stored in the named volume). The change takes effect
|
||||
// on the next explicit rebuild instead.
|
||||
|
||||
// ── Auth mode ────────────────────────────────────────────────────────
|
||||
let current_auth_mode = format!("{:?}", project.auth_mode);
|
||||
if let Some(container_auth_mode) = get_label("triple-c.auth-mode") {
|
||||
if container_auth_mode != current_auth_mode {
|
||||
log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_auth_mode);
|
||||
// ── Backend ──────────────────────────────────────────────────────────
|
||||
let current_backend = format!("{:?}", project.backend);
|
||||
// Check new label name, falling back to old "triple-c.auth-mode" for pre-rename containers
|
||||
let container_backend = get_label("triple-c.backend").or_else(|| get_label("triple-c.auth-mode"));
|
||||
if let Some(container_backend) = container_backend {
|
||||
if container_backend != current_backend {
|
||||
log::info!("Backend mismatch (container={:?}, project={:?})", container_backend, current_backend);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -801,6 +940,22 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Ollama config fingerprint ────────────────────────────────────────
|
||||
let expected_ollama_fp = compute_ollama_fingerprint(project);
|
||||
let container_ollama_fp = get_label("triple-c.ollama-fingerprint").unwrap_or_default();
|
||||
if container_ollama_fp != expected_ollama_fp {
|
||||
log::info!("Ollama config mismatch");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── OpenAI Compatible config fingerprint ────────────────────────────
|
||||
let expected_oai_fp = compute_openai_compatible_fingerprint(project);
|
||||
let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default();
|
||||
if container_oai_fp != expected_oai_fp {
|
||||
log::info!("OpenAI Compatible config mismatch");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Image ────────────────────────────────────────────────────────────
|
||||
// The image label is set at creation time; if the user changed the
|
||||
// configured image we need to recreate. We only compare when the
|
||||
@@ -885,11 +1040,20 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Mission Control ────────────────────────────────────────────────────
|
||||
let expected_mc = project.mission_control_enabled.to_string();
|
||||
let container_mc = get_label("triple-c.mission-control").unwrap_or_else(|| "false".to_string());
|
||||
if container_mc != expected_mc {
|
||||
log::info!("Mission Control mismatch (container={:?}, expected={:?})", container_mc, expected_mc);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Claude instructions ───────────────────────────────────────────────
|
||||
let expected_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
project.mission_control_enabled,
|
||||
);
|
||||
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
||||
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
||||
@@ -939,6 +1103,16 @@ pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInf
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a Docker container is currently running.
|
||||
/// Returns false if the container doesn't exist or Docker is unavailable.
|
||||
pub async fn is_container_running(container_id: &str) -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
match docker.inspect_container(container_id, None).await {
|
||||
Ok(info) => Ok(info.state.and_then(|s| s.running).unwrap_or(false)),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -971,11 +1145,6 @@ pub fn any_stdio_docker_mcp(servers: &[McpServer]) -> bool {
|
||||
servers.iter().any(|s| s.is_docker() && s.transport_type == McpTransportType::Stdio)
|
||||
}
|
||||
|
||||
/// Returns true if any MCP server uses Docker.
|
||||
pub fn any_docker_mcp(servers: &[McpServer]) -> bool {
|
||||
servers.iter().any(|s| s.is_docker())
|
||||
}
|
||||
|
||||
/// Find an existing MCP container by its expected name.
|
||||
pub async fn find_mcp_container(server: &McpServer) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -22,6 +22,7 @@ impl ExecSession {
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
@@ -60,6 +61,22 @@ impl ExecSessionManager {
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
@@ -72,7 +89,7 @@ impl ExecSessionManager {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(true),
|
||||
tty: Some(tty),
|
||||
cmd: Some(cmd),
|
||||
user: Some("claude".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
@@ -261,3 +278,41 @@ impl ExecSessionManager {
|
||||
Ok(format!("/tmp/{}", file_name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(cmd),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, .. } => {
|
||||
let mut stdout = String::new();
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||
}
|
||||
}
|
||||
Ok(stdout)
|
||||
}
|
||||
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,38 @@ pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||
Ok(!images.is_empty())
|
||||
}
|
||||
|
||||
/// Returns the first repo digest (e.g. "sha256:abc...") for the given image,
|
||||
/// or None if the image doesn't exist locally or has no repo digests.
|
||||
pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec![image_name.to_string()],
|
||||
)]);
|
||||
|
||||
let images: Vec<ImageSummary> = docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list images: {}", e))?;
|
||||
|
||||
if let Some(img) = images.first() {
|
||||
// RepoDigests contains entries like "registry/repo@sha256:abc..."
|
||||
if let Some(digest_str) = img.repo_digests.first() {
|
||||
// Extract the sha256:... part after '@'
|
||||
if let Some(pos) = digest_str.find('@') {
|
||||
return Ok(Some(digest_str[pos + 1..].to_string()));
|
||||
}
|
||||
return Ok(Some(digest_str.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
|
||||
@@ -4,8 +4,13 @@ pub mod image;
|
||||
pub mod exec;
|
||||
pub mod network;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use client::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use container::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use image::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use exec::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use network::*;
|
||||
|
||||
@@ -48,6 +48,7 @@ pub async fn ensure_project_network(project_id: &str) -> Result<String, String>
|
||||
}
|
||||
|
||||
/// Connect a container to the project network.
|
||||
#[allow(dead_code)]
|
||||
pub async fn connect_container_to_network(
|
||||
container_id: &str,
|
||||
network_name: &str,
|
||||
|
||||
@@ -88,6 +88,7 @@ pub fn run() {
|
||||
commands::project_commands::start_project_container,
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
// Settings
|
||||
commands::settings_commands::get_settings,
|
||||
commands::settings_commands::update_settings,
|
||||
@@ -101,14 +102,26 @@ pub fn run() {
|
||||
commands::terminal_commands::terminal_resize,
|
||||
commands::terminal_commands::close_terminal_session,
|
||||
commands::terminal_commands::paste_image_to_terminal,
|
||||
commands::terminal_commands::start_audio_bridge,
|
||||
commands::terminal_commands::send_audio_data,
|
||||
commands::terminal_commands::stop_audio_bridge,
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::upload_file_to_container,
|
||||
// MCP
|
||||
commands::mcp_commands::list_mcp_servers,
|
||||
commands::mcp_commands::add_mcp_server,
|
||||
commands::mcp_commands::update_mcp_server,
|
||||
commands::mcp_commands::remove_mcp_server,
|
||||
// AWS
|
||||
commands::aws_commands::aws_sso_refresh,
|
||||
// Updates
|
||||
commands::update_commands::get_app_version,
|
||||
commands::update_commands::check_for_updates,
|
||||
commands::update_commands::check_image_update,
|
||||
// Help
|
||||
commands::help_commands::get_help_content,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -70,6 +70,10 @@ pub struct AppSettings {
|
||||
pub dismissed_update_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timezone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_microphone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dismissed_image_digest: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -87,6 +91,8 @@ impl Default for AppSettings {
|
||||
auto_check_updates: true,
|
||||
dismissed_update_version: None,
|
||||
timezone: None,
|
||||
default_microphone: None,
|
||||
dismissed_image_digest: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct ContainerInfo {
|
||||
|
||||
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
|
||||
pub const IMAGE_TAG: &str = "latest";
|
||||
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
||||
pub const REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-sandbox:latest";
|
||||
|
||||
pub fn local_build_image_name() -> String {
|
||||
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
|
||||
|
||||
@@ -24,6 +24,10 @@ fn default_protocol() -> String {
|
||||
"tcp".to_string()
|
||||
}
|
||||
|
||||
fn default_full_permissions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
@@ -31,11 +35,19 @@ pub struct Project {
|
||||
pub paths: Vec<ProjectPath>,
|
||||
pub container_id: Option<String>,
|
||||
pub status: ProjectStatus,
|
||||
pub auth_mode: AuthMode,
|
||||
#[serde(alias = "auth_mode")]
|
||||
pub backend: Backend,
|
||||
pub bedrock_config: Option<BedrockConfig>,
|
||||
pub ollama_config: Option<OllamaConfig>,
|
||||
#[serde(alias = "litellm_config")]
|
||||
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
||||
pub allow_docker_access: bool,
|
||||
#[serde(default)]
|
||||
pub mission_control_enabled: bool,
|
||||
#[serde(default = "default_full_permissions")]
|
||||
pub full_permissions: bool,
|
||||
pub ssh_key_path: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub git_token: Option<String>,
|
||||
pub git_user_name: Option<String>,
|
||||
pub git_user_email: Option<String>,
|
||||
@@ -61,20 +73,24 @@ pub enum ProjectStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
/// How the project authenticates with Claude.
|
||||
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
||||
/// persisted in the config volume)
|
||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
||||
/// Which AI model backend/provider the project uses.
|
||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||
/// - `Ollama`: Local or remote Ollama server
|
||||
/// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMode {
|
||||
pub enum Backend {
|
||||
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||
#[serde(alias = "login", alias = "api_key")]
|
||||
Anthropic,
|
||||
Bedrock,
|
||||
Ollama,
|
||||
#[serde(alias = "lite_llm", alias = "litellm")]
|
||||
OpenAiCompatible,
|
||||
}
|
||||
|
||||
impl Default for AuthMode {
|
||||
impl Default for Backend {
|
||||
fn default() -> Self {
|
||||
Self::Anthropic
|
||||
}
|
||||
@@ -100,19 +116,43 @@ impl Default for BedrockAuthMethod {
|
||||
pub struct BedrockConfig {
|
||||
pub auth_method: BedrockAuthMethod,
|
||||
pub aws_region: String,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_session_token: Option<String>,
|
||||
pub aws_profile: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_bearer_token: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub disable_prompt_caching: bool,
|
||||
}
|
||||
|
||||
/// Ollama configuration for a project.
|
||||
/// Ollama exposes an Anthropic-compatible API endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OllamaConfig {
|
||||
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
|
||||
pub base_url: String,
|
||||
/// Optional model override (e.g., "qwen3.5:27b")
|
||||
pub model_id: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI Compatible endpoint configuration for a project.
|
||||
/// Routes Anthropic API calls through any OpenAI API-compatible endpoint
|
||||
/// (e.g., LiteLLM, vLLM, or other compatible gateways).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAiCompatibleConfig {
|
||||
/// The base URL of the OpenAI-compatible endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
|
||||
pub base_url: String,
|
||||
/// API key for the OpenAI-compatible endpoint
|
||||
#[serde(skip_serializing, default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Optional model override
|
||||
pub model_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
@@ -122,9 +162,13 @@ impl Project {
|
||||
paths,
|
||||
container_id: None,
|
||||
status: ProjectStatus::Stopped,
|
||||
auth_mode: AuthMode::default(),
|
||||
backend: Backend::default(),
|
||||
bedrock_config: None,
|
||||
ollama_config: None,
|
||||
openai_compatible_config: None,
|
||||
allow_docker_access: false,
|
||||
mission_control_enabled: false,
|
||||
full_permissions: false,
|
||||
ssh_key_path: None,
|
||||
git_token: None,
|
||||
git_user_name: None,
|
||||
|
||||
@@ -18,20 +18,31 @@ pub struct ReleaseAsset {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Gitea API release response (internal).
|
||||
/// GitHub API release response (internal).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GiteaRelease {
|
||||
pub struct GitHubRelease {
|
||||
pub tag_name: String,
|
||||
pub html_url: String,
|
||||
pub body: String,
|
||||
pub assets: Vec<GiteaAsset>,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
/// Gitea API asset response (internal).
|
||||
/// GitHub API asset response (internal).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GiteaAsset {
|
||||
pub struct GitHubAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Info returned to the frontend about an available container image update.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageUpdateInfo {
|
||||
/// The remote digest (e.g. sha256:abc...)
|
||||
pub remote_digest: String,
|
||||
/// The local digest, if available
|
||||
pub local_digest: Option<String>,
|
||||
/// When the remote image was last updated (if known)
|
||||
pub remote_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ pub mod secure;
|
||||
pub mod settings_store;
|
||||
pub mod mcp_store;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use projects_store::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use secure::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use settings_store::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use mcp_store::*;
|
||||
|
||||
@@ -72,6 +72,8 @@ impl ProjectsStore {
|
||||
|
||||
// Reconcile stale transient statuses: on a cold app start no Docker
|
||||
// operations can be in flight, so Starting/Stopping are always stale.
|
||||
// Running/Error are left as-is and reconciled against Docker later
|
||||
// via the reconcile_project_statuses command.
|
||||
let mut projects = projects;
|
||||
let mut needs_save = needs_save;
|
||||
for p in projects.iter_mut() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "Triple-C",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.triple-c.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -10,15 +10,16 @@ import { useProjects } from "./hooks/useProjects";
|
||||
import { useMcpServers } from "./hooks/useMcpServers";
|
||||
import { useUpdates } from "./hooks/useUpdates";
|
||||
import { useAppState } from "./store/appState";
|
||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||
|
||||
export default function App() {
|
||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||
const { loadSettings } = useSettings();
|
||||
const { refresh } = useProjects();
|
||||
const { refresh: refreshMcp } = useMcpServers();
|
||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||
const { sessions, activeSessionId } = useAppState(
|
||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
||||
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||
const { sessions, activeSessionId, setProjects } = useAppState(
|
||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
||||
);
|
||||
|
||||
// Initialize on mount
|
||||
@@ -28,6 +29,14 @@ export default function App() {
|
||||
checkDocker().then((available) => {
|
||||
if (available) {
|
||||
checkImage();
|
||||
// Reconcile project statuses against actual Docker container state,
|
||||
// then refresh the project list so the UI reflects reality.
|
||||
reconcileProjectStatuses().then((projects) => {
|
||||
setProjects(projects);
|
||||
}).catch(() => {
|
||||
// If reconciliation fails (e.g. Docker hiccup), just load from store
|
||||
refresh();
|
||||
});
|
||||
} else {
|
||||
stopPolling = startDockerPolling();
|
||||
}
|
||||
@@ -37,7 +46,10 @@ export default function App() {
|
||||
|
||||
// Update detection
|
||||
loadVersion();
|
||||
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
|
||||
const updateTimer = setTimeout(() => {
|
||||
checkForUpdates();
|
||||
checkImageUpdate();
|
||||
}, 3000);
|
||||
const cleanup = startPeriodicCheck();
|
||||
return () => {
|
||||
clearTimeout(updateTimer);
|
||||
|
||||
218
app/src/components/layout/HelpDialog.tsx
Normal file
218
app/src/components/layout/HelpDialog.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { getHelpContent } from "../../lib/tauri-commands";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Convert header text to a URL-friendly slug for anchor links. */
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
||||
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
||||
.replace(/\s+/g, "-") // spaces to dashes
|
||||
.replace(/-+/g, "-") // collapse consecutive dashes
|
||||
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
||||
}
|
||||
|
||||
/** Simple markdown-to-HTML converter for the help content. */
|
||||
function renderMarkdown(md: string): string {
|
||||
let html = md;
|
||||
|
||||
// Normalize line endings
|
||||
html = html.replace(/\r\n/g, "\n");
|
||||
|
||||
// Escape HTML entities (but we'll re-introduce tags below)
|
||||
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// Fenced code blocks (```...```)
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
return `<pre class="help-code-block"><code>${code.trimEnd()}</code></pre>`;
|
||||
});
|
||||
|
||||
// Inline code (`...`)
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="help-inline-code">$1</code>');
|
||||
|
||||
// Tables
|
||||
html = html.replace(
|
||||
/(?:^|\n)(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/g,
|
||||
(_m, headerRow: string, _sep: string, bodyRows: string) => {
|
||||
const headers = headerRow
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((c: string) => `<th>${c.trim()}</th>`)
|
||||
.join("");
|
||||
const rows = bodyRows
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((row: string) => {
|
||||
const cells = row
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((c: string) => `<td>${c.trim()}</td>`)
|
||||
.join("");
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<table class="help-table"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
},
|
||||
);
|
||||
|
||||
// Blockquotes (> ...)
|
||||
html = html.replace(/(?:^|\n)> (.+)/g, '<blockquote class="help-blockquote">$1</blockquote>');
|
||||
// Merge adjacent blockquotes
|
||||
html = html.replace(/<\/blockquote>\s*<blockquote class="help-blockquote">/g, "<br/>");
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/\n---\n/g, '<hr class="help-hr"/>');
|
||||
|
||||
// Headers with id attributes for anchor navigation (process from h4 down to h1)
|
||||
html = html.replace(/^#### (.+)$/gm, (_m, title) => `<h4 class="help-h4" id="${slugify(title)}">${title}</h4>`);
|
||||
html = html.replace(/^### (.+)$/gm, (_m, title) => `<h3 class="help-h3" id="${slugify(title)}">${title}</h3>`);
|
||||
html = html.replace(/^## (.+)$/gm, (_m, title) => `<h2 class="help-h2" id="${slugify(title)}">${title}</h2>`);
|
||||
html = html.replace(/^# (.+)$/gm, (_m, title) => `<h1 class="help-h1" id="${slugify(title)}">${title}</h1>`);
|
||||
|
||||
// Bold (**...**)
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
|
||||
// Italic (*...*)
|
||||
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
|
||||
// Markdown-style anchor links [text](#anchor)
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
||||
'<a class="help-link" href="#$2">$1</a>',
|
||||
);
|
||||
|
||||
// Markdown-style external links [text](url)
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
||||
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
);
|
||||
|
||||
// Unordered list items (- ...)
|
||||
// Group consecutive list items
|
||||
html = html.replace(/((?:^|\n)- .+(?:\n- .+)*)/g, (block) => {
|
||||
const items = block
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => `<li>${line.replace(/^- /, "")}</li>`)
|
||||
.join("");
|
||||
return `<ul class="help-ul">${items}</ul>`;
|
||||
});
|
||||
|
||||
// Ordered list items (1. ...)
|
||||
html = html.replace(/((?:^|\n)\d+\. .+(?:\n\d+\. .+)*)/g, (block) => {
|
||||
const items = block
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => `<li>${line.replace(/^\d+\. /, "")}</li>`)
|
||||
.join("");
|
||||
return `<ol class="help-ol">${items}</ol>`;
|
||||
});
|
||||
|
||||
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
||||
html = html.replace(
|
||||
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
||||
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
);
|
||||
|
||||
// Wrap remaining loose text lines in paragraphs
|
||||
// Split by double newlines for paragraph breaks
|
||||
const blocks = html.split(/\n\n+/);
|
||||
html = blocks
|
||||
.map((block) => {
|
||||
const trimmed = block.trim();
|
||||
if (!trimmed) return "";
|
||||
// Don't wrap blocks that are already HTML elements
|
||||
if (
|
||||
/^<(h[1-4]|ul|ol|pre|table|blockquote|hr)/.test(trimmed)
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
// Wrap in paragraph, replacing single newlines with <br/>
|
||||
return `<p class="help-p">${trimmed.replace(/\n/g, "<br/>")}</p>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export default function HelpDialog({ onClose }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
getHelpContent()
|
||||
.then(setMarkdown)
|
||||
.catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Handle anchor link clicks to scroll within the dialog
|
||||
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest("a");
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href || !href.startsWith("#")) return;
|
||||
e.preventDefault();
|
||||
const el = contentRef.current?.querySelector(href);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
onClick={handleContentClick}
|
||||
className="flex-1 overflow-y-auto px-6 py-4 help-content"
|
||||
>
|
||||
{error && (
|
||||
<p className="text-[var(--error)] text-sm">Failed to load help content: {error}</p>
|
||||
)}
|
||||
{!markdown && !error && (
|
||||
<p className="text-[var(--text-secondary)] text-sm">Loading...</p>
|
||||
)}
|
||||
{markdown && (
|
||||
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppState } from "../../store/appState";
|
||||
|
||||
export default function StatusBar() {
|
||||
const { projects, sessions } = useAppState(
|
||||
useShallow(s => ({ projects: s.projects, sessions: s.sessions }))
|
||||
const { projects, sessions, terminalHasSelection } = useAppState(
|
||||
useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection }))
|
||||
);
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
|
||||
@@ -20,6 +20,12 @@ export default function StatusBar() {
|
||||
<span>
|
||||
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{terminalHasSelection && (
|
||||
<>
|
||||
<span className="mx-2">|</span>
|
||||
<span className="text-[var(--accent)]">Ctrl+Shift+C to copy</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,19 +4,25 @@ import TerminalTabs from "../terminal/TerminalTabs";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import UpdateDialog from "../settings/UpdateDialog";
|
||||
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
||||
import HelpDialog from "./HelpDialog";
|
||||
|
||||
export default function TopBar() {
|
||||
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
|
||||
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
||||
useShallow(s => ({
|
||||
dockerAvailable: s.dockerAvailable,
|
||||
imageExists: s.imageExists,
|
||||
updateInfo: s.updateInfo,
|
||||
imageUpdateInfo: s.imageUpdateInfo,
|
||||
appVersion: s.appVersion,
|
||||
setUpdateInfo: s.setUpdateInfo,
|
||||
setImageUpdateInfo: s.setImageUpdateInfo,
|
||||
}))
|
||||
);
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||
const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false);
|
||||
const [showHelpDialog, setShowHelpDialog] = useState(false);
|
||||
|
||||
const handleDismiss = async () => {
|
||||
if (appSettings && updateInfo) {
|
||||
@@ -29,6 +35,17 @@ export default function TopBar() {
|
||||
setShowUpdateDialog(false);
|
||||
};
|
||||
|
||||
const handleImageUpdateDismiss = async () => {
|
||||
if (appSettings && imageUpdateInfo) {
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
dismissed_image_digest: imageUpdateInfo.remote_digest,
|
||||
});
|
||||
}
|
||||
setImageUpdateInfo(null);
|
||||
setShowImageUpdateDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
@@ -44,8 +61,24 @@ export default function TopBar() {
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
{imageUpdateInfo && (
|
||||
<button
|
||||
onClick={() => setShowImageUpdateDialog(true)}
|
||||
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--warning,#f59e0b)] text-white hover:opacity-80 transition-colors"
|
||||
title="A newer container image is available"
|
||||
>
|
||||
Image Update
|
||||
</button>
|
||||
)}
|
||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||
<StatusDot ok={imageExists === true} label="Image" />
|
||||
<button
|
||||
onClick={() => setShowHelpDialog(true)}
|
||||
title="Help"
|
||||
className="ml-1 w-5 h-5 flex items-center justify-center rounded-full border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showUpdateDialog && updateInfo && (
|
||||
@@ -56,6 +89,16 @@ export default function TopBar() {
|
||||
onClose={() => setShowUpdateDialog(false)}
|
||||
/>
|
||||
)}
|
||||
{showImageUpdateDialog && imageUpdateInfo && (
|
||||
<ImageUpdateDialog
|
||||
imageUpdateInfo={imageUpdateInfo}
|
||||
onDismiss={handleImageUpdateDismiss}
|
||||
onClose={() => setShowImageUpdateDialog(false)}
|
||||
/>
|
||||
)}
|
||||
{showHelpDialog && (
|
||||
<HelpDialog onClose={() => setShowHelpDialog(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
||||
className={inputCls}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
||||
Set a Docker image to run this MCP server as a container. Leave empty for manual mode.
|
||||
Set a Docker image to run this MCP server in its own container. Leave empty to run commands inside the project container. Images are pulled automatically if not present.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +171,14 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode description */}
|
||||
<p className="text-xs text-[var(--text-secondary)] opacity-60">
|
||||
{transportType === "stdio" && isDocker && "Runs via docker exec in a separate MCP container."}
|
||||
{transportType === "stdio" && !isDocker && "Runs inside the project container (e.g. npx commands)."}
|
||||
{transportType === "http" && isDocker && "Runs in a separate container, reached by hostname on the project network."}
|
||||
{transportType === "http" && !isDocker && "Connects to an MCP server at the URL you specify."}
|
||||
</p>
|
||||
|
||||
{/* Container Port (HTTP+Docker only) */}
|
||||
{transportType === "http" && isDocker && (
|
||||
<div>
|
||||
@@ -183,7 +191,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
||||
className={inputCls}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
||||
Port inside the MCP container (default: 3000)
|
||||
Port the MCP server listens on inside its container. The URL is auto-generated as http://<container>:<port>/mcp on the project network.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
55
app/src/components/projects/ConfirmRemoveModal.tsx
Normal file
55
app/src/components/projects/ConfirmRemoveModal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[24rem] shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-5">
|
||||
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
app/src/components/projects/FileManagerModal.tsx
Normal file
197
app/src/components/projects/FileManagerModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useFileManager } from "../../hooks/useFileManager";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
|
||||
const {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
} = useFileManager(projectId);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load initial directory
|
||||
useEffect(() => {
|
||||
navigate("/workspace");
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Build breadcrumbs from current path
|
||||
const breadcrumbs = currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
||||
if (i === 0) {
|
||||
acc.push({ label: "/", path: "/" });
|
||||
} else if (part) {
|
||||
const parentPath = acc[acc.length - 1].path;
|
||||
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
||||
acc.push({ label: part, path: fullPath });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)]">
|
||||
<h2 className="text-sm font-semibold">Files — {projectName}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Path bar */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={crumb.path} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||
<button
|
||||
onClick={() => navigate(crumb.path)}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
|
||||
title="Refresh"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{error && (
|
||||
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{/* Go up entry */}
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
onClick={() => goUp()}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.name}
|
||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||
className={`${
|
||||
entry.is_directory ? "cursor-pointer" : ""
|
||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||
>
|
||||
<td className="px-4 py-1.5">
|
||||
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
|
||||
{entry.is_directory ? "📁 " : ""}{entry.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
|
||||
{!entry.is_directory && formatSize(entry.size)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{entry.modified}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{!entry.is_directory && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry);
|
||||
}}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
|
||||
title="Download"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
||||
Empty directory
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)]">
|
||||
<button
|
||||
onClick={uploadFile}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Upload file
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ const mockProject: Project = {
|
||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
auth_mode: "anthropic",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
allow_docker_access: false,
|
||||
ssh_key_path: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
@@ -10,6 +10,9 @@ import EnvVarsModal from "./EnvVarsModal";
|
||||
import PortMappingsModal from "./PortMappingsModal";
|
||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||
import ContainerProgressModal from "./ContainerProgressModal";
|
||||
import FileManagerModal from "./FileManagerModal";
|
||||
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -27,9 +30,13 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState(project.name);
|
||||
const isSelected = selectedProjectId === project.id;
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
|
||||
@@ -52,8 +59,18 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
|
||||
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
|
||||
|
||||
// Ollama local state
|
||||
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
||||
const [ollamaModelId, setOllamaModelId] = useState(project.ollama_config?.model_id ?? "");
|
||||
|
||||
// OpenAI Compatible local state
|
||||
const [openaiCompatibleBaseUrl, setOpenaiCompatibleBaseUrl] = useState(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
|
||||
const [openaiCompatibleApiKey, setOpenaiCompatibleApiKey] = useState(project.openai_compatible_config?.api_key ?? "");
|
||||
const [openaiCompatibleModelId, setOpenaiCompatibleModelId] = useState(project.openai_compatible_config?.model_id ?? "");
|
||||
|
||||
// Sync local state when project prop changes (e.g., after save or external update)
|
||||
useEffect(() => {
|
||||
setEditName(project.name);
|
||||
setPaths(project.paths ?? []);
|
||||
setSshKeyPath(project.ssh_key_path ?? "");
|
||||
setGitName(project.git_user_name ?? "");
|
||||
@@ -69,6 +86,11 @@ export default function ProjectCard({ project }: Props) {
|
||||
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
|
||||
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
|
||||
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
||||
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||
setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
|
||||
setOpenaiCompatibleApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||
setOpenaiCompatibleModelId(project.openai_compatible_config?.model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
// Listen for container progress events
|
||||
@@ -135,6 +157,14 @@ export default function ProjectCard({ project }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBashShell = async () => {
|
||||
try {
|
||||
await openTerminal(project.id, project.name, "bash");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceStop = async () => {
|
||||
try {
|
||||
await stop(project.id);
|
||||
@@ -162,12 +192,29 @@ export default function ProjectCard({ project }: Props) {
|
||||
disable_prompt_caching: false,
|
||||
};
|
||||
|
||||
const handleAuthModeChange = async (mode: AuthMode) => {
|
||||
const defaultOllamaConfig: OllamaConfig = {
|
||||
base_url: "http://host.docker.internal:11434",
|
||||
model_id: null,
|
||||
};
|
||||
|
||||
const defaultOpenAiCompatibleConfig: OpenAiCompatibleConfig = {
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
api_key: null,
|
||||
model_id: null,
|
||||
};
|
||||
|
||||
const handleBackendChange = async (mode: Backend) => {
|
||||
try {
|
||||
const updates: Partial<Project> = { auth_mode: mode };
|
||||
const updates: Partial<Project> = { backend: mode };
|
||||
if (mode === "bedrock" && !project.bedrock_config) {
|
||||
updates.bedrock_config = defaultBedrockConfig;
|
||||
}
|
||||
if (mode === "ollama" && !project.ollama_config) {
|
||||
updates.ollama_config = defaultOllamaConfig;
|
||||
}
|
||||
if (mode === "open_ai_compatible" && !project.openai_compatible_config) {
|
||||
updates.openai_compatible_config = defaultOpenAiCompatibleConfig;
|
||||
}
|
||||
await update({ ...project, ...updates });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -290,6 +337,51 @@ export default function ProjectCard({ project }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOllamaBaseUrlBlur = async () => {
|
||||
try {
|
||||
const current = project.ollama_config ?? defaultOllamaConfig;
|
||||
await update({ ...project, ollama_config: { ...current, base_url: ollamaBaseUrl } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update Ollama base URL:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOllamaModelIdBlur = async () => {
|
||||
try {
|
||||
const current = project.ollama_config ?? defaultOllamaConfig;
|
||||
await update({ ...project, ollama_config: { ...current, model_id: ollamaModelId || null } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update Ollama model ID:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenaiCompatibleBaseUrlBlur = async () => {
|
||||
try {
|
||||
const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
|
||||
await update({ ...project, openai_compatible_config: { ...current, base_url: openaiCompatibleBaseUrl } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update OpenAI Compatible base URL:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenaiCompatibleApiKeyBlur = async () => {
|
||||
try {
|
||||
const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
|
||||
await update({ ...project, openai_compatible_config: { ...current, api_key: openaiCompatibleApiKey || null } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update OpenAI Compatible API key:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenaiCompatibleModelIdBlur = async () => {
|
||||
try {
|
||||
const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
|
||||
await update({ ...project, openai_compatible_config: { ...current, model_id: openaiCompatibleModelId || null } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update OpenAI Compatible model ID:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = {
|
||||
stopped: "bg-[var(--text-secondary)]",
|
||||
starting: "bg-[var(--warning)]",
|
||||
@@ -309,7 +401,41 @@ export default function ProjectCard({ project }: Props) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
||||
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
||||
{isEditingName ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={async () => {
|
||||
setIsEditingName(false);
|
||||
const trimmed = editName.trim();
|
||||
if (trimmed && trimmed !== project.name) {
|
||||
try {
|
||||
await update({ ...project, name: trimmed });
|
||||
} catch (err) {
|
||||
console.error("Failed to rename project:", err);
|
||||
setEditName(project.name);
|
||||
}
|
||||
} else {
|
||||
setEditName(project.name);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); }
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium truncate flex-1 cursor-text"
|
||||
title="Double-click to rename"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setIsEditingName(true); }}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 ml-4 space-y-0.5">
|
||||
{project.paths.map((pp, i) => (
|
||||
@@ -321,31 +447,21 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{isSelected && (
|
||||
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
|
||||
{/* Auth mode selector */}
|
||||
{/* Backend selector */}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
|
||||
<span className="text-[var(--text-secondary)] mr-1">Backend:<Tooltip text="Choose the AI model provider for this project. Anthropic: Connect directly to Claude via OAuth login (run 'claude login' in terminal). Bedrock: Route through AWS Bedrock using your AWS credentials. Ollama: Use locally-hosted open-source models (Llama, Mistral, etc.) via an Ollama server. OpenAI Compatible: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) to access 100+ model providers." /></span>
|
||||
<select
|
||||
value={project.backend}
|
||||
onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
project.auth_mode === "anthropic"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||
>
|
||||
Anthropic
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
project.auth_mode === "bedrock"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
Bedrock
|
||||
</button>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="bedrock">Bedrock</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="open_ai_compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
@@ -371,6 +487,8 @@ export default function ProjectCard({ project }: Props) {
|
||||
<>
|
||||
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
||||
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -386,11 +504,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
label={showConfig ? "Hide" : "Config"}
|
||||
/>
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
if (confirm(`Remove project "${project.name}"?`)) {
|
||||
await remove(project.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowRemoveModal(true)}
|
||||
disabled={loading}
|
||||
label="Remove"
|
||||
danger
|
||||
@@ -496,7 +610,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* SSH Key */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory<Tooltip text="Path to your .ssh directory. Mounted into the container so Claude can authenticate with Git remotes over SSH." /></label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={sshKeyPath}
|
||||
@@ -518,7 +632,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Git Name */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name<Tooltip text="Sets git user.name inside the container for commit authorship." /></label>
|
||||
<input
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
@@ -531,7 +645,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Git Email */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email<Tooltip text="Sets git user.email inside the container for commit authorship." /></label>
|
||||
<input
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
@@ -544,7 +658,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Git Token (HTTPS) */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token<Tooltip text="A personal access token (e.g. GitHub PAT) for HTTPS git operations inside the container." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={gitToken}
|
||||
@@ -558,7 +672,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Docker access toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
|
||||
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning<Tooltip text="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox." /></label>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
|
||||
@@ -576,10 +690,58 @@ export default function ProjectCard({ project }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mission Control toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await update({ ...project, mission_control_enabled: !project.mission_control_enabled });
|
||||
} catch (err) {
|
||||
console.error("Failed to update Mission Control setting:", err);
|
||||
}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
||||
project.mission_control_enabled
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Full Permissions toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Full Permissions
|
||||
<span className="text-[var(--error)] font-semibold ml-1">(CAUTION)</span>
|
||||
<Tooltip text="When enabled, Claude runs with --dangerously-skip-permissions and auto-approves all tool calls without prompting. Only enable this if you trust the sandboxed environment to contain all actions. When disabled, Claude will ask for your approval before running commands, editing files, etc." />
|
||||
</label>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await update({ ...project, full_permissions: !project.full_permissions });
|
||||
} catch (err) {
|
||||
console.error("Failed to update full permissions setting:", err);
|
||||
}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
||||
project.full_permissions
|
||||
? "bg-[var(--error)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{project.full_permissions ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
|
||||
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}<Tooltip text="Custom env vars injected into this project's container. Useful for API keys or tool configuration." />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowEnvVarsModal(true)}
|
||||
@@ -592,7 +754,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Port Mappings */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
|
||||
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}<Tooltip text="Map container ports to host ports so you can access dev servers running inside the container." />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowPortMappingsModal(true)}
|
||||
@@ -605,7 +767,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Claude Instructions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Claude Instructions{claudeInstructions ? " (set)" : ""}
|
||||
Claude Instructions{claudeInstructions ? " (set)" : ""}<Tooltip text="Project-specific instructions written to CLAUDE.md. Guides Claude's behavior for this project." />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowClaudeInstructionsModal(true)}
|
||||
@@ -618,7 +780,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* MCP Servers */}
|
||||
{mcpServers.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers<Tooltip text="Model Context Protocol servers give Claude access to external tools and data sources." /></label>
|
||||
<div className="space-y-1">
|
||||
{mcpServers.map((server) => {
|
||||
const enabled = project.enabled_mcp_servers.includes(server.id);
|
||||
@@ -659,7 +821,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
)}
|
||||
|
||||
{/* Bedrock config */}
|
||||
{project.auth_mode === "bedrock" && (() => {
|
||||
{project.backend === "bedrock" && (() => {
|
||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
||||
return (
|
||||
@@ -669,25 +831,22 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Sub-method selector */}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)] mr-1">Method:</span>
|
||||
{(["static_credentials", "profile", "bearer_token"] as BedrockAuthMethod[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => updateBedrockConfig({ auth_method: m })}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
bc.auth_method === m
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{m === "static_credentials" ? "Keys" : m === "profile" ? "Profile" : "Token"}
|
||||
</button>
|
||||
))}
|
||||
<select
|
||||
value={bc.auth_method}
|
||||
onChange={(e) => updateBedrockConfig({ auth_method: e.target.value as BedrockAuthMethod })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!isStopped}
|
||||
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||
>
|
||||
<option value="static_credentials">Keys</option>
|
||||
<option value="profile">Profile</option>
|
||||
<option value="bearer_token">Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* AWS Region (always shown) */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region<Tooltip text="The AWS region where your Bedrock endpoint is available (e.g. us-east-1)." /></label>
|
||||
<input
|
||||
value={bedrockRegion}
|
||||
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||
@@ -702,7 +861,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{bc.auth_method === "static_credentials" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID<Tooltip text="Your AWS IAM access key ID for Bedrock API authentication." /></label>
|
||||
<input
|
||||
value={bedrockAccessKeyId}
|
||||
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
|
||||
@@ -713,7 +872,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key<Tooltip text="Your AWS IAM secret key. Stored locally and injected as an env var into the container." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={bedrockSecretKey}
|
||||
@@ -724,7 +883,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)<Tooltip text="Temporary session token for assumed-role or MFA-based AWS credentials." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={bedrockSessionToken}
|
||||
@@ -740,7 +899,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Profile field */}
|
||||
{bc.auth_method === "profile" && (
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile<Tooltip text="Named profile from your AWS config/credentials files (e.g. 'default' or 'prod')." /></label>
|
||||
<input
|
||||
value={bedrockProfile}
|
||||
onChange={(e) => setBedrockProfile(e.target.value)}
|
||||
@@ -755,7 +914,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Bearer token field */}
|
||||
{bc.auth_method === "bearer_token" && (
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token<Tooltip text="An SSO or identity-center bearer token for Bedrock authentication." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={bedrockBearerToken}
|
||||
@@ -769,7 +928,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Model override */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)<Tooltip text="Override the default Bedrock model. Leave blank to use Claude's default." /></label>
|
||||
<input
|
||||
value={bedrockModelId}
|
||||
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||
@@ -782,6 +941,99 @@ export default function ProjectCard({ project }: Props) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Ollama config */}
|
||||
{project.backend === "ollama" && (() => {
|
||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
||||
return (
|
||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
||||
<label className="block text-xs font-medium text-[var(--text-primary)]">Ollama</label>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Connect to an Ollama server running locally or on a remote host.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your Ollama server. Use host.docker.internal to reach the host machine from inside the container." /></label>
|
||||
<input
|
||||
value={ollamaBaseUrl}
|
||||
onChange={(e) => setOllamaBaseUrl(e.target.value)}
|
||||
onBlur={handleOllamaBaseUrlBlur}
|
||||
placeholder="http://host.docker.internal:11434"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
|
||||
Use host.docker.internal for the host machine, or an IP/hostname for remote.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (required)<Tooltip text="Ollama model name to use (e.g. qwen3.5:27b). The model must be pulled in Ollama before starting the container." /></label>
|
||||
<input
|
||||
value={ollamaModelId}
|
||||
onChange={(e) => setOllamaModelId(e.target.value)}
|
||||
onBlur={handleOllamaModelIdBlur}
|
||||
placeholder="qwen3.5:27b"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* OpenAI Compatible config */}
|
||||
{project.backend === "open_ai_compatible" && (() => {
|
||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
||||
return (
|
||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
||||
<label className="block text-xs font-medium text-[var(--text-primary)]">OpenAI Compatible Endpoint</label>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your OpenAI API-compatible server. Use host.docker.internal for a locally running service." /></label>
|
||||
<input
|
||||
value={openaiCompatibleBaseUrl}
|
||||
onChange={(e) => setOpenaiCompatibleBaseUrl(e.target.value)}
|
||||
onBlur={handleOpenaiCompatibleBaseUrlBlur}
|
||||
placeholder="http://host.docker.internal:4000"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
|
||||
Use host.docker.internal for local, or a URL for a remote OpenAI-compatible service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key<Tooltip text="Authentication key for your OpenAI-compatible endpoint, if required." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={openaiCompatibleApiKey}
|
||||
onChange={(e) => setOpenaiCompatibleApiKey(e.target.value)}
|
||||
onBlur={handleOpenaiCompatibleApiKeyBlur}
|
||||
placeholder="sk-..."
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Model identifier as configured in your provider (e.g. gpt-4o, gemini-pro)." /></label>
|
||||
<input
|
||||
value={openaiCompatibleModelId}
|
||||
onChange={(e) => setOpenaiCompatibleModelId(e.target.value)}
|
||||
onBlur={handleOpenaiCompatibleModelIdBlur}
|
||||
placeholder="gpt-4o / gemini-pro / etc."
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -827,6 +1079,25 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFileManager && (
|
||||
<FileManagerModal
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
onClose={() => setShowFileManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRemoveModal && (
|
||||
<ConfirmRemoveModal
|
||||
projectName={project.name}
|
||||
onConfirm={async () => {
|
||||
setShowRemoveModal(false);
|
||||
await remove(project.id);
|
||||
}}
|
||||
onCancel={() => setShowRemoveModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeOperation && (
|
||||
<ContainerProgressModal
|
||||
projectName={project.name}
|
||||
@@ -869,3 +1140,4 @@ function ActionButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export default function ApiKeyInput() {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||
<label className="block text-sm font-medium mb-1">Backend</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set backend per-project.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import * as commands from "../../lib/tauri-commands";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
export default function AwsSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
@@ -56,7 +57,7 @@ export default function AwsSettings() {
|
||||
|
||||
{/* AWS Config Path */}
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path<Tooltip text="Path to your AWS config/credentials directory. Mounted into containers for Bedrock auth." /></span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -80,7 +81,7 @@ export default function AwsSettings() {
|
||||
|
||||
{/* AWS Profile */}
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile<Tooltip text="AWS named profile to use by default. Per-project settings can override this." /></span>
|
||||
<select
|
||||
value={globalAws.aws_profile ?? ""}
|
||||
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
||||
@@ -95,7 +96,7 @@ export default function AwsSettings() {
|
||||
|
||||
{/* AWS Region */}
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region<Tooltip text="Default AWS region for Bedrock API calls (e.g. us-east-1). Can be overridden per project." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalAws.aws_region ?? ""}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useDocker } from "../../hooks/useDocker";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import type { ImageSource } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
||||
|
||||
@@ -87,7 +88,7 @@ export default function DockerSettings() {
|
||||
|
||||
{/* Image Source Selector */}
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source<Tooltip text="Registry pulls the pre-built image. Local Build compiles from the bundled Dockerfile. Custom lets you specify any image." /></span>
|
||||
<div className="flex gap-1">
|
||||
{IMAGE_SOURCE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -109,7 +110,7 @@ export default function DockerSettings() {
|
||||
{/* Custom image input */}
|
||||
{imageSource === "custom" && (
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image<Tooltip text="Full image name including registry and tag (e.g. myregistry.com/image:tag)." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={customInput}
|
||||
@@ -121,9 +122,9 @@ export default function DockerSettings() {
|
||||
)}
|
||||
|
||||
{/* Resolved image display */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">Image</span>
|
||||
<span className="text-xs text-[var(--text-secondary)] truncate max-w-[200px]" title={resolvedImageName}>
|
||||
<span className="block text-xs text-[var(--text-secondary)] font-mono mt-0.5 truncate" title={resolvedImageName}>
|
||||
{resolvedImageName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
91
app/src/components/settings/ImageUpdateDialog.tsx
Normal file
91
app/src/components/settings/ImageUpdateDialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { ImageUpdateInfo } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
imageUpdateInfo: ImageUpdateInfo;
|
||||
onDismiss: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImageUpdateDialog({
|
||||
imageUpdateInfo,
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const shortDigest = (digest: string) => {
|
||||
// Show first 16 chars of the hash part (after "sha256:")
|
||||
const hash = digest.startsWith("sha256:") ? digest.slice(7) : digest;
|
||||
return hash.slice(0, 16);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-3">Container Image Update</h2>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
A newer version of the container image is available in the registry.
|
||||
Re-pull the image in Docker settings to get the latest tools and fixes.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded p-3 border border-[var(--border-color)]">
|
||||
{imageUpdateInfo.local_digest && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Local digest</span>
|
||||
<span className="font-mono text-[var(--text-primary)]">
|
||||
{shortDigest(imageUpdateInfo.local_digest)}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Remote digest</span>
|
||||
<span className="font-mono text-[var(--accent)]">
|
||||
{shortDigest(imageUpdateInfo.remote_digest)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||
Go to Settings > Docker and click "Re-pull Image" to update.
|
||||
Running containers will not be affected until restarted.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function MicrophoneSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||
const [selected, setSelected] = useState(appSettings?.default_microphone ?? "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [permissionNeeded, setPermissionNeeded] = useState(false);
|
||||
|
||||
// Sync local state when appSettings change
|
||||
useEffect(() => {
|
||||
setSelected(appSettings?.default_microphone ?? "");
|
||||
}, [appSettings?.default_microphone]);
|
||||
|
||||
const enumerateDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setPermissionNeeded(false);
|
||||
try {
|
||||
// Request mic permission first so device labels are available
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const mics = allDevices
|
||||
.filter((d) => d.kind === "audioinput")
|
||||
.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
label: d.label || `Microphone (${d.deviceId.slice(0, 8)}...)`,
|
||||
}));
|
||||
setDevices(mics);
|
||||
} catch {
|
||||
setPermissionNeeded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Enumerate devices on mount
|
||||
useEffect(() => {
|
||||
enumerateDevices();
|
||||
}, [enumerateDevices]);
|
||||
|
||||
const handleChange = async (deviceId: string) => {
|
||||
setSelected(deviceId);
|
||||
if (appSettings) {
|
||||
await saveSettings({ ...appSettings, default_microphone: deviceId || null });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Microphone</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Audio input device for Claude Code voice mode (/voice)
|
||||
</p>
|
||||
{permissionNeeded ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
Microphone permission required
|
||||
</span>
|
||||
<button
|
||||
onClick={enumerateDevices}
|
||||
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
Grant Access
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={loading}
|
||||
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={enumerateDevices}
|
||||
disabled={loading}
|
||||
title="Refresh microphone list"
|
||||
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import ApiKeyInput from "./ApiKeyInput";
|
||||
import DockerSettings from "./DockerSettings";
|
||||
import AwsSettings from "./AwsSettings";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
@@ -8,10 +7,11 @@ import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||
import type { EnvVar } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const { appVersion, checkForUpdates } = useUpdates();
|
||||
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
|
||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||
@@ -39,7 +39,7 @@ export default function SettingsPanel() {
|
||||
const handleCheckNow = async () => {
|
||||
setCheckingUpdates(true);
|
||||
try {
|
||||
await checkForUpdates();
|
||||
await Promise.all([checkForUpdates(), checkImageUpdate()]);
|
||||
} finally {
|
||||
setCheckingUpdates(false);
|
||||
}
|
||||
@@ -55,13 +55,12 @@ export default function SettingsPanel() {
|
||||
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||
Settings
|
||||
</h2>
|
||||
<ApiKeyInput />
|
||||
<DockerSettings />
|
||||
<AwsSettings />
|
||||
|
||||
{/* Container Timezone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||
<label className="block text-sm font-medium mb-1">Container Timezone<Tooltip text="Sets the timezone inside containers. Affects scheduled task timing and log timestamps." /></label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
||||
</p>
|
||||
@@ -81,7 +80,7 @@ export default function SettingsPanel() {
|
||||
|
||||
{/* Global Claude Instructions */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
||||
<label className="block text-sm font-medium mb-1">Claude Instructions<Tooltip text="Global instructions applied to all projects. Written to ~/.claude/CLAUDE.md in every container." /></label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
||||
</p>
|
||||
@@ -100,7 +99,7 @@ export default function SettingsPanel() {
|
||||
|
||||
{/* Global Environment Variables */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
|
||||
<label className="block text-sm font-medium mb-1">Global Environment Variables<Tooltip text="Env vars injected into all containers. Per-project vars with the same key take precedence." /></label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Applied to all project containers. Per-project variables override global ones with the same key.
|
||||
</p>
|
||||
@@ -119,7 +118,7 @@ export default function SettingsPanel() {
|
||||
|
||||
{/* Updates section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Updates</label>
|
||||
<label className="block text-sm font-medium mb-2">Updates<Tooltip text="Check for new versions of the Triple-C app and container image." /></label>
|
||||
<div className="space-y-2">
|
||||
{appVersion && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
@@ -146,6 +145,12 @@ export default function SettingsPanel() {
|
||||
>
|
||||
{checkingUpdates ? "Checking..." : "Check now"}
|
||||
</button>
|
||||
{imageUpdateInfo && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning,#f59e0b)] rounded">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning,#f59e0b)]" />
|
||||
<span>A newer container image is available. Re-pull the image in Docker settings above to update.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ export default function TerminalTabs() {
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate max-w-[120px]">{session.projectName}</span>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -6,6 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { awsSsoRefresh } from "../../lib/tauri-commands";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import UrlToast from "./UrlToast";
|
||||
|
||||
@@ -22,6 +24,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||
|
||||
const ssoBufferRef = useRef("");
|
||||
const ssoTriggeredRef = useRef(false);
|
||||
const projectId = useAppState(
|
||||
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
||||
);
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
@@ -72,6 +81,22 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Ctrl+Shift+C copies selected terminal text to clipboard.
|
||||
// This prevents the keystroke from reaching the container (where
|
||||
// Ctrl+C would send SIGINT and cancel running work).
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel).catch((e) =>
|
||||
console.error("Ctrl+Shift+C clipboard write failed:", e),
|
||||
);
|
||||
}
|
||||
return false; // prevent xterm from processing this key
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// WebGL addon is loaded/disposed dynamically in the active effect
|
||||
// to avoid exhausting the browser's limited WebGL context pool.
|
||||
|
||||
@@ -82,6 +107,25 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// Send initial size
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
|
||||
// Handle OSC 52 clipboard write sequences from programs inside the container.
|
||||
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
|
||||
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return false;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return false; // clipboard read request, not supported
|
||||
try {
|
||||
const decoded = atob(payload);
|
||||
navigator.clipboard.writeText(decoded).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("OSC 52 decode failed:", e);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle user input -> backend
|
||||
const inputDisposable = term.onData((data) => {
|
||||
sendInput(sessionId, data);
|
||||
@@ -93,6 +137,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
setIsAtBottom(buf.viewportY >= buf.baseY);
|
||||
});
|
||||
|
||||
// Track text selection to show copy hint in status bar
|
||||
const selectionDisposable = term.onSelectionChange(() => {
|
||||
setTerminalHasSelection(term.hasSelection());
|
||||
});
|
||||
|
||||
// Handle image paste: intercept paste events with image data,
|
||||
// upload to the container, and inject the file path into terminal input.
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
@@ -133,10 +182,30 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||
detectorRef.current = detector;
|
||||
|
||||
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const outputPromise = onOutput(sessionId, (data) => {
|
||||
if (aborted) return;
|
||||
term.write(data);
|
||||
detector.feed(data);
|
||||
|
||||
// Scan for SSO refresh marker in terminal output
|
||||
if (!ssoTriggeredRef.current && projectId) {
|
||||
const text = textDecoder.decode(data, { stream: true });
|
||||
// Combine with overlap from previous chunk to handle marker spanning chunks
|
||||
const combined = ssoBufferRef.current + text;
|
||||
if (combined.includes(SSO_MARKER)) {
|
||||
ssoTriggeredRef.current = true;
|
||||
ssoBufferRef.current = "";
|
||||
awsSsoRefresh(projectId).catch((e) =>
|
||||
console.error("AWS SSO refresh failed:", e)
|
||||
);
|
||||
} else {
|
||||
// Keep last N chars as overlap for next chunk
|
||||
ssoBufferRef.current = combined.slice(-SSO_MARKER.length);
|
||||
}
|
||||
}
|
||||
}).then((unlisten) => {
|
||||
if (aborted) unlisten();
|
||||
return unlisten;
|
||||
@@ -170,8 +239,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
aborted = true;
|
||||
detector.dispose();
|
||||
detectorRef.current = null;
|
||||
ssoTriggeredRef.current = false;
|
||||
ssoBufferRef.current = "";
|
||||
osc52Disposable.dispose();
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
selectionDisposable.dispose();
|
||||
setTerminalHasSelection(false);
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
|
||||
59
app/src/components/terminal/osc52.test.ts
Normal file
59
app/src/components/terminal/osc52.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
|
||||
* Extracted here to validate the decode/write path independently.
|
||||
*/
|
||||
|
||||
// Mirrors the handler registered in TerminalView.tsx
|
||||
function handleOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null;
|
||||
try {
|
||||
return atob(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("OSC 52 clipboard handler", () => {
|
||||
it("decodes a valid clipboard write sequence", () => {
|
||||
// "c;BASE64" where BASE64 encodes "https://example.com"
|
||||
const encoded = btoa("https://example.com");
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("decodes multi-line content", () => {
|
||||
const text = "line1\nline2\nline3";
|
||||
const encoded = btoa(text);
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it("handles primary selection target (p)", () => {
|
||||
const encoded = btoa("selected text");
|
||||
const result = handleOsc52(`p;${encoded}`);
|
||||
expect(result).toBe("selected text");
|
||||
});
|
||||
|
||||
it("returns null for clipboard read request (?)", () => {
|
||||
expect(handleOsc52("c;?")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for missing semicolon", () => {
|
||||
expect(handleOsc52("invalid")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", () => {
|
||||
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
});
|
||||
|
||||
it("handles empty payload after selection target", () => {
|
||||
// btoa("") = ""
|
||||
const result = handleOsc52("c;");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
78
app/src/components/ui/Tooltip.tsx
Normal file
78
app/src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useRef, useLayoutEffect, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
text: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A small circled question-mark icon that shows a tooltip on hover.
|
||||
* Uses a portal to render at `document.body` so the tooltip is never
|
||||
* clipped by ancestor `overflow: hidden` containers.
|
||||
*/
|
||||
export default function Tooltip({ text, children }: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
const [, setPlacement] = useState<"top" | "bottom">("top");
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible || !triggerRef.current || !tooltipRef.current) return;
|
||||
|
||||
const trigger = triggerRef.current.getBoundingClientRect();
|
||||
const tooltip = tooltipRef.current.getBoundingClientRect();
|
||||
const gap = 6;
|
||||
|
||||
// Vertical: prefer above, fall back to below
|
||||
const above = trigger.top - tooltip.height - gap >= 4;
|
||||
const pos = above ? "top" : "bottom";
|
||||
setPlacement(pos);
|
||||
|
||||
const top =
|
||||
pos === "top"
|
||||
? trigger.top - tooltip.height - gap
|
||||
: trigger.bottom + gap;
|
||||
|
||||
// Horizontal: center on trigger, clamp to viewport
|
||||
let left = trigger.left + trigger.width / 2 - tooltip.width / 2;
|
||||
left = Math.max(4, Math.min(left, window.innerWidth - tooltip.width - 4));
|
||||
|
||||
setCoords({ top, left });
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className="inline-flex items-center ml-1"
|
||||
onMouseEnter={() => setVisible(true)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
>
|
||||
{children ?? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[9px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
aria-label="Help"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
{visible &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className={`px-2.5 py-1.5 text-[11px] leading-snug text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded shadow-lg whitespace-normal max-w-[280px] w-max pointer-events-none`}
|
||||
>
|
||||
{text}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
74
app/src/hooks/useFileManager.ts
Normal file
74
app/src/hooks/useFileManager.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
export function useFileManager(projectId: string) {
|
||||
const [currentPath, setCurrentPath] = useState("/workspace");
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const navigate = useCallback(
|
||||
async (path: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.listContainerFiles(projectId, path);
|
||||
setEntries(result);
|
||||
setCurrentPath(path);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (currentPath === "/") return;
|
||||
const parent = currentPath.replace(/\/[^/]+$/, "") || "/";
|
||||
navigate(parent);
|
||||
}, [currentPath, navigate]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
navigate(currentPath);
|
||||
}, [currentPath, navigate]);
|
||||
|
||||
const downloadFile = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
try {
|
||||
const hostPath = await save({ defaultPath: entry.name });
|
||||
if (!hostPath) return;
|
||||
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(async () => {
|
||||
try {
|
||||
const selected = await openDialog({ multiple: false, directory: false });
|
||||
if (!selected) return;
|
||||
await commands.uploadFileToContainer(projectId, selected as string, currentPath);
|
||||
await navigate(currentPath);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, [projectId, currentPath, navigate]);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
@@ -17,10 +17,10 @@ export function useTerminal() {
|
||||
);
|
||||
|
||||
const open = useCallback(
|
||||
async (projectId: string, projectName: string) => {
|
||||
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await commands.openTerminalSession(projectId, sessionId);
|
||||
addSession({ id: sessionId, projectId, projectName });
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType);
|
||||
addSession({ id: sessionId, projectId, projectName, sessionType });
|
||||
return sessionId;
|
||||
},
|
||||
[addSession],
|
||||
|
||||
@@ -6,16 +6,25 @@ import * as commands from "../lib/tauri-commands";
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export function useUpdates() {
|
||||
const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } =
|
||||
useAppState(
|
||||
useShallow((s) => ({
|
||||
updateInfo: s.updateInfo,
|
||||
setUpdateInfo: s.setUpdateInfo,
|
||||
appVersion: s.appVersion,
|
||||
setAppVersion: s.setAppVersion,
|
||||
appSettings: s.appSettings,
|
||||
})),
|
||||
);
|
||||
const {
|
||||
updateInfo,
|
||||
setUpdateInfo,
|
||||
imageUpdateInfo,
|
||||
setImageUpdateInfo,
|
||||
appVersion,
|
||||
setAppVersion,
|
||||
appSettings,
|
||||
} = useAppState(
|
||||
useShallow((s) => ({
|
||||
updateInfo: s.updateInfo,
|
||||
setUpdateInfo: s.setUpdateInfo,
|
||||
imageUpdateInfo: s.imageUpdateInfo,
|
||||
setImageUpdateInfo: s.setImageUpdateInfo,
|
||||
appVersion: s.appVersion,
|
||||
setAppVersion: s.setAppVersion,
|
||||
appSettings: s.appSettings,
|
||||
})),
|
||||
);
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -47,11 +56,31 @@ export function useUpdates() {
|
||||
}
|
||||
}, [setUpdateInfo, appSettings?.dismissed_update_version]);
|
||||
|
||||
const checkImageUpdate = useCallback(async () => {
|
||||
try {
|
||||
const info = await commands.checkImageUpdate();
|
||||
if (info) {
|
||||
// Respect dismissed image digest
|
||||
const dismissed = appSettings?.dismissed_image_digest;
|
||||
if (dismissed && dismissed === info.remote_digest) {
|
||||
setImageUpdateInfo(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
setImageUpdateInfo(info);
|
||||
return info;
|
||||
} catch (e) {
|
||||
console.error("Failed to check for image updates:", e);
|
||||
return null;
|
||||
}
|
||||
}, [setImageUpdateInfo, appSettings?.dismissed_image_digest]);
|
||||
|
||||
const startPeriodicCheck = useCallback(() => {
|
||||
if (intervalRef.current) return;
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (appSettings?.auto_check_updates !== false) {
|
||||
checkForUpdates();
|
||||
checkImageUpdate();
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
return () => {
|
||||
@@ -60,13 +89,15 @@ export function useUpdates() {
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [checkForUpdates, appSettings?.auto_check_updates]);
|
||||
}, [checkForUpdates, checkImageUpdate, appSettings?.auto_check_updates]);
|
||||
|
||||
return {
|
||||
updateInfo,
|
||||
imageUpdateInfo,
|
||||
appVersion,
|
||||
loadVersion,
|
||||
checkForUpdates,
|
||||
checkImageUpdate,
|
||||
startPeriodicCheck,
|
||||
};
|
||||
}
|
||||
|
||||
103
app/src/hooks/useVoice.ts
Normal file
103
app/src/hooks/useVoice.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
type VoiceState = "inactive" | "starting" | "active" | "error";
|
||||
|
||||
export function useVoice(sessionId: string, deviceId?: string | null) {
|
||||
const [state, setState] = useState<VoiceState>("inactive");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const workletRef = useRef<AudioWorkletNode | null>(null);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (state === "active" || state === "starting") return;
|
||||
setState("starting");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Start the audio bridge in the container (creates FIFO writer)
|
||||
await commands.startAudioBridge(sessionId);
|
||||
|
||||
// 2. Get microphone access (use specific device if configured)
|
||||
const audioConstraints: MediaTrackConstraints = {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
};
|
||||
if (deviceId) {
|
||||
audioConstraints.deviceId = { exact: deviceId };
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: audioConstraints,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
// 3. Create AudioContext at 16kHz (browser handles resampling)
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
// 4. Load AudioWorklet processor
|
||||
await audioContext.audioWorklet.addModule("/audio-capture-processor.js");
|
||||
|
||||
// 5. Connect: mic → worklet → (silent) destination
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = new AudioWorkletNode(audioContext, "audio-capture-processor");
|
||||
workletRef.current = processor;
|
||||
|
||||
// 6. Handle PCM chunks from the worklet
|
||||
processor.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
|
||||
const bytes = Array.from(new Uint8Array(event.data));
|
||||
commands.sendAudioData(sessionId, bytes).catch(() => {
|
||||
// Audio bridge may have been closed — ignore send errors
|
||||
});
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
setState("active");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setState("error");
|
||||
// Clean up on failure
|
||||
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||
}
|
||||
}, [sessionId, state, deviceId]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
// Tear down audio pipeline
|
||||
workletRef.current?.disconnect();
|
||||
workletRef.current = null;
|
||||
|
||||
if (audioContextRef.current) {
|
||||
await audioContextRef.current.close().catch(() => {});
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Stop the container-side audio bridge
|
||||
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||
|
||||
setState("inactive");
|
||||
setError(null);
|
||||
}, [sessionId]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (state === "active") {
|
||||
await stop();
|
||||
} else {
|
||||
await start();
|
||||
}
|
||||
}, [state, start, stop]);
|
||||
|
||||
return { state, error, start, stop, toggle };
|
||||
}
|
||||
@@ -53,3 +53,135 @@ body {
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
.animate-slide-down { animation: slide-down 0.2s ease-out; }
|
||||
|
||||
/* Help dialog content styles */
|
||||
.help-content {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-content .help-h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-content .help-h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
padding-bottom: 0.375rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-content .help-h3 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 1.25rem 0 0.5rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-content .help-h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.375rem 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.help-content .help-p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.help-content .help-ul,
|
||||
.help-content .help-ol {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.help-content .help-ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.help-content .help-ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.help-content .help-ul li,
|
||||
.help-content .help-ol li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.help-content .help-code-block {
|
||||
display: block;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.help-content .help-inline-code {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.help-content .help-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.help-content .help-table th,
|
||||
.help-content .help-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.375rem 0.625rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.help-content .help-table th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-content .help-table td {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.help-content .help-blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--bg-primary);
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.help-content .help-hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.help-content .help-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.help-content .help-link:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -24,6 +24,8 @@ export const stopProjectContainer = (projectId: string) =>
|
||||
invoke<void>("stop_project_container", { projectId });
|
||||
export const rebuildProjectContainer = (projectId: string) =>
|
||||
invoke<Project>("rebuild_project_container", { projectId });
|
||||
export const reconcileProjectStatuses = () =>
|
||||
invoke<Project[]>("reconcile_project_statuses");
|
||||
|
||||
// Settings
|
||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||
@@ -38,9 +40,13 @@ export const listAwsProfiles = () =>
|
||||
export const detectHostTimezone = () =>
|
||||
invoke<string>("detect_host_timezone");
|
||||
|
||||
// AWS
|
||||
export const awsSsoRefresh = (projectId: string) =>
|
||||
invoke<void>("aws_sso_refresh", { projectId });
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId });
|
||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
|
||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("terminal_input", { sessionId, data });
|
||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
@@ -49,6 +55,12 @@ export const closeTerminalSession = (sessionId: string) =>
|
||||
invoke<void>("close_terminal_session", { sessionId });
|
||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||
export const startAudioBridge = (sessionId: string) =>
|
||||
invoke<void>("start_audio_bridge", { sessionId });
|
||||
export const sendAudioData = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("send_audio_data", { sessionId, data });
|
||||
export const stopAudioBridge = (sessionId: string) =>
|
||||
invoke<void>("stop_audio_bridge", { sessionId });
|
||||
|
||||
// MCP Servers
|
||||
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
|
||||
@@ -59,7 +71,20 @@ export const updateMcpServer = (server: McpServer) =>
|
||||
export const removeMcpServer = (serverId: string) =>
|
||||
invoke<void>("remove_mcp_server", { serverId });
|
||||
|
||||
// Files
|
||||
export const listContainerFiles = (projectId: string, path: string) =>
|
||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
|
||||
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
||||
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
|
||||
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
|
||||
|
||||
// Updates
|
||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||
export const checkForUpdates = () =>
|
||||
invoke<UpdateInfo | null>("check_for_updates");
|
||||
export const checkImageUpdate = () =>
|
||||
invoke<ImageUpdateInfo | null>("check_image_update");
|
||||
|
||||
// Help
|
||||
export const getHelpContent = () => invoke<string>("get_help_content");
|
||||
|
||||
@@ -20,9 +20,13 @@ export interface Project {
|
||||
paths: ProjectPath[];
|
||||
container_id: string | null;
|
||||
status: ProjectStatus;
|
||||
auth_mode: AuthMode;
|
||||
backend: Backend;
|
||||
bedrock_config: BedrockConfig | null;
|
||||
ollama_config: OllamaConfig | null;
|
||||
openai_compatible_config: OpenAiCompatibleConfig | null;
|
||||
allow_docker_access: boolean;
|
||||
mission_control_enabled: boolean;
|
||||
full_permissions: boolean;
|
||||
ssh_key_path: string | null;
|
||||
git_token: string | null;
|
||||
git_user_name: string | null;
|
||||
@@ -42,7 +46,7 @@ export type ProjectStatus =
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type AuthMode = "anthropic" | "bedrock";
|
||||
export type Backend = "anthropic" | "bedrock" | "ollama" | "open_ai_compatible";
|
||||
|
||||
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||
|
||||
@@ -58,6 +62,17 @@ export interface BedrockConfig {
|
||||
disable_prompt_caching: boolean;
|
||||
}
|
||||
|
||||
export interface OllamaConfig {
|
||||
base_url: string;
|
||||
model_id: string | null;
|
||||
}
|
||||
|
||||
export interface OpenAiCompatibleConfig {
|
||||
base_url: string;
|
||||
api_key: string | null;
|
||||
model_id: string | null;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
container_id: string;
|
||||
project_id: string;
|
||||
@@ -77,6 +92,7 @@ export interface TerminalSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sessionType: "claude" | "bash";
|
||||
}
|
||||
|
||||
export type ImageSource = "registry" | "local_build" | "custom";
|
||||
@@ -100,6 +116,8 @@ export interface AppSettings {
|
||||
auto_check_updates: boolean;
|
||||
dismissed_update_version: string | null;
|
||||
timezone: string | null;
|
||||
default_microphone: string | null;
|
||||
dismissed_image_digest: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
@@ -117,6 +135,12 @@ export interface ReleaseAsset {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ImageUpdateInfo {
|
||||
remote_digest: string;
|
||||
local_digest: string | null;
|
||||
remote_updated_at: string | null;
|
||||
}
|
||||
|
||||
export type McpTransportType = "stdio" | "http";
|
||||
|
||||
export interface McpServer {
|
||||
@@ -133,3 +157,12 @@ export interface McpServer {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types";
|
||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
|
||||
|
||||
interface AppState {
|
||||
// Projects
|
||||
@@ -24,6 +24,8 @@ interface AppState {
|
||||
removeMcpServerFromList: (id: string) => void;
|
||||
|
||||
// UI state
|
||||
terminalHasSelection: boolean;
|
||||
setTerminalHasSelection: (has: boolean) => void;
|
||||
sidebarView: "projects" | "mcp" | "settings";
|
||||
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
||||
dockerAvailable: boolean | null;
|
||||
@@ -39,6 +41,10 @@ interface AppState {
|
||||
setUpdateInfo: (info: UpdateInfo | null) => void;
|
||||
appVersion: string;
|
||||
setAppVersion: (version: string) => void;
|
||||
|
||||
// Image update info
|
||||
imageUpdateInfo: ImageUpdateInfo | null;
|
||||
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
|
||||
}
|
||||
|
||||
export const useAppState = create<AppState>((set) => ({
|
||||
@@ -96,6 +102,8 @@ export const useAppState = create<AppState>((set) => ({
|
||||
})),
|
||||
|
||||
// UI state
|
||||
terminalHasSelection: false,
|
||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||
sidebarView: "projects",
|
||||
setSidebarView: (view) => set({ sidebarView: view }),
|
||||
dockerAvailable: null,
|
||||
@@ -111,4 +119,8 @@ export const useAppState = create<AppState>((set) => ({
|
||||
setUpdateInfo: (info) => set({ updateInfo: info }),
|
||||
appVersion: "",
|
||||
setAppVersion: (version) => set({ appVersion: version }),
|
||||
|
||||
// Image update info
|
||||
imageUpdateInfo: null,
|
||||
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
|
||||
}));
|
||||
|
||||
@@ -101,6 +101,27 @@ WORKDIR /workspace
|
||||
|
||||
# ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
|
||||
USER root
|
||||
|
||||
# ── OSC 52 clipboard support ─────────────────────────────────────────────
|
||||
# Provides xclip/xsel/pbcopy shims that emit OSC 52 escape sequences,
|
||||
# allowing programs inside the container to copy to the host clipboard.
|
||||
COPY osc52-clipboard /usr/local/bin/osc52-clipboard
|
||||
RUN chmod +x /usr/local/bin/osc52-clipboard \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xclip \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xsel \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/pbcopy
|
||||
|
||||
# ── Audio capture shim (voice mode) ────────────────────────────────────────
|
||||
# Provides fake rec/arecord that read PCM from a FIFO instead of a real mic,
|
||||
# allowing Claude Code voice mode to work inside the container.
|
||||
COPY audio-shim /usr/local/bin/audio-shim
|
||||
RUN chmod +x /usr/local/bin/audio-shim \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord
|
||||
|
||||
COPY triple-c-sso-refresh /usr/local/bin/triple-c-sso-refresh
|
||||
RUN chmod +x /usr/local/bin/triple-c-sso-refresh
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||
|
||||
16
container/audio-shim
Normal file
16
container/audio-shim
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Audio capture shim for Triple-C voice mode.
|
||||
# Claude Code spawns `rec` or `arecord` to capture mic audio.
|
||||
# Inside Docker there is no mic, so this shim reads PCM data from a
|
||||
# FIFO that the Tauri host app writes to, and outputs it on stdout.
|
||||
|
||||
FIFO=/tmp/triple-c-audio-input
|
||||
|
||||
# Create the FIFO if it doesn't already exist
|
||||
[ -p "$FIFO" ] || mkfifo "$FIFO" 2>/dev/null
|
||||
|
||||
# Clean exit on SIGTERM (Claude Code sends this when recording stops)
|
||||
trap 'exit 0' TERM INT
|
||||
|
||||
# Stream PCM from the FIFO to stdout until we get a signal or EOF
|
||||
cat "$FIFO"
|
||||
@@ -73,6 +73,44 @@ su -s /bin/bash claude -c '
|
||||
sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts
|
||||
'
|
||||
|
||||
# ── AWS config setup ──────────────────────────────────────────────────────────
|
||||
# Host AWS dir is mounted read-only at /tmp/.host-aws.
|
||||
# Copy to /home/claude/.aws so AWS CLI can write to sso/cache and cli/cache.
|
||||
if [ -d /tmp/.host-aws ]; then
|
||||
rm -rf /home/claude/.aws
|
||||
cp -a /tmp/.host-aws /home/claude/.aws
|
||||
chown -R claude:claude /home/claude/.aws
|
||||
chmod 700 /home/claude/.aws
|
||||
# Ensure writable cache directories exist
|
||||
mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache
|
||||
chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli
|
||||
|
||||
# Inline sso_session properties into profile sections so AWS SDKs that don't
|
||||
# support the sso_session indirection format can resolve sso_region, etc.
|
||||
if [ -f /home/claude/.aws/config ]; then
|
||||
python3 -c '
|
||||
import configparser, sys
|
||||
c = configparser.ConfigParser()
|
||||
c.read(sys.argv[1])
|
||||
for sec in c.sections():
|
||||
if not sec.startswith("profile ") and sec != "default":
|
||||
continue
|
||||
session = c.get(sec, "sso_session", fallback=None)
|
||||
if not session or c.has_option(sec, "sso_start_url"):
|
||||
continue
|
||||
ss = f"sso-session {session}"
|
||||
if not c.has_section(ss):
|
||||
continue
|
||||
for key in ("sso_start_url", "sso_region", "sso_registration_scopes"):
|
||||
val = c.get(ss, key, fallback=None)
|
||||
if val:
|
||||
c.set(sec, key, val)
|
||||
with open(sys.argv[1], "w") as f:
|
||||
c.write(f)
|
||||
' /home/claude/.aws/config 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
|
||||
if [ -n "$GIT_TOKEN" ]; then
|
||||
CRED_FILE="/home/claude/.git-credentials"
|
||||
@@ -103,6 +141,33 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
||||
unset CLAUDE_INSTRUCTIONS
|
||||
fi
|
||||
|
||||
# ── Mission Control setup ───────────────────────────────────────────────────
|
||||
if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then
|
||||
MC_HOME="/home/claude/mission-control"
|
||||
MC_LINK="/workspace/mission-control"
|
||||
if [ ! -d "$MC_HOME/.git" ]; then
|
||||
echo "entrypoint: cloning mission-control..."
|
||||
su -s /bin/bash claude -c \
|
||||
'git clone https://github.com/msieurthenardier/mission-control.git /home/claude/mission-control' \
|
||||
|| echo "entrypoint: warning — failed to clone mission-control"
|
||||
else
|
||||
echo "entrypoint: mission-control already present, skipping clone"
|
||||
fi
|
||||
# Symlink into workspace so Claude sees it at /workspace/mission-control
|
||||
ln -sfn "$MC_HOME" "$MC_LINK"
|
||||
chown -h claude:claude "$MC_LINK"
|
||||
|
||||
# Install skills to ~/.claude/skills/ so Claude Code discovers them automatically
|
||||
if [ -d "$MC_HOME/.claude/skills" ]; then
|
||||
mkdir -p /home/claude/.claude/skills
|
||||
cp -r "$MC_HOME/.claude/skills/"* /home/claude/.claude/skills/ 2>/dev/null
|
||||
chown -R claude:claude /home/claude/.claude/skills
|
||||
echo "entrypoint: mission-control skills installed to ~/.claude/skills/"
|
||||
fi
|
||||
|
||||
unset MISSION_CONTROL_ENABLED
|
||||
fi
|
||||
|
||||
# ── MCP server configuration ────────────────────────────────────────────────
|
||||
# Merge MCP server config into ~/.claude.json (preserves existing keys like
|
||||
# OAuth tokens). Creates the file if it doesn't exist.
|
||||
@@ -124,6 +189,24 @@ if [ -n "$MCP_SERVERS_JSON" ]; then
|
||||
unset MCP_SERVERS_JSON
|
||||
fi
|
||||
|
||||
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
||||
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
|
||||
# triple-c-sso-refresh when AWS credentials expire mid-session.
|
||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||
CLAUDE_JSON="/home/claude/.claude.json"
|
||||
if [ -f "$CLAUDE_JSON" ]; then
|
||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||
fi
|
||||
else
|
||||
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
|
||||
fi
|
||||
chown claude:claude "$CLAUDE_JSON"
|
||||
chmod 600 "$CLAUDE_JSON"
|
||||
unset AWS_SSO_AUTH_REFRESH_CMD
|
||||
fi
|
||||
|
||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
|
||||
26
container/osc52-clipboard
Normal file
26
container/osc52-clipboard
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# OSC 52 clipboard provider — sends clipboard data to the host system clipboard
|
||||
# via OSC 52 terminal escape sequences. Installed as xclip/xsel/pbcopy so that
|
||||
# programs inside the container (e.g. Claude Code) can copy to clipboard.
|
||||
#
|
||||
# Supports common invocations:
|
||||
# echo "text" | xclip -selection clipboard
|
||||
# echo "text" | xsel --clipboard --input
|
||||
# echo "text" | pbcopy
|
||||
#
|
||||
# Paste/output requests exit silently (not supported via OSC 52).
|
||||
|
||||
# Detect paste/output mode — exit silently since we can't read the host clipboard
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-o|--output) exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Read all input from stdin
|
||||
data=$(cat)
|
||||
[ -z "$data" ] && exit 0
|
||||
|
||||
# Base64 encode and write OSC 52 escape sequence to the controlling terminal
|
||||
encoded=$(printf '%s' "$data" | base64 | tr -d '\n')
|
||||
printf '\033]52;c;%s\a' "$encoded" > /dev/tty 2>/dev/null
|
||||
33
container/triple-c-sso-refresh
Executable file
33
container/triple-c-sso-refresh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Signal Triple-C to perform host-side AWS SSO login, then sync the result.
|
||||
CACHE_DIR="$HOME/.aws/sso/cache"
|
||||
HOST_CACHE="/tmp/.host-aws/sso/cache"
|
||||
MARKER="/tmp/.sso-refresh-marker"
|
||||
|
||||
touch "$MARKER"
|
||||
|
||||
# Emit marker for Triple-C app to detect in terminal output
|
||||
echo "###TRIPLE_C_SSO_REFRESH###"
|
||||
echo "Waiting for SSO login to complete on host..."
|
||||
|
||||
TIMEOUT=120
|
||||
ELAPSED=0
|
||||
while [ $ELAPSED -lt $TIMEOUT ]; do
|
||||
if [ -d "$HOST_CACHE" ]; then
|
||||
NEW=$(find "$HOST_CACHE" -name "*.json" -newer "$MARKER" 2>/dev/null | head -1)
|
||||
if [ -n "$NEW" ]; then
|
||||
mkdir -p "$CACHE_DIR"
|
||||
cp -f "$HOST_CACHE"/*.json "$CACHE_DIR/" 2>/dev/null
|
||||
chown -R "$(whoami)" "$CACHE_DIR"
|
||||
echo "AWS SSO credentials refreshed successfully."
|
||||
rm -f "$MARKER"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
ELAPSED=$((ELAPSED + 2))
|
||||
done
|
||||
|
||||
echo "SSO refresh timed out (${TIMEOUT}s). Please try again."
|
||||
rm -f "$MARKER"
|
||||
exit 1
|
||||
Reference in New Issue
Block a user