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:
2026-04-16 08:46:03 -07:00
parent ef67b447b3
commit d6ac3ae6c6
16 changed files with 636 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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