From d6ac3ae6c60dbc6a86f207b3333c57dde1129d15 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 16 Apr 2026 08:46:03 -0700 Subject: [PATCH] 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) --- CLAUDE.md | 4 +- HOW-TO-USE.md | 53 ++++- README.md | 9 +- .../src/commands/project_commands.rs | 12 ++ .../src/commands/terminal_commands.rs | 19 +- app/src-tauri/src/docker/container.rs | 177 ++++++++++++++-- app/src-tauri/src/models/app_settings.rs | 5 +- app/src-tauri/src/models/project.rs | 33 +++ .../projects/ClaudeCodeSettingsModal.tsx | 191 ++++++++++++++++++ app/src/components/projects/ProjectCard.tsx | 26 +++ app/src/components/settings/SettingsPanel.tsx | 96 ++++++++- app/src/components/terminal/TerminalTabs.tsx | 4 +- app/src/hooks/useTerminal.ts | 6 +- app/src/lib/tauri-commands.ts | 4 +- app/src/lib/types.ts | 14 ++ container/entrypoint.sh | 23 +++ 16 files changed, 636 insertions(+), 40 deletions(-) create mode 100644 app/src/components/projects/ClaudeCodeSettingsModal.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 8f61b8a..0fc7db7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,13 +76,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades - `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect - `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()` -- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend. +- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend. - **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs` ### Container (`container/`) - **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed -- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, then `sleep infinity` +- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity` - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations ### Container Lifecycle diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 59c850f..6c92943 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -253,7 +253,7 @@ When **disabled** (default), Claude prompts you for approval before executing ea Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key. -> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `CLAUDE_`, `TRIPLE_C_`) are filtered out to prevent conflicts with internal variables. +> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `TRIPLE_C_`) and specific internal variables (`CLAUDE_INSTRUCTIONS`, `MCP_SERVERS_JSON`, etc.) are filtered out to prevent conflicts. `CLAUDE_CODE_*` variables are now allowed, so you can set Claude Code feature flags directly (e.g., `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`). ### Port Mappings @@ -268,6 +268,25 @@ Each mapping specifies: Click **Edit** to write per-project instructions for Claude Code. These are written to `~/.claude/CLAUDE.md` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions. +### Claude Code Settings + +Click **Edit** next to "Claude Code Settings" to configure Claude Code CLI behavior for this project. These settings control how Claude Code operates inside the container: + +| Setting | What It Does | +|---------|-------------| +| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) | +| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) | +| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response | +| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses | +| **Session Recap** | Provides context when returning to a session after being away | +| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode | +| **Env Scrub** | Strips credentials from subprocess environments for security | +| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes | + +Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected. + +> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect. + --- ## MCP Servers (Beta) @@ -481,6 +500,18 @@ Instructions applied to **all** projects. Written to `~/.claude/CLAUDE.md` in ev Environment variables applied to **all** project containers. Per-project variables with the same key take precedence. +### Default SSH Key Directory + +Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence. + +### Default Git Name / Email + +Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project. + +### Claude Code Settings (Global Defaults) + +Default Claude Code CLI settings applied to all projects. See [Claude Code Settings](#claude-code-settings) in the Project Configuration section for a description of each setting. Per-project settings override these global defaults. + ### Web Terminal Enable remote access to your project terminals from any device on the local network (tablets, phones, other computers). @@ -543,7 +574,7 @@ The web terminal UI mirrors the desktop app's terminal experience: ### Multiple Sessions -You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name, with a "(bash)" suffix for shell sessions. +You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name (or custom session name if provided), with a "(bash)" suffix for shell sessions. ### Bash Shell Sessions @@ -668,6 +699,24 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal --- +## Claude Code Tips + +These features are built into Claude Code and work inside Triple-C containers with no extra configuration: + +| Feature | How to Use | +|---------|-----------| +| **Focus Mode** | Run `/focus` or press `Ctrl+O` in the terminal to toggle collapsed tool output | +| **Session Recap** | Run `/recap` to get a summary of what happened in the current session | +| **Session Color** | Run `/color red` (or any color) to color-code your terminal prompt bar | +| **Recurring Tasks** | Run `/loop 5m check the deploy` to repeat a prompt every 5 minutes | +| **Interactive Lessons** | Run `/powerup` to learn Claude Code features with animated demos | +| **Team Onboarding** | Run `/team-onboarding` to generate a teammate ramp-up guide | +| **Bedrock Setup** | Select "3rd-party platform" on the login screen for an interactive Bedrock setup wizard | +| **Vertex AI Setup** | Select "3rd-party platform" on the login screen for an interactive Vertex AI setup wizard | +| **MCP Elicitation** | MCP servers can now request structured user input mid-task — works automatically | + +--- + ## Troubleshooting ### Docker is "Not Available" diff --git a/README.md b/README.md index 58b3da3..a0b7244 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi ### Container Lifecycle 1. **Create**: New container created with bind mounts, env vars, and labels -2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers +2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers, injects Claude Code settings 3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY 4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped 5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint) @@ -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/StatusBar.tsx` | Running project/terminal counts | | `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons | +| `app/src/components/projects/ClaudeCodeSettingsModal.tsx` | Claude Code CLI settings modal (TUI mode, effort, focus, caching) | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | | `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) | | `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress | @@ -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/file_commands.rs` | File manager Tauri commands (list, download, upload) | | `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands | -| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, MCP servers, Mission Control) | +| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, Claude Code settings, MCP servers, Mission Control) | | `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) | -| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, web terminal, 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/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) | @@ -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/server.py` | STT HTTP server (POST /transcribe endpoint) | | `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims | -| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Mission Control setup | +| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Claude Code settings injection, Mission Control setup | | `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) | | `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode | diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index b3a3972..8ce0306 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -338,6 +338,10 @@ pub async fn start_project_container( &settings.global_custom_env_vars, settings.timezone.as_deref(), &enabled_mcp, + settings.global_claude_code_settings.as_ref(), + settings.default_ssh_key_path.as_deref(), + settings.default_git_user_name.as_deref(), + settings.default_git_user_email.as_deref(), ).await.unwrap_or(false); if needs_recreate { @@ -370,6 +374,10 @@ pub async fn start_project_container( settings.timezone.as_deref(), &enabled_mcp, network_name.as_deref(), + settings.global_claude_code_settings.as_ref(), + settings.default_ssh_key_path.as_deref(), + settings.default_git_user_name.as_deref(), + settings.default_git_user_email.as_deref(), ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; @@ -403,6 +411,10 @@ pub async fn start_project_container( settings.timezone.as_deref(), &enabled_mcp, network_name.as_deref(), + settings.global_claude_code_settings.as_ref(), + settings.default_ssh_key_path.as_deref(), + settings.default_git_user_name.as_deref(), + settings.default_git_user_email.as_deref(), ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index 00faab4..dbc1b27 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -9,7 +9,7 @@ use crate::AppState; /// For Bedrock Profile projects, wraps `claude` in a bash script that validates /// the AWS session first. If the SSO session is expired, runs `aws sso login` /// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon). -fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec { +fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec { let is_bedrock_profile = project.backend == Backend::Bedrock && project .bedrock_config @@ -22,6 +22,12 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec { if project.full_permissions { cmd.push("--dangerously-skip-permissions".to_string()); } + if let Some(name) = session_name { + if !name.is_empty() { + cmd.push("-n".to_string()); + cmd.push(name.to_string()); + } + } return cmd; } @@ -32,10 +38,14 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec { // Build a bash wrapper that validates credentials, re-auths if needed, // then exec's into claude. + let name_flag = session_name + .filter(|n| !n.is_empty()) + .map(|n| format!(" -n '{}'", n.replace('\'', "'\\''"))) + .unwrap_or_default(); let claude_cmd = if project.full_permissions { - "exec claude --dangerously-skip-permissions" + format!("exec claude --dangerously-skip-permissions{}", name_flag) } else { - "exec claude" + format!("exec claude{}", name_flag) }; let script = format!( @@ -81,6 +91,7 @@ pub async fn open_terminal_session( project_id: String, session_id: String, session_type: Option, + session_name: Option, app_handle: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -96,7 +107,7 @@ pub async fn open_terminal_session( let cmd = match session_type.as_deref() { Some("bash") => vec!["bash".to_string(), "-l".to_string()], - _ => build_terminal_cmd(&project, &state), + _ => build_terminal_cmd(&project, &state, session_name.as_deref()), }; let output_event = format!("terminal-output-{}", session_id); diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 2915b3f..c8d29cd 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use sha2::{Sha256, Digest}; use super::client::get_docker; -use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; +use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks @@ -132,14 +132,17 @@ fn build_claude_instructions( /// Compute a fingerprint string for the custom environment variables. /// Sorted alphabetically so order changes do not cause spurious recreation. fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { - let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"]; + let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; + let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { let key = env_var.key.trim(); if key.is_empty() { continue; } - let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p)); + let upper = key.to_uppercase(); + let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p)) + || reserved_exact.iter().any(|e| upper == *e); if is_reserved { continue; } @@ -282,6 +285,80 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String { sha256_hex(&joined) } +/// Merge global and per-project ClaudeCodeSettings. +/// Per-project fields override global fields when set (non-default). +fn merge_claude_code_settings( + global: Option<&ClaudeCodeSettings>, + project: Option<&ClaudeCodeSettings>, +) -> Option { + 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 { + let mut map = serde_json::Map::new(); + + if let Some(ref tui) = settings.tui_mode { + map.insert("tui".to_string(), serde_json::json!(tui)); + } + if let Some(ref effort) = settings.effort { + map.insert("effort".to_string(), serde_json::json!(effort)); + } + if settings.auto_scroll_disabled { + map.insert("autoScrollEnabled".to_string(), serde_json::json!(false)); + } + if settings.focus_mode { + map.insert("focusMode".to_string(), serde_json::json!(true)); + } + if settings.show_thinking_summaries { + map.insert("showThinkingSummaries".to_string(), serde_json::json!(true)); + } + + if map.is_empty() { + None + } else { + Some(serde_json::Value::Object(map).to_string()) + } +} + /// Build the JSON value for MCP servers config to be injected into ~/.claude.json. /// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`. /// @@ -400,6 +477,10 @@ pub async fn create_container( timezone: Option<&str>, mcp_servers: &[McpServer], network_name: Option<&str>, + global_claude_code_settings: Option<&ClaudeCodeSettings>, + default_ssh_key_path: Option<&str>, + default_git_user_name: Option<&str>, + default_git_user_email: Option<&str>, ) -> Result { let docker = get_docker()?; let container_name = project.container_name(); @@ -445,10 +526,13 @@ pub async fn create_container( if let Some(ref token) = project.git_token { env_vars.push(format!("GIT_TOKEN={}", token)); } - if let Some(ref name) = project.git_user_name { + // Per-project git user overrides global defaults + let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name); + let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email); + if let Some(name) = effective_git_name { env_vars.push(format!("GIT_USER_NAME={}", name)); } - if let Some(ref email) = project.git_user_email { + if let Some(email) = effective_git_email { env_vars.push(format!("GIT_USER_EMAIL={}", email)); } @@ -531,13 +615,16 @@ pub async fn create_container( // Custom environment variables (global + per-project, project overrides global for same key) let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); - let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"]; + let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; + let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; for env_var in &merged_env { let key = env_var.key.trim(); if key.is_empty() { continue; } - let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p)); + let upper = key.to_uppercase(); + let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p)) + || reserved_exact.iter().any(|e| upper == *e); if is_reserved { log::warn!("Skipping reserved env var: {}", key); continue; @@ -577,6 +664,32 @@ pub async fn create_container( env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json)); } + // Claude Code settings (global + per-project merged) + let merged_cc_settings = merge_claude_code_settings( + global_claude_code_settings, + project.claude_code_settings.as_ref(), + ); + if let Some(ref cc) = merged_cc_settings { + // Env-var-based settings (these are read directly by Claude Code) + if cc.tui_mode.as_deref() == Some("fullscreen") { + env_vars.push("CLAUDE_CODE_NO_FLICKER=1".to_string()); + } + if cc.enable_session_recap { + env_vars.push("CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1".to_string()); + } + if cc.env_scrub { + env_vars.push("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1".to_string()); + } + if cc.prompt_caching_1h { + env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string()); + } + + // settings.json-based settings (written by the entrypoint) + if let Some(settings_json) = build_claude_code_settings_json(cc) { + env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json)); + } + } + let mut mounts: Vec = Vec::new(); // Project directories -> /workspace/{mount_name} @@ -612,10 +725,12 @@ pub async fn create_container( }); // SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms) - if let Some(ref ssh_path) = project.ssh_key_path { + // Per-project ssh_key_path overrides global default_ssh_key_path + let effective_ssh_path = project.ssh_key_path.as_deref().or(default_ssh_key_path); + if let Some(ssh_path) = effective_ssh_path { mounts.push(Mount { target: Some("/tmp/.host-ssh".to_string()), - source: Some(ssh_path.clone()), + source: Some(ssh_path.to_string()), typ: Some(MountTypeEnum::BIND), read_only: Some(true), ..Default::default() @@ -705,10 +820,12 @@ pub async fn create_container( labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers)); labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string()); labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone()); + labels.insert("triple-c.claude-code-settings-fingerprint".to_string(), + compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref())); labels.insert("triple-c.instructions-fingerprint".to_string(), combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default()); - labels.insert("triple-c.git-user-name".to_string(), project.git_user_name.clone().unwrap_or_default()); - labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default()); + labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string()); + labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string()); labels.insert("triple-c.git-token-hash".to_string(), project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default()); @@ -877,6 +994,10 @@ pub async fn container_needs_recreation( global_custom_env_vars: &[EnvVar], timezone: Option<&str>, mcp_servers: &[McpServer], + global_claude_code_settings: Option<&ClaudeCodeSettings>, + default_ssh_key_path: Option<&str>, + default_git_user_name: Option<&str>, + default_git_user_email: Option<&str>, ) -> Result { let docker = get_docker()?; let info = docker @@ -997,28 +1118,34 @@ pub async fn container_needs_recreation( .find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh")) }) .and_then(|mount| mount.source.as_deref()); - let project_ssh = project.ssh_key_path.as_deref(); - if ssh_mount_source != project_ssh { + let effective_ssh = project.ssh_key_path.as_deref().or(default_ssh_key_path); + if ssh_mount_source != effective_ssh { log::info!( - "SSH key path mismatch (container={:?}, project={:?})", + "SSH key path mismatch (container={:?}, expected={:?})", ssh_mount_source, - project_ssh + effective_ssh ); return Ok(true); } // ── Git settings (label-based to avoid stale snapshot env vars) ───── - let expected_git_name = project.git_user_name.clone().unwrap_or_default(); + let expected_git_name = project.git_user_name.as_deref() + .or(default_git_user_name) + .unwrap_or_default() + .to_string(); let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default(); if container_git_name != expected_git_name { - log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, expected_git_name); + log::info!("GIT_USER_NAME mismatch (container={:?}, expected={:?})", container_git_name, expected_git_name); return Ok(true); } - let expected_git_email = project.git_user_email.clone().unwrap_or_default(); + let expected_git_email = project.git_user_email.as_deref() + .or(default_git_user_email) + .unwrap_or_default() + .to_string(); let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default(); if container_git_email != expected_git_email { - log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, expected_git_email); + log::info!("GIT_USER_EMAIL mismatch (container={:?}, expected={:?})", container_git_email, expected_git_email); return Ok(true); } @@ -1060,6 +1187,18 @@ pub async fn container_needs_recreation( return Ok(true); } + // ── Claude Code settings fingerprint ─────────────────────────────── + let merged_cc = merge_claude_code_settings( + global_claude_code_settings, + project.claude_code_settings.as_ref(), + ); + let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref()); + let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default(); + if container_cc_fp != expected_cc_fp { + log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp); + return Ok(true); + } + // ── MCP servers fingerprint ───────────────────────────────────────── let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers); let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default(); diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 870d702..67a8373 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use super::project::EnvVar; +use super::project::{ClaudeCodeSettings, EnvVar}; fn default_true() -> bool { true @@ -78,6 +78,8 @@ pub struct AppSettings { pub web_terminal: WebTerminalSettings, #[serde(default)] pub stt: SttSettings, + #[serde(default)] + pub global_claude_code_settings: Option, } fn default_stt_model() -> String { @@ -163,6 +165,7 @@ impl Default for AppSettings { dismissed_image_digest: None, web_terminal: WebTerminalSettings::default(), stt: SttSettings::default(), + global_claude_code_settings: None, } } } diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index c4b24d0..dc2c339 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -28,6 +28,36 @@ fn default_full_permissions() -> bool { true } +/// Settings for Claude Code CLI behavior inside the container. +/// These map to Claude Code env vars and ~/.claude/settings.json entries. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ClaudeCodeSettings { + /// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen + #[serde(default)] + pub tui_mode: Option, + /// Effort level: None = default, Some("low"|"medium"|"high") + #[serde(default)] + pub effort: Option, + /// Disable auto-scroll in fullscreen TUI mode + #[serde(default)] + pub auto_scroll_disabled: bool, + /// Enable focus mode (collapsed tool output) + #[serde(default)] + pub focus_mode: bool, + /// Show thinking summaries in responses + #[serde(default)] + pub show_thinking_summaries: bool, + /// Enable session recap when returning to a session + #[serde(default)] + pub enable_session_recap: bool, + /// Strip credentials from subprocess environments + #[serde(default)] + pub env_scrub: bool, + /// Enable 1-hour prompt cache TTL (vs default 5-minute) + #[serde(default)] + pub prompt_caching_1h: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { pub id: String, @@ -59,6 +89,8 @@ pub struct Project { pub claude_instructions: Option, #[serde(default)] pub enabled_mcp_servers: Vec, + #[serde(default)] + pub claude_code_settings: Option, pub created_at: String, pub updated_at: String, } @@ -177,6 +209,7 @@ impl Project { port_mappings: Vec::new(), claude_instructions: None, enabled_mcp_servers: Vec::new(), + claude_code_settings: None, created_at: now.clone(), updated_at: now, } diff --git a/app/src/components/projects/ClaudeCodeSettingsModal.tsx b/app/src/components/projects/ClaudeCodeSettingsModal.tsx new file mode 100644 index 0000000..b02b347 --- /dev/null +++ b/app/src/components/projects/ClaudeCodeSettingsModal.tsx @@ -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; + 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(settings ?? { ...DEFAULTS }); + const overlayRef = useRef(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) => { + if (e.target === overlayRef.current) onClose(); + }, + [onClose], + ); + + const update = async (patch: Partial) => { + 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) => ( +
+
+
{label}
+
{description}
+
+ +
+ ); + + return ( +
+
+

Claude Code Settings

+ + {disabled && ( +
+ Container must be stopped to change Claude Code settings. +
+ )} + +
+ {/* TUI Mode */} +
+
+
TUI Mode
+
Enables flicker-free alt-screen rendering
+
+ +
+ + {/* Effort Level */} +
+
+
Effort Level
+
Controls how much reasoning Claude applies
+
+ +
+ + {/* 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 }), + )} +
+ +
+ +
+
+
+ ); +} diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index bc78f58..5cca104 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -9,6 +9,7 @@ import { useAppState } from "../../store/appState"; import EnvVarsModal from "./EnvVarsModal"; import PortMappingsModal from "./PortMappingsModal"; import ClaudeInstructionsModal from "./ClaudeInstructionsModal"; +import ClaudeCodeSettingsModal from "./ClaudeCodeSettingsModal"; import ContainerProgressModal from "./ContainerProgressModal"; import FileManagerModal from "./FileManagerModal"; import ConfirmRemoveModal from "./ConfirmRemoveModal"; @@ -30,6 +31,7 @@ export default function ProjectCard({ project }: Props) { const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showPortMappingsModal, setShowPortMappingsModal] = useState(false); const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false); + const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false); const [showFileManager, setShowFileManager] = useState(false); const [progressMsg, setProgressMsg] = useState(null); const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null); @@ -777,6 +779,19 @@ export default function ProjectCard({ project }: Props) { + {/* Claude Code Settings */} +
+ + +
+ {/* MCP Servers */} {mcpServers.length > 0 && (
@@ -1079,6 +1094,17 @@ export default function ProjectCard({ project }: Props) { /> )} + {showClaudeCodeSettingsModal && ( + { + await update({ ...project, claude_code_settings: ccSettings }); + }} + onClose={() => setShowClaudeCodeSettingsModal(false)} + /> + )} + {showFileManager && ( (appSettings?.global_custom_env_vars ?? []); const [checkingUpdates, setCheckingUpdates] = useState(false); const [timezone, setTimezone] = useState(appSettings?.timezone ?? ""); + const [sshKeyPath, setSshKeyPath] = useState(appSettings?.default_ssh_key_path ?? ""); + const [gitName, setGitName] = useState(appSettings?.default_git_user_name ?? ""); + const [gitEmail, setGitEmail] = useState(appSettings?.default_git_user_email ?? ""); const [showInstructionsModal, setShowInstructionsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); + const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false); // Sync local state when appSettings change useEffect(() => { setGlobalInstructions(appSettings?.global_claude_instructions ?? ""); setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []); setTimezone(appSettings?.timezone ?? ""); - }, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]); + setSshKeyPath(appSettings?.default_ssh_key_path ?? ""); + setGitName(appSettings?.default_git_user_name ?? ""); + setGitEmail(appSettings?.default_git_user_email ?? ""); + }, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone, appSettings?.default_ssh_key_path, appSettings?.default_git_user_name, appSettings?.default_git_user_email]); // Auto-detect timezone on first load if not yet set useEffect(() => { @@ -60,6 +68,60 @@ export default function SettingsPanel() { + {/* Default SSH Key Directory */} +
+ +

+ Mounted into all containers unless overridden by a per-project setting. +

+ 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)]" + /> +
+ + {/* Default Git Name */} +
+ + 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)]" + /> +
+ + {/* Default Git Email */} +
+ + 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)]" + /> +
+ {/* Container Timezone */}
@@ -118,6 +180,25 @@ export default function SettingsPanel() {
+ {/* Global Claude Code Settings */} +
+ +

+ Default Claude Code CLI settings applied to all projects. Per-project settings take precedence. +

+
+ + {appSettings?.global_claude_code_settings ? "Configured" : "Using defaults"} + + +
+
+ {/* Web Terminal */} @@ -189,6 +270,19 @@ export default function SettingsPanel() { onClose={() => setShowEnvVarsModal(false)} /> )} + + {showClaudeCodeSettingsModal && ( + { + if (appSettings) { + await saveSettings({ ...appSettings, global_claude_code_settings: ccSettings }); + } + }} + onClose={() => setShowClaudeCodeSettingsModal(false)} + /> + )} ); } diff --git a/app/src/components/terminal/TerminalTabs.tsx b/app/src/components/terminal/TerminalTabs.tsx index 840ac3c..14ac0c3 100644 --- a/app/src/components/terminal/TerminalTabs.tsx +++ b/app/src/components/terminal/TerminalTabs.tsx @@ -23,8 +23,8 @@ export default function TerminalTabs() { : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" }`} > - - {session.projectName}{session.sessionType === "bash" ? " (bash)" : ""} + + {session.sessionName ?? session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}