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

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

View File

@@ -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<String> {
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
@@ -22,6 +22,12 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
if project.full_permissions {
cmd.push("--dangerously-skip-permissions".to_string());
}
if let Some(name) = session_name {
if !name.is_empty() {
cmd.push("-n".to_string());
cmd.push(name.to_string());
}
}
return cmd;
}
@@ -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,
// 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<String>,
session_name: Option<String>,
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);

View File

@@ -8,7 +8,7 @@ use std::collections::HashMap;
use sha2::{Sha256, Digest};
use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
@@ -132,14 +132,17 @@ fn build_claude_instructions(
/// Compute a fingerprint string for the custom environment variables.
/// Sorted alphabetically so order changes do not cause spurious recreation.
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
let mut parts: Vec<String> = Vec::new();
for env_var in custom_env_vars {
let key = env_var.key.trim();
if key.is_empty() {
continue;
}
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
let upper = key.to_uppercase();
let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p))
|| reserved_exact.iter().any(|e| upper == *e);
if is_reserved {
continue;
}
@@ -282,6 +285,80 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
sha256_hex(&joined)
}
/// Merge global and per-project ClaudeCodeSettings.
/// Per-project fields override global fields when set (non-default).
fn merge_claude_code_settings(
global: Option<&ClaudeCodeSettings>,
project: Option<&ClaudeCodeSettings>,
) -> Option<ClaudeCodeSettings> {
match (global, project) {
(None, None) => None,
(Some(g), None) => Some(g.clone()),
(None, Some(p)) => Some(p.clone()),
(Some(g), Some(p)) => {
// Project overrides global for each field when the project value is non-default
Some(ClaudeCodeSettings {
tui_mode: p.tui_mode.clone().or_else(|| g.tui_mode.clone()),
effort: p.effort.clone().or_else(|| g.effort.clone()),
auto_scroll_disabled: if p.auto_scroll_disabled { true } else { g.auto_scroll_disabled },
focus_mode: if p.focus_mode { true } else { g.focus_mode },
show_thinking_summaries: if p.show_thinking_summaries { true } else { g.show_thinking_summaries },
enable_session_recap: if p.enable_session_recap { true } else { g.enable_session_recap },
env_scrub: if p.env_scrub { true } else { g.env_scrub },
prompt_caching_1h: if p.prompt_caching_1h { true } else { g.prompt_caching_1h },
})
}
}
}
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings>) -> String {
match settings {
None => String::new(),
Some(s) => {
let parts = vec![
s.tui_mode.as_deref().unwrap_or("").to_string(),
s.effort.as_deref().unwrap_or("").to_string(),
format!("{}", s.auto_scroll_disabled),
format!("{}", s.focus_mode),
format!("{}", s.show_thinking_summaries),
format!("{}", s.enable_session_recap),
format!("{}", s.env_scrub),
format!("{}", s.prompt_caching_1h),
];
sha256_hex(&parts.join("|"))
}
}
}
/// Build the settings.json content for Claude Code from ClaudeCodeSettings.
/// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
fn build_claude_code_settings_json(settings: &ClaudeCodeSettings) -> Option<String> {
let mut map = serde_json::Map::new();
if let Some(ref tui) = settings.tui_mode {
map.insert("tui".to_string(), serde_json::json!(tui));
}
if let Some(ref effort) = settings.effort {
map.insert("effort".to_string(), serde_json::json!(effort));
}
if settings.auto_scroll_disabled {
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
}
if settings.focus_mode {
map.insert("focusMode".to_string(), serde_json::json!(true));
}
if settings.show_thinking_summaries {
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
}
if map.is_empty() {
None
} else {
Some(serde_json::Value::Object(map).to_string())
}
}
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
///
@@ -400,6 +477,10 @@ pub async fn create_container(
timezone: Option<&str>,
mcp_servers: &[McpServer],
network_name: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>,
default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>,
) -> Result<String, String> {
let docker = get_docker()?;
let container_name = project.container_name();
@@ -445,10 +526,13 @@ pub async fn create_container(
if let Some(ref token) = project.git_token {
env_vars.push(format!("GIT_TOKEN={}", token));
}
if let Some(ref name) = project.git_user_name {
// Per-project git user overrides global defaults
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
if let Some(name) = effective_git_name {
env_vars.push(format!("GIT_USER_NAME={}", name));
}
if let Some(ref email) = project.git_user_email {
if let Some(email) = effective_git_email {
env_vars.push(format!("GIT_USER_EMAIL={}", email));
}
@@ -531,13 +615,16 @@ pub async fn create_container(
// Custom environment variables (global + per-project, project overrides global for same key)
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
for env_var in &merged_env {
let key = env_var.key.trim();
if key.is_empty() {
continue;
}
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
let upper = key.to_uppercase();
let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p))
|| reserved_exact.iter().any(|e| upper == *e);
if is_reserved {
log::warn!("Skipping reserved env var: {}", key);
continue;
@@ -577,6 +664,32 @@ pub async fn create_container(
env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json));
}
// Claude Code settings (global + per-project merged)
let merged_cc_settings = merge_claude_code_settings(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
if let Some(ref cc) = merged_cc_settings {
// Env-var-based settings (these are read directly by Claude Code)
if cc.tui_mode.as_deref() == Some("fullscreen") {
env_vars.push("CLAUDE_CODE_NO_FLICKER=1".to_string());
}
if cc.enable_session_recap {
env_vars.push("CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1".to_string());
}
if cc.env_scrub {
env_vars.push("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1".to_string());
}
if cc.prompt_caching_1h {
env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string());
}
// settings.json-based settings (written by the entrypoint)
if let Some(settings_json) = build_claude_code_settings_json(cc) {
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json));
}
}
let mut mounts: Vec<Mount> = Vec::new();
// Project directories -> /workspace/{mount_name}
@@ -612,10 +725,12 @@ pub async fn create_container(
});
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
if let Some(ref ssh_path) = project.ssh_key_path {
// Per-project ssh_key_path overrides global default_ssh_key_path
let effective_ssh_path = project.ssh_key_path.as_deref().or(default_ssh_key_path);
if let Some(ssh_path) = effective_ssh_path {
mounts.push(Mount {
target: Some("/tmp/.host-ssh".to_string()),
source: Some(ssh_path.clone()),
source: Some(ssh_path.to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..Default::default()
@@ -705,10 +820,12 @@ pub async fn create_container(
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref()));
labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
labels.insert("triple-c.git-user-name".to_string(), project.git_user_name.clone().unwrap_or_default());
labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default());
labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
labels.insert("triple-c.git-token-hash".to_string(),
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
@@ -877,6 +994,10 @@ pub async fn container_needs_recreation(
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
mcp_servers: &[McpServer],
global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>,
default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>,
) -> Result<bool, String> {
let docker = get_docker()?;
let info = docker
@@ -997,28 +1118,34 @@ pub async fn container_needs_recreation(
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
})
.and_then(|mount| mount.source.as_deref());
let project_ssh = project.ssh_key_path.as_deref();
if ssh_mount_source != project_ssh {
let effective_ssh = project.ssh_key_path.as_deref().or(default_ssh_key_path);
if ssh_mount_source != effective_ssh {
log::info!(
"SSH key path mismatch (container={:?}, project={:?})",
"SSH key path mismatch (container={:?}, expected={:?})",
ssh_mount_source,
project_ssh
effective_ssh
);
return Ok(true);
}
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
let expected_git_name = project.git_user_name.clone().unwrap_or_default();
let expected_git_name = project.git_user_name.as_deref()
.or(default_git_user_name)
.unwrap_or_default()
.to_string();
let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
if container_git_name != expected_git_name {
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, expected_git_name);
log::info!("GIT_USER_NAME mismatch (container={:?}, expected={:?})", container_git_name, expected_git_name);
return Ok(true);
}
let expected_git_email = project.git_user_email.clone().unwrap_or_default();
let expected_git_email = project.git_user_email.as_deref()
.or(default_git_user_email)
.unwrap_or_default()
.to_string();
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
if container_git_email != expected_git_email {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, expected_git_email);
log::info!("GIT_USER_EMAIL mismatch (container={:?}, expected={:?})", container_git_email, expected_git_email);
return Ok(true);
}
@@ -1060,6 +1187,18 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── Claude Code settings fingerprint ───────────────────────────────
let merged_cc = merge_claude_code_settings(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref());
let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default();
if container_cc_fp != expected_cc_fp {
log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp);
return Ok(true);
}
// ── MCP servers fingerprint ─────────────────────────────────────────
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers);
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default();

View File

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

View File

@@ -28,6 +28,36 @@ fn default_full_permissions() -> bool {
true
}
/// Settings for Claude Code CLI behavior inside the container.
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ClaudeCodeSettings {
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
#[serde(default)]
pub tui_mode: Option<String>,
/// Effort level: None = default, Some("low"|"medium"|"high")
#[serde(default)]
pub effort: Option<String>,
/// Disable auto-scroll in fullscreen TUI mode
#[serde(default)]
pub auto_scroll_disabled: bool,
/// Enable focus mode (collapsed tool output)
#[serde(default)]
pub focus_mode: bool,
/// Show thinking summaries in responses
#[serde(default)]
pub show_thinking_summaries: bool,
/// Enable session recap when returning to a session
#[serde(default)]
pub enable_session_recap: bool,
/// Strip credentials from subprocess environments
#[serde(default)]
pub env_scrub: bool,
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
#[serde(default)]
pub prompt_caching_1h: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
@@ -59,6 +89,8 @@ pub struct Project {
pub claude_instructions: Option<String>,
#[serde(default)]
pub enabled_mcp_servers: Vec<String>,
#[serde(default)]
pub claude_code_settings: Option<ClaudeCodeSettings>,
pub created_at: String,
pub updated_at: String,
}
@@ -177,6 +209,7 @@ impl Project {
port_mappings: Vec::new(),
claude_instructions: None,
enabled_mcp_servers: Vec::new(),
claude_code_settings: None,
created_at: now.clone(),
updated_at: now,
}

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 PortMappingsModal from "./PortMappingsModal";
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
import ClaudeCodeSettingsModal from "./ClaudeCodeSettingsModal";
import ContainerProgressModal from "./ContainerProgressModal";
import FileManagerModal from "./FileManagerModal";
import ConfirmRemoveModal from "./ConfirmRemoveModal";
@@ -30,6 +31,7 @@ export default function ProjectCard({ project }: Props) {
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
const [showFileManager, setShowFileManager] = useState(false);
const [progressMsg, setProgressMsg] = useState<string | null>(null);
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
@@ -777,6 +779,19 @@ export default function ProjectCard({ project }: Props) {
</button>
</div>
{/* Claude Code Settings */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Claude Code Settings{project.claude_code_settings ? " (set)" : ""}<Tooltip text="Configure Claude Code CLI behavior: TUI mode, effort level, focus mode, prompt caching, and more. These override global defaults for this project." />
</label>
<button
onClick={() => setShowClaudeCodeSettingsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* MCP Servers */}
{mcpServers.length > 0 && (
<div>
@@ -1079,6 +1094,17 @@ export default function ProjectCard({ project }: Props) {
/>
)}
{showClaudeCodeSettingsModal && (
<ClaudeCodeSettingsModal
settings={project.claude_code_settings}
disabled={!isStopped}
onSave={async (ccSettings) => {
await update({ ...project, claude_code_settings: ccSettings });
}}
onClose={() => setShowClaudeCodeSettingsModal(false)}
/>
)}
{showFileManager && (
<FileManagerModal
projectId={project.id}

View File

@@ -4,6 +4,7 @@ import AwsSettings from "./AwsSettings";
import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates";
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
import ClaudeCodeSettingsModal from "../projects/ClaudeCodeSettingsModal";
import EnvVarsModal from "../projects/EnvVarsModal";
import { detectHostTimezone } from "../../lib/tauri-commands";
import type { EnvVar } from "../../lib/types";
@@ -18,15 +19,22 @@ export default function SettingsPanel() {
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
const [checkingUpdates, setCheckingUpdates] = useState(false);
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
const [sshKeyPath, setSshKeyPath] = useState(appSettings?.default_ssh_key_path ?? "");
const [gitName, setGitName] = useState(appSettings?.default_git_user_name ?? "");
const [gitEmail, setGitEmail] = useState(appSettings?.default_git_user_email ?? "");
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
setTimezone(appSettings?.timezone ?? "");
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]);
setSshKeyPath(appSettings?.default_ssh_key_path ?? "");
setGitName(appSettings?.default_git_user_name ?? "");
setGitEmail(appSettings?.default_git_user_email ?? "");
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone, appSettings?.default_ssh_key_path, appSettings?.default_git_user_name, appSettings?.default_git_user_email]);
// Auto-detect timezone on first load if not yet set
useEffect(() => {
@@ -60,6 +68,60 @@ export default function SettingsPanel() {
<DockerSettings />
<AwsSettings />
{/* Default SSH Key Directory */}
<div>
<label className="block text-sm font-medium mb-1">Default SSH Key Directory<Tooltip text="Global default SSH key directory. Mounted into containers that don't have a per-project SSH path set." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Mounted into all containers unless overridden by a per-project setting.
</p>
<input
type="text"
value={sshKeyPath}
onChange={(e) => setSshKeyPath(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_ssh_key_path: sshKeyPath || null });
}
}}
placeholder="~/.ssh"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Git Name */}
<div>
<label className="block text-sm font-medium mb-1">Default Git Name<Tooltip text="Sets git user.name inside containers. Per-project Git Name takes precedence." /></label>
<input
type="text"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_git_user_name: gitName || null });
}
}}
placeholder="Your Name"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Git Email */}
<div>
<label className="block text-sm font-medium mb-1">Default Git Email<Tooltip text="Sets git user.email inside containers. Per-project Git Email takes precedence." /></label>
<input
type="text"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_git_user_email: gitEmail || null });
}
}}
placeholder="you@example.com"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Container Timezone */}
<div>
<label className="block text-sm font-medium mb-1">Container Timezone<Tooltip text="Sets the timezone inside containers. Affects scheduled task timing and log timestamps." /></label>
@@ -118,6 +180,25 @@ export default function SettingsPanel() {
</div>
</div>
{/* Global Claude Code Settings */}
<div>
<label className="block text-sm font-medium mb-1">Claude Code Settings<Tooltip text="Global defaults for Claude Code CLI behavior (TUI mode, effort, focus mode, caching, etc.). Per-project settings override these." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Default Claude Code CLI settings applied to all projects. Per-project settings take precedence.
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{appSettings?.global_claude_code_settings ? "Configured" : "Using defaults"}
</span>
<button
onClick={() => setShowClaudeCodeSettingsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</div>
{/* Web Terminal */}
<WebTerminalSettings />
@@ -189,6 +270,19 @@ export default function SettingsPanel() {
onClose={() => setShowEnvVarsModal(false)}
/>
)}
{showClaudeCodeSettingsModal && (
<ClaudeCodeSettingsModal
settings={appSettings?.global_claude_code_settings ?? null}
disabled={false}
onSave={async (ccSettings) => {
if (appSettings) {
await saveSettings({ ...appSettings, global_claude_code_settings: ccSettings });
}
}}
onClose={() => setShowClaudeCodeSettingsModal(false)}
/>
)}
</div>
);
}

View File

@@ -23,8 +23,8 @@ export default function TerminalTabs() {
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<span className="truncate max-w-[120px]">
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
<span className="truncate max-w-[140px]">
{session.sessionName ?? session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
</span>
<button
onClick={(e) => {

View File

@@ -17,10 +17,10 @@ export function useTerminal() {
);
const open = useCallback(
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
const sessionId = crypto.randomUUID();
await commands.openTerminalSession(projectId, sessionId, sessionType);
addSession({ id: sessionId, projectId, projectName, sessionType });
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
return sessionId;
},
[addSession],

View File

@@ -45,8 +45,8 @@ export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId });
// Terminal
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
export const terminalInput = (sessionId: string, data: number[]) =>
invoke<void>("terminal_input", { sessionId, data });
export const terminalResize = (sessionId: string, cols: number, rows: number) =>

View File

@@ -35,6 +35,7 @@ export interface Project {
port_mappings: PortMapping[];
claude_instructions: string | null;
enabled_mcp_servers: string[];
claude_code_settings: ClaudeCodeSettings | null;
created_at: string;
updated_at: string;
}
@@ -73,6 +74,17 @@ export interface OpenAiCompatibleConfig {
model_id: string | null;
}
export interface ClaudeCodeSettings {
tui_mode: string | null;
effort: string | null;
auto_scroll_disabled: boolean;
focus_mode: boolean;
show_thinking_summaries: boolean;
enable_session_recap: boolean;
env_scrub: boolean;
prompt_caching_1h: boolean;
}
export interface ContainerInfo {
container_id: string;
project_id: string;
@@ -93,6 +105,7 @@ export interface TerminalSession {
projectId: string;
projectName: string;
sessionType: "claude" | "bash";
sessionName: string | null;
}
export type ImageSource = "registry" | "local_build" | "custom";
@@ -120,6 +133,7 @@ export interface AppSettings {
dismissed_image_digest: string | null;
web_terminal: WebTerminalSettings;
stt: SttSettings;
global_claude_code_settings: ClaudeCodeSettings | null;
}
export interface SttSettings {