Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Configuration

Module: src-tauri/src/config.rs

Manages all application configuration as JSON files in the platform config directory.

Config Directory

PlatformPath
macOS~/Library/Application Support/com.tuic.commander/
Linux~/.config/com.tuic.commander/
Windows%APPDATA%/com.tuic.commander/

Legacy paths {platform_config}/tuicommander/, {platform_config}/tui-commander/ and ~/.tuicommander/ are auto-migrated on first launch.

Debug and release builds share this one directoryconfig_dir() never branches on cfg!(debug_assertions). The single-instance lock is release-only (lib.rs, #[cfg(not(debug_assertions))]), so a make dev build runs happily alongside the installed app, and both read and write the exact same config.json, repositories.json, and every other file below. What makes that safe is the locking model in ConfigFile<T> (see Core Functions): a cross-process advisory file lock. Ordinary AppConfig writes and upstream MCP writes additionally apply caller deltas to the latest value while that lock is held, so independent edits from two processes compose instead of becoming ordered whole-document overwrites. repositories.json used to be the one exception, seeded into a separate ~/.tuicommander-dev/ directory on first debug run; that seeding path is gone and it now lives here like everything else (see below).

Core Functions

#![allow(unused)]
fn main() {
pub fn config_dir() -> PathBuf
pub fn load_json_config<T: DeserializeOwned + Default>(filename: &str) -> T
}

Config domains write through ConfigFile<T>:

#![allow(unused)]
fn main() {
impl<T: Serialize + DeserializeOwned + Default> ConfigFile<T> {
    pub fn load(&self) -> (T, Stamp)
    pub fn update<F: FnOnce(&mut T) -> bool>(&self, mutate: F) -> Result<(), String>
    pub fn update_with<R, F>(&self, mutate: F) -> Result<R, String>
    pub fn update_with_strict<R, F>(&self, mutate: F) -> Result<R, String>
    pub fn save_checked(&self, value: &T, stamp: Stamp) -> Result<(), ConfigWriteError>
    pub fn save(&self, value: &T) -> Result<(), String>
}
}

Two locks protect every write: an in-process CONFIG_WRITE_LOCK mutex, and a cross-process advisory file lock (std::fs::File::lock() on a sibling <file>.lock) that serializes writers across the debug/release instances that now share one config dir. save_checked additionally compares a Stamp (mtime+len, captured at load()) against the file’s current on-disk state and returns ConfigWriteError::Conflict instead of overwriting a change it never saw — used by most per-domain files (notifications.json, ui-prefs.json, repo-settings.json, repositories.json, etc.). Those callers capture the stamp immediately before saving, so this narrows only the backend write race; it is not a user-session conflict protocol. config.json (AppConfig) and mcp-upstreams.json use delta-under-lock instead. See 2026-08-08-config-deltas-under-lock.md.

Config Files and Commands

Application Config (config.json)

Type: AppConfig

Frontend surfaces that update this full-document configuration use the shared updateAppConfig() queue. It serializes each fresh load → owned-field mutation → save sequence so simultaneous General, Services, and plugin changes cannot overwrite one another with stale snapshots.

Ordinary saves merge under the cross-process lock; they do not replace the document. PUT /config and the MCP config tool (action: "save") accept a body that mentions only the fields being changed. IPC save_config retains its typed full-config shape, but the backend derives the cache-to-request delta. commit_config_change locks config.json, reloads and hydrates the latest disk value, applies only the requested delta, persists it, and refreshes state.config from the result. Objects merge key by key; arrays and scalars replace wholesale (so an empty array still clears a list, null clears an optional field, and "" still blanks a string). This is not cosmetic: every field carries #[serde(default)], so deserializing a partial body on its own reset the omitted ones — services.server.enabled defaults to false, which is how a partial save used to switch remote access off on disk while the already-bound listener kept serving, surfacing only at the next boot.

All three writers also share server_settings_changed and rebind the listener through restart_after_server_settings_change when services.server.{enabled,port, ipv6_enabled} or services.auth.{username,password_hash} move, so the running process can never serve a configuration the disk disagrees with.

FieldTypeDefaultDescription
shellOption<String>NoneShell override (platform default if None)
font_familyString"JetBrains Mono"Terminal font family
font_sizeu1614Terminal font size
themeString"vscode-dark"Terminal theme
ideString""IDE for “Open in…”
default_font_sizeu1613Default font size for reset
mcp_server_enabledbooltrueEnable MCP HTTP server
mcp_portu169876Fixed port for MCP server (0 = OS-assigned)
collapse_toolsboolfalseReplace the full MCP tool list with 3 lazy-discovery meta-tools (search_tools, get_tool_schema, call_tool). Grok sessions use this surface automatically without changing the stored value — see mcp-http.md
servicesServicesConfig{}Nested remote-access config: server, auth, tls, relay, push (replaces the former flat remote_access_*/push_enabled/relay_enabled fields)

Remote-access secrets under services are not persisted in plaintext config.json: auth.session_token, relay.token, and push.vapid_private_key live in the OS keyring-backed credential vault. The JSON file keeps only the non-secret settings plus session_token_exists, token_exists, and vapid_private_key_exists booleans for UI state.

A vault read failure is never treated as “the secret is absent”: on error hydrate_one_secret keeps the *_exists flag that config.json recorded, so a momentarily locked keychain cannot flip the flag to false and make the next save delete a live credential. Plaintext still found in config.json is moved into the vault at load time and the file is rewritten immediately, so the cleartext copy does not survive on disk.

| confirm_before_quit | bool | true | Show quit confirmation | | confirm_before_closing_tab | bool | true | Show tab close confirmation | | copy_on_select | bool | true | Auto-copy terminal selection to clipboard | | osc52_clipboard | bool | true | Honor OSC 52 clipboard-write sequences from terminal output (a notice shows on each write; disable to ignore them) | | bell_style | String | "visual" | Terminal bell: “none”, “visual”, “sound”, “both” | | disabled_agents | Vec<String> | [] | Agent IDs hidden from the Add menu | | global_hotkey | Option<String> | null | OS-level window toggle hotkey combo | | intent_tab_title | bool | true | Show agent intent as tab title | | language | String | "en" | UI language code | | max_tab_name_length | u32 | 25 | Max tab name display length | | tab_cycling_all_types | bool | false | When true, next/prev-tab shortcuts cycle file/diff/markdown/editor tabs too (default cycles terminals only) | | tab_tree_enabled | bool | false | When true, a branch with >1 terminal shows a collapsible nested list of its terminals under the branch row in the sidebar | | prevent_sleep_when_busy | bool | false | Prevent macOS sleep when terminal is busy | | suggest_followups | bool | true | Show suggest: follow-up actions | | issue_filter | Option<String> | "assigned" | GitHub Issues filter: “assigned”, “created”, “mentioned”, “all”, “disabled” | | experimental_features_enabled | bool | false | Master toggle for experimental features | | ai_chat_enabled | bool | false | Sub-flag: enable AI Chat panel and shortcuts (requires experimental_features_enabled) | | scroll_history_enabled | bool | false | Sub-flag: scrollback history overlay on scroll-up in agent mode (requires experimental_features_enabled) | | ai_terminal_mcp_enabled | bool | false | Expose ai_terminal_* tools to external MCP clients. Off by default — see mcp-http.md | | auto_show_pr_popover | bool | false | Auto-show PR popover when switching to a branch with a PR | | update_channel | String | "stable" | Update channel: “stable” or “nightly” | | inline_blame_enabled | bool | true | Show GitLens-style inline git blame on the code editor’s active line |

Commands: load_app_config(), save_app_config(config)

Every writer of config.json — IPC save_config, PUT /config, MCP config action=save, session-token rotation, set_global_hotkey, the disabled_mcp_agents toggle and the push auto-enable on first subscription — goes through config::commit_config_change, which holds one process-wide mutex across the whole cache-delta → file-lock → latest-disk-read → delta-merge → preserve-secrets → write → update-state.config sequence. The cross-process file lock spans the authoritative disk read and write. This distinction matters: locking whole-document saves merely orders lost updates, while applying the delta after the locked read preserves unrelated fields written by another debug or release process. Rotation (config::rotate_session_token, shared by the desktop command and POST /auth/rotate-session-token) goes through the same path so the vault, the file and state.config cannot disagree — previously the in-memory config kept the pre-rotation token and the next unrelated save wrote it back.

The vault and config.json are one logical commit. Before changing any of the three vault-backed fields, save_app_config snapshots their previous values. If either a later vault operation or the atomic file replacement fails, all three vault values are restored before the error returns; state.config and the live authentication token are updated only after success. A rollback failure is appended to the original persistence error instead of being hidden. Individual credential set and delete operations also publish their in-memory vault clone only after the OS keyring accepts it.

Routing every writer through it also guarantees the file is produced by config_for_disk. A writer that serialized the config itself (the disabled_mcp_agents toggle called save_json_config("config.json", ..)) skipped the stripping step and wrote the session token, relay token and VAPID private key to disk in cleartext.

MCP Bridge Auto-Install

On every launch agent_mcp::ensure_mcp_configs writes the tuicommander bridge entry into each supported agent’s own MCP config, and repairs the path when the sidecar moves. Each target is written in the format its tool reads:

AgentConfig fileShape
Claude Code~/.claude.jsonJSON mcpServers
Cursor~/.cursor/mcp.jsonJSON mcpServers
Windsurf~/.codeium/windsurf/mcp_config.jsonJSON mcpServers
VS Code<user dir>/mcp.jsonJSON servers
Zed~/.config/zed/settings.jsonJSON context_servers
Amp~/.config/amp/settings.jsonJSON amp.mcpServers
Gemini CLI~/.gemini/settings.jsonJSON mcpServers
Droid~/.factory/mcp.jsonJSON mcpServers
opencode~/.config/opencode/opencode.json[c]JSON mcp, {type:"local", command:[…]}
Codex~/.codex/config.tomlTOML [mcp_servers] + env_vars allowlist
Grok~/.grok/config.tomlTOML [mcp_servers]
goose~/.config/goose/config.yamlYAML extensions (ExtensionEntry)
pi~/.pi/agent/mcp.jsonJSON mcpServers (pi-mcp-adapter extension)

Aider is absent because it has no MCP client.

A target is written only when it is installed. The writer creates every missing parent directory, so an unconditional pass used to create ~/.cursor/, ~/.gemini/, ~/.config/amp/ and friends for tools the user never had — which makes other software report Cursor or Windsurf as installed. Presence is proven two ways, cheapest first:

  1. the config directory holds a file that is not the one we write (.DS_Store and stale *.tmp staging files do not count), or
  2. one of the target’s CLI binaries resolves via cli::has_cli.

Claude’s config sits in $HOME, so it uses ~/.claude as its presence directory instead of the config file’s parent. pi is stricter still: its MCP support comes from the optional pi-mcp-adapter extension, which owns ~/.pi/agent/mcp.json — with no such file there is no adapter, so an auto-written entry would configure nothing.

A target that already holds a tuicommander entry keeps getting path repairs even when presence no longer resolves, so a stale bridge path is never left behind. Both gates live in auto_install_allowed, which only the launch pass consults: Settings → Agents installs on demand through ensure_spec_entry directly, because pressing Install states that the target is there — that is an explicit request, not a guess.

Configs that exist but do not parse are never overwritten (JSON, TOML and YAML alike): VS Code’s mcp.json and opencode’s config both allow comments, which serde_json rejects, and treating a parse failure as an empty document would replace the user’s whole config with our single entry.

Upstream MCP Config (mcp-upstreams.json)

Type: UpstreamMcpConfig

Interactive saves carry both the configuration the caller loaded (base) and its desired config. The backend derives additions, intentional removals, order changes, and per-server field deltas keyed by stable server ID, then applies them to the latest document inside ConfigFile::update_with. A popup toggle therefore changes only enabled; an OAuth/DCR auth record written after the popup loaded is preserved. Removing a server or clearing an optional auth field remains explicit and is not mistaken for an omitted/unchanged field.

Validation and the runtime registry diff use the exact merged pre/post values from the locked transaction. The lock is released before asynchronous reconnect work starts.

Commands: load_mcp_upstreams(), save_mcp_upstreams(base, config)

Notification Config (notifications.json)

Type: NotificationConfig

FieldTypeDefaultDescription
enabledbooltrueGlobal enable
volumef640.5Volume (0.0-1.0)
sounds.questionbooltruePlay on agent question
sounds.errorbooltruePlay on error
sounds.completionbooltruePlay on completion
sounds.warningbooltruePlay on warning
silence_remote_completionsbooltrueSuppress the completion chime for HTTP/MCP-created sessions
toasts_in_bellbooltrueMirror every toast into the toolbar bell, under a MESSAGES section

Commands: load_notification_config(), save_notification_config(config)

AI Chat Config (ai-chat-config.json)

Type: AiChatConfig

FieldTypeDefaultDescription
providerString"ollama"AI provider: ollama, anthropic, openai, openrouter, custom
modelString""Model name
base_urlOption<String>per-providerEndpoint base URL
temperaturef320.7Sampling temperature
context_linesu32150VtLogBuffer rows injected per turn
experimental_ai_block_enrichmentboolfalseEnrich OSC 133 blocks with semantic intent
agent_model_overridesOption<HashMap<ToolPhase, String>>NonePer-phase model routing. Keys: plan, search, read, write

Commands: load_ai_chat_config(), save_ai_chat_config(config)

Cron Scheduler Config (ai-cron.json)

Type: SchedulerConfig

FieldTypeDefaultDescription
jobsVec<ScheduledJob>[]List of scheduled agent jobs

Each ScheduledJob:

FieldTypeDescription
idStringUnique job identifier
cron_exprStringCron expression (validated on save)
goalStringAgent goal to execute

Commands: load_scheduler_config(), save_scheduler_config(config)

UI Preferences (ui-prefs.json)

Type: UIPrefsConfig

FieldTypeDefaultDescription
sidebar_visiblebooltrueSidebar visibility
sidebar_widthu32280Sidebar width in pixels
error_handling.strategyString"retry"Error strategy
error_handling.max_retriesu323Max retry count

Commands: load_ui_prefs(), save_ui_prefs(config)

Repository Settings (repo-settings.json)

Type: RepoSettingsMap (HashMap of RepoSettingsEntry)

Per-repository fields:

FieldTypeDefaultDescription
pathStringRepository path
display_nameStringDisplay name
base_branchString"main"Base branch for worktrees
copy_ignored_filesboolfalseCopy .gitignored files to worktree
copy_untracked_filesboolfalseCopy untracked files to worktree
setup_scriptString""Script to run after worktree creation
run_scriptString""Default run command
auto_fetch_interval_minutesu320Auto-fetch interval in minutes (0 = disabled)
auto_delete_on_pr_closeAutoDeleteOnPrClose"off"Auto-delete branch when PR merged/closed (off/ask/auto)
archive_scriptString""Script to run before archive/delete (non-zero exit blocks)

Commands: load_repo_settings(), save_repo_settings(config), check_has_custom_settings(path)

Repository Defaults (repo-defaults.json)

Type: RepoDefaultsConfig

Default values applied to new repositories when no per-repo override exists.

FieldTypeDefaultDescription
base_branchString"automatic"Default base branch
copy_ignored_filesboolfalseCopy .gitignored files to worktree
copy_untracked_filesboolfalseCopy untracked files to worktree
setup_scriptString""Default setup script
run_scriptString""Default run command
archive_scriptString""Default archive script

Commands: load_repo_defaults(), save_repo_defaults(config)

Repositories (repositories.json)

Type: serde_json::Value (flexible JSON, shape defined by frontend)

Stored in the shared config directory like every other file (see Config Directory) — debug and release builds read and write the same repositories.json. Writes go through ConfigFile::save_checked (see Core Functions). Its stamp is captured inside the save command, so it protects the backend read-to-write interval only; unlike the delta-backed config.json and mcp-upstreams.json paths, it is not a cross-process UI-session merge protocol.

repositories.json used to be the one file exempt from the (then-real) debug/release split: it was seeded into a separate ~/.tuicommander-dev/ directory on first debug run so a dev instance wouldn’t start with an empty repo list. That seeding path is gone now that both builds share one directory for repositories.json and all other config domains covered by this document. (~/.tuicommander-dev/ itself still exists for an unrelated purpose — see credentials.rs’s debug-only credential store.)

Commands: load_repositories(), save_repositories(config)

Prompt Library (prompt-library.json)

Type: PromptLibraryConfig

#![allow(unused)]
fn main() {
struct PromptEntry {
    id: String,
    label: String,
    text: String,
    pinned: bool,
}
}

Commands: load_prompt_library(), save_prompt_library(config)

AI Prompts (ai-prompts.json)

Type: AiPromptsConfig

FieldTypeDefaultDescription
diff_triage_system_promptOption<String>NoneCustom system prompt for diff triage LLM classification. Falls back to built-in default when None or empty.

Commands: load_ai_prompts(), save_ai_prompts(config)

MCP actions: list_ai_prompts, load_ai_prompt (requires service), save_ai_prompt (requires service + prompt, localhost only)

Notes (notes.json)

Type: serde_json::Value (flexible JSON, shape defined by frontend)

Commands: load_notes(), save_notes(config)

Keybindings (keybindings.json)

Type: serde_json::Value (flexible JSON, shape defined by frontend)

Custom keyboard shortcut overrides.

Commands: load_keybindings(), save_keybindings(config)

Agents Config (agents.json)

Type: AgentsConfig

Per-agent run configurations (custom commands, arguments, environment variables).

#![allow(unused)]
fn main() {
struct AgentRunConfig {
    name: String,
    command: String,
    args: Vec<String>,
    env: HashMap<String, String>,
    is_default: bool,
}

struct AgentSettings {
    run_configs: Vec<AgentRunConfig>,
}

struct AgentsConfig {
    agents: HashMap<String, AgentSettings>,
}
}

Commands: load_agents_config(), save_agents_config(config)

AI Chat Config (ai-chat-config.json)

Type: AiChatConfig

FieldTypeDefaultDescription
providerString"ollama"Provider: "ollama", "anthropic", "openai", "openrouter", "custom"
modelStringprovider-specificModel name (free text; settings tab suggests per provider)
base_urlOption<String>provider-specificPre-filled per provider, editable. Ollama default: http://localhost:11434/v1/
temperaturef320.7Sampling temperature passed through to provider
context_linesu32150Maximum VtLogBuffer lines injected into each turn’s context

Commands: load_ai_chat_config(), save_ai_chat_config(config)

API keys are stored in the OS keyring — service tuicommander-ai-chat, user api-key — via save_ai_chat_api_key / delete_ai_chat_api_key. Saved conversations live in <config_dir>/ai-chat-conversations/<id>.json.

Dictation Config (dictation-config.json)

Type: DictationConfig

FieldTypeDefaultDescription
enabledboolfalseDictation enabled
hotkeyString"CommandOrControl+Shift+D"Push-to-talk hotkey
languageString"en"Transcription language
modelString"large-v3-turbo"Whisper model name
auto_sendboolfalseAuto-submit after transcription

Commands: get_dictation_config(), set_dictation_config(config)

Cache Files

Claude Usage Cache (claude-usage-cache.json)

Module: src-tauri/src/claude_usage.rs

Persistent cache for incremental JSONL parsing of Claude session transcripts. Stored in the config directory. The cache maps project_slug -> (filename -> CachedFileStats) and tracks per-file byte offsets so only newly appended data is parsed on subsequent scans.

This is an internal cache file, not user-editable. It is automatically pruned when projects or session files are deleted.

Repo-Local Config (.tuic.json)

Module: src-tauri/src/config.rs

A .tuic.json file in the repository root provides team-shareable settings. It is read-only from the app — teams edit it directly in their repo and commit it.

Precedence chain: .tuic.json > per-repo app settings (repo-settings.json) > global defaults (repo-defaults.json)

Type: RepoLocalConfig (all fields Option<T>, missing fields fall through to lower tiers)

FieldTypeDescription
base_branchStringBase branch for worktrees
copy_ignored_filesboolCopy .gitignored files to worktree
copy_untracked_filesboolCopy untracked files to worktree
setup_scriptStringScript to run after worktree creation
run_scriptStringDefault run command
archive_scriptStringScript to run before archive/delete
worktree_storageWorktreeStorageStorage strategy (sibling/app-dir/inside-repo)
delete_branch_on_removeboolDelete branch when removing worktree
auto_archive_mergedboolAuto-archive merged worktrees
orphan_cleanupOrphanCleanupOrphan worktree handling
pr_merge_strategyMergeStrategyPR merge method preference
after_mergeWorktreeAfterMergePost-merge worktree action
auto_delete_on_pr_closeAutoDeleteOnPrCloseAuto-delete on PR close

Command: load_repo_local_config(repo_path) — returns RepoLocalConfig or null if file is missing or malformed.

Additional Commands

CommandModuleDescription
hash_password(password)lib.rsBcrypt hash for remote access authentication
list_markdown_files(path)lib.rsList .md files in a directory
read_file(path, file)lib.rsRead a file’s contents
get_mcp_status()lib.rsGet MCP server status (enabled, port, connected clients)
clear_caches()lib.rsClear in-memory caches
get_local_ip()lib.rsGet primary local IP address
get_local_ips()lib.rsList all local network interfaces
get_claude_usage_api()claude_usage.rsFetch rate-limit usage from Anthropic OAuth API
get_claude_usage_timeline(scope, days?)claude_usage.rsGet hourly token usage timeline from session transcripts
get_claude_session_stats(scope)claude_usage.rsScan JSONL transcripts for aggregated token/session stats
get_claude_project_list()claude_usage.rsList Claude project slugs with session counts
fetch_plugin_registry()registry.rsFetch remote plugin registry index