Compare commits

...

27 Commits

Author SHA1 Message Date
57a7cee544 Make topbar and tab bar sticky in web terminal
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 4m13s
Build App / build-linux (push) Successful in 4m51s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Adds position:sticky to the topbar and tab bar so they stay pinned
at the top when the virtual keyboard opens on tablets. Also uses
100dvh (dynamic viewport height) so the layout properly shrinks
when the keyboard appears on mobile browsers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:15:31 -07:00
6369f7e0a8 Document web terminal feature across all docs
Adds web terminal documentation to README (architecture, key files),
HOW-TO-USE (setup guide, usage, security tips), TECHNICAL (system
diagram, communication flow, dependencies, project structure), and
CLAUDE.md (backend structure).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:09:05 -07:00
9ee0d34c19 Fix tablet keyboard input lag and add scroll-to-bottom button
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 3m55s
Build App / build-linux (push) Successful in 4m34s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 9s
Adds a dedicated input bar at the bottom that bypasses mobile IME
composition buffering — characters are sent immediately on each input
event instead of waiting for word boundaries. Also adds helper buttons
(Enter, Tab, Ctrl+C) and a floating scroll-to-bottom button that
appears when scrolled up from the terminal output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:03:13 -07:00
922543cc04 Add web terminal for remote tablet/phone access to project terminals
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m56s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Adds an axum HTTP+WebSocket server that runs alongside the Tauri app,
serving a standalone xterm.js-based terminal UI accessible from any
browser on the local network. Shares the existing ExecSessionManager
via Arc-wrapped stores, with token-based authentication and automatic
session cleanup on disconnect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:31:16 -07:00
13038989b8 Fix spurious container snapshot on every start for projects with env vars
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 4m7s
Build App / build-linux (push) Successful in 4m58s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Migrate env-var-based checks in container_needs_recreation() to label-based
checks. When a container snapshot is committed, Docker merges the image's env
vars with the container's, causing get_env() to return stale values and
triggering an infinite snapshot→recreate loop. Labels are immutable after
creation and immune to this merging behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:17:00 -07:00
b55de8d75e Fix Jump to Current button not appearing on scroll-up
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 5m9s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 12s
The onScroll RAF optimization (only fire when atBottom changes) prevented
the button from showing because xterm's onScroll may not fire from wheel
events. Fix by setting isAtBottom(false) directly in the wheel handler
and removing the RAF guard to always schedule state updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:28:28 -07:00
8512ca615d Fix terminal scroll glitch and add auto-follow toggle button
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m4s
Build App / build-linux (push) Successful in 4m53s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 11s
Prevent viewport jumping during Claude output by only re-enabling
auto-follow on user-initiated scrolls (wheel events within 300ms),
not on write-triggered xterm scroll events. Add a "Following/Paused"
toggle button in the top-right corner of the terminal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 07:00:09 -07:00
ebae39026f Fix terminal auto-scroll and jump-to-bottom button coexistence
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 2m33s
Build App / build-linux (push) Successful in 6m3s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
The previous fix checked isAtBottomRef inside the write callback, but
xterm's own scroll events during write processing could set the ref to
false (viewport desync), breaking auto-follow entirely.

Introduce a separate autoFollowRef that tracks user intent:
- Set to false only by explicit mouse wheel scroll-up (capture phase)
- Set to true when viewport reaches bottom or user clicks the button
- Write callback uses autoFollowRef so desync doesn't kill auto-follow
  but user scroll-up correctly pauses it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:00:16 -07:00
d34e8e2c6d Fix jump-to-bottom button broken during active terminal output
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m4s
Build App / build-linux (push) Successful in 5m31s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
The shouldFollow flag was captured before term.write() but the callback
ran asynchronously — if the user scrolled up in between, the stale flag
forced the viewport back to bottom, preventing the button from appearing.

Check isAtBottomRef at callback time instead so user scroll-up is respected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:35:58 -07:00
3935104cb5 Fix xterm scroll jump and viewport desync during long output
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Auto-scroll viewport on new output when user is at bottom, debounce
scroll state updates to reduce re-renders, preserve scroll position
across resize reflows, and fix "Jump to Current" button by re-fitting
the terminal to clear viewport desync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 12:59:19 -07:00
b17c759bd6 Fix remaining repo.anhonesthost.net references in user-facing code
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m35s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 14s
- help_commands.rs: fetch HOW-TO-USE.md from GitHub raw instead of Gitea
- DockerSettings.tsx: display GHCR image address in settings UI
- HOW-TO-USE.md: update registry description to ghcr.io

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:31:46 -07:00
bab1df1c57 Switch distribution from Gitea to GitHub/GHCR
All checks were successful
Build App / compute-version (push) Successful in 6s
Build Container / build-container (push) Successful in 1m44s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m52s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Work VPN blocks repo.anhonesthost.net, breaking update checks and image
pulls. Move all user-facing distribution to GitHub (releases API) and
GHCR (container images) while keeping Gitea as the source of truth for
development and CI.

- CI: push container images to GHCR alongside Gitea registry
- App updates: switch releases API to api.github.com, filter by asset
  filename instead of tag suffix for unified releases
- Image updates: switch registry to ghcr.io with anonymous token auth
- Container pull: point REGISTRY_IMAGE to ghcr.io/shadowdao/triple-c-sandbox
- Rename GiteaRelease/GiteaAsset structs to GitHubRelease/GitHubAsset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:55:41 -07:00
b952b8e8de Add per-project full permissions toggle for --dangerously-skip-permissions
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 4m43s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
New projects default to standard permission mode (Claude asks before acting).
Existing projects default to full permissions ON, preserving current behavior.
UI toggle uses red/caution styling to highlight the security implications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:58:13 -07:00
d7d7a83aec Rename LiteLLM backend to OpenAI Compatible
All checks were successful
Build App / compute-version (push) Successful in 8s
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
Reflects that this backend works with any OpenAI API-compatible endpoint
(LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.),
not just LiteLLM. Includes serde aliases for backward compatibility with
existing projects.json files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:16:05 -07:00
879322bc9a Add copy/paste keyboard shortcut docs to How-To guide
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:31:41 -07:00
ecaa42fa77 Remove unused useShallow import to fix tsc build
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 3m33s
Build App / build-linux (push) Successful in 4m49s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:20:08 -07:00
280358166a Show copy hint in status bar when terminal text is selected
Some checks failed
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Failing after 7s
Build App / build-windows (push) Failing after 19s
Build App / build-linux (push) Failing after 1m57s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
When the user highlights text in the terminal, a "Ctrl+Shift+C to copy"
hint appears in the status bar next to the project/terminal counts.
The hint disappears when the selection is cleared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:14:08 -07:00
4732feb33e Add Ctrl+Shift+C keyboard shortcut for copying terminal text
All checks were successful
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m25s
Build App / build-linux (push) Successful in 5m33s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Ctrl+C in the terminal sends SIGINT which cancels running Claude work.
This adds a custom key handler so Ctrl+Shift+C copies selected text to
the clipboard without interrupting the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:05:10 -07:00
5977024953 Update Ollama docs and UI to mark model as required
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m25s
Build App / build-linux (push) Successful in 4m48s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 14s
The model field must be set and the model must be pre-pulled in Ollama
before the container will work. Updated README, HOW-TO-USE, and the
ProjectCard UI label/tooltip to reflect this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:53:24 -07:00
27007b90e3 Fetch help content from repo, add TOC and marketplace troubleshooting
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m57s
Build App / build-linux (push) Successful in 5m2s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 10s
Help dialog now fetches HOW-TO-USE.md live from the gitea repo on open,
falling back to the compile-time embedded copy when offline. Content is
cached for the session. Removes the ~600-line hardcoded markdown constant
from HelpDialog.tsx in favor of a single source of truth.

Adds a Table of Contents with anchor links for quick navigation and a new
troubleshooting entry for the "Failed to install Anthropic marketplace"
error with the jq fix. Markdown renderer updated to support anchor links
and header id attributes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:00:59 -07:00
38e65619e9 Fix tooltips clipped by overflow containers, improve Backend tooltip text
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m44s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 12s
Rewrite Tooltip to use React portal (createPortal to document.body) so
tooltips render above all UI elements regardless of ancestor overflow:hidden.
Also increased max-width from 220px to 280px for longer descriptions.

Expanded Backend tooltip to explain each option (Anthropic, Bedrock,
Ollama, LiteLLM) with practical context for new users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:56:50 -07:00
d2c1c2108a Fix update checker to use full semver comparison and correct platform filtering
Some checks failed
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m21s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Has been cancelled
The version comparison was only comparing the patch number, ignoring major
and minor versions. This meant 0.1.75 (patch=75) appeared "newer" than
0.2.1 (patch=1), and updates within 0.2.x were missed entirely.

Also fixed platform filtering to handle -mac suffix (previously only
filtered -win, so Linux users would see macOS releases too).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:45:43 -07:00
cc163e6650 Add help dialog and tooltip indicators throughout the UI
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m29s
Build App / build-linux (push) Successful in 5m43s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 13s
- Add circled ? help button in TopBar that opens a dialog with HOW-TO-USE.md content
- Create reusable Tooltip component with viewport-aware positioning
- Add 32 tooltip indicators across project config and settings panels
- Covers backend selection, Bedrock/Ollama/LiteLLM fields, Docker, AWS, MCP, and more

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:35:04 -07:00
38082059a5 Rename AuthMode to Backend, fix LiteLLM variant typo, add image update alerts, clean up Settings
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m14s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
- Fix serde deserialization error: TypeScript sent "lit_llm" but Rust expected "lite_llm"
- Rename AuthMode enum to Backend across Rust and TypeScript (with serde alias for backward compat)
- Add container image update checking via registry digest comparison
- Improve Settings page: fix image address display spacing, remove per-project auth section
- Update UI labels from "Auth" to "Backend" throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:26:58 -07:00
beae0942a1 Add dynamic versioning with VERSION file and tag-based patch counting
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 6m14s
Build App / create-tag (push) Successful in 8s
Build App / sync-to-github (push) Successful in 10s
- Create VERSION file (currently `0.2`) as the single source of truth for major.minor
- Add compute-version job that reads VERSION and counts commits since the last
  matching v{major.minor}.N tag to derive the patch number
- Patch resets automatically when VERSION is bumped (no matching tags exist yet)
- Add create-tag job that tags the repo after all platform builds succeed,
  so subsequent builds count from the new tag
- All platform jobs now consume the shared version instead of computing their own
- VERSION file changes trigger the build workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:11:04 -07:00
6b49981b3a Update build workflow version from 0.1.x to 0.2.x
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m19s
Build App / build-linux (push) Successful in 6m37s
Build App / sync-to-github (push) Successful in 13s
The computed build version was hardcoded as 0.1.${COMMIT_COUNT} across all
three platform jobs (Linux, macOS, Windows), producing 0.1.x releases even
though the source files were bumped to 0.2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:03:53 -07:00
b46b392a9a Update package-lock.json version to 0.2.0
All checks were successful
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 5m2s
Build App / build-macos (push) Successful in 2m32s
Build App / sync-to-github (push) Successful in 13s
The lock file was still at 0.1.0 after the v0.2.0 version bump.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:23:08 -07:00
46 changed files with 3449 additions and 344 deletions

View File

@@ -5,11 +5,13 @@ 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:
@@ -18,10 +20,43 @@ env:
REPO: ${{ gitea.repository }}
jobs:
build-linux:
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: |
@@ -54,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
@@ -133,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}" \
@@ -156,6 +183,7 @@ jobs:
build-macos:
runs-on: macos-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
@@ -183,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
@@ -243,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}"
@@ -266,6 +286,7 @@ jobs:
build-windows:
runs-on: windows-latest
needs: [compute-version]
defaults:
run:
shell: cmd
@@ -275,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
@@ -367,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%
@@ -378,9 +391,36 @@ jobs:
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: [build-linux, build-macos, build-windows]
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
env:
GH_PAT: ${{ secrets.GH_PAT }}
@@ -389,7 +429,7 @@ jobs:
- name: Download artifacts from Gitea releases
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.build-linux.outputs.version }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
set -e
mkdir -p artifacts
@@ -418,7 +458,7 @@ jobs:
- name: Create GitHub release and upload artifacts
env:
VERSION: ${{ needs.build-linux.outputs.version }}
VERSION: ${{ needs.compute-version.outputs.version }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -e

View File

@@ -36,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:
@@ -46,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

View File

@@ -62,7 +62,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
- **`components/settings/`** — Settings panels for API keys, Docker, AWS
- **`components/settings/`** — Settings panels for API keys, Docker, AWS, Web Terminal
### Backend Structure (`app/src-tauri/src/`)
@@ -72,7 +72,11 @@ 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`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). 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/`)
@@ -91,7 +95,7 @@ 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`)
- **LiteLLM** — Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers
- **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
## Styling

View File

@@ -4,6 +4,26 @@ 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)
- [Web Terminal (Remote Access)](#web-terminal-remote-access)
- [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
@@ -34,7 +54,7 @@ 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)
- **LiteLLM** — A LiteLLM proxy gateway providing access to 100+ model providers (best-effort support)
- **OpenAI Compatible** — Any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) (best-effort support)
---
@@ -51,7 +71,7 @@ Choose an **Image Source**:
| Source | Description | When to Use |
|--------|-------------|-------------|
| **Registry** | Pulls the pre-built image from `repo.anhonesthost.net` | Fastest setup — recommended for most users |
| **Registry** | Pulls the pre-built image from `ghcr.io` | Fastest setup — recommended for most users |
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
@@ -73,7 +93,7 @@ Select your project in the sidebar and click **Start**. A progress modal appears
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
@@ -86,22 +106,23 @@ 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 auth mode 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). Optionally set a model ID.
4. Start the container again.
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.
**LiteLLM:**
**OpenAI Compatible:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the auth mode to **LiteLLM**.
3. Expand the **Config** panel and set the base URL of your LiteLLM proxy (defaults to `http://host.docker.internal:4000`). Optionally set an API key and model ID.
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.
---
@@ -216,6 +237,18 @@ Available skills include `/mission`, `/flight`, `/leg`, `/agentic-workflow`, `/f
> 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.
@@ -361,7 +394,7 @@ MCP server configuration is tracked via SHA-256 fingerprints stored as Docker la
## 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
@@ -390,12 +423,12 @@ Per-project settings always override these global defaults.
## Ollama Configuration
To use Claude Code with a local or remote Ollama server, switch the auth mode to **Ollama** on the project card.
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** — Optional. Override the model to use (e.g., `qwen3.5:27b`).
- **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
@@ -403,23 +436,25 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in
> **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.
---
## LiteLLM Configuration
## OpenAI Compatible Configuration
To use Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy gateway, switch the auth mode to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy.
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 LiteLLM proxy. Defaults to `http://host.docker.internal:4000` for a locally running proxy.
- **API Key** — Optional. The API key for your LiteLLM proxy, if authentication is required. Stored securely in your OS keychain.
- **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 LiteLLM proxy. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
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:** LiteLLM 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 proxy.
> **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.
---
@@ -446,6 +481,17 @@ Instructions applied to **all** projects. Written to `~/.claude/CLAUDE.md` in ev
Environment variables applied to **all** project containers. Per-project variables with the same key take precedence.
### Web Terminal
Enable remote access to your project terminals from any device on the local network (tablets, phones, other computers).
- **Toggle** — Click ON/OFF to start or stop the web terminal server.
- **URL** — When running, shows the full URL including the access token. Click **Copy URL** to copy it to your clipboard, then open it in a browser on your tablet or phone.
- **Token** — An access token is auto-generated on first enable. Click **Copy** to copy the token, or **Regenerate** to create a new one (this disconnects existing web sessions).
- **Port** — Defaults to 7681. Configurable in `settings.json` if needed.
The web terminal server auto-starts on app launch if it was previously enabled, and stops when the app closes.
### Updates
- **Current Version** — The installed version of Triple-C.
@@ -456,6 +502,43 @@ When an update is available, a pulsing **Update** button appears in the top bar.
---
## Web Terminal (Remote Access)
The web terminal lets you access your running project terminals from a tablet, phone, or any other device on the local network — no app installation required, just a web browser.
### Setup
1. Go to **Settings** in the sidebar.
2. Find the **Web Terminal** section and click the toggle to **ON**.
3. A URL appears (e.g., `http://192.168.1.100:7681?token=...`). Click **Copy URL**.
4. Open the URL in a browser on your tablet or other device.
### Using the Web Terminal
The web terminal UI mirrors the desktop app's terminal experience:
- **Project picker** — Select a running project from the dropdown at the top.
- **Claude / Bash buttons** — Open a new Claude Code or bash session for the selected project.
- **Tab bar** — Switch between multiple open sessions. Click the **x** on a tab to close it.
- **Input bar** — A text input at the bottom optimized for mobile/tablet keyboards. Characters are sent immediately without waiting for autocomplete. Helper buttons for **Enter**, **Tab**, and **^C** (Ctrl+C) are provided for keys that are awkward on virtual keyboards.
- **Scroll to bottom** — A floating arrow button appears when you scroll up, letting you jump back to the latest output.
### Security
- Access requires a token in the URL query string. Without the correct token, connections are rejected.
- The token is auto-generated (32 bytes, base64url-encoded) and can be regenerated at any time from Settings.
- The server only listens on port 7681 (configurable) — make sure this port is not exposed to the public internet.
- All sessions opened from a browser tab are automatically cleaned up when the tab is closed or the WebSocket disconnects.
### Tips
- **Bookmark the URL** on your tablet for quick access.
- The web terminal works best in landscape orientation on tablets.
- If the connection drops (e.g., Wi-Fi interruption), the web terminal auto-reconnects after 2 seconds.
- Regenerating the token invalidates all existing browser sessions — you'll need to update bookmarks with the new URL.
---
## Terminal Features
### Multiple Sessions
@@ -472,6 +555,10 @@ 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.
@@ -622,3 +709,13 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
- 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.

View File

@@ -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
@@ -49,10 +49,10 @@ 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). 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`). Optional model override.
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
- **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 LiteLLM 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.
> **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)
@@ -85,6 +85,18 @@ Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io
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.
### Web Terminal (Remote Access)
Triple-C includes an optional web terminal server for accessing project terminals from tablets, phones, or other devices on the local network. When enabled in Settings, an axum HTTP+WebSocket server starts inside the Tauri process, serving a standalone xterm.js-based terminal UI.
- **URL**: `http://<LAN_IP>:7681?token=...` (port configurable)
- **Authentication**: Token-based (auto-generated, copyable from Settings)
- **Protocol**: JSON over WebSocket with base64-encoded terminal data
- **Features**: Project picker, multiple tabs (Claude + bash sessions), mobile-optimized input bar, scroll-to-bottom button
- **Session cleanup**: All terminal sessions are closed when the browser disconnects
The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stores — same Docker exec sessions, different transport (WebSocket instead of Tauri IPC events).
### Docker Socket Path
The socket path is OS-aware:
@@ -102,13 +114,14 @@ 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/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/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, and global settings |
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
| `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) |
@@ -122,9 +135,13 @@ Users can override this in Settings via the global `docker_socket_path` option.
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
| `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 (auth mode, Docker access, MCP servers, Mission Control) |
| `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/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, web terminal) |
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
| `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 |

View File

@@ -100,9 +100,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
│ │ Project Management │◄─┤ ProjectsStore │ │
│ │ Settings UI │ │ bollard Docker Client │ │
│ │ │ │ keyring Credential Mgr │ │
│ └───────────┬───────────┘ └────────────┬─────────────┘
│ └───────────┬───────────┘ │ Web Terminal Server │
│ │ └────────────┬─────────────┘ │
│ │ Tauri IPC (invoke/emit) │ │
│ └───────────┬───────────────┘ │
│ ▲ │
│ axum HTTP+WS│(port 7681) │
│ │ │
└──────────────────────────┼───────────────────────────────┘
│ Docker Socket
@@ -129,6 +133,8 @@ The application uses two IPC mechanisms between the React frontend and Rust back
**Request/Response** (`invoke()`): Used for discrete operations — starting containers, saving settings, listing projects. The frontend calls `invoke("command_name", { args })` and awaits a typed result.
**WebSocket Streaming** (Web Terminal): Used for remote terminal access from browsers on the local network. An axum HTTP+WebSocket server runs inside the Tauri process, sharing the same `ExecSessionManager` via `Arc`-wrapped stores. The WebSocket uses a JSON protocol with base64-encoded terminal data. Each browser connection can open multiple terminal sessions; all sessions are cleaned up when the WebSocket disconnects.
**Event Streaming** (`emit()`/`listen()`): Used for continuous data — terminal I/O. When a terminal session is opened, the Rust backend spawns two tokio tasks:
1. **Output reader** — Reads from the Docker exec stdout stream and emits `terminal-output-{sessionId}` events to the frontend.
2. **Input writer** — Listens on an `mpsc::unbounded_channel` for data sent from the frontend via `invoke("terminal_input")` and writes it to the Docker exec stdin.
@@ -263,7 +269,7 @@ triple-c/
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
│ │ # FileManagerModal, ContainerProgressModal, modals
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
│ │ # UpdateDialog
│ │ # WebTerminalSettings, UpdateDialog
│ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
└── src-tauri/ # Rust backend
@@ -282,7 +288,13 @@ triple-c/
│ ├── 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
── update_commands.rs # App update checking
│ └── web_terminal_commands.rs # Web terminal start/stop/status
├── web_terminal/ # Remote terminal access
│ ├── mod.rs # Module root
│ ├── server.rs # Axum HTTP+WS server lifecycle
│ ├── ws_handler.rs # WebSocket connection handler
│ └── terminal.html # Embedded xterm.js web UI
├── docker/ # Docker API layer
│ ├── client.rs # bollard singleton connection
│ ├── container.rs # Create, start, stop, remove, fingerprinting
@@ -290,7 +302,7 @@ triple-c/
│ ├── image.rs # Build from Dockerfile, pull from registry
│ └── network.rs # Per-project bridge networks for MCP
├── models/ # Data structures
│ ├── project.rs # Project, AuthMode, BedrockConfig
│ ├── 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
@@ -323,6 +335,11 @@ triple-c/
| `tar` | 0.4 | In-memory tar archives for Docker build context |
| `dirs` | 6.x | Cross-platform app data directory paths |
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
| `axum` | 0.8 | HTTP+WebSocket server for web terminal |
| `tower-http` | 0.6 | CORS middleware for web terminal |
| `base64` | 0.22 | Terminal data encoding over WebSocket |
| `rand` | 0.9 | Access token generation |
| `local-ip-address` | 0.6 | LAN IP detection for web terminal URL |
### JavaScript (Frontend)

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2

68
app/package-lock.json generated
View File

@@ -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",

279
app/src-tauri/Cargo.lock generated
View File

@@ -213,6 +213,61 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -664,14 +719,38 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.21.3",
"darling_macro 0.21.3",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.117",
]
[[package]]
@@ -688,17 +767,34 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"darling_core 0.21.3",
"quote",
"syn 2.0.117",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -709,6 +805,37 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -841,6 +968,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -1309,6 +1442,18 @@ dependencies = [
"wasip3",
]
[[package]]
name = "getset"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912"
dependencies = [
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -2085,6 +2230,17 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "local-ip-address"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a"
dependencies = [
"libc",
"neli",
"windows-sys 0.61.2",
]
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2143,6 +2299,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -2246,6 +2408,35 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "neli"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87"
dependencies = [
"bitflags 2.11.0",
"byteorder",
"derive_builder",
"getset",
"libc",
"log",
"neli-proc-macros",
"parking_lot",
]
[[package]]
name = "neli-proc-macros"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
dependencies = [
"either",
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -2916,6 +3107,28 @@ dependencies = [
"version_check",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "proc-macro-hack"
version = "0.5.20+deprecated"
@@ -3594,6 +3807,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -3660,7 +3884,7 @@ version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
dependencies = [
"darling",
"darling 0.21.3",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -3698,6 +3922,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4459,6 +4694,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4581,6 +4828,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -4619,6 +4867,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -4670,6 +4919,8 @@ dependencies = [
name = "triple-c"
version = "0.2.0"
dependencies = [
"axum",
"base64 0.22.1",
"bollard",
"chrono",
"dirs",
@@ -4677,7 +4928,9 @@ dependencies = [
"futures-util",
"iana-time-zone",
"keyring",
"local-ip-address",
"log",
"rand 0.9.2",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -4689,6 +4942,7 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-store",
"tokio",
"tower-http",
"uuid",
]
@@ -4698,6 +4952,23 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"

View File

@@ -31,6 +31,11 @@ tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
iana-time-zone = "0.1"
sha2 = "0.10"
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22"
rand = "0.9"
local-ip-address = "0.6"
[build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@@ -0,0 +1,60 @@
use std::sync::OnceLock;
use tokio::sync::Mutex;
const HELP_URL: &str =
"https://raw.githubusercontent.com/shadowdao/triple-c/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))
}

View File

@@ -1,8 +1,10 @@
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;
pub mod terminal_commands;
pub mod update_commands;
pub mod web_terminal_commands;

View File

@@ -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,9 +34,9 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
}
}
if let Some(ref litellm) = project.litellm_config {
if let Some(ref v) = litellm.api_key {
secure::store_project_secret(&project.id, "litellm-api-key", 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(())
@@ -56,8 +56,8 @@ 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 litellm) = project.litellm_config {
litellm.api_key = secure::get_project_secret(&project.id, "litellm-api-key")
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);
}
}
@@ -179,29 +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.auth_mode == AuthMode::Ollama {
if project.backend == Backend::Ollama {
let ollama = project.ollama_config.as_ref()
.ok_or_else(|| "Ollama auth mode selected but no Ollama configuration found.".to_string())?;
.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.auth_mode == AuthMode::LiteLlm {
let litellm = project.litellm_config.as_ref()
.ok_or_else(|| "LiteLLM auth mode selected but no LiteLLM configuration found.".to_string())?;
if litellm.base_url.is_empty() {
return Err("LiteLLM 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());
}
}

View File

@@ -1,6 +1,6 @@
use tauri::{AppHandle, Emitter, State};
use crate::models::{AuthMode, BedrockAuthMethod, Project};
use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState;
/// Build the command to run in the container terminal.
@@ -9,7 +9,7 @@ use crate::AppState;
/// 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.auth_mode == AuthMode::Bedrock
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
.as_ref()
@@ -17,10 +17,11 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
.unwrap_or(false);
if !is_bedrock_profile {
return vec![
"claude".to_string(),
"--dangerously-skip-permissions".to_string(),
];
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"
@@ -33,6 +34,12 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<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}'..."
@@ -58,9 +65,10 @@ else
echo ""
fi
fi
exec claude --dangerously-skip-permissions
{claude_cmd}
"#,
profile = profile
profile = profile,
claude_cmd = claude_cmd
);
vec![

View File

@@ -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)
}

View File

@@ -0,0 +1,143 @@
use serde::Serialize;
use tauri::State;
use crate::web_terminal::WebTerminalServer;
use crate::AppState;
#[derive(Serialize)]
pub struct WebTerminalInfo {
pub running: bool,
pub port: u16,
pub access_token: String,
pub local_ip: Option<String>,
pub url: Option<String>,
}
fn generate_token() -> String {
use rand::Rng;
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
}
fn get_local_ip() -> Option<String> {
local_ip_address::local_ip().ok().map(|ip| ip.to_string())
}
fn build_info(running: bool, port: u16, token: &str) -> WebTerminalInfo {
let local_ip = get_local_ip();
let url = if running {
local_ip
.as_ref()
.map(|ip| format!("http://{}:{}?token={}", ip, port, token))
} else {
None
};
WebTerminalInfo {
running,
port,
access_token: token.to_string(),
local_ip,
url,
}
}
#[tauri::command]
pub async fn start_web_terminal(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
let mut server_guard = state.web_terminal_server.lock().await;
if server_guard.is_some() {
return Err("Web terminal server is already running".to_string());
}
let mut settings = state.settings_store.get();
// Auto-generate token if not set
if settings.web_terminal.access_token.is_none() {
settings.web_terminal.access_token = Some(generate_token());
settings.web_terminal.enabled = true;
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
}
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
let port = settings.web_terminal.port;
let server = WebTerminalServer::start(
port,
token.clone(),
state.exec_manager.clone(),
state.projects_store.clone(),
state.settings_store.clone(),
)
.await?;
*server_guard = Some(server);
// Mark as enabled in settings
if !settings.web_terminal.enabled {
settings.web_terminal.enabled = true;
let _ = state.settings_store.update(settings);
}
Ok(build_info(true, port, &token))
}
#[tauri::command]
pub async fn stop_web_terminal(state: State<'_, AppState>) -> Result<(), String> {
let mut server_guard = state.web_terminal_server.lock().await;
if let Some(server) = server_guard.take() {
server.stop();
}
// Mark as disabled in settings
let mut settings = state.settings_store.get();
if settings.web_terminal.enabled {
settings.web_terminal.enabled = false;
let _ = state.settings_store.update(settings);
}
Ok(())
}
#[tauri::command]
pub async fn get_web_terminal_status(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
let server_guard = state.web_terminal_server.lock().await;
let settings = state.settings_store.get();
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
let running = server_guard.is_some();
Ok(build_info(running, settings.web_terminal.port, &token))
}
#[tauri::command]
pub async fn regenerate_web_terminal_token(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
// Stop current server if running
{
let mut server_guard = state.web_terminal_server.lock().await;
if let Some(server) = server_guard.take() {
server.stop();
}
}
// Generate new token and save
let new_token = generate_token();
let mut settings = state.settings_store.get();
settings.web_terminal.access_token = Some(new_token.clone());
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
// Restart if was enabled
if settings.web_terminal.enabled {
let server = WebTerminalServer::start(
settings.web_terminal.port,
new_token.clone(),
state.exec_manager.clone(),
state.projects_store.clone(),
state.settings_store.clone(),
)
.await?;
let mut server_guard = state.web_terminal_server.lock().await;
*server_guard = Some(server);
return Ok(build_info(true, settings.web_terminal.port, &new_token));
}
Ok(build_info(false, settings.web_terminal.port, &new_token))
}

View File

@@ -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
@@ -244,13 +244,13 @@ fn compute_ollama_fingerprint(project: &Project) -> String {
}
}
/// Compute a fingerprint for the LiteLLM configuration so we can detect changes.
fn compute_litellm_fingerprint(project: &Project) -> String {
if let Some(ref litellm) = project.litellm_config {
/// 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![
litellm.base_url.clone(),
litellm.api_key.as_deref().unwrap_or("").to_string(),
litellm.model_id.as_deref().unwrap_or("").to_string(),
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 {
@@ -453,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());
@@ -506,7 +506,7 @@ pub async fn create_container(
}
// Ollama configuration
if project.auth_mode == AuthMode::Ollama {
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());
@@ -516,14 +516,14 @@ pub async fn create_container(
}
}
// LiteLLM configuration
if project.auth_mode == AuthMode::LiteLlm {
if let Some(ref litellm) = project.litellm_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url));
if let Some(ref key) = litellm.api_key {
// 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) = litellm.model_id {
if let Some(ref model) = config.model_id {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
}
@@ -624,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 {
@@ -694,16 +694,23 @@ 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.litellm-fingerprint".to_string(), compute_litellm_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());
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
labels.insert("triple-c.git-user-name".to_string(), project.git_user_name.clone().unwrap_or_default());
labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default());
labels.insert("triple-c.git-token-hash".to_string(),
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
let host_config = HostConfig {
mounts: Some(mounts),
@@ -897,11 +904,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);
}
}
@@ -946,11 +955,11 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── LiteLLM config fingerprint ───────────────────────────────────────
let expected_litellm_fp = compute_litellm_fingerprint(project);
let container_litellm_fp = get_label("triple-c.litellm-fingerprint").unwrap_or_default();
if container_litellm_fp != expected_litellm_fp {
log::info!("LiteLLM config mismatch");
// ── 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);
}
@@ -998,41 +1007,32 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── Git environment variables ────────────────────────────────────────
let env_vars = info
.config
.as_ref()
.and_then(|c| c.env.as_ref());
let get_env = |name: &str| -> Option<String> {
env_vars.and_then(|vars| {
vars.iter()
.find(|v| v.starts_with(&format!("{}=", name)))
.map(|v| v[name.len() + 1..].to_string())
})
};
let container_git_name = get_env("GIT_USER_NAME");
let container_git_email = get_env("GIT_USER_EMAIL");
let container_git_token = get_env("GIT_TOKEN");
if container_git_name.as_deref() != project.git_user_name.as_deref() {
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
let expected_git_name = project.git_user_name.clone().unwrap_or_default();
let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
if container_git_name != expected_git_name {
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, expected_git_name);
return Ok(true);
}
if container_git_email.as_deref() != project.git_user_email.as_deref() {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email);
let expected_git_email = project.git_user_email.clone().unwrap_or_default();
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
if container_git_email != expected_git_email {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, expected_git_email);
return Ok(true);
}
if container_git_token.as_deref() != project.git_token.as_deref() {
let expected_git_token_hash = project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default();
let container_git_token_hash = get_label("triple-c.git-token-hash").unwrap_or_default();
if container_git_token_hash != expected_git_token_hash {
log::info!("GIT_TOKEN mismatch");
return Ok(true);
}
// ── Custom environment variables ──────────────────────────────────────
// ── Custom environment variables (label-based fingerprint) ──────────
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let expected_fingerprint = compute_env_fingerprint(&merged_env);
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
let container_fingerprint = get_label("triple-c.custom-env-fingerprint").unwrap_or_default();
if container_fingerprint != expected_fingerprint {
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
return Ok(true);
@@ -1046,15 +1046,16 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── Claude instructions ───────────────────────────────────────────────
// ── Claude instructions (label-based fingerprint) ─────────────────────
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() {
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
if container_instructions_fp != expected_instructions_fp {
log::info!("CLAUDE_INSTRUCTIONS mismatch");
return Ok(true);
}

View File

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

View File

@@ -3,44 +3,55 @@ mod docker;
mod logging;
mod models;
mod storage;
pub mod web_terminal;
use std::sync::Arc;
use docker::exec::ExecSessionManager;
use storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore;
use storage::mcp_store::McpStore;
use tauri::Manager;
use web_terminal::WebTerminalServer;
pub struct AppState {
pub projects_store: ProjectsStore,
pub settings_store: SettingsStore,
pub mcp_store: McpStore,
pub exec_manager: ExecSessionManager,
pub projects_store: Arc<ProjectsStore>,
pub settings_store: Arc<SettingsStore>,
pub mcp_store: Arc<McpStore>,
pub exec_manager: Arc<ExecSessionManager>,
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
}
pub fn run() {
logging::init();
let projects_store = match ProjectsStore::new() {
let projects_store = Arc::new(match ProjectsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize projects store: {}", e);
panic!("Failed to initialize projects store: {}", e);
}
};
let settings_store = match SettingsStore::new() {
});
let settings_store = Arc::new(match SettingsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize settings store: {}", e);
panic!("Failed to initialize settings store: {}", e);
}
};
let mcp_store = match McpStore::new() {
});
let mcp_store = Arc::new(match McpStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize MCP store: {}", e);
panic!("Failed to initialize MCP store: {}", e);
}
};
});
let exec_manager = Arc::new(ExecSessionManager::new());
// Clone Arcs for the setup closure (web terminal auto-start)
let projects_store_setup = projects_store.clone();
let settings_store_setup = settings_store.clone();
let exec_manager_setup = exec_manager.clone();
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
@@ -50,9 +61,10 @@ pub fn run() {
projects_store,
settings_store,
mcp_store,
exec_manager: ExecSessionManager::new(),
exec_manager,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
})
.setup(|app| {
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
Ok(icon) => {
if let Some(window) = app.get_webview_window("main") {
@@ -63,12 +75,54 @@ pub fn run() {
log::error!("Failed to load window icon: {}", e);
}
}
// Auto-start web terminal server if enabled in settings
let settings = settings_store_setup.get();
if settings.web_terminal.enabled {
if let Some(token) = &settings.web_terminal.access_token {
let token = token.clone();
let port = settings.web_terminal.port;
let exec_mgr = exec_manager_setup.clone();
let proj_store = projects_store_setup.clone();
let set_store = settings_store_setup.clone();
let state = app.state::<AppState>();
let web_server_mutex = state.web_terminal_server.clone();
tauri::async_runtime::spawn(async move {
match WebTerminalServer::start(
port,
token,
exec_mgr,
proj_store,
set_store,
)
.await
{
Ok(server) => {
let mut guard = web_server_mutex.lock().await;
*guard = Some(server);
log::info!("Web terminal auto-started on port {}", port);
}
Err(e) => {
log::error!("Failed to auto-start web terminal: {}", e);
}
}
});
}
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let state = window.state::<AppState>();
tauri::async_runtime::block_on(async {
// Stop web terminal server
let mut server_guard = state.web_terminal_server.lock().await;
if let Some(server) = server_guard.take() {
server.stop();
}
// Close all exec sessions
state.exec_manager.close_all_sessions().await;
});
}
@@ -119,6 +173,14 @@ pub fn run() {
// 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,
// Web Terminal
commands::web_terminal_commands::start_web_terminal,
commands::web_terminal_commands::stop_web_terminal,
commands::web_terminal_commands::get_web_terminal_status,
commands::web_terminal_commands::regenerate_web_terminal_token,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -72,6 +72,34 @@ pub struct AppSettings {
pub timezone: Option<String>,
#[serde(default)]
pub default_microphone: Option<String>,
#[serde(default)]
pub dismissed_image_digest: Option<String>,
#[serde(default)]
pub web_terminal: WebTerminalSettings,
}
fn default_web_terminal_port() -> u16 {
7681
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebTerminalSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_web_terminal_port")]
pub port: u16,
#[serde(default)]
pub access_token: Option<String>,
}
impl Default for WebTerminalSettings {
fn default() -> Self {
Self {
enabled: false,
port: 7681,
access_token: None,
}
}
}
impl Default for AppSettings {
@@ -90,6 +118,8 @@ impl Default for AppSettings {
dismissed_update_version: None,
timezone: None,
default_microphone: None,
dismissed_image_digest: None,
web_terminal: WebTerminalSettings::default(),
}
}
}

View File

@@ -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}")

View File

@@ -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,13 +35,17 @@ 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>,
pub litellm_config: Option<LiteLlmConfig>,
#[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, default)]
pub git_token: Option<String>,
@@ -65,23 +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 = "litellm")]
LiteLlm,
#[serde(alias = "lite_llm", alias = "litellm")]
OpenAiCompatible,
}
impl Default for AuthMode {
impl Default for Backend {
fn default() -> Self {
Self::Anthropic
}
@@ -130,13 +139,14 @@ pub struct OllamaConfig {
pub model_id: Option<String>,
}
/// LiteLLM gateway configuration for a project.
/// LiteLLM translates Anthropic API calls to 100+ model providers.
/// 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 LiteLlmConfig {
/// The base URL of the LiteLLM proxy (e.g., "http://host.docker.internal:4000" or "https://litellm.example.com")
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 LiteLLM proxy
/// API key for the OpenAI-compatible endpoint
#[serde(skip_serializing, default)]
pub api_key: Option<String>,
/// Optional model override
@@ -152,12 +162,13 @@ impl Project {
paths,
container_id: None,
status: ProjectStatus::Stopped,
auth_mode: AuthMode::default(),
backend: Backend::default(),
bedrock_config: None,
ollama_config: None,
litellm_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,

View File

@@ -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>,
}

View File

@@ -0,0 +1,4 @@
pub mod server;
mod ws_handler;
pub use server::WebTerminalServer;

View File

@@ -0,0 +1,155 @@
use std::sync::Arc;
use axum::extract::{Query, State as AxumState, WebSocketUpgrade};
use axum::response::{Html, IntoResponse};
use axum::routing::get;
use axum::Router;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use tower_http::cors::CorsLayer;
use crate::docker::exec::ExecSessionManager;
use crate::storage::projects_store::ProjectsStore;
use crate::storage::settings_store::SettingsStore;
use super::ws_handler;
/// Shared state passed to all axum handlers.
pub struct WebTerminalState {
pub exec_manager: Arc<ExecSessionManager>,
pub projects_store: Arc<ProjectsStore>,
pub settings_store: Arc<SettingsStore>,
pub access_token: String,
}
/// Manages the lifecycle of the axum HTTP+WS server.
pub struct WebTerminalServer {
shutdown_tx: watch::Sender<()>,
port: u16,
}
#[derive(Deserialize)]
pub struct TokenQuery {
pub token: Option<String>,
}
#[derive(Serialize)]
struct ProjectInfo {
id: String,
name: String,
status: String,
}
impl WebTerminalServer {
/// Start the web terminal server on the given port.
pub async fn start(
port: u16,
access_token: String,
exec_manager: Arc<ExecSessionManager>,
projects_store: Arc<ProjectsStore>,
settings_store: Arc<SettingsStore>,
) -> Result<Self, String> {
let (shutdown_tx, shutdown_rx) = watch::channel(());
let shared_state = Arc::new(WebTerminalState {
exec_manager,
projects_store,
settings_store,
access_token,
});
let app = Router::new()
.route("/", get(serve_html))
.route("/ws", get(ws_upgrade))
.route("/api/projects", get(list_projects))
.layer(CorsLayer::permissive())
.with_state(shared_state);
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| format!("Failed to bind web terminal to {}: {}", addr, e))?;
log::info!("Web terminal server listening on {}", addr);
let mut shutdown_rx_clone = shutdown_rx.clone();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx_clone.changed().await;
})
.await
.unwrap_or_else(|e| {
log::error!("Web terminal server error: {}", e);
});
log::info!("Web terminal server shut down");
});
Ok(Self { shutdown_tx, port })
}
/// Stop the server gracefully.
pub fn stop(&self) {
log::info!("Stopping web terminal server on port {}", self.port);
let _ = self.shutdown_tx.send(());
}
pub fn port(&self) -> u16 {
self.port
}
}
/// Serve the embedded HTML page.
async fn serve_html() -> Html<&'static str> {
Html(include_str!("terminal.html"))
}
/// Validate token from query params.
fn validate_token(state: &WebTerminalState, token: &Option<String>) -> bool {
match token {
Some(t) => t == &state.access_token,
None => false,
}
}
/// WebSocket upgrade handler.
async fn ws_upgrade(
ws: WebSocketUpgrade,
AxumState(state): AxumState<Arc<WebTerminalState>>,
Query(query): Query<TokenQuery>,
) -> impl IntoResponse {
if !validate_token(&state, &query.token) {
return (axum::http::StatusCode::UNAUTHORIZED, "Invalid token").into_response();
}
ws.on_upgrade(move |socket| ws_handler::handle_connection(socket, state))
.into_response()
}
/// List running projects (REST endpoint).
async fn list_projects(
AxumState(state): AxumState<Arc<WebTerminalState>>,
Query(query): Query<TokenQuery>,
) -> impl IntoResponse {
if !validate_token(&state, &query.token) {
return (
axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "Invalid token"})),
)
.into_response();
}
let projects = state.projects_store.list();
let infos: Vec<ProjectInfo> = projects
.into_iter()
.map(|p| ProjectInfo {
id: p.id,
name: p.name,
status: serde_json::to_value(&p.status)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string()),
})
.collect();
axum::Json(infos).into_response()
}

View File

@@ -0,0 +1,669 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Triple-C Web Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<style>
:root {
--bg-primary: #1a1b26;
--bg-secondary: #24283b;
--bg-tertiary: #2f3347;
--text-primary: #c0caf5;
--text-secondary: #565f89;
--accent: #7aa2f7;
--accent-hover: #89b4fa;
--border: #3b3f57;
--success: #9ece6a;
--warning: #e0af68;
--error: #f7768e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
height: 100vh;
height: 100dvh; /* dynamic viewport height — shrinks when mobile keyboard opens */
display: flex;
flex-direction: column;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* ── Top Bar ─────────────────────────────── */
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 42px;
position: sticky;
top: 0;
z-index: 20;
}
.topbar-title {
font-size: 13px;
font-weight: 600;
color: var(--accent);
white-space: nowrap;
margin-right: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error);
flex-shrink: 0;
}
.status-dot.connected { background: var(--success); }
.status-dot.reconnecting { background: var(--warning); animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
select, button {
font-size: 12px;
padding: 4px 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
touch-action: manipulation;
}
select:focus, button:focus { outline: none; border-color: var(--accent); }
button:hover { background: var(--border); }
button:active { background: var(--accent); color: var(--bg-primary); }
.btn-new {
font-weight: 600;
min-width: 44px;
min-height: 32px;
}
/* ── Tab Bar ─────────────────────────────── */
.tabbar {
display: flex;
align-items: center;
gap: 1px;
padding: 0 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
min-height: 32px;
position: sticky;
top: 42px; /* below .topbar min-height */
z-index: 20;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: all 0.15s;
min-height: 32px;
}
.tab:hover { color: var(--text-primary); }
.tab.active {
color: var(--text-primary);
border-bottom-color: var(--accent);
}
.tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 3px;
font-size: 12px;
line-height: 1;
color: var(--text-secondary);
background: none;
border: none;
padding: 0;
min-width: unset;
min-height: unset;
}
.tab-close:hover { background: var(--error); color: white; }
/* ── Terminal Area ───────────────────────── */
.terminal-area {
flex: 1;
position: relative;
overflow: hidden;
}
.terminal-container {
position: absolute;
inset: 0;
display: none;
padding: 4px;
}
.terminal-container.active { display: block; }
/* ── Input Bar (mobile/tablet) ──────────── */
.input-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.input-bar input {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 16px; /* prevents iOS zoom on focus */
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
-webkit-appearance: none;
}
.input-bar input:focus { border-color: var(--accent); }
.input-bar .key-btn {
padding: 8px 10px;
font-size: 11px;
font-weight: 600;
min-width: 40px;
min-height: 36px;
border-radius: 6px;
white-space: nowrap;
}
/* ── Scroll-to-bottom FAB ──────────────── */
.scroll-bottom-btn {
position: absolute;
bottom: 12px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: var(--bg-primary);
border: none;
font-size: 18px;
font-weight: bold;
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
z-index: 10;
padding: 0;
min-width: unset;
min-height: unset;
line-height: 1;
}
.scroll-bottom-btn:hover { background: var(--accent-hover); }
.scroll-bottom-btn.visible { display: flex; }
/* ── Empty State ─────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
font-size: 14px;
gap: 12px;
}
.empty-state .hint {
font-size: 12px;
opacity: 0.7;
}
/* ── Scrollbar ───────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<!-- Top Bar -->
<div class="topbar">
<span class="topbar-title">Triple-C</span>
<span class="status-dot" id="statusDot"></span>
<select id="projectSelect" style="flex:1; max-width:240px;">
<option value="">Select project...</option>
</select>
<button class="btn-new" id="btnClaude" title="New Claude session">Claude</button>
<button class="btn-new" id="btnBash" title="New Bash session">Bash</button>
</div>
<!-- Tab Bar -->
<div class="tabbar" id="tabbar"></div>
<!-- Terminal Area -->
<div class="terminal-area" id="terminalArea">
<div class="empty-state" id="emptyState">
<div>Select a project and open a terminal session</div>
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
</div>
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">&#8595;</button>
</div>
<!-- Input Bar for mobile/tablet -->
<div class="input-bar" id="inputBar">
<input type="text" id="mobileInput" placeholder="Type here..."
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
enterkeyhint="send" inputmode="text">
<button class="key-btn" id="btnEnter">Enter</button>
<button class="key-btn" id="btnTab">Tab</button>
<button class="key-btn" id="btnCtrlC">^C</button>
</div>
<script>
(function() {
'use strict';
// ── State ──────────────────────────────────
const params = new URLSearchParams(window.location.search);
const TOKEN = params.get('token') || '';
let ws = null;
let reconnectTimer = null;
let sessions = {}; // { sessionId: { term, fitAddon, projectName, type, containerId } }
let activeSessionId = null;
// ── DOM refs ───────────────────────────────
const statusDot = document.getElementById('statusDot');
const projectSelect = document.getElementById('projectSelect');
const btnClaude = document.getElementById('btnClaude');
const btnBash = document.getElementById('btnBash');
const tabbar = document.getElementById('tabbar');
const terminalArea = document.getElementById('terminalArea');
const emptyState = document.getElementById('emptyState');
const mobileInput = document.getElementById('mobileInput');
const btnEnter = document.getElementById('btnEnter');
const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
// ── WebSocket ──────────────────────────────
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(TOKEN)}`;
ws = new WebSocket(url);
ws.onopen = () => {
statusDot.className = 'status-dot connected';
clearTimeout(reconnectTimer);
send({ type: 'list_projects' });
// Start keepalive
ws._pingInterval = setInterval(() => send({ type: 'ping' }), 30000);
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
handleMessage(msg);
} catch (e) {
console.error('Parse error:', e);
}
};
ws.onclose = () => {
statusDot.className = 'status-dot reconnecting';
if (ws && ws._pingInterval) clearInterval(ws._pingInterval);
reconnectTimer = setTimeout(connect, 2000);
};
ws.onerror = () => {
ws.close();
};
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
// ── Message handling ───────────────────────
function handleMessage(msg) {
switch (msg.type) {
case 'projects':
updateProjectList(msg.projects);
break;
case 'opened':
onSessionOpened(msg.session_id, msg.project_name);
break;
case 'output':
onSessionOutput(msg.session_id, msg.data);
break;
case 'exit':
onSessionExit(msg.session_id);
break;
case 'error':
console.error('Server error:', msg.message);
// Show in active terminal if available
if (activeSessionId && sessions[activeSessionId]) {
sessions[activeSessionId].term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`);
}
break;
case 'pong':
break;
}
}
function updateProjectList(projects) {
const current = projectSelect.value;
projectSelect.innerHTML = '<option value="">Select project...</option>';
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.name} (${p.status})`;
opt.disabled = p.status !== 'running';
projectSelect.appendChild(opt);
});
// Restore selection if still valid
if (current) projectSelect.value = current;
}
// ── Session management ─────────────────────
let pendingSessionType = null;
function openSession(type) {
const projectId = projectSelect.value;
if (!projectId) {
alert('Please select a running project first.');
return;
}
pendingSessionType = type;
send({
type: 'open',
project_id: projectId,
session_type: type,
});
}
function onSessionOpened(sessionId, projectName) {
const sessionType = pendingSessionType || 'claude';
pendingSessionType = null;
// Create terminal
const term = new Terminal({
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
selectionBackground: '#33467c',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
fontSize: 14,
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
cursorBlink: true,
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
// Create container div
const container = document.createElement('div');
container.className = 'terminal-container';
container.id = `term-${sessionId}`;
terminalArea.appendChild(container);
term.open(container);
fitAddon.fit();
// Send initial resize
send({
type: 'resize',
session_id: sessionId,
cols: term.cols,
rows: term.rows,
});
// Handle user input
term.onData(data => {
const bytes = new TextEncoder().encode(data);
const b64 = btoa(String.fromCharCode(...bytes));
send({
type: 'input',
session_id: sessionId,
data: b64,
});
});
// Track scroll position for scroll-to-bottom button
term.onScroll(() => updateScrollButton());
// Store session
sessions[sessionId] = { term, fitAddon, projectName, type: sessionType, container };
// Add tab and switch to it
addTab(sessionId, projectName, sessionType);
switchToSession(sessionId);
emptyState.style.display = 'none';
}
function onSessionOutput(sessionId, b64data) {
const session = sessions[sessionId];
if (!session) return;
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
session.term.write(bytes);
// Update scroll button if this is the active session
if (sessionId === activeSessionId) updateScrollButton();
}
function onSessionExit(sessionId) {
const session = sessions[sessionId];
if (!session) return;
session.term.writeln('\r\n\x1b[90m[Session ended]\x1b[0m');
}
function closeSession(sessionId) {
send({ type: 'close', session_id: sessionId });
removeSession(sessionId);
}
function removeSession(sessionId) {
const session = sessions[sessionId];
if (!session) return;
session.term.dispose();
session.container.remove();
delete sessions[sessionId];
// Remove tab
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) tab.remove();
// Switch to another session or show empty state
const remaining = Object.keys(sessions);
if (remaining.length > 0) {
switchToSession(remaining[remaining.length - 1]);
} else {
activeSessionId = null;
emptyState.style.display = '';
}
}
// ── Tab bar ────────────────────────────────
function addTab(sessionId, projectName, sessionType) {
const tab = document.createElement('div');
tab.className = 'tab';
tab.id = `tab-${sessionId}`;
const label = document.createElement('span');
label.textContent = `${projectName} (${sessionType})`;
tab.appendChild(label);
const close = document.createElement('button');
close.className = 'tab-close';
close.textContent = '\u00d7';
close.onclick = (e) => { e.stopPropagation(); closeSession(sessionId); };
tab.appendChild(close);
tab.onclick = () => switchToSession(sessionId);
tabbar.appendChild(tab);
}
function switchToSession(sessionId) {
activeSessionId = sessionId;
// Update tab styles
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) tab.classList.add('active');
// Show/hide terminal containers
document.querySelectorAll('.terminal-container').forEach(c => c.classList.remove('active'));
const container = document.getElementById(`term-${sessionId}`);
if (container) {
container.classList.add('active');
const session = sessions[sessionId];
if (session) {
// Fit after making visible
requestAnimationFrame(() => {
session.fitAddon.fit();
session.term.focus();
updateScrollButton();
});
}
}
}
// ── Resize handling ────────────────────────
function handleResize() {
if (activeSessionId && sessions[activeSessionId]) {
const session = sessions[activeSessionId];
session.fitAddon.fit();
send({
type: 'resize',
session_id: activeSessionId,
cols: session.term.cols,
rows: session.term.rows,
});
}
}
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(handleResize, 100);
});
// ── Send helper ─────────────────────────────
function sendTerminalInput(str) {
if (!activeSessionId) return;
const bytes = new TextEncoder().encode(str);
const b64 = btoa(String.fromCharCode(...bytes));
send({
type: 'input',
session_id: activeSessionId,
data: b64,
});
}
// ── Input bar (mobile/tablet) ──────────────
// Send characters immediately, bypassing IME composition buffering.
// Clearing value on each input event cancels any active composition.
mobileInput.addEventListener('input', () => {
const val = mobileInput.value;
if (val) {
sendTerminalInput(val);
mobileInput.value = '';
}
});
// Catch Enter in the input field itself
mobileInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = mobileInput.value;
if (val) {
sendTerminalInput(val);
mobileInput.value = '';
}
sendTerminalInput('\r');
} else if (e.key === 'Tab') {
e.preventDefault();
sendTerminalInput('\t');
}
});
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
// ── Scroll to bottom ──────────────────────
function updateScrollButton() {
if (!activeSessionId || !sessions[activeSessionId]) {
scrollBottomBtn.classList.remove('visible');
return;
}
const term = sessions[activeSessionId].term;
const isAtBottom = term.buffer.active.viewportY >= term.buffer.active.baseY;
scrollBottomBtn.classList.toggle('visible', !isAtBottom);
}
scrollBottomBtn.onclick = () => {
if (activeSessionId && sessions[activeSessionId]) {
sessions[activeSessionId].term.scrollToBottom();
scrollBottomBtn.classList.remove('visible');
}
};
// ── Event listeners ────────────────────────
btnClaude.onclick = () => openSession('claude');
btnBash.onclick = () => openSession('bash');
// ── Init ───────────────────────────────────
connect();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,324 @@
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus};
use super::server::WebTerminalState;
// ── Wire protocol types ──────────────────────────────────────────────
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage {
ListProjects,
Open {
project_id: String,
session_type: Option<String>,
},
Input {
session_id: String,
data: String, // base64
},
Resize {
session_id: String,
cols: u16,
rows: u16,
},
Close {
session_id: String,
},
Ping,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ServerMessage {
Projects {
projects: Vec<ProjectEntry>,
},
Opened {
session_id: String,
project_name: String,
},
Output {
session_id: String,
data: String, // base64
},
Exit {
session_id: String,
},
Error {
message: String,
},
Pong,
}
#[derive(Serialize)]
struct ProjectEntry {
id: String,
name: String,
status: String,
}
// ── Connection handler ───────────────────────────────────────────────
pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>) {
let (mut ws_tx, mut ws_rx) = socket.split();
// Channel for sending messages from session output tasks → WS writer
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<ServerMessage>();
// Track session IDs owned by this connection for cleanup
let owned_sessions: Arc<tokio::sync::Mutex<Vec<String>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
// Writer task: serializes ServerMessages and sends as WS text frames
let writer_handle = tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
if let Ok(json) = serde_json::to_string(&msg) {
if ws_tx.send(Message::Text(json.into())).await.is_err() {
break;
}
}
}
});
// Reader loop: parse incoming messages and dispatch
while let Some(Ok(msg)) = ws_rx.next().await {
let text = match &msg {
Message::Text(t) => t.to_string(),
Message::Close(_) => break,
_ => continue,
};
let client_msg: ClientMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
let _ = out_tx.send(ServerMessage::Error {
message: format!("Invalid message: {}", e),
});
continue;
}
};
match client_msg {
ClientMessage::Ping => {
let _ = out_tx.send(ServerMessage::Pong);
}
ClientMessage::ListProjects => {
let projects = state.projects_store.list();
let entries: Vec<ProjectEntry> = projects
.into_iter()
.map(|p| ProjectEntry {
id: p.id,
name: p.name,
status: serde_json::to_value(&p.status)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string()),
})
.collect();
let _ = out_tx.send(ServerMessage::Projects { projects: entries });
}
ClientMessage::Open {
project_id,
session_type,
} => {
let result = handle_open(
&state,
&project_id,
session_type.as_deref(),
&out_tx,
&owned_sessions,
)
.await;
if let Err(e) = result {
let _ = out_tx.send(ServerMessage::Error { message: e });
}
}
ClientMessage::Input { session_id, data } => {
match BASE64.decode(&data) {
Ok(bytes) => {
if let Err(e) = state.exec_manager.send_input(&session_id, bytes).await {
let _ = out_tx.send(ServerMessage::Error {
message: format!("Input error: {}", e),
});
}
}
Err(e) => {
let _ = out_tx.send(ServerMessage::Error {
message: format!("Base64 decode error: {}", e),
});
}
}
}
ClientMessage::Resize {
session_id,
cols,
rows,
} => {
if let Err(e) = state.exec_manager.resize(&session_id, cols, rows).await {
let _ = out_tx.send(ServerMessage::Error {
message: format!("Resize error: {}", e),
});
}
}
ClientMessage::Close { session_id } => {
state.exec_manager.close_session(&session_id).await;
// Remove from owned list
owned_sessions
.lock()
.await
.retain(|id| id != &session_id);
}
}
}
// Connection closed — clean up all owned sessions
log::info!("Web terminal WebSocket disconnected, cleaning up sessions");
let sessions = owned_sessions.lock().await.clone();
for session_id in sessions {
state.exec_manager.close_session(&session_id).await;
}
writer_handle.abort();
}
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> 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;
}
let profile = project
.bedrock_config
.as_ref()
.and_then(|b| b.aws_profile.clone())
.or_else(|| settings_store.get().global_aws.aws_profile.clone())
.unwrap_or_else(|| "default".to_string());
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."
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]
}
/// Open a new terminal session for a project.
async fn handle_open(
state: &WebTerminalState,
project_id: &str,
session_type: Option<&str>,
out_tx: &mpsc::UnboundedSender<ServerMessage>,
owned_sessions: &Arc<tokio::sync::Mutex<Vec<String>>>,
) -> Result<(), String> {
let project = state
.projects_store
.get(project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
if project.status != ProjectStatus::Running {
return Err(format!("Project '{}' is not running", project.name));
}
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let cmd = match session_type {
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state.settings_store),
};
let session_id = uuid::Uuid::new_v4().to_string();
let project_name = project.name.clone();
// Set up output routing through the WS channel
let out_tx_output = out_tx.clone();
let session_id_output = session_id.clone();
let on_output = move |data: Vec<u8>| {
let encoded = BASE64.encode(&data);
let _ = out_tx_output.send(ServerMessage::Output {
session_id: session_id_output.clone(),
data: encoded,
});
};
let out_tx_exit = out_tx.clone();
let session_id_exit = session_id.clone();
let on_exit = Box::new(move || {
let _ = out_tx_exit.send(ServerMessage::Exit {
session_id: session_id_exit,
});
});
state
.exec_manager
.create_session(container_id, &session_id, cmd, on_output, on_exit)
.await?;
// Track this session for cleanup on disconnect
owned_sessions.lock().await.push(session_id.clone());
let _ = out_tx.send(ServerMessage::Opened {
session_id,
project_name,
});
Ok(())
}

View File

@@ -17,7 +17,7 @@ export default function App() {
const { loadSettings } = useSettings();
const { refresh } = useProjects();
const { refresh: refreshMcp } = useMcpServers();
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, setProjects } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
);
@@ -46,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);

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// 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)&gt; (.+)/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>
);
}

View File

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

View File

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

View File

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

View File

@@ -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, OllamaConfig, LiteLlmConfig } 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";
@@ -12,6 +12,7 @@ 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;
@@ -62,10 +63,10 @@ export default function ProjectCard({ project }: Props) {
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
const [ollamaModelId, setOllamaModelId] = useState(project.ollama_config?.model_id ?? "");
// LiteLLM local state
const [litellmBaseUrl, setLitellmBaseUrl] = useState(project.litellm_config?.base_url ?? "http://host.docker.internal:4000");
const [litellmApiKey, setLitellmApiKey] = useState(project.litellm_config?.api_key ?? "");
const [litellmModelId, setLitellmModelId] = useState(project.litellm_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(() => {
@@ -87,9 +88,9 @@ export default function ProjectCard({ project }: Props) {
setBedrockModelId(project.bedrock_config?.model_id ?? "");
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
setOllamaModelId(project.ollama_config?.model_id ?? "");
setLitellmBaseUrl(project.litellm_config?.base_url ?? "http://host.docker.internal:4000");
setLitellmApiKey(project.litellm_config?.api_key ?? "");
setLitellmModelId(project.litellm_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
@@ -196,23 +197,23 @@ export default function ProjectCard({ project }: Props) {
model_id: null,
};
const defaultLiteLlmConfig: LiteLlmConfig = {
const defaultOpenAiCompatibleConfig: OpenAiCompatibleConfig = {
base_url: "http://host.docker.internal:4000",
api_key: null,
model_id: null,
};
const handleAuthModeChange = async (mode: AuthMode) => {
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 === "lit_llm" && !project.litellm_config) {
updates.litellm_config = defaultLiteLlmConfig;
if (mode === "open_ai_compatible" && !project.openai_compatible_config) {
updates.openai_compatible_config = defaultOpenAiCompatibleConfig;
}
await update({ ...project, ...updates });
} catch (e) {
@@ -354,30 +355,30 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleLitellmBaseUrlBlur = async () => {
const handleOpenaiCompatibleBaseUrlBlur = async () => {
try {
const current = project.litellm_config ?? defaultLiteLlmConfig;
await update({ ...project, litellm_config: { ...current, base_url: litellmBaseUrl } });
const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
await update({ ...project, openai_compatible_config: { ...current, base_url: openaiCompatibleBaseUrl } });
} catch (err) {
console.error("Failed to update LiteLLM base URL:", err);
console.error("Failed to update OpenAI Compatible base URL:", err);
}
};
const handleLitellmApiKeyBlur = async () => {
const handleOpenaiCompatibleApiKeyBlur = async () => {
try {
const current = project.litellm_config ?? defaultLiteLlmConfig;
await update({ ...project, litellm_config: { ...current, api_key: litellmApiKey || null } });
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 LiteLLM API key:", err);
console.error("Failed to update OpenAI Compatible API key:", err);
}
};
const handleLitellmModelIdBlur = async () => {
const handleOpenaiCompatibleModelIdBlur = async () => {
try {
const current = project.litellm_config ?? defaultLiteLlmConfig;
await update({ ...project, litellm_config: { ...current, model_id: litellmModelId || null } });
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 LiteLLM model ID:", err);
console.error("Failed to update OpenAI Compatible model ID:", err);
}
};
@@ -446,12 +447,12 @@ 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>
<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.auth_mode}
onChange={(e) => { e.stopPropagation(); handleAuthModeChange(e.target.value as AuthMode); }}
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 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"
@@ -459,7 +460,7 @@ export default function ProjectCard({ project }: Props) {
<option value="anthropic">Anthropic</option>
<option value="bedrock">Bedrock</option>
<option value="ollama">Ollama</option>
<option value="lit_llm">LiteLLM</option>
<option value="open_ai_compatible">OpenAI Compatible</option>
</select>
</div>
@@ -609,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}
@@ -631,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)}
@@ -644,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)}
@@ -657,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}
@@ -671,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) {
@@ -691,7 +692,7 @@ export default function ProjectCard({ project }: Props) {
{/* Mission Control toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Mission Control</label>
<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 {
@@ -711,10 +712,36 @@ export default function ProjectCard({ project }: Props) {
</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)}
@@ -727,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)}
@@ -740,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)}
@@ -753,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);
@@ -794,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 (
@@ -819,7 +846,7 @@ export default function ProjectCard({ project }: Props) {
{/* 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)}
@@ -834,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)}
@@ -845,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}
@@ -856,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}
@@ -872,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)}
@@ -887,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}
@@ -901,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)}
@@ -916,7 +943,7 @@ export default function ProjectCard({ project }: Props) {
})()}
{/* Ollama config */}
{project.auth_mode === "ollama" && (() => {
{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)]">
@@ -926,7 +953,7 @@ export default function ProjectCard({ project }: Props) {
</p>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL</label>
<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)}
@@ -941,7 +968,7 @@ export default function ProjectCard({ project }: Props) {
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)</label>
<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)}
@@ -955,38 +982,38 @@ export default function ProjectCard({ project }: Props) {
);
})()}
{/* LiteLLM config */}
{project.auth_mode === "lit_llm" && (() => {
{/* 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)]">LiteLLM Gateway</label>
<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 a LiteLLM proxy to use 100+ model providers.
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</label>
<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={litellmBaseUrl}
onChange={(e) => setLitellmBaseUrl(e.target.value)}
onBlur={handleLitellmBaseUrlBlur}
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 remote/containerized LiteLLM.
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</label>
<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={litellmApiKey}
onChange={(e) => setLitellmApiKey(e.target.value)}
onBlur={handleLitellmApiKeyBlur}
value={openaiCompatibleApiKey}
onChange={(e) => setOpenaiCompatibleApiKey(e.target.value)}
onBlur={handleOpenaiCompatibleApiKeyBlur}
placeholder="sk-..."
disabled={!isStopped}
className={inputCls}
@@ -994,11 +1021,11 @@ export default function ProjectCard({ project }: Props) {
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)</label>
<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={litellmModelId}
onChange={(e) => setLitellmModelId(e.target.value)}
onBlur={handleLitellmModelIdBlur}
value={openaiCompatibleModelId}
onChange={(e) => setOpenaiCompatibleModelId(e.target.value)}
onBlur={handleOpenaiCompatibleModelIdBlur}
placeholder="gpt-4o / gemini-pro / etc."
disabled={!isStopped}
className={inputCls}

View File

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

View File

@@ -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 ?? ""}

View File

@@ -2,8 +2,9 @@ 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";
const REGISTRY_IMAGE = "ghcr.io/shadowdao/triple-c-sandbox:latest";
const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [
{ value: "registry", label: "Registry", description: "Pull from container registry" },
@@ -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>

View 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 &gt; Docker and click &quot;Re-pull Image&quot; 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>
);
}

View File

@@ -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,12 @@ 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";
import WebTerminalSettings from "./WebTerminalSettings";
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 +40,7 @@ export default function SettingsPanel() {
const handleCheckNow = async () => {
setCheckingUpdates(true);
try {
await checkForUpdates();
await Promise.all([checkForUpdates(), checkImageUpdate()]);
} finally {
setCheckingUpdates(false);
}
@@ -55,13 +56,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 +81,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 +100,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>
@@ -117,9 +117,12 @@ export default function SettingsPanel() {
</div>
</div>
{/* Web Terminal */}
<WebTerminalSettings />
{/* 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 +149,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>

View File

@@ -0,0 +1,128 @@
import { useState, useEffect } from "react";
import { startWebTerminal, stopWebTerminal, getWebTerminalStatus, regenerateWebTerminalToken } from "../../lib/tauri-commands";
import type { WebTerminalInfo } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
export default function WebTerminalSettings() {
const [info, setInfo] = useState<WebTerminalInfo | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
getWebTerminalStatus().then(setInfo).catch(console.error);
}, []);
const handleToggle = async () => {
setLoading(true);
try {
if (info?.running) {
await stopWebTerminal();
const updated = await getWebTerminalStatus();
setInfo(updated);
} else {
const updated = await startWebTerminal();
setInfo(updated);
}
} catch (e) {
console.error("Web terminal toggle failed:", e);
} finally {
setLoading(false);
}
};
const handleRegenerate = async () => {
try {
const updated = await regenerateWebTerminalToken();
setInfo(updated);
} catch (e) {
console.error("Token regeneration failed:", e);
}
};
const handleCopyUrl = async () => {
if (info?.url) {
await navigator.clipboard.writeText(info.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleCopyToken = async () => {
if (info?.access_token) {
await navigator.clipboard.writeText(info.access_token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div>
<label className="block text-sm font-medium mb-1">
Web Terminal
<Tooltip text="Access your terminals from a tablet or phone on the local network via a web browser." />
</label>
<p className="text-xs text-[var(--text-secondary)] mb-2">
Serves a browser-based terminal UI on your local network for remote access to running projects.
</p>
<div className="space-y-2">
{/* Toggle */}
<div className="flex items-center gap-2">
<button
onClick={handleToggle}
disabled={loading}
className={`px-2 py-0.5 text-xs rounded transition-colors ${
info?.running
? "bg-[var(--success)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{loading ? "..." : info?.running ? "ON" : "OFF"}
</button>
<span className="text-xs text-[var(--text-secondary)]">
{info?.running
? `Running on port ${info.port}`
: "Stopped"}
</span>
</div>
{/* URL + Copy */}
{info?.running && info.url && (
<div className="flex items-center gap-2">
<code className="text-xs text-[var(--accent)] bg-[var(--bg-primary)] px-2 py-1 rounded border border-[var(--border-color)] truncate flex-1">
{info.url}
</code>
<button
onClick={handleCopyUrl}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
{copied ? "Copied!" : "Copy URL"}
</button>
</div>
)}
{/* Token */}
{info && (
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--text-secondary)]">Token:</span>
<code className="text-xs text-[var(--text-primary)] bg-[var(--bg-primary)] px-2 py-0.5 rounded border border-[var(--border-color)] truncate max-w-[160px]">
{info.access_token ? `${info.access_token.slice(0, 12)}...` : "None"}
</code>
<button
onClick={handleCopyToken}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Copy
</button>
<button
onClick={handleRegenerate}
className="text-xs px-2 py-0.5 text-[var(--warning,#f59e0b)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Regenerate
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -24,6 +24,7 @@ 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);
@@ -34,6 +35,12 @@ export default function TerminalView({ sessionId, active }: Props) {
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
const isAtBottomRef = useRef(true);
// Tracks user intent to follow output — only set to false by explicit user
// actions (mouse wheel up), not by xterm scroll events during writes.
const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0);
useEffect(() => {
if (!containerRef.current) return;
@@ -80,6 +87,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.
@@ -114,10 +137,45 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data);
});
// Track scroll position to show "Jump to Current" button
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
// Captured during capture phase so it fires before xterm's own handler.
const handleWheel = (e: WheelEvent) => {
lastUserScrollTimeRef.current = Date.now();
if (e.deltaY < 0) {
autoFollowRef.current = false;
setIsAutoFollow(false);
isAtBottomRef.current = false;
setIsAtBottom(false);
}
};
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
// Track scroll position to show "Jump to Current" button.
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
let scrollStateRafId: number | null = null;
const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active;
setIsAtBottom(buf.viewportY >= buf.baseY);
const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom;
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
if (atBottom && isUserScroll && !autoFollowRef.current) {
autoFollowRef.current = true;
setIsAutoFollow(true);
}
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current);
});
}
});
// 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,
@@ -165,7 +223,15 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data);
term.write(data, () => {
if (autoFollowRef.current) {
term.scrollToBottom();
if (!isAtBottomRef.current) {
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}
});
detector.feed(data);
// Scan for SSO refresh marker in terminal output
@@ -209,6 +275,9 @@ export default function TerminalView({ sessionId, active }: Props) {
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
fitAddon.fit();
resize(sessionId, term.cols, term.rows);
if (autoFollowRef.current) {
term.scrollToBottom();
}
});
});
resizeObserver.observe(containerRef.current);
@@ -222,9 +291,13 @@ export default function TerminalView({ sessionId, active }: Props) {
osc52Disposable.dispose();
inputDisposable.dispose();
scrollDisposable.dispose();
selectionDisposable.dispose();
setTerminalHasSelection(false);
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -256,6 +329,9 @@ export default function TerminalView({ sessionId, active }: Props) {
}
}
fitRef.current?.fit();
if (autoFollowRef.current) {
term.scrollToBottom();
}
term.focus();
} else {
// Release WebGL context for inactive terminals
@@ -290,8 +366,30 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [detectedUrl]);
const handleScrollToBottom = useCallback(() => {
termRef.current?.scrollToBottom();
setIsAtBottom(true);
const term = termRef.current;
if (term) {
autoFollowRef.current = true;
setIsAutoFollow(true);
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}, []);
const handleToggleAutoFollow = useCallback(() => {
const next = !autoFollowRef.current;
autoFollowRef.current = next;
setIsAutoFollow(next);
if (next) {
const term = termRef.current;
if (term) {
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}
}, []);
return (
@@ -314,6 +412,19 @@ export default function TerminalView({ sessionId, active }: Props) {
{imagePasteMsg}
</div>
)}
{/* Auto-follow toggle - top right */}
<button
onClick={handleToggleAutoFollow}
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
isAutoFollow
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
}`}
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
>
{isAutoFollow ? "▼ Following" : "▽ Paused"}
</button>
{/* Jump to Current - bottom right, when scrolled up */}
{!isAtBottom && (
<button
onClick={handleScrollToBottom}

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

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer, FileEntry } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -83,3 +83,18 @@ export const uploadFileToContainer = (projectId: string, hostPath: string, conta
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");
// Web Terminal
export const startWebTerminal = () =>
invoke<WebTerminalInfo>("start_web_terminal");
export const stopWebTerminal = () =>
invoke<void>("stop_web_terminal");
export const getWebTerminalStatus = () =>
invoke<WebTerminalInfo>("get_web_terminal_status");
export const regenerateWebTerminalToken = () =>
invoke<WebTerminalInfo>("regenerate_web_terminal_token");

View File

@@ -20,12 +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;
litellm_config: LiteLlmConfig | 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;
@@ -45,7 +46,7 @@ export type ProjectStatus =
| "stopping"
| "error";
export type AuthMode = "anthropic" | "bedrock" | "ollama" | "lit_llm";
export type Backend = "anthropic" | "bedrock" | "ollama" | "open_ai_compatible";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
@@ -66,7 +67,7 @@ export interface OllamaConfig {
model_id: string | null;
}
export interface LiteLlmConfig {
export interface OpenAiCompatibleConfig {
base_url: string;
api_key: string | null;
model_id: string | null;
@@ -116,6 +117,22 @@ export interface AppSettings {
dismissed_update_version: string | null;
timezone: string | null;
default_microphone: string | null;
dismissed_image_digest: string | null;
web_terminal: WebTerminalSettings;
}
export interface WebTerminalSettings {
enabled: boolean;
port: number;
access_token: string | null;
}
export interface WebTerminalInfo {
running: boolean;
port: number;
access_token: string;
local_ip: string | null;
url: string | null;
}
export interface UpdateInfo {
@@ -133,6 +150,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 {

View File

@@ -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 }),
}));