Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d60124f1bd | ||
|
|
4f23951379 | ||
|
|
d6ac3ae6c6 | ||
|
|
ef67b447b3 | ||
|
|
15b03173a5 | ||
|
|
a0b4dca0bd | ||
|
|
17c5d699f9 | ||
|
|
e62af502d3 | ||
|
|
3e9053946f | ||
|
|
3bbd7fd55f | ||
|
|
49d09e4447 | ||
|
|
702ebb7247 | ||
|
|
caf3e26816 | ||
|
|
765ba91d7b | ||
|
|
532de77927 |
@@ -40,7 +40,8 @@ jobs:
|
||||
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)
|
||||
# `|| true` so an empty grep result doesn't fail the step under pipefail.
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
echo "Latest matching tag: ${LATEST_TAG}"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Build STT Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "stt-container/**"
|
||||
- ".gitea/workflows/build-stt.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "stt-container/**"
|
||||
- ".gitea/workflows/build-stt.yml"
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
IMAGE_NAME: cybercovellc/triple-c/triple-c-stt
|
||||
|
||||
jobs:
|
||||
build-stt-container:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
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 STT container image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./stt-container
|
||||
file: ./stt-container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ gitea.event_name == 'push' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-stt:latest
|
||||
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -76,13 +76,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- `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.
|
||||
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `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/`)
|
||||
|
||||
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, then `sleep infinity`
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||
|
||||
### Container Lifecycle
|
||||
|
||||
+51
-2
@@ -253,7 +253,7 @@ When **disabled** (default), Claude prompts you for approval before executing ea
|
||||
|
||||
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.
|
||||
|
||||
> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `CLAUDE_`, `TRIPLE_C_`) are filtered out to prevent conflicts with internal variables.
|
||||
> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `TRIPLE_C_`) and specific internal variables (`CLAUDE_INSTRUCTIONS`, `MCP_SERVERS_JSON`, etc.) are filtered out to prevent conflicts. `CLAUDE_CODE_*` variables are now allowed, so you can set Claude Code feature flags directly (e.g., `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`).
|
||||
|
||||
### Port Mappings
|
||||
|
||||
@@ -268,6 +268,25 @@ Each mapping specifies:
|
||||
|
||||
Click **Edit** to write per-project instructions for Claude Code. These are written to `~/.claude/CLAUDE.md` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions.
|
||||
|
||||
### Claude Code Settings
|
||||
|
||||
Click **Edit** next to "Claude Code Settings" to configure Claude Code CLI behavior for this project. These settings control how Claude Code operates inside the container:
|
||||
|
||||
| Setting | What It Does |
|
||||
|---------|-------------|
|
||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
||||
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
|
||||
| **Session Recap** | Provides context when returning to a session after being away |
|
||||
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
||||
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
|
||||
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
|
||||
|
||||
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
|
||||
|
||||
---
|
||||
|
||||
## MCP Servers (Beta)
|
||||
@@ -481,6 +500,18 @@ 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.
|
||||
|
||||
### Default SSH Key Directory
|
||||
|
||||
Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence.
|
||||
|
||||
### Default Git Name / Email
|
||||
|
||||
Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project.
|
||||
|
||||
### Claude Code Settings (Global Defaults)
|
||||
|
||||
Default Claude Code CLI settings applied to all projects. See [Claude Code Settings](#claude-code-settings) in the Project Configuration section for a description of each setting. Per-project settings override these global defaults.
|
||||
|
||||
### Web Terminal
|
||||
|
||||
Enable remote access to your project terminals from any device on the local network (tablets, phones, other computers).
|
||||
@@ -543,7 +574,7 @@ The web terminal UI mirrors the desktop app's terminal experience:
|
||||
|
||||
### Multiple Sessions
|
||||
|
||||
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name, with a "(bash)" suffix for shell sessions.
|
||||
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name (or custom session name if provided), with a "(bash)" suffix for shell sessions.
|
||||
|
||||
### Bash Shell Sessions
|
||||
|
||||
@@ -668,6 +699,24 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
|
||||
|
||||
---
|
||||
|
||||
## Claude Code Tips
|
||||
|
||||
These features are built into Claude Code and work inside Triple-C containers with no extra configuration:
|
||||
|
||||
| Feature | How to Use |
|
||||
|---------|-----------|
|
||||
| **Focus Mode** | Run `/focus` or press `Ctrl+O` in the terminal to toggle collapsed tool output |
|
||||
| **Session Recap** | Run `/recap` to get a summary of what happened in the current session |
|
||||
| **Session Color** | Run `/color red` (or any color) to color-code your terminal prompt bar |
|
||||
| **Recurring Tasks** | Run `/loop 5m check the deploy` to repeat a prompt every 5 minutes |
|
||||
| **Interactive Lessons** | Run `/powerup` to learn Claude Code features with animated demos |
|
||||
| **Team Onboarding** | Run `/team-onboarding` to generate a teammate ramp-up guide |
|
||||
| **Bedrock Setup** | Select "3rd-party platform" on the login screen for an interactive Bedrock setup wizard |
|
||||
| **Vertex AI Setup** | Select "3rd-party platform" on the login screen for an interactive Vertex AI setup wizard |
|
||||
| **MCP Elicitation** | MCP servers can now request structured user input mid-task — works automatically |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker is "Not Available"
|
||||
|
||||
@@ -27,7 +27,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
||||
### Container Lifecycle
|
||||
|
||||
1. **Create**: New container created with bind mounts, env vars, and labels
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers, injects Claude Code settings
|
||||
3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY
|
||||
4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped
|
||||
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
|
||||
@@ -97,6 +97,19 @@ Triple-C includes an optional web terminal server for accessing project terminal
|
||||
|
||||
The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stores — same Docker exec sessions, different transport (WebSocket instead of Tauri IPC events).
|
||||
|
||||
### Speech-to-Text (Voice Mode)
|
||||
|
||||
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the bottom-left corner of each terminal view.
|
||||
|
||||
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||
- **Port**: Default `9876` (configurable)
|
||||
- **Language**: Optional language hint for transcription
|
||||
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
||||
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
||||
|
||||
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
@@ -115,6 +128,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
| `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, backend selector, action buttons |
|
||||
| `app/src/components/projects/ClaudeCodeSettingsModal.tsx` | Claude Code CLI settings modal (TUI mode, effort, focus, caching) |
|
||||
| `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 |
|
||||
@@ -122,12 +136,14 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
|
||||
| `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/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
|
||||
| `app/src/components/terminal/SttButton.tsx` | Mic button overlay with on-demand container start |
|
||||
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||
| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations |
|
||||
| `app/src/hooks/useVoice.ts` | Voice mode audio capture (currently hidden) |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting |
|
||||
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar |
|
||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||
@@ -135,16 +151,21 @@ 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 (backend, Docker access, MCP servers, Mission Control) |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, Claude Code settings, 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, web terminal) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
|
||||
| `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/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `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) |
|
||||
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
|
||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Mission Control setup |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Claude Code settings injection, Mission Control setup |
|
||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -9,7 +9,7 @@
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
@@ -1984,12 +1984,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
|
||||
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
|
||||
Generated
+30
-10
@@ -1045,7 +1045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2345,6 +2345,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3233,7 +3243,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3454,6 +3464,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -3462,6 +3473,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3580,7 +3592,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4378,9 +4390,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
@@ -4396,13 +4408,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
@@ -4565,7 +4579,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4936,7 +4950,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "triple-c"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -5053,6 +5067,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -5444,7 +5464,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "triple-c"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -29,7 +29,7 @@ log = "0.4"
|
||||
fern = { version = "0.7", features = ["date-based"] }
|
||||
tar = "0.4"
|
||||
include_dir = "0.7"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
|
||||
iana-time-zone = "0.1"
|
||||
sha2 = "0.10"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::models::Project;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aws_sso_refresh(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state.projects_store.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let profile = project.bedrock_config.as_ref()
|
||||
/// Resolve AWS profile: project-level → global settings → "default".
|
||||
pub fn resolve_profile_for_project(project: &Project, global_profile: Option<&str>) -> String {
|
||||
project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
.and_then(|b| b.aws_profile.clone())
|
||||
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
.or_else(|| global_profile.map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "default".to_string())
|
||||
}
|
||||
|
||||
/// Check if the AWS session is valid for the given profile on the host.
|
||||
/// Returns `Ok(true)` if valid, `Ok(false)` if expired/invalid.
|
||||
pub async fn check_sso_session(profile: &str) -> Result<bool, String> {
|
||||
let output = tokio::process::Command::new("aws")
|
||||
.args(["sts", "get-caller-identity", "--profile", profile])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run aws sts get-caller-identity: {}", e))?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
/// Check if the given AWS profile uses SSO (has sso_start_url or sso_session configured).
|
||||
pub async fn is_sso_profile(profile: &str) -> Result<bool, String> {
|
||||
let check_start_url = tokio::process::Command::new("aws")
|
||||
.args(["configure", "get", "sso_start_url", "--profile", profile])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(out) = check_start_url {
|
||||
if out.status.success() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let check_session = tokio::process::Command::new("aws")
|
||||
.args(["configure", "get", "sso_session", "--profile", profile])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(out) = check_session {
|
||||
if out.status.success() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Run `aws sso login --profile X` on the host. This is interactive (opens a browser).
|
||||
pub async fn run_sso_login(profile: &str) -> Result<(), String> {
|
||||
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
|
||||
|
||||
let status = tokio::process::Command::new("aws")
|
||||
.args(["sso", "login", "--profile", &profile])
|
||||
.args(["sso", "login", "--profile", profile])
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
|
||||
@@ -28,3 +63,19 @@ pub async fn aws_sso_refresh(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aws_sso_refresh(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state.projects_store.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let profile = resolve_profile_for_project(
|
||||
&project,
|
||||
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
run_sso_login(&profile).await
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod help_commands;
|
||||
pub mod mcp_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod stt_commands;
|
||||
pub mod terminal_commands;
|
||||
pub mod update_commands;
|
||||
pub mod web_terminal_commands;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, Backend, McpServer, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, Backend, BedrockAuthMethod, McpServer, Project, ProjectPath, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -208,6 +209,76 @@ pub async fn start_project_container(
|
||||
// Update status to starting
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||
|
||||
// Pre-validate AWS SSO session on the host for Bedrock Profile projects.
|
||||
// If the session is expired, trigger `aws sso login` before starting the container
|
||||
// so the entrypoint copies already-fresh credentials from the host mount.
|
||||
if project.backend == Backend::Bedrock {
|
||||
if let Some(ref bedrock) = project.bedrock_config {
|
||||
if bedrock.auth_method == BedrockAuthMethod::Profile {
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
&project,
|
||||
settings.global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Validating AWS session...");
|
||||
|
||||
let session_valid = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
aws_commands::check_sso_session(&profile),
|
||||
)
|
||||
.await;
|
||||
|
||||
match session_valid {
|
||||
Ok(Ok(true)) => {
|
||||
emit_progress(&app_handle, &project_id, "AWS session valid.");
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
// Session expired — check if this is an SSO profile
|
||||
if aws_commands::is_sso_profile(&profile).await.unwrap_or(false) {
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"AWS session expired. Starting SSO login (check your browser)...",
|
||||
);
|
||||
match aws_commands::run_sso_login(&profile).await {
|
||||
Ok(()) => {
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"SSO login successful.",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"SSO login failed for profile '{}': {} — continuing anyway",
|
||||
profile,
|
||||
e
|
||||
);
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"SSO login failed or cancelled. Continuing...",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"AWS session invalid for profile '{}' (not SSO). Continuing...",
|
||||
profile
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("Failed to check AWS session: {} — continuing anyway", e);
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("AWS session check timed out — continuing anyway");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap container operations so that any failure resets status to Stopped.
|
||||
let result: Result<String, String> = async {
|
||||
// Ensure image exists
|
||||
@@ -267,6 +338,10 @@ pub async fn start_project_container(
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if needs_recreate {
|
||||
@@ -299,6 +374,10 @@ pub async fn start_project_container(
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
network_name.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
@@ -332,6 +411,10 @@ pub async fn start_project_container(
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
network_name.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::docker::stt;
|
||||
use crate::models::app_settings::SttStatus;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_stt_status(state: State<'_, AppState>) -> Result<SttStatus, String> {
|
||||
let settings = state.settings_store.get();
|
||||
stt::get_stt_status(&settings.stt).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_stt(state: State<'_, AppState>) -> Result<SttStatus, String> {
|
||||
let settings = state.settings_store.get();
|
||||
stt::ensure_stt_running(&settings.stt).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_stt() -> Result<(), String> {
|
||||
stt::stop_stt_container().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_stt_image(app_handle: AppHandle) -> Result<(), String> {
|
||||
stt::build_stt_image(move |msg| {
|
||||
let _ = app_handle.emit("stt-build-progress", &msg);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pull_stt_image(app_handle: AppHandle) -> Result<(), String> {
|
||||
stt::pull_stt_image(move |msg| {
|
||||
let _ = app_handle.emit("stt-pull-progress", &msg);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_audio(
|
||||
audio_data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let settings = state.settings_store.get();
|
||||
if !settings.stt.enabled {
|
||||
return Err("STT is not enabled".to_string());
|
||||
}
|
||||
|
||||
let url = format!("http://127.0.0.1:{}/transcribe", settings.stt.port);
|
||||
|
||||
let file_part = reqwest::multipart::Part::bytes(audio_data)
|
||||
.file_name("recording.wav")
|
||||
.mime_str("audio/wav")
|
||||
.map_err(|e| format!("Failed to create multipart: {}", e))?;
|
||||
|
||||
let mut form = reqwest::multipart::Form::new().part("file", file_part);
|
||||
|
||||
if let Some(ref lang) = settings.stt.language {
|
||||
form = form.text("language", lang.clone());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_connect() {
|
||||
"STT container is not running. Start it from Settings.".to_string()
|
||||
} else {
|
||||
format!("Transcription request failed: {}", e)
|
||||
}
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Transcription failed ({}): {}", status, body));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse transcription response: {}", e))?;
|
||||
|
||||
result["text"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No text in transcription response".to_string())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||
use crate::AppState;
|
||||
|
||||
@@ -8,7 +9,7 @@ use crate::AppState;
|
||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
.bedrock_config
|
||||
@@ -21,23 +22,30 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
||||
if project.full_permissions {
|
||||
cmd.push("--dangerously-skip-permissions".to_string());
|
||||
}
|
||||
if let Some(name) = session_name {
|
||||
if !name.is_empty() {
|
||||
cmd.push("-n".to_string());
|
||||
cmd.push(name.to_string());
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Resolve AWS profile: project-level → global settings → "default"
|
||||
let profile = project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
.and_then(|b| b.aws_profile.clone())
|
||||
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||
// then exec's into claude.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
||||
.unwrap_or_default();
|
||||
let claude_cmd = if project.full_permissions {
|
||||
"exec claude --dangerously-skip-permissions"
|
||||
format!("exec claude --dangerously-skip-permissions{}", name_flag)
|
||||
} else {
|
||||
"exec claude"
|
||||
format!("exec claude{}", name_flag)
|
||||
};
|
||||
|
||||
let script = format!(
|
||||
@@ -83,6 +91,7 @@ pub async fn open_terminal_session(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
session_type: Option<String>,
|
||||
session_name: Option<String>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@@ -98,7 +107,7 @@ pub async fn open_terminal_session(
|
||||
|
||||
let cmd = match session_type.as_deref() {
|
||||
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||
_ => build_terminal_cmd(&project, &state),
|
||||
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
|
||||
};
|
||||
|
||||
let output_event = format!("terminal-output-{}", session_id);
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
|
||||
@@ -132,14 +132,17 @@ fn build_claude_instructions(
|
||||
/// Compute a fingerprint string for the custom environment variables.
|
||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for env_var in custom_env_vars {
|
||||
let key = env_var.key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
|
||||
let upper = key.to_uppercase();
|
||||
let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p))
|
||||
|| reserved_exact.iter().any(|e| upper == *e);
|
||||
if is_reserved {
|
||||
continue;
|
||||
}
|
||||
@@ -282,6 +285,80 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
||||
sha256_hex(&joined)
|
||||
}
|
||||
|
||||
/// Merge global and per-project ClaudeCodeSettings.
|
||||
/// Per-project fields override global fields when set (non-default).
|
||||
fn merge_claude_code_settings(
|
||||
global: Option<&ClaudeCodeSettings>,
|
||||
project: Option<&ClaudeCodeSettings>,
|
||||
) -> Option<ClaudeCodeSettings> {
|
||||
match (global, project) {
|
||||
(None, None) => None,
|
||||
(Some(g), None) => Some(g.clone()),
|
||||
(None, Some(p)) => Some(p.clone()),
|
||||
(Some(g), Some(p)) => {
|
||||
// Project overrides global for each field when the project value is non-default
|
||||
Some(ClaudeCodeSettings {
|
||||
tui_mode: p.tui_mode.clone().or_else(|| g.tui_mode.clone()),
|
||||
effort: p.effort.clone().or_else(|| g.effort.clone()),
|
||||
auto_scroll_disabled: if p.auto_scroll_disabled { true } else { g.auto_scroll_disabled },
|
||||
focus_mode: if p.focus_mode { true } else { g.focus_mode },
|
||||
show_thinking_summaries: if p.show_thinking_summaries { true } else { g.show_thinking_summaries },
|
||||
enable_session_recap: if p.enable_session_recap { true } else { g.enable_session_recap },
|
||||
env_scrub: if p.env_scrub { true } else { g.env_scrub },
|
||||
prompt_caching_1h: if p.prompt_caching_1h { true } else { g.prompt_caching_1h },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
|
||||
fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings>) -> String {
|
||||
match settings {
|
||||
None => String::new(),
|
||||
Some(s) => {
|
||||
let parts = vec![
|
||||
s.tui_mode.as_deref().unwrap_or("").to_string(),
|
||||
s.effort.as_deref().unwrap_or("").to_string(),
|
||||
format!("{}", s.auto_scroll_disabled),
|
||||
format!("{}", s.focus_mode),
|
||||
format!("{}", s.show_thinking_summaries),
|
||||
format!("{}", s.enable_session_recap),
|
||||
format!("{}", s.env_scrub),
|
||||
format!("{}", s.prompt_caching_1h),
|
||||
];
|
||||
sha256_hex(&parts.join("|"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the settings.json content for Claude Code from ClaudeCodeSettings.
|
||||
/// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
|
||||
fn build_claude_code_settings_json(settings: &ClaudeCodeSettings) -> Option<String> {
|
||||
let mut map = serde_json::Map::new();
|
||||
|
||||
if let Some(ref tui) = settings.tui_mode {
|
||||
map.insert("tui".to_string(), serde_json::json!(tui));
|
||||
}
|
||||
if let Some(ref effort) = settings.effort {
|
||||
map.insert("effort".to_string(), serde_json::json!(effort));
|
||||
}
|
||||
if settings.auto_scroll_disabled {
|
||||
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
|
||||
}
|
||||
if settings.focus_mode {
|
||||
map.insert("focusMode".to_string(), serde_json::json!(true));
|
||||
}
|
||||
if settings.show_thinking_summaries {
|
||||
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
if map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::Value::Object(map).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
|
||||
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
|
||||
///
|
||||
@@ -400,6 +477,10 @@ pub async fn create_container(
|
||||
timezone: Option<&str>,
|
||||
mcp_servers: &[McpServer],
|
||||
network_name: Option<&str>,
|
||||
global_claude_code_settings: Option<&ClaudeCodeSettings>,
|
||||
default_ssh_key_path: Option<&str>,
|
||||
default_git_user_name: Option<&str>,
|
||||
default_git_user_email: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
@@ -445,10 +526,13 @@ pub async fn create_container(
|
||||
if let Some(ref token) = project.git_token {
|
||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||
}
|
||||
if let Some(ref name) = project.git_user_name {
|
||||
// Per-project git user overrides global defaults
|
||||
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
|
||||
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
|
||||
if let Some(name) = effective_git_name {
|
||||
env_vars.push(format!("GIT_USER_NAME={}", name));
|
||||
}
|
||||
if let Some(ref email) = project.git_user_email {
|
||||
if let Some(email) = effective_git_email {
|
||||
env_vars.push(format!("GIT_USER_EMAIL={}", email));
|
||||
}
|
||||
|
||||
@@ -531,13 +615,16 @@ pub async fn create_container(
|
||||
|
||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
|
||||
for env_var in &merged_env {
|
||||
let key = env_var.key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
|
||||
let upper = key.to_uppercase();
|
||||
let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p))
|
||||
|| reserved_exact.iter().any(|e| upper == *e);
|
||||
if is_reserved {
|
||||
log::warn!("Skipping reserved env var: {}", key);
|
||||
continue;
|
||||
@@ -577,6 +664,32 @@ pub async fn create_container(
|
||||
env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json));
|
||||
}
|
||||
|
||||
// Claude Code settings (global + per-project merged)
|
||||
let merged_cc_settings = merge_claude_code_settings(
|
||||
global_claude_code_settings,
|
||||
project.claude_code_settings.as_ref(),
|
||||
);
|
||||
if let Some(ref cc) = merged_cc_settings {
|
||||
// Env-var-based settings (these are read directly by Claude Code)
|
||||
if cc.tui_mode.as_deref() == Some("fullscreen") {
|
||||
env_vars.push("CLAUDE_CODE_NO_FLICKER=1".to_string());
|
||||
}
|
||||
if cc.enable_session_recap {
|
||||
env_vars.push("CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1".to_string());
|
||||
}
|
||||
if cc.env_scrub {
|
||||
env_vars.push("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1".to_string());
|
||||
}
|
||||
if cc.prompt_caching_1h {
|
||||
env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string());
|
||||
}
|
||||
|
||||
// settings.json-based settings (written by the entrypoint)
|
||||
if let Some(settings_json) = build_claude_code_settings_json(cc) {
|
||||
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json));
|
||||
}
|
||||
}
|
||||
|
||||
let mut mounts: Vec<Mount> = Vec::new();
|
||||
|
||||
// Project directories -> /workspace/{mount_name}
|
||||
@@ -612,10 +725,12 @@ pub async fn create_container(
|
||||
});
|
||||
|
||||
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
|
||||
if let Some(ref ssh_path) = project.ssh_key_path {
|
||||
// Per-project ssh_key_path overrides global default_ssh_key_path
|
||||
let effective_ssh_path = project.ssh_key_path.as_deref().or(default_ssh_key_path);
|
||||
if let Some(ssh_path) = effective_ssh_path {
|
||||
mounts.push(Mount {
|
||||
target: Some("/tmp/.host-ssh".to_string()),
|
||||
source: Some(ssh_path.clone()),
|
||||
source: Some(ssh_path.to_string()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(true),
|
||||
..Default::default()
|
||||
@@ -705,10 +820,12 @@ pub async fn create_container(
|
||||
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.claude-code-settings-fingerprint".to_string(),
|
||||
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref()));
|
||||
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-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
|
||||
labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
|
||||
labels.insert("triple-c.git-token-hash".to_string(),
|
||||
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
|
||||
|
||||
@@ -877,6 +994,10 @@ pub async fn container_needs_recreation(
|
||||
global_custom_env_vars: &[EnvVar],
|
||||
timezone: Option<&str>,
|
||||
mcp_servers: &[McpServer],
|
||||
global_claude_code_settings: Option<&ClaudeCodeSettings>,
|
||||
default_ssh_key_path: Option<&str>,
|
||||
default_git_user_name: Option<&str>,
|
||||
default_git_user_email: Option<&str>,
|
||||
) -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
let info = docker
|
||||
@@ -997,28 +1118,34 @@ pub async fn container_needs_recreation(
|
||||
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
|
||||
})
|
||||
.and_then(|mount| mount.source.as_deref());
|
||||
let project_ssh = project.ssh_key_path.as_deref();
|
||||
if ssh_mount_source != project_ssh {
|
||||
let effective_ssh = project.ssh_key_path.as_deref().or(default_ssh_key_path);
|
||||
if ssh_mount_source != effective_ssh {
|
||||
log::info!(
|
||||
"SSH key path mismatch (container={:?}, project={:?})",
|
||||
"SSH key path mismatch (container={:?}, expected={:?})",
|
||||
ssh_mount_source,
|
||||
project_ssh
|
||||
effective_ssh
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
|
||||
let expected_git_name = project.git_user_name.clone().unwrap_or_default();
|
||||
let expected_git_name = project.git_user_name.as_deref()
|
||||
.or(default_git_user_name)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
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);
|
||||
log::info!("GIT_USER_NAME mismatch (container={:?}, expected={:?})", container_git_name, expected_git_name);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let expected_git_email = project.git_user_email.clone().unwrap_or_default();
|
||||
let expected_git_email = project.git_user_email.as_deref()
|
||||
.or(default_git_user_email)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
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);
|
||||
log::info!("GIT_USER_EMAIL mismatch (container={:?}, expected={:?})", container_git_email, expected_git_email);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -1060,6 +1187,18 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Claude Code settings fingerprint ───────────────────────────────
|
||||
let merged_cc = merge_claude_code_settings(
|
||||
global_claude_code_settings,
|
||||
project.claude_code_settings.as_ref(),
|
||||
);
|
||||
let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref());
|
||||
let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default();
|
||||
if container_cc_fp != expected_cc_fp {
|
||||
log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── MCP servers fingerprint ─────────────────────────────────────────
|
||||
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers);
|
||||
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default();
|
||||
|
||||
@@ -3,7 +3,10 @@ pub mod container;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
pub mod network;
|
||||
pub mod stt;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use stt::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use client::*;
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
use bollard::container::{
|
||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||
StartContainerOptions, StopContainerOptions,
|
||||
};
|
||||
use bollard::image::BuildImageOptions;
|
||||
use bollard::models::{HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::app_settings::{SttSettings, SttStatus};
|
||||
|
||||
const STT_CONTAINER_NAME: &str = "triple-c-stt";
|
||||
const STT_MODEL_VOLUME: &str = "triple-c-stt-model-cache";
|
||||
const STT_REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-stt:latest";
|
||||
const STT_LOCAL_IMAGE: &str = "triple-c-stt:latest";
|
||||
const STT_DOCKERFILE: &str = include_str!("../../../../stt-container/Dockerfile");
|
||||
const STT_SERVER: &str = include_str!("../../../../stt-container/server.py");
|
||||
|
||||
pub async fn get_stt_status(settings: &SttSettings) -> Result<SttStatus, String> {
|
||||
let image_exists = super::image::image_exists(STT_REGISTRY_IMAGE).await.unwrap_or(false)
|
||||
|| super::image::image_exists(STT_LOCAL_IMAGE).await.unwrap_or(false);
|
||||
|
||||
let (container_exists, running, model) = match find_stt_container().await? {
|
||||
Some((_, state, env_model)) => (true, state == "running", env_model),
|
||||
None => (false, false, settings.model.clone()),
|
||||
};
|
||||
|
||||
Ok(SttStatus {
|
||||
container_exists,
|
||||
running,
|
||||
port: settings.port,
|
||||
model,
|
||||
image_exists,
|
||||
})
|
||||
}
|
||||
|
||||
async fn find_stt_container() -> Result<Option<(String, String, String)>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"name".to_string(),
|
||||
vec![format!("/{}", STT_CONTAINER_NAME)],
|
||||
)]);
|
||||
|
||||
let containers = docker
|
||||
.list_containers(Some(ListContainersOptions {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||
|
||||
if let Some(container) = containers.first() {
|
||||
let id = container.id.clone().unwrap_or_default();
|
||||
let state = container.state.clone().unwrap_or_default();
|
||||
|
||||
// Extract WHISPER_MODEL from container env
|
||||
let model = container
|
||||
.labels
|
||||
.as_ref()
|
||||
.and_then(|l| l.get("triple-c.stt.model"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "tiny".to_string());
|
||||
|
||||
return Ok(Some((id, state, model)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn create_stt_container(settings: &SttSettings) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Try local image first, fall back to registry
|
||||
let image = if super::image::image_exists(STT_LOCAL_IMAGE).await.unwrap_or(false) {
|
||||
STT_LOCAL_IMAGE.to_string()
|
||||
} else if super::image::image_exists(STT_REGISTRY_IMAGE).await.unwrap_or(false) {
|
||||
STT_REGISTRY_IMAGE.to_string()
|
||||
} else {
|
||||
return Err("STT image not found. Please build or pull the image first.".to_string());
|
||||
};
|
||||
|
||||
let port_binding = PortBinding {
|
||||
host_ip: Some("127.0.0.1".to_string()),
|
||||
host_port: Some(settings.port.to_string()),
|
||||
};
|
||||
|
||||
let mut port_bindings = HashMap::new();
|
||||
port_bindings.insert(
|
||||
"9876/tcp".to_string(),
|
||||
Some(vec![port_binding]),
|
||||
);
|
||||
|
||||
let host_config = HostConfig {
|
||||
port_bindings: Some(port_bindings),
|
||||
mounts: Some(vec![Mount {
|
||||
target: Some("/root/.cache/huggingface".to_string()),
|
||||
source: Some(STT_MODEL_VOLUME.to_string()),
|
||||
typ: Some(MountTypeEnum::VOLUME),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert(
|
||||
"triple-c.stt.model".to_string(),
|
||||
settings.model.clone(),
|
||||
);
|
||||
labels.insert(
|
||||
"triple-c.stt.port".to_string(),
|
||||
settings.port.to_string(),
|
||||
);
|
||||
|
||||
let config = Config {
|
||||
image: Some(image),
|
||||
env: Some(vec![format!("WHISPER_MODEL={}", settings.model)]),
|
||||
host_config: Some(host_config),
|
||||
labels: Some(labels),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let options = CreateContainerOptions {
|
||||
name: STT_CONTAINER_NAME,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let response = docker
|
||||
.create_container(Some(options), config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create STT container: {}", e))?;
|
||||
|
||||
Ok(response.id)
|
||||
}
|
||||
|
||||
pub async fn ensure_stt_running(settings: &SttSettings) -> Result<SttStatus, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Check if container exists and if settings match
|
||||
if let Some((id, state, model)) = find_stt_container().await? {
|
||||
let needs_recreate = model != settings.model;
|
||||
|
||||
if needs_recreate {
|
||||
// Settings changed, recreate
|
||||
if state == "running" {
|
||||
docker
|
||||
.stop_container(&id, None::<StopContainerOptions>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stop STT container: {}", e))?;
|
||||
}
|
||||
docker
|
||||
.remove_container(
|
||||
&id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove STT container: {}", e))?;
|
||||
} else if state == "running" {
|
||||
return get_stt_status(settings).await;
|
||||
} else {
|
||||
// Container exists but stopped, start it
|
||||
docker
|
||||
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start STT container: {}", e))?;
|
||||
return get_stt_status(settings).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and start new container
|
||||
let id = create_stt_container(settings).await?;
|
||||
docker
|
||||
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start STT container: {}", e))?;
|
||||
|
||||
get_stt_status(settings).await
|
||||
}
|
||||
|
||||
pub async fn stop_stt_container() -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
if let Some((id, state, _)) = find_stt_container().await? {
|
||||
if state == "running" {
|
||||
docker
|
||||
.stop_container(&id, None::<StopContainerOptions>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stop STT container: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pull_stt_image<F>(on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
{
|
||||
super::image::pull_image(STT_REGISTRY_IMAGE, on_progress).await
|
||||
}
|
||||
|
||||
pub async fn build_stt_image<F>(on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
{
|
||||
let docker = get_docker()?;
|
||||
|
||||
let tar_bytes = create_stt_build_context()
|
||||
.map_err(|e| format!("Failed to create STT build context: {}", e))?;
|
||||
|
||||
let options = BuildImageOptions {
|
||||
t: STT_LOCAL_IMAGE,
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if let Some(stream) = output.stream {
|
||||
on_progress(stream);
|
||||
}
|
||||
if let Some(error) = output.error {
|
||||
return Err(format!("Build error: {}", error));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Build stream error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_stt_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut archive = tar::Builder::new(&mut buf);
|
||||
|
||||
let mut dockerfile_header = tar::Header::new_gnu();
|
||||
dockerfile_header.set_size(STT_DOCKERFILE.len() as u64);
|
||||
dockerfile_header.set_mode(0o644);
|
||||
dockerfile_header.set_cksum();
|
||||
archive.append_data(&mut dockerfile_header, "Dockerfile", STT_DOCKERFILE.as_bytes())?;
|
||||
|
||||
let mut server_header = tar::Header::new_gnu();
|
||||
server_header.set_size(STT_SERVER.len() as u64);
|
||||
server_header.set_mode(0o644);
|
||||
server_header.set_cksum();
|
||||
archive.append_data(&mut server_header, "server.py", STT_SERVER.as_bytes())?;
|
||||
|
||||
archive.finish()?;
|
||||
}
|
||||
|
||||
let _ = buf.flush();
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,25 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start STT container if enabled in settings
|
||||
if settings.stt.enabled {
|
||||
let stt_settings = settings.stt.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match docker::stt::ensure_stt_running(&stt_settings).await {
|
||||
Ok(status) => {
|
||||
if status.running {
|
||||
log::info!("STT container auto-started on port {}", stt_settings.port);
|
||||
} else {
|
||||
log::warn!("STT auto-start: container not running after ensure_stt_running");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-start STT container: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -122,6 +141,8 @@ pub fn run() {
|
||||
if let Some(server) = server_guard.take() {
|
||||
server.stop();
|
||||
}
|
||||
// Stop STT container
|
||||
let _ = docker::stt::stop_stt_container().await;
|
||||
// Close all exec sessions
|
||||
state.exec_manager.close_all_sessions().await;
|
||||
});
|
||||
@@ -181,6 +202,13 @@ pub fn run() {
|
||||
commands::web_terminal_commands::stop_web_terminal,
|
||||
commands::web_terminal_commands::get_web_terminal_status,
|
||||
commands::web_terminal_commands::regenerate_web_terminal_token,
|
||||
// STT
|
||||
commands::stt_commands::get_stt_status,
|
||||
commands::stt_commands::start_stt,
|
||||
commands::stt_commands::stop_stt,
|
||||
commands::stt_commands::build_stt_image,
|
||||
commands::stt_commands::pull_stt_image,
|
||||
commands::stt_commands::transcribe_audio,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::project::EnvVar;
|
||||
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
@@ -76,6 +76,50 @@ pub struct AppSettings {
|
||||
pub dismissed_image_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub web_terminal: WebTerminalSettings,
|
||||
#[serde(default)]
|
||||
pub stt: SttSettings,
|
||||
#[serde(default)]
|
||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
}
|
||||
|
||||
fn default_stt_model() -> String {
|
||||
"tiny".to_string()
|
||||
}
|
||||
|
||||
fn default_stt_port() -> u16 {
|
||||
9876
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SttSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_stt_model")]
|
||||
pub model: String,
|
||||
#[serde(default = "default_stt_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SttSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
model: default_stt_model(),
|
||||
port: 9876,
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SttStatus {
|
||||
pub container_exists: bool,
|
||||
pub running: bool,
|
||||
pub port: u16,
|
||||
pub model: String,
|
||||
pub image_exists: bool,
|
||||
}
|
||||
|
||||
fn default_web_terminal_port() -> u16 {
|
||||
@@ -120,6 +164,8 @@ impl Default for AppSettings {
|
||||
default_microphone: None,
|
||||
dismissed_image_digest: None,
|
||||
web_terminal: WebTerminalSettings::default(),
|
||||
stt: SttSettings::default(),
|
||||
global_claude_code_settings: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,36 @@ fn default_full_permissions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Settings for Claude Code CLI behavior inside the container.
|
||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ClaudeCodeSettings {
|
||||
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
|
||||
#[serde(default)]
|
||||
pub tui_mode: Option<String>,
|
||||
/// Effort level: None = default, Some("low"|"medium"|"high")
|
||||
#[serde(default)]
|
||||
pub effort: Option<String>,
|
||||
/// Disable auto-scroll in fullscreen TUI mode
|
||||
#[serde(default)]
|
||||
pub auto_scroll_disabled: bool,
|
||||
/// Enable focus mode (collapsed tool output)
|
||||
#[serde(default)]
|
||||
pub focus_mode: bool,
|
||||
/// Show thinking summaries in responses
|
||||
#[serde(default)]
|
||||
pub show_thinking_summaries: bool,
|
||||
/// Enable session recap when returning to a session
|
||||
#[serde(default)]
|
||||
pub enable_session_recap: bool,
|
||||
/// Strip credentials from subprocess environments
|
||||
#[serde(default)]
|
||||
pub env_scrub: bool,
|
||||
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||
#[serde(default)]
|
||||
pub prompt_caching_1h: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
@@ -59,6 +89,8 @@ pub struct Project {
|
||||
pub claude_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled_mcp_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -177,6 +209,7 @@ impl Project {
|
||||
port_mappings: Vec::new(),
|
||||
claude_instructions: None,
|
||||
enabled_mcp_servers: Vec::new(),
|
||||
claude_code_settings: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus};
|
||||
|
||||
use super::server::WebTerminalState;
|
||||
@@ -212,12 +213,10 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
||||
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 profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
let claude_cmd = if project.full_permissions {
|
||||
"exec claude --dangerously-skip-permissions"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "Triple-C",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"identifier": "com.triple-c.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
settings: ClaudeCodeSettings | null;
|
||||
disabled: boolean;
|
||||
onSave: (settings: ClaudeCodeSettings | null) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DEFAULTS: ClaudeCodeSettings = {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: false,
|
||||
focus_mode: false,
|
||||
show_thinking_summaries: false,
|
||||
enable_session_recap: false,
|
||||
env_scrub: false,
|
||||
prompt_caching_1h: false,
|
||||
};
|
||||
|
||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||
return (
|
||||
s.tui_mode === null &&
|
||||
s.effort === null &&
|
||||
s.auto_scroll_disabled === false &&
|
||||
s.focus_mode === false &&
|
||||
s.show_thinking_summaries === false &&
|
||||
s.enable_session_recap === false &&
|
||||
s.env_scrub === false &&
|
||||
s.prompt_caching_1h === false
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClaudeCodeSettingsModal({ settings, disabled, onSave, onClose }: Props) {
|
||||
const [local, setLocal] = useState<ClaudeCodeSettings>(settings ?? { ...DEFAULTS });
|
||||
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 update = async (patch: Partial<ClaudeCodeSettings>) => {
|
||||
const next = { ...local, ...patch };
|
||||
setLocal(next);
|
||||
try {
|
||||
await onSave(isAllDefaults(next) ? null : next);
|
||||
} catch (err) {
|
||||
console.error("Failed to save Claude Code settings:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleButton = (label: string, description: string, value: boolean, onChange: (v: boolean) => void) => (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{label}</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">{description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!value)}
|
||||
disabled={disabled}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 shrink-0 ${
|
||||
value
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{value ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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-[32rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold mb-4">Claude Code Settings</h2>
|
||||
|
||||
{disabled && (
|
||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||
Container must be stopped to change Claude Code settings.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* TUI Mode */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">TUI Mode</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Enables flicker-free alt-screen rendering</div>
|
||||
</div>
|
||||
<select
|
||||
value={local.tui_mode ?? ""}
|
||||
onChange={(e) => update({ tui_mode: e.target.value || null })}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="fullscreen">Fullscreen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Effort Level */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">Effort Level</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Controls how much reasoning Claude applies</div>
|
||||
</div>
|
||||
<select
|
||||
value={local.effort ?? ""}
|
||||
onChange={(e) => update({ effort: e.target.value || null })}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Boolean toggles */}
|
||||
{toggleButton(
|
||||
"Focus Mode",
|
||||
"Collapses tool output to one-line summaries",
|
||||
local.focus_mode,
|
||||
(v) => update({ focus_mode: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Thinking Summaries",
|
||||
"Shows thinking process as summaries",
|
||||
local.show_thinking_summaries,
|
||||
(v) => update({ show_thinking_summaries: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Session Recap",
|
||||
"Provides context when returning to a session",
|
||||
local.enable_session_recap,
|
||||
(v) => update({ enable_session_recap: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Auto-Scroll Disabled",
|
||||
"Disables auto-scroll when in fullscreen TUI mode",
|
||||
local.auto_scroll_disabled,
|
||||
(v) => update({ auto_scroll_disabled: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Env Scrub",
|
||||
"Strips credentials from subprocess environments for security",
|
||||
local.env_scrub,
|
||||
(v) => update({ env_scrub: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Prompt Caching (1h)",
|
||||
"Enables 1-hour prompt cache TTL instead of 5 minutes",
|
||||
local.prompt_caching_1h,
|
||||
(v) => update({ prompt_caching_1h: v }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useAppState } from "../../store/appState";
|
||||
import EnvVarsModal from "./EnvVarsModal";
|
||||
import PortMappingsModal from "./PortMappingsModal";
|
||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||
import ClaudeCodeSettingsModal from "./ClaudeCodeSettingsModal";
|
||||
import ContainerProgressModal from "./ContainerProgressModal";
|
||||
import FileManagerModal from "./FileManagerModal";
|
||||
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
||||
@@ -30,6 +31,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||
@@ -777,6 +779,19 @@ export default function ProjectCard({ project }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Claude Code Settings */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Claude Code Settings{project.claude_code_settings ? " (set)" : ""}<Tooltip text="Configure Claude Code CLI behavior: TUI mode, effort level, focus mode, prompt caching, and more. These override global defaults for this project." />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowClaudeCodeSettingsModal(true)}
|
||||
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MCP Servers */}
|
||||
{mcpServers.length > 0 && (
|
||||
<div>
|
||||
@@ -1079,6 +1094,17 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClaudeCodeSettingsModal && (
|
||||
<ClaudeCodeSettingsModal
|
||||
settings={project.claude_code_settings}
|
||||
disabled={!isStopped}
|
||||
onSave={async (ccSettings) => {
|
||||
await update({ ...project, claude_code_settings: ccSettings });
|
||||
}}
|
||||
onClose={() => setShowClaudeCodeSettingsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFileManager && (
|
||||
<FileManagerModal
|
||||
projectId={project.id}
|
||||
|
||||
@@ -4,11 +4,13 @@ import AwsSettings from "./AwsSettings";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { useUpdates } from "../../hooks/useUpdates";
|
||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||
import ClaudeCodeSettingsModal from "../projects/ClaudeCodeSettingsModal";
|
||||
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";
|
||||
import SttSettings from "./SttSettings";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
@@ -17,15 +19,22 @@ export default function SettingsPanel() {
|
||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
|
||||
const [sshKeyPath, setSshKeyPath] = useState(appSettings?.default_ssh_key_path ?? "");
|
||||
const [gitName, setGitName] = useState(appSettings?.default_git_user_name ?? "");
|
||||
const [gitEmail, setGitEmail] = useState(appSettings?.default_git_user_email ?? "");
|
||||
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
||||
|
||||
// Sync local state when appSettings change
|
||||
useEffect(() => {
|
||||
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
||||
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
||||
setTimezone(appSettings?.timezone ?? "");
|
||||
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]);
|
||||
setSshKeyPath(appSettings?.default_ssh_key_path ?? "");
|
||||
setGitName(appSettings?.default_git_user_name ?? "");
|
||||
setGitEmail(appSettings?.default_git_user_email ?? "");
|
||||
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone, appSettings?.default_ssh_key_path, appSettings?.default_git_user_name, appSettings?.default_git_user_email]);
|
||||
|
||||
// Auto-detect timezone on first load if not yet set
|
||||
useEffect(() => {
|
||||
@@ -59,6 +68,60 @@ export default function SettingsPanel() {
|
||||
<DockerSettings />
|
||||
<AwsSettings />
|
||||
|
||||
{/* Default SSH Key Directory */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Default SSH Key Directory<Tooltip text="Global default SSH key directory. Mounted into containers that don't have a per-project SSH path set." /></label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Mounted into all containers unless overridden by a per-project setting.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={sshKeyPath}
|
||||
onChange={(e) => setSshKeyPath(e.target.value)}
|
||||
onBlur={async () => {
|
||||
if (appSettings) {
|
||||
await saveSettings({ ...appSettings, default_ssh_key_path: sshKeyPath || null });
|
||||
}
|
||||
}}
|
||||
placeholder="~/.ssh"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Git Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Default Git Name<Tooltip text="Sets git user.name inside containers. Per-project Git Name takes precedence." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
onBlur={async () => {
|
||||
if (appSettings) {
|
||||
await saveSettings({ ...appSettings, default_git_user_name: gitName || null });
|
||||
}
|
||||
}}
|
||||
placeholder="Your Name"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Git Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Default Git Email<Tooltip text="Sets git user.email inside containers. Per-project Git Email takes precedence." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
onBlur={async () => {
|
||||
if (appSettings) {
|
||||
await saveSettings({ ...appSettings, default_git_user_email: gitEmail || null });
|
||||
}
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Container Timezone */}
|
||||
<div>
|
||||
<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>
|
||||
@@ -117,9 +180,31 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Claude Code Settings */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Claude Code Settings<Tooltip text="Global defaults for Claude Code CLI behavior (TUI mode, effort, focus mode, caching, etc.). Per-project settings override these." /></label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Default Claude Code CLI settings applied to all projects. Per-project settings take precedence.
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{appSettings?.global_claude_code_settings ? "Configured" : "Using defaults"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowClaudeCodeSettingsModal(true)}
|
||||
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Web Terminal */}
|
||||
<WebTerminalSettings />
|
||||
|
||||
{/* Speech to Text */}
|
||||
<SttSettings />
|
||||
|
||||
{/* Updates section */}
|
||||
<div>
|
||||
<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>
|
||||
@@ -185,6 +270,19 @@ export default function SettingsPanel() {
|
||||
onClose={() => setShowEnvVarsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClaudeCodeSettingsModal && (
|
||||
<ClaudeCodeSettingsModal
|
||||
settings={appSettings?.global_claude_code_settings ?? null}
|
||||
disabled={false}
|
||||
onSave={async (ccSettings) => {
|
||||
if (appSettings) {
|
||||
await saveSettings({ ...appSettings, global_claude_code_settings: ccSettings });
|
||||
}
|
||||
}}
|
||||
onClose={() => setShowClaudeCodeSettingsModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { getSttStatus, startStt, stopStt, pullSttImage, buildSttImage } from "../../lib/tauri-commands";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { SttStatus } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
export default function SttSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const [status, setStatus] = useState<SttStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [buildLog, setBuildLog] = useState<string | null>(null);
|
||||
const [model, setModel] = useState(appSettings?.stt?.model ?? "tiny");
|
||||
const [port, setPort] = useState(String(appSettings?.stt?.port ?? 9876));
|
||||
const [language, setLanguage] = useState(appSettings?.stt?.language ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setModel(appSettings?.stt?.model ?? "tiny");
|
||||
setPort(String(appSettings?.stt?.port ?? 9876));
|
||||
setLanguage(appSettings?.stt?.language ?? "");
|
||||
}, [appSettings?.stt?.model, appSettings?.stt?.port, appSettings?.stt?.language]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, []);
|
||||
|
||||
const refreshStatus = () => {
|
||||
getSttStatus().then(setStatus).catch(console.error);
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
if (!appSettings) return;
|
||||
const newEnabled = !appSettings.stt.enabled;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
stt: { ...appSettings.stt, enabled: newEnabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveModel = async () => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
stt: { ...appSettings.stt, model },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSavePort = async () => {
|
||||
if (!appSettings) return;
|
||||
const portNum = parseInt(port, 10);
|
||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
stt: { ...appSettings.stt, port: portNum },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveLanguage = async () => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
stt: { ...appSettings.stt, language: language || null },
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartStop = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (status?.running) {
|
||||
await stopStt();
|
||||
} else {
|
||||
await startStt();
|
||||
}
|
||||
refreshStatus();
|
||||
} catch (e) {
|
||||
console.error("STT toggle failed:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePull = async () => {
|
||||
setPulling(true);
|
||||
setBuildLog(null);
|
||||
const unlisten = await listen<string>("stt-pull-progress", (event) => {
|
||||
setBuildLog(event.payload);
|
||||
});
|
||||
try {
|
||||
await pullSttImage();
|
||||
refreshStatus();
|
||||
} catch (e) {
|
||||
console.error("STT image pull failed:", e);
|
||||
setBuildLog(`Error: ${e}`);
|
||||
} finally {
|
||||
setPulling(false);
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuild = async () => {
|
||||
setBuilding(true);
|
||||
setBuildLog(null);
|
||||
const unlisten = await listen<string>("stt-build-progress", (event) => {
|
||||
setBuildLog(event.payload);
|
||||
});
|
||||
try {
|
||||
await buildSttImage();
|
||||
refreshStatus();
|
||||
} catch (e) {
|
||||
console.error("STT image build failed:", e);
|
||||
setBuildLog(`Error: ${e}`);
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Speech to Text
|
||||
<Tooltip text="Transcribe speech to text using Faster Whisper in a Docker container. Adds a mic button to the terminal." />
|
||||
</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-2">
|
||||
Click the mic button in the terminal to dictate text via speech recognition.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleToggleEnabled}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||
appSettings?.stt?.enabled
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{appSettings?.stt?.enabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{appSettings?.stt?.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{appSettings?.stt?.enabled && (
|
||||
<>
|
||||
{/* Model selector */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">Model</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
onBlur={handleSaveModel}
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="tiny">Tiny (fastest, ~75MB)</option>
|
||||
<option value="small">Small (balanced, ~500MB)</option>
|
||||
<option value="medium">Medium (most accurate, ~1.5GB)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onBlur={handleSavePort}
|
||||
min={1}
|
||||
max={65535}
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">Language (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
onBlur={handleSaveLanguage}
|
||||
placeholder="Auto-detect"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Container status + controls */}
|
||||
<div className="pt-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">STT Container</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{status?.image_exists
|
||||
? status.running
|
||||
? `Running (port ${status.port}, model: ${status.model})`
|
||||
: status.container_exists
|
||||
? "Stopped"
|
||||
: "Image ready"
|
||||
: "No image"}
|
||||
</span>
|
||||
{status?.image_exists && (
|
||||
<button
|
||||
onClick={handleStartStop}
|
||||
disabled={loading}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||
status?.running
|
||||
? "text-[var(--error)] hover:bg-[var(--bg-primary)]"
|
||||
: "text-[var(--success)] hover:bg-[var(--bg-primary)]"
|
||||
}`}
|
||||
>
|
||||
{loading ? "..." : status?.running ? "Stop" : "Start"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image actions */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<button
|
||||
onClick={handlePull}
|
||||
disabled={pulling || building}
|
||||
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{pulling ? "Pulling..." : "Pull Image"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={pulling || building}
|
||||
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{building ? "Building..." : "Build Locally"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{buildLog && (
|
||||
<pre className="mt-2 text-[10px] text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded px-2 py-1 max-h-20 overflow-y-auto whitespace-pre-wrap">
|
||||
{buildLog}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SttState } from "../../hooks/useSTT";
|
||||
import * as commands from "../../lib/tauri-commands";
|
||||
|
||||
interface Props {
|
||||
state: SttState;
|
||||
error: string | null;
|
||||
onToggle: () => Promise<void>;
|
||||
onCancel: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function SttButton({ state, error, onToggle, onCancel }: Props) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Track recording duration
|
||||
useEffect(() => {
|
||||
if (state === "recording") {
|
||||
setElapsed(0);
|
||||
timerRef.current = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
// Auto-start STT container if not running
|
||||
if (state === "idle") {
|
||||
try {
|
||||
const status = await commands.getSttStatus();
|
||||
if (!status.running) {
|
||||
await commands.startStt();
|
||||
}
|
||||
} catch {
|
||||
// Container start failed, toggle will still attempt transcription
|
||||
}
|
||||
}
|
||||
await onToggle();
|
||||
}, [state, onToggle]);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (state === "recording") {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[state, onCancel],
|
||||
);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-1 left-1 z-50 flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={(e) => e.preventDefault()} // prevent stealing focus from terminal
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
disabled={state === "transcribing"}
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all cursor-pointer ${
|
||||
state === "recording"
|
||||
? "bg-[#f85149] text-white shadow-lg animate-pulse"
|
||||
: state === "transcribing"
|
||||
? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80"
|
||||
: "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]"
|
||||
}`}
|
||||
>
|
||||
{state === "transcribing" ? (
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{hovered && state !== "recording" && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none">
|
||||
{state === "transcribing" ? "Transcribing..." : (
|
||||
<>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{state === "recording" && (
|
||||
<span className="text-xs text-[#f85149] font-mono bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d]">
|
||||
{formatTime(elapsed)}
|
||||
</span>
|
||||
)}
|
||||
{state === "error" && error && (
|
||||
<span className="text-xs text-[#f85149] bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d] max-w-[200px] truncate">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,8 +23,8 @@ export default function TerminalTabs() {
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
|
||||
<span className="truncate max-w-[140px]">
|
||||
{session.sessionName ?? session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { useSTT } from "../../hooks/useSTT";
|
||||
import SttButton from "./SttButton";
|
||||
import { awsSsoRefresh } from "../../lib/tauri-commands";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import UrlToast from "./UrlToast";
|
||||
@@ -25,6 +27,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||
const sttEnabled = useAppState(s => s.appSettings?.stt?.enabled);
|
||||
const stt = useSTT(sessionId, sendInput);
|
||||
const sttToggleRef = useRef(stt.toggle);
|
||||
sttToggleRef.current = stt.toggle;
|
||||
|
||||
const ssoBufferRef = useRef("");
|
||||
const ssoTriggeredRef = useRef(false);
|
||||
@@ -100,6 +106,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
return false; // prevent xterm from processing this key
|
||||
}
|
||||
// Ctrl+Shift+M toggles speech-to-text recording
|
||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
|
||||
sttToggleRef.current();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -424,6 +435,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
>
|
||||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||||
</button>
|
||||
{/* STT mic button - bottom left */}
|
||||
{sttEnabled && <SttButton state={stt.state} error={stt.error} onToggle={stt.toggle} onCancel={stt.cancelRecording} />}
|
||||
{/* Jump to Current - bottom right, when scrolled up */}
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { encodeWav } from "../lib/wav";
|
||||
import { useAppState } from "../store/appState";
|
||||
|
||||
export type SttState = "idle" | "recording" | "transcribing" | "error";
|
||||
|
||||
export function useSTT(sessionId: string, sendInput: (sessionId: string, data: string) => Promise<void>) {
|
||||
const [state, setState] = useState<SttState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const workletRef = useRef<AudioWorkletNode | null>(null);
|
||||
const chunksRef = useRef<Int16Array[]>([]);
|
||||
|
||||
const appSettings = useAppState((s) => s.appSettings);
|
||||
const deviceId = appSettings?.default_microphone;
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (state === "recording" || state === "transcribing") return;
|
||||
setState("recording");
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
|
||||
try {
|
||||
const audioConstraints: MediaTrackConstraints = {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
};
|
||||
if (deviceId) {
|
||||
audioConstraints.deviceId = { exact: deviceId };
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
|
||||
streamRef.current = stream;
|
||||
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
await audioContext.audioWorklet.addModule("/audio-capture-processor.js");
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = new AudioWorkletNode(audioContext, "audio-capture-processor");
|
||||
workletRef.current = processor;
|
||||
|
||||
processor.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
|
||||
chunksRef.current.push(new Int16Array(event.data));
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setState("error");
|
||||
}
|
||||
}, [state, deviceId]);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (state !== "recording") return;
|
||||
|
||||
// Stop audio capture
|
||||
workletRef.current?.disconnect();
|
||||
workletRef.current = null;
|
||||
|
||||
if (audioContextRef.current) {
|
||||
await audioContextRef.current.close().catch(() => {});
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Concatenate PCM chunks
|
||||
const chunks = chunksRef.current;
|
||||
chunksRef.current = [];
|
||||
|
||||
if (chunks.length === 0) {
|
||||
setState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const pcm = new Int16Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
pcm.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Encode to WAV and transcribe
|
||||
setState("transcribing");
|
||||
try {
|
||||
const wavBlob = encodeWav(pcm, 16000);
|
||||
const wavBuffer = await wavBlob.arrayBuffer();
|
||||
const audioData = Array.from(new Uint8Array(wavBuffer));
|
||||
|
||||
const text = await commands.transcribeAudio(audioData);
|
||||
if (text) {
|
||||
await sendInput(sessionId, text);
|
||||
}
|
||||
setState("idle");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setState("error");
|
||||
// Reset to idle after a brief delay so the UI shows the error
|
||||
setTimeout(() => setState("idle"), 3000);
|
||||
}
|
||||
}, [state, sessionId, sendInput]);
|
||||
|
||||
const cancelRecording = useCallback(async () => {
|
||||
workletRef.current?.disconnect();
|
||||
workletRef.current = null;
|
||||
|
||||
if (audioContextRef.current) {
|
||||
await audioContextRef.current.close().catch(() => {});
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
chunksRef.current = [];
|
||||
setState("idle");
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (state === "recording") {
|
||||
await stopRecording();
|
||||
} else if (state === "idle" || state === "error") {
|
||||
await startRecording();
|
||||
}
|
||||
}, [state, startRecording, stopRecording]);
|
||||
|
||||
return { state, error, startRecording, stopRecording, cancelRecording, toggle };
|
||||
}
|
||||
@@ -17,10 +17,10 @@ export function useTerminal() {
|
||||
);
|
||||
|
||||
const open = useCallback(
|
||||
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
|
||||
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType);
|
||||
addSession({ id: sessionId, projectId, projectName, sessionType });
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
|
||||
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
|
||||
return sessionId;
|
||||
},
|
||||
[addSession],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -45,8 +45,8 @@ export const awsSsoRefresh = (projectId: string) =>
|
||||
invoke<void>("aws_sso_refresh", { projectId });
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
|
||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
|
||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("terminal_input", { sessionId, data });
|
||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
@@ -98,3 +98,12 @@ export const getWebTerminalStatus = () =>
|
||||
invoke<WebTerminalInfo>("get_web_terminal_status");
|
||||
export const regenerateWebTerminalToken = () =>
|
||||
invoke<WebTerminalInfo>("regenerate_web_terminal_token");
|
||||
|
||||
// STT
|
||||
export const getSttStatus = () => invoke<SttStatus>("get_stt_status");
|
||||
export const startStt = () => invoke<SttStatus>("start_stt");
|
||||
export const stopStt = () => invoke<void>("stop_stt");
|
||||
export const buildSttImage = () => invoke<void>("build_stt_image");
|
||||
export const pullSttImage = () => invoke<void>("pull_stt_image");
|
||||
export const transcribeAudio = (audioData: number[]) =>
|
||||
invoke<string>("transcribe_audio", { audioData });
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Project {
|
||||
port_mappings: PortMapping[];
|
||||
claude_instructions: string | null;
|
||||
enabled_mcp_servers: string[];
|
||||
claude_code_settings: ClaudeCodeSettings | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -73,6 +74,17 @@ export interface OpenAiCompatibleConfig {
|
||||
model_id: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSettings {
|
||||
tui_mode: string | null;
|
||||
effort: string | null;
|
||||
auto_scroll_disabled: boolean;
|
||||
focus_mode: boolean;
|
||||
show_thinking_summaries: boolean;
|
||||
enable_session_recap: boolean;
|
||||
env_scrub: boolean;
|
||||
prompt_caching_1h: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
container_id: string;
|
||||
project_id: string;
|
||||
@@ -93,6 +105,7 @@ export interface TerminalSession {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sessionType: "claude" | "bash";
|
||||
sessionName: string | null;
|
||||
}
|
||||
|
||||
export type ImageSource = "registry" | "local_build" | "custom";
|
||||
@@ -119,6 +132,23 @@ export interface AppSettings {
|
||||
default_microphone: string | null;
|
||||
dismissed_image_digest: string | null;
|
||||
web_terminal: WebTerminalSettings;
|
||||
stt: SttSettings;
|
||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||
}
|
||||
|
||||
export interface SttSettings {
|
||||
enabled: boolean;
|
||||
model: string;
|
||||
port: number;
|
||||
language: string | null;
|
||||
}
|
||||
|
||||
export interface SttStatus {
|
||||
container_exists: boolean;
|
||||
running: boolean;
|
||||
port: number;
|
||||
model: string;
|
||||
image_exists: boolean;
|
||||
}
|
||||
|
||||
export interface WebTerminalSettings {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Encode PCM Int16 samples into a WAV file blob.
|
||||
* Assumes mono channel at the given sample rate.
|
||||
*/
|
||||
export function encodeWav(samples: Int16Array, sampleRate: number): Blob {
|
||||
const byteLength = samples.length * 2;
|
||||
const buffer = new ArrayBuffer(44 + byteLength);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// RIFF header
|
||||
writeString(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + byteLength, true);
|
||||
writeString(view, 8, "WAVE");
|
||||
|
||||
// fmt chunk
|
||||
writeString(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true); // chunk size
|
||||
view.setUint16(20, 1, true); // PCM format
|
||||
view.setUint16(22, 1, true); // mono
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true); // byte rate
|
||||
view.setUint16(32, 2, true); // block align
|
||||
view.setUint16(34, 16, true); // bits per sample
|
||||
|
||||
// data chunk
|
||||
writeString(view, 36, "data");
|
||||
view.setUint32(40, byteLength, true);
|
||||
|
||||
// PCM samples
|
||||
const output = new Int16Array(buffer, 44);
|
||||
output.set(samples);
|
||||
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function writeString(view: DataView, offset: number, str: string) {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -5,7 +5,17 @@ FROM ubuntu:24.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── System packages ──────────────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# The shell retry loop handles transient mirror-sync failures where
|
||||
# archive.ubuntu.com returns stale Packages.gz files with mismatched hashes
|
||||
# during hourly resyncs. Clearing /var/lib/apt/lists/* between attempts
|
||||
# forces a fresh fetch.
|
||||
RUN for i in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=3 update && break; \
|
||||
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
sleep 10; \
|
||||
done \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
@@ -38,7 +48,13 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y gh \
|
||||
&& for i in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=3 update && break; \
|
||||
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
sleep 10; \
|
||||
done \
|
||||
&& apt-get install -y gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Node.js LTS (22.x) + pnpm ───────────────────────────────────────────────
|
||||
@@ -48,7 +64,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& npm install -g pnpm
|
||||
|
||||
# ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get -o Acquire::Retries=3 update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
@@ -61,7 +77,7 @@ RUN install -m 0755 -d /etc/apt/keyrings \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
> /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update && apt-get install -y docker-ce-cli \
|
||||
&& apt-get -o Acquire::Retries=3 update && apt-get install -y docker-ce-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -188,6 +188,29 @@ if [ -n "$MCP_SERVERS_JSON" ]; then
|
||||
unset MCP_SERVERS_JSON
|
||||
fi
|
||||
|
||||
# ── Claude Code settings ────────────────────────────────────────────────────
|
||||
# Merge Claude Code settings into ~/.claude/settings.json (preserves existing
|
||||
# keys). Creates the file if it doesn't exist. These control TUI mode, effort
|
||||
# level, focus mode, thinking summaries, and other CLI behavior.
|
||||
if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
|
||||
SETTINGS_FILE="/home/claude/.claude/settings.json"
|
||||
mkdir -p /home/claude/.claude
|
||||
if [ -f "$SETTINGS_FILE" ]; then
|
||||
# Merge: existing settings + new settings (new keys override on conflict)
|
||||
MERGED=$(jq -s '.[0] * .[1]' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
|
||||
else
|
||||
echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
|
||||
fi
|
||||
else
|
||||
printf '%s\n' "$CLAUDE_CODE_SETTINGS_JSON" > "$SETTINGS_FILE"
|
||||
fi
|
||||
chown claude:claude "$SETTINGS_FILE"
|
||||
chmod 600 "$SETTINGS_FILE"
|
||||
unset CLAUDE_CODE_SETTINGS_JSON
|
||||
fi
|
||||
|
||||
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
||||
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
|
||||
# triple-c-sso-refresh when AWS credentials expire mid-session.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
faster-whisper \
|
||||
fastapi \
|
||||
uvicorn[standard] \
|
||||
python-multipart
|
||||
|
||||
COPY server.py /app/server.py
|
||||
|
||||
EXPOSE 9876
|
||||
|
||||
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "9876"]
|
||||
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
app = FastAPI()
|
||||
model: WhisperModel | None = None
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def load_model():
|
||||
global model
|
||||
model_size = os.environ.get("WHISPER_MODEL", "tiny")
|
||||
model = WhisperModel(model_size, device="cpu", compute_type="int8")
|
||||
|
||||
|
||||
@app.post("/transcribe")
|
||||
async def transcribe(
|
||||
file: UploadFile = File(...),
|
||||
language: str = Form(None),
|
||||
):
|
||||
if model is None:
|
||||
return JSONResponse(status_code=503, content={"error": "Model not loaded"})
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tmp:
|
||||
tmp.write(await file.read())
|
||||
tmp.flush()
|
||||
kwargs = {}
|
||||
if language:
|
||||
kwargs["language"] = language
|
||||
segments, info = model.transcribe(tmp.name, **kwargs)
|
||||
text = " ".join(s.text for s in segments).strip()
|
||||
|
||||
return {"text": text, "language": info.language}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user