diff --git a/src/apps/desktop/src/api/terminal_api.rs b/src/apps/desktop/src/api/terminal_api.rs index 24962acde5..a98d18edf4 100644 --- a/src/apps/desktop/src/api/terminal_api.rs +++ b/src/apps/desktop/src/api/terminal_api.rs @@ -10,6 +10,7 @@ use tokio::sync::Mutex; use bitfun_core::service::remote_ssh::workspace_state::get_remote_workspace_manager; use bitfun_core::service::runtime::RuntimeManager; +use bitfun_core::service::config::load_terminal_env_vars; use bitfun_core::service::terminal::TerminalEvent; use bitfun_core::service::terminal::{ AcknowledgeRequest as CoreAcknowledgeRequest, CloseSessionRequest as CoreCloseSessionRequest, @@ -424,12 +425,14 @@ pub async fn terminal_create( let api = state.get_or_init_api().await?; let parsed_shell_type = request.shell_type.and_then(|s| parse_shell_type(&s)); + let mut env = request.env.unwrap_or_default(); + env.extend(load_terminal_env_vars().await); let core_request = CoreCreateSessionRequest { session_id: request.session_id, name: request.name, shell_type: parsed_shell_type, working_directory: request.working_directory, - env: request.env, + env: if env.is_empty() { None } else { Some(env) }, cols: request.cols, rows: request.rows, remote_connection_id: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs index dfe0ad0111..126f3a4c63 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs @@ -7,7 +7,7 @@ use crate::infrastructure::events::event_system::BackendEvent::{ ToolExecutionProgress, ToolTerminalReady, }; use crate::infrastructure::events::{emit_global_event, BackendEvent}; -use crate::service::config::global::get_global_config_service; +use crate::service::config::global::{get_global_config_service, load_terminal_env_vars}; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; @@ -714,6 +714,8 @@ Usage notes: // 3. Foreground: get or create the primary terminal session let terminal_ready_started_at = Instant::now(); + let mut session_env = Self::noninteractive_env(); + session_env.extend(load_terminal_env_vars().await); let primary_session_id = binding .get_or_create( chat_session_id, @@ -725,7 +727,7 @@ Usage notes: &chat_session_id[..8.min(chat_session_id.len())] )), shell_type: shell_type.clone(), - env: Some(Self::noninteractive_env()), + env: Some(session_env), source: Some(SessionSource::Agent), ..Default::default() }, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs index 063d1f4c55..43e61ccb16 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs @@ -11,6 +11,7 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, Validat use crate::infrastructure::events::event_system::{ get_global_event_system, BackendEvent::BackgroundCommandLifecycle, }; +use crate::service::config::load_terminal_env_vars; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::event::BackgroundCommandLifecycleInfo; use async_trait::async_trait; @@ -661,10 +662,12 @@ Output: None }; + let mut command_env = Self::command_env(); + command_env.extend(load_terminal_env_vars().await); let request = TerminalExecCommandRequest { argv: Self::argv_for_shell(&shell.path, &shell.shell_type, cmd), cwd: workdir.clone(), - env: Self::command_env(), + env: command_env, tty, yield_time_ms: Some(yield_time_ms), max_output_chars: None, diff --git a/src/crates/assembly/core/src/service/config/global.rs b/src/crates/assembly/core/src/service/config/global.rs index ddffab44c1..af3648fc32 100644 --- a/src/crates/assembly/core/src/service/config/global.rs +++ b/src/crates/assembly/core/src/service/config/global.rs @@ -232,6 +232,23 @@ pub async fn get_global_config_service() -> BitFunResult> { GlobalConfigManager::get_service().await } +/// Load user-defined terminal environment variables (`terminal.env_vars`) +/// from the global configuration. Returns an empty map when the config +/// service is unavailable or the key is unset, so callers can always merge +/// the result over a base environment. +pub async fn load_terminal_env_vars() -> std::collections::HashMap { + let Ok(config_service) = get_global_config_service().await else { + return std::collections::HashMap::new(); + }; + match config_service + .get_config::(Some("terminal")) + .await + { + Ok(cfg) => cfg.env_vars, + Err(_) => std::collections::HashMap::new(), + } +} + /// Convenience helper: initialize the global configuration service. pub async fn initialize_global_config() -> BitFunResult<()> { GlobalConfigManager::initialize().await diff --git a/src/crates/assembly/core/src/service/config/mod.rs b/src/crates/assembly/core/src/service/config/mod.rs index 577feea3f0..449ac613b3 100644 --- a/src/crates/assembly/core/src/service/config/mod.rs +++ b/src/crates/assembly/core/src/service/config/mod.rs @@ -19,8 +19,8 @@ pub use app_language::{ }; pub use factory::ConfigFactory; pub use global::{ - get_global_config_service, initialize_global_config, reload_global_config, - subscribe_config_updates, ConfigUpdateEvent, GlobalConfigManager, + get_global_config_service, initialize_global_config, load_terminal_env_vars, + reload_global_config, subscribe_config_updates, ConfigUpdateEvent, GlobalConfigManager, }; pub use manager::{ConfigManager, ConfigManagerSettings, ConfigStatistics}; #[cfg(feature = "product-full")] diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index d4201034e3..180d90396c 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -321,6 +321,10 @@ pub struct TerminalConfig { /// Terminal ANSI palette/defaults. This keeps the historical /// `terminal.theme` config key but is not the GUI theme contract. pub theme: TerminalThemeConfig, + + /// User-defined environment variables applied to every new terminal + /// session and to agent/tool one-shot command execution. + pub env_vars: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1468,6 +1472,7 @@ impl Default for TerminalConfig { cursor_style: "block".to_string(), scrollback: 1000, theme: TerminalThemeConfig::default(), + env_vars: HashMap::new(), } } } diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.scss b/src/web-ui/src/infrastructure/config/components/BasicsConfig.scss index 7a6a260df1..fe7de37603 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.scss @@ -98,3 +98,65 @@ font-size: var(--font-size-sm); } } + +.bitfun-env-vars-config { + &__content { + display: flex; + flex-direction: column; + gap: $size-gap-4; + } + + &__actions { + display: flex; + align-items: center; + gap: 8px; + } + + &__table { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 40px; + align-items: center; + gap: 8px; + + &--header { + .bitfun-env-vars-config__cell { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + } + } + } + + &__cell { + min-width: 0; + + &--action { + display: flex; + align-items: center; + justify-content: center; + } + } + + &__empty { + padding: 12px 16px; + border: 1px dashed var(--border-base); + border-radius: $size-radius-sm; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + } + + &__footer { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + } +} diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx index 5e9a55e15e..d82fb401f0 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Archive, FolderOpen } from 'lucide-react'; +import { Archive, FolderOpen, Plus, Trash2, Upload } from 'lucide-react'; import { Alert, Button, + Input, Select, Switch, Tooltip, @@ -708,6 +709,274 @@ function BasicsNotificationsSection() { const { t } = useTranslation('settings/ ); } +interface EnvVarRow { + id: string; + key: string; + value: string; +} + +let envVarRowSeq = 0; +const newEnvVarRowId = (): string => `envvar-${Date.now()}-${envVarRowSeq++}`; + +/** + * Parse environment-variable text (e.g. a `.env` file) into a key/value map. + * Supports `KEY=VALUE`, `export KEY=VALUE`, and `KEY: VALUE` lines. Skips blank + * and `#`-comment lines, strips surrounding quotes, and reports unparseable lines. + */ +function parseEnvText(text: string): { parsed: Record; skipped: number } { + const parsed: Record = {}; + let skipped = 0; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === '' || line.startsWith('#')) continue; + let work = line; + if (/^export\s+/.test(work)) { + work = work.replace(/^export\s+/, ''); + } + const match = work.match(/^([^=:]+)[=:](.*)$/); + if (!match) { + skipped += 1; + continue; + } + const key = match[1].trim(); + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (key === '') { + skipped += 1; + continue; + } + if (key.toUpperCase() === 'PATH') { + skipped += 1; + continue; + } + parsed[key] = value; + } + return { parsed, skipped }; +} + +function BasicsEnvVarsSection() { + const { t } = useTranslation('settings/basics'); + const isTauri = typeof window !== 'undefined' && '__TAURI__' in window; + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + + const showMessage = useCallback((type: 'success' | 'error' | 'info', text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 3000); + }, []); + + const loadData = useCallback(async () => { + try { + setLoading(true); + const terminalConfig = await configManager.getConfig('terminal'); + const envVars = terminalConfig?.env_vars ?? {}; + const next: EnvVarRow[] = Object.keys(envVars) + .sort((a, b) => a.localeCompare(b)) + .map((key) => ({ id: newEnvVarRowId(), key, value: envVars[key] ?? '' })); + setRows(next); + } catch (error) { + log.error('Failed to load terminal env vars', error); + showMessage('error', t('terminal.envVars.messages.loadFailed')); + } finally { + setLoading(false); + } + }, [showMessage, t]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + const handleAddRow = useCallback(() => { + setRows((prev) => [...prev, { id: newEnvVarRowId(), key: '', value: '' }]); + }, []); + + const handleRemoveRow = useCallback((id: string) => { + setRows((prev) => prev.filter((r) => r.id !== id)); + }, []); + + const handleRowChange = useCallback((id: string, field: 'key' | 'value', val: string) => { + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: val } : r))); + }, []); + + const buildRecord = useCallback((source: EnvVarRow[]): Record => { + const record: Record = {}; + for (const row of source) { + const key = row.key.trim(); + if (key === '') continue; + record[key] = row.value; + } + return record; + }, []); + + const persist = useCallback( + async (source: EnvVarRow[]): Promise => { + const record = buildRecord(source); + await configManager.setConfig>('terminal.env_vars', record); + configManager.clearCache(); + }, + [buildRecord] + ); + + const handleSave = useCallback(async () => { + try { + setSaving(true); + await persist(rows); + showMessage('success', t('terminal.envVars.messages.saved')); + } catch (error) { + log.error('Failed to save terminal env vars', error); + showMessage('error', t('terminal.envVars.messages.saveFailed')); + } finally { + setSaving(false); + } + }, [persist, rows, showMessage, t]); + + const handleImportFile = useCallback(async () => { + try { + const filePath = await workspaceAPI.open_oh_file_dialog(); + if (!filePath) return; + const text = await workspaceAPI.readFileContent(filePath); + const { parsed, skipped } = parseEnvText(text); + const parsedCount = Object.keys(parsed).length; + if (parsedCount === 0) { + showMessage('error', t('terminal.envVars.messages.importFailed')); + return; + } + const merged = (() => { + const byKey = new Map(); + for (const r of rows) byKey.set(r.key.trim(), r); + for (const [k, v] of Object.entries(parsed)) { + const existing = byKey.get(k); + if (existing) { + existing.value = v; + } else { + byKey.set(k, { id: newEnvVarRowId(), key: k, value: v }); + } + } + return Array.from(byKey.values()).sort((a, b) => a.key.localeCompare(b.key)); + })(); + setRows(merged); + await persist(merged); + showMessage( + 'success', + skipped > 0 + ? t('terminal.envVars.messages.importedWithSkipped', { count: parsedCount, skipped }) + : t('terminal.envVars.messages.imported', { count: parsedCount }) + ); + } catch (error) { + log.error('Failed to import env vars from file', error); + showMessage('error', t('terminal.envVars.messages.importFailed')); + } + }, [persist, rows, showMessage, t]); + + if (!isTauri) return null; + + if (loading) { + return ; + } + + return ( +
+
+ + + + +
+ } + > + {rows.length === 0 ? ( +
{t('terminal.envVars.empty')}
+ ) : ( +
+
+
+ {t('terminal.envVars.columns.key')} +
+
+ {t('terminal.envVars.columns.value')} +
+
+
+ {rows.map((row) => ( +
+
+ handleRowChange(row.id, 'key', e.target.value)} + placeholder={t('terminal.envVars.columns.keyPlaceholder')} + disabled={saving} + inputSize="small" + /> +
+
+ handleRowChange(row.id, 'value', e.target.value)} + placeholder={t('terminal.envVars.columns.valuePlaceholder')} + disabled={saving} + inputSize="small" + /> +
+
+ + + +
+
+ ))} +
+ )} +
+ +
+ +
+
+ ); +} + const BasicsConfig: React.FC = () => { const { t } = useTranslation('settings/basics'); @@ -719,6 +988,7 @@ const BasicsConfig: React.FC = () => { + diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index 42e6ec0234..7f3be54aa2 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -578,6 +578,7 @@ export type ConfigPath = | 'terminal' | 'terminal.default_shell' | 'terminal.terminal_panel_position' + | 'terminal.env_vars' | 'workspace' | 'ai' | 'ai.default_model' diff --git a/src/web-ui/src/locales/en-US/settings/basics.json b/src/web-ui/src/locales/en-US/settings/basics.json index 2dae0332bc..07d7e7d052 100644 --- a/src/web-ui/src/locales/en-US/settings/basics.json +++ b/src/web-ui/src/locales/en-US/settings/basics.json @@ -175,6 +175,35 @@ "panelPositionUpdated": "Terminal panel location updated", "saveFailed": "Failed to save configuration", "refreshed": "Available terminals list refreshed" + }, + "envVars": { + "title": "Environment Variables", + "hint": "Custom environment variables applied to every new terminal session and to agent command execution. Changes take effect for new terminals; already-open sessions are not affected. Setting PATH overrides the managed runtime PATH.", + "save": "Save", + "empty": "No environment variables configured. Add a row or import a file.", + "importFile": { + "button": "Import from File", + "filterName": "Environment files" + }, + "columns": { + "key": "Key", + "value": "Value", + "keyPlaceholder": "VAR_NAME", + "valuePlaceholder": "value" + }, + "actions": { + "addRow": "Add Row", + "delete": "Delete" + }, + "messages": { + "loading": "Loading...", + "loadFailed": "Failed to load environment variables", + "saved": "Environment variables saved", + "saveFailed": "Failed to save environment variables", + "imported": "Imported {{count}} variable(s)", + "importedWithSkipped": "Imported {{count}} variable(s), skipped {{skipped}} invalid line(s)", + "importFailed": "Failed to import environment variables" + } } }, "windowBehavior": { diff --git a/src/web-ui/src/locales/zh-CN/settings/basics.json b/src/web-ui/src/locales/zh-CN/settings/basics.json index 6d0fe7c4eb..f534cc7017 100644 --- a/src/web-ui/src/locales/zh-CN/settings/basics.json +++ b/src/web-ui/src/locales/zh-CN/settings/basics.json @@ -175,6 +175,35 @@ "panelPositionUpdated": "终端显示位置已更新", "saveFailed": "保存配置失败", "refreshed": "已刷新可用终端列表" + }, + "envVars": { + "title": "环境变量", + "hint": "自定义环境变量,将应用于每个新终端会话及 Agent 命令执行。更改对新开终端生效;已打开的会话不受影响。设置 PATH 会覆盖运行时托管的 PATH。", + "save": "保存", + "empty": "尚未配置环境变量。可添加一行或从文件导入。", + "importFile": { + "button": "从文件导入", + "filterName": "环境变量文件" + }, + "columns": { + "key": "变量名", + "value": "变量值", + "keyPlaceholder": "VAR_NAME", + "valuePlaceholder": "value" + }, + "actions": { + "addRow": "添加一行", + "delete": "删除" + }, + "messages": { + "loading": "加载中...", + "loadFailed": "加载环境变量失败", + "saved": "环境变量已保存", + "saveFailed": "保存环境变量失败", + "imported": "已导入 {{count}} 个变量", + "importedWithSkipped": "已导入 {{count}} 个变量,跳过 {{skipped}} 行无效内容", + "importFailed": "导入环境变量失败" + } } }, "windowBehavior": { diff --git a/src/web-ui/src/locales/zh-TW/settings/basics.json b/src/web-ui/src/locales/zh-TW/settings/basics.json index 7aec1194d9..a1cb849d55 100644 --- a/src/web-ui/src/locales/zh-TW/settings/basics.json +++ b/src/web-ui/src/locales/zh-TW/settings/basics.json @@ -161,6 +161,35 @@ "panelPositionUpdated": "終端顯示位置已更新", "saveFailed": "儲存設定失敗", "refreshed": "已重新整理可用終端列表" + }, + "envVars": { + "title": "環境變數", + "hint": "自訂環境變數,將套用於每個新終端工作階段及 Agent 命令執行。變更新對新開終端生效;已開啟的工作階段不受影響。設定 PATH 會覆寫託管執行時期的 PATH。", + "save": "儲存", + "empty": "尚未設定環境變數。可新增一列或從檔案匯入。", + "importFile": { + "button": "從檔案匯入", + "filterName": "環境變數檔案" + }, + "columns": { + "key": "變數名稱", + "value": "變數值", + "keyPlaceholder": "VAR_NAME", + "valuePlaceholder": "value" + }, + "actions": { + "addRow": "新增一列", + "delete": "刪除" + }, + "messages": { + "loading": "載入中...", + "loadFailed": "載入環境變數失敗", + "saved": "環境變數已儲存", + "saveFailed": "儲存環境變數失敗", + "imported": "已匯入 {{count}} 個變數", + "importedWithSkipped": "已匯入 {{count}} 個變數,跳過 {{skipped}} 行無效內容", + "importFailed": "匯入環境變數失敗" + } } }, "windowBehavior": {