Add Claude Code settings infrastructure, TUI mode, session naming, and global defaults
Adds first-class support for Claude Code CLI features (2.1.71-2.1.110): - New ClaudeCodeSettings struct with per-project and global defaults for TUI mode, effort level, focus mode, thinking summaries, session recap, auto-scroll, env scrub, and 1-hour prompt caching - Settings injected as env vars (CLAUDE_CODE_NO_FLICKER, etc.) and ~/.claude/settings.json entries via entrypoint.sh merge block - New ClaudeCodeSettingsModal component for configuring settings - Session naming support (-n flag passed to claude CLI, shown in tabs) - Relaxed reserved prefix filter: CLAUDE_CODE_* env vars now allowed in custom env vars UI for power users - Global SSH key path, git name, and git email now used as fallbacks when per-project values are not set, with UI in SettingsPanel - Fingerprint-based change detection triggers container recreation when Claude Code settings change - Updated README, HOW-TO-USE, and CLAUDE.md documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
- `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
|
- `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!()`
|
- `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`
|
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||||
|
|
||||||
### Container (`container/`)
|
### 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
|
- **`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
|
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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.
|
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)
|
## 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.
|
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
|
### Web Terminal
|
||||||
|
|
||||||
Enable remote access to your project terminals from any device on the local network (tablets, phones, other computers).
|
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
|
### 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
|
### 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
|
## Troubleshooting
|
||||||
|
|
||||||
### Docker is "Not Available"
|
### Docker is "Not Available"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
|||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
1. **Create**: New container created with bind mounts, env vars, and labels
|
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
|
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
|
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)
|
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
|
||||||
@@ -128,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/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
| `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/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/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
||||||
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
||||||
@@ -150,9 +151,9 @@ 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/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/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/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/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, STT) |
|
| `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/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/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/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
||||||
@@ -164,7 +165,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
|||||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
| `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/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/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,10 @@ pub async fn start_project_container(
|
|||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
&enabled_mcp,
|
&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);
|
).await.unwrap_or(false);
|
||||||
|
|
||||||
if needs_recreate {
|
if needs_recreate {
|
||||||
@@ -370,6 +374,10 @@ pub async fn start_project_container(
|
|||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
&enabled_mcp,
|
&enabled_mcp,
|
||||||
network_name.as_deref(),
|
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?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
@@ -403,6 +411,10 @@ pub async fn start_project_container(
|
|||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
&enabled_mcp,
|
&enabled_mcp,
|
||||||
network_name.as_deref(),
|
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?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::AppState;
|
|||||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
/// 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`
|
/// 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).
|
/// 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
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
&& project
|
&& project
|
||||||
.bedrock_config
|
.bedrock_config
|
||||||
@@ -22,6 +22,12 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
|||||||
if project.full_permissions {
|
if project.full_permissions {
|
||||||
cmd.push("--dangerously-skip-permissions".to_string());
|
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;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +38,14 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
|||||||
|
|
||||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||||
// then exec's into claude.
|
// 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 {
|
let claude_cmd = if project.full_permissions {
|
||||||
"exec claude --dangerously-skip-permissions"
|
format!("exec claude --dangerously-skip-permissions{}", name_flag)
|
||||||
} else {
|
} else {
|
||||||
"exec claude"
|
format!("exec claude{}", name_flag)
|
||||||
};
|
};
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
@@ -81,6 +91,7 @@ pub async fn open_terminal_session(
|
|||||||
project_id: String,
|
project_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
session_type: Option<String>,
|
session_type: Option<String>,
|
||||||
|
session_name: Option<String>,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -96,7 +107,7 @@ pub async fn open_terminal_session(
|
|||||||
|
|
||||||
let cmd = match session_type.as_deref() {
|
let cmd = match session_type.as_deref() {
|
||||||
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
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);
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
|||||||
use sha2::{Sha256, Digest};
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
use super::client::get_docker;
|
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
|
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||||
|
|
||||||
@@ -132,14 +132,17 @@ fn build_claude_instructions(
|
|||||||
/// Compute a fingerprint string for the custom environment variables.
|
/// Compute a fingerprint string for the custom environment variables.
|
||||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||||
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
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();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
for env_var in custom_env_vars {
|
for env_var in custom_env_vars {
|
||||||
let key = env_var.key.trim();
|
let key = env_var.key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
continue;
|
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 {
|
if is_reserved {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -282,6 +285,80 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
|||||||
sha256_hex(&joined)
|
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.
|
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
|
||||||
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
|
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
|
||||||
///
|
///
|
||||||
@@ -400,6 +477,10 @@ pub async fn create_container(
|
|||||||
timezone: Option<&str>,
|
timezone: Option<&str>,
|
||||||
mcp_servers: &[McpServer],
|
mcp_servers: &[McpServer],
|
||||||
network_name: Option<&str>,
|
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> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
@@ -445,10 +526,13 @@ pub async fn create_container(
|
|||||||
if let Some(ref token) = project.git_token {
|
if let Some(ref token) = project.git_token {
|
||||||
env_vars.push(format!("GIT_TOKEN={}", 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));
|
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));
|
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)
|
// 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 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 {
|
for env_var in &merged_env {
|
||||||
let key = env_var.key.trim();
|
let key = env_var.key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
continue;
|
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 {
|
if is_reserved {
|
||||||
log::warn!("Skipping reserved env var: {}", key);
|
log::warn!("Skipping reserved env var: {}", key);
|
||||||
continue;
|
continue;
|
||||||
@@ -577,6 +664,32 @@ pub async fn create_container(
|
|||||||
env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json));
|
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();
|
let mut mounts: Vec<Mount> = Vec::new();
|
||||||
|
|
||||||
// Project directories -> /workspace/{mount_name}
|
// 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)
|
// 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 {
|
mounts.push(Mount {
|
||||||
target: Some("/tmp/.host-ssh".to_string()),
|
target: Some("/tmp/.host-ssh".to_string()),
|
||||||
source: Some(ssh_path.clone()),
|
source: Some(ssh_path.to_string()),
|
||||||
typ: Some(MountTypeEnum::BIND),
|
typ: Some(MountTypeEnum::BIND),
|
||||||
read_only: Some(true),
|
read_only: Some(true),
|
||||||
..Default::default()
|
..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.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.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.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(),
|
labels.insert("triple-c.instructions-fingerprint".to_string(),
|
||||||
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
|
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-name".to_string(), effective_git_name.unwrap_or_default().to_string());
|
||||||
labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default());
|
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(),
|
labels.insert("triple-c.git-token-hash".to_string(),
|
||||||
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
|
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],
|
global_custom_env_vars: &[EnvVar],
|
||||||
timezone: Option<&str>,
|
timezone: Option<&str>,
|
||||||
mcp_servers: &[McpServer],
|
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> {
|
) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let info = docker
|
let info = docker
|
||||||
@@ -997,28 +1118,34 @@ pub async fn container_needs_recreation(
|
|||||||
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
|
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
|
||||||
})
|
})
|
||||||
.and_then(|mount| mount.source.as_deref());
|
.and_then(|mount| mount.source.as_deref());
|
||||||
let project_ssh = project.ssh_key_path.as_deref();
|
let effective_ssh = project.ssh_key_path.as_deref().or(default_ssh_key_path);
|
||||||
if ssh_mount_source != project_ssh {
|
if ssh_mount_source != effective_ssh {
|
||||||
log::info!(
|
log::info!(
|
||||||
"SSH key path mismatch (container={:?}, project={:?})",
|
"SSH key path mismatch (container={:?}, expected={:?})",
|
||||||
ssh_mount_source,
|
ssh_mount_source,
|
||||||
project_ssh
|
effective_ssh
|
||||||
);
|
);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
|
// ── 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();
|
let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
|
||||||
if container_git_name != expected_git_name {
|
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);
|
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();
|
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
|
||||||
if container_git_email != expected_git_email {
|
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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,6 +1187,18 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
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 ─────────────────────────────────────────
|
// ── MCP servers fingerprint ─────────────────────────────────────────
|
||||||
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers);
|
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers);
|
||||||
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default();
|
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::project::EnvVar;
|
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
@@ -78,6 +78,8 @@ pub struct AppSettings {
|
|||||||
pub web_terminal: WebTerminalSettings,
|
pub web_terminal: WebTerminalSettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stt: SttSettings,
|
pub stt: SttSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_stt_model() -> String {
|
fn default_stt_model() -> String {
|
||||||
@@ -163,6 +165,7 @@ impl Default for AppSettings {
|
|||||||
dismissed_image_digest: None,
|
dismissed_image_digest: None,
|
||||||
web_terminal: WebTerminalSettings::default(),
|
web_terminal: WebTerminalSettings::default(),
|
||||||
stt: SttSettings::default(),
|
stt: SttSettings::default(),
|
||||||
|
global_claude_code_settings: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,36 @@ fn default_full_permissions() -> bool {
|
|||||||
true
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -59,6 +89,8 @@ pub struct Project {
|
|||||||
pub claude_instructions: Option<String>,
|
pub claude_instructions: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled_mcp_servers: Vec<String>,
|
pub enabled_mcp_servers: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -177,6 +209,7 @@ impl Project {
|
|||||||
port_mappings: Vec::new(),
|
port_mappings: Vec::new(),
|
||||||
claude_instructions: None,
|
claude_instructions: None,
|
||||||
enabled_mcp_servers: Vec::new(),
|
enabled_mcp_servers: Vec::new(),
|
||||||
|
claude_code_settings: None,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|||||||
191
app/src/components/projects/ClaudeCodeSettingsModal.tsx
Normal file
191
app/src/components/projects/ClaudeCodeSettingsModal.tsx
Normal file
@@ -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 EnvVarsModal from "./EnvVarsModal";
|
||||||
import PortMappingsModal from "./PortMappingsModal";
|
import PortMappingsModal from "./PortMappingsModal";
|
||||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
|
import ClaudeCodeSettingsModal from "./ClaudeCodeSettingsModal";
|
||||||
import ContainerProgressModal from "./ContainerProgressModal";
|
import ContainerProgressModal from "./ContainerProgressModal";
|
||||||
import FileManagerModal from "./FileManagerModal";
|
import FileManagerModal from "./FileManagerModal";
|
||||||
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
||||||
@@ -30,6 +31,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||||
|
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
||||||
const [showFileManager, setShowFileManager] = useState(false);
|
const [showFileManager, setShowFileManager] = useState(false);
|
||||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||||
@@ -777,6 +779,19 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* MCP Servers */}
|
||||||
{mcpServers.length > 0 && (
|
{mcpServers.length > 0 && (
|
||||||
<div>
|
<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 && (
|
{showFileManager && (
|
||||||
<FileManagerModal
|
<FileManagerModal
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AwsSettings from "./AwsSettings";
|
|||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { useUpdates } from "../../hooks/useUpdates";
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
|
import ClaudeCodeSettingsModal from "../projects/ClaudeCodeSettingsModal";
|
||||||
import EnvVarsModal from "../projects/EnvVarsModal";
|
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||||
import { detectHostTimezone } from "../../lib/tauri-commands";
|
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||||
import type { EnvVar } from "../../lib/types";
|
import type { EnvVar } from "../../lib/types";
|
||||||
@@ -18,15 +19,22 @@ export default function SettingsPanel() {
|
|||||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||||
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
|
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 [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
||||||
|
|
||||||
// Sync local state when appSettings change
|
// Sync local state when appSettings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
||||||
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
||||||
setTimezone(appSettings?.timezone ?? "");
|
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
|
// Auto-detect timezone on first load if not yet set
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,6 +68,60 @@ export default function SettingsPanel() {
|
|||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<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 */}
|
{/* Container Timezone */}
|
||||||
<div>
|
<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>
|
<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>
|
||||||
@@ -118,6 +180,25 @@ export default function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Web Terminal */}
|
||||||
<WebTerminalSettings />
|
<WebTerminalSettings />
|
||||||
|
|
||||||
@@ -189,6 +270,19 @@ export default function SettingsPanel() {
|
|||||||
onClose={() => setShowEnvVarsModal(false)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export default function TerminalTabs() {
|
|||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[120px]">
|
<span className="truncate max-w-[140px]">
|
||||||
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
|
{session.sessionName ?? session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export function useTerminal() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const open = useCallback(
|
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();
|
const sessionId = crypto.randomUUID();
|
||||||
await commands.openTerminalSession(projectId, sessionId, sessionType);
|
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
|
||||||
addSession({ id: sessionId, projectId, projectName, sessionType });
|
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
|
||||||
return sessionId;
|
return sessionId;
|
||||||
},
|
},
|
||||||
[addSession],
|
[addSession],
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export const awsSsoRefresh = (projectId: string) =>
|
|||||||
invoke<void>("aws_sso_refresh", { projectId });
|
invoke<void>("aws_sso_refresh", { projectId });
|
||||||
|
|
||||||
// Terminal
|
// Terminal
|
||||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
|
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
|
||||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
|
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
|
||||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||||
invoke<void>("terminal_input", { sessionId, data });
|
invoke<void>("terminal_input", { sessionId, data });
|
||||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface Project {
|
|||||||
port_mappings: PortMapping[];
|
port_mappings: PortMapping[];
|
||||||
claude_instructions: string | null;
|
claude_instructions: string | null;
|
||||||
enabled_mcp_servers: string[];
|
enabled_mcp_servers: string[];
|
||||||
|
claude_code_settings: ClaudeCodeSettings | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,17 @@ export interface OpenAiCompatibleConfig {
|
|||||||
model_id: string | null;
|
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 {
|
export interface ContainerInfo {
|
||||||
container_id: string;
|
container_id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
@@ -93,6 +105,7 @@ export interface TerminalSession {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
sessionType: "claude" | "bash";
|
sessionType: "claude" | "bash";
|
||||||
|
sessionName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageSource = "registry" | "local_build" | "custom";
|
export type ImageSource = "registry" | "local_build" | "custom";
|
||||||
@@ -120,6 +133,7 @@ export interface AppSettings {
|
|||||||
dismissed_image_digest: string | null;
|
dismissed_image_digest: string | null;
|
||||||
web_terminal: WebTerminalSettings;
|
web_terminal: WebTerminalSettings;
|
||||||
stt: SttSettings;
|
stt: SttSettings;
|
||||||
|
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SttSettings {
|
export interface SttSettings {
|
||||||
|
|||||||
@@ -188,6 +188,29 @@ if [ -n "$MCP_SERVERS_JSON" ]; then
|
|||||||
unset MCP_SERVERS_JSON
|
unset MCP_SERVERS_JSON
|
||||||
fi
|
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 ──────────────────────────────────────────────
|
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
||||||
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
|
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
|
||||||
# triple-c-sso-refresh when AWS credentials expire mid-session.
|
# triple-c-sso-refresh when AWS credentials expire mid-session.
|
||||||
|
|||||||
Reference in New Issue
Block a user