Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src-tauri/src/cli/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2078,22 +2078,38 @@ pub mod texts {
}
}

pub fn tui_label_codex_anthropic_auth_field() -> &'static str {
fn tui_label_auth_field() -> &'static str {
if is_chinese() {
"认证字段"
} else {
"Auth field"
}
}

pub fn tui_codex_anthropic_auth_field_value(api_key_field: &str) -> &'static str {
pub fn tui_label_codex_anthropic_auth_field() -> &'static str {
tui_label_auth_field()
}

fn tui_auth_field_value(api_key_field: &str) -> &'static str {
if api_key_field == "ANTHROPIC_API_KEY" {
"ANTHROPIC_API_KEY (x-api-key)"
} else {
"ANTHROPIC_AUTH_TOKEN (Authorization)"
}
}

pub fn tui_codex_anthropic_auth_field_value(api_key_field: &str) -> &'static str {
tui_auth_field_value(api_key_field)
}

pub fn tui_label_claude_auth_field() -> &'static str {
tui_label_auth_field()
}

pub fn tui_claude_auth_field_value(api_key_field: &str) -> &'static str {
tui_auth_field_value(api_key_field)
}

pub fn tui_label_codex_impersonate_claude_code() -> &'static str {
if is_chinese() {
"模拟 Claude Code 客户端"
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/cli/tui/app/form_handlers/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,8 @@ impl App {
};
Action::None
}
ProviderAddField::CodexAnthropicApiKeyField => {
ProviderAddField::CodexAnthropicApiKeyField
| ProviderAddField::ClaudeAnthropicApiKeyField => {
if !matches!(key.code, KeyCode::Enter) {
return Action::None;
}
Expand Down
40 changes: 40 additions & 0 deletions src-tauri/src/cli/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12592,6 +12592,46 @@ mod tests {
assert_eq!(focus, super::super::form::FormFocus::Fields);
}

#[test]
fn provider_add_form_claude_auth_field_enter_toggles_api_key_field() {
let mut app = App::new(Some(AppType::Claude));
app.route = Route::Providers;
app.focus = Focus::Content;

let data = UiData::default();
app.on_key(key(KeyCode::Char('a')), &data);
app.on_key(key(KeyCode::Enter), &data);

// Position the cursor on the Claude auth field selector.
let auth_field = super::super::form::ProviderAddField::ClaudeAnthropicApiKeyField;
if let Some(super::super::form::FormState::ProviderAdd(provider)) = app.form.as_mut() {
provider.focus = super::super::form::FormFocus::Fields;
let fields = provider.fields();
let idx = fields
.iter()
.position(|field| *field == auth_field)
.expect("ClaudeAnthropicApiKeyField should be present");
provider.field_idx = idx;
assert_eq!(
provider.claude_api_key_field,
crate::provider::ClaudeApiKeyField::AuthToken
);
} else {
panic!("expected ProviderAdd form");
}

let action = app.on_key(key(KeyCode::Enter), &data);
assert!(matches!(action, Action::None));

let field = match app.form.as_ref() {
Some(super::super::form::FormState::ProviderAdd(provider)) => {
provider.claude_api_key_field
}
other => panic!("expected ProviderAdd form, got: {other:?}"),
};
assert_eq!(field, crate::provider::ClaudeApiKeyField::ApiKey);
}

#[test]
fn provider_form_esc_dirty_opens_save_before_close_confirm() {
let mut app = App::new(Some(AppType::Claude));
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/cli/tui/form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ pub enum ProviderAddField {
ClaudeBaseUrl,
ClaudeApiFormat,
ClaudeApiKey,
ClaudeAnthropicApiKeyField,
ClaudeModelConfig,
ClaudeFallbackModel,
ClaudeAdvancedDivider,
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/cli/tui/form/provider_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ impl ProviderAddFormState {
} else if !self.is_claude_official_provider() {
fields.push(ProviderAddField::ClaudeBaseUrl);
fields.push(ProviderAddField::ClaudeApiKey);
fields.push(ProviderAddField::ClaudeAnthropicApiKeyField);
fields.push(ProviderAddField::ClaudeAdvancedDivider);
fields.push(ProviderAddField::ClaudeApiFormat);
fields.push(ProviderAddField::ClaudeModelConfig);
Expand Down Expand Up @@ -696,6 +697,7 @@ impl ProviderAddFormState {
ProviderAddField::HermesRateLimitDelay => Some(&self.hermes_rate_limit_delay),
ProviderAddField::CodexOAuthAccount
| ProviderAddField::CodexFastMode
| ProviderAddField::ClaudeAnthropicApiKeyField
| ProviderAddField::CodexAnthropicApiKeyField
| ProviderAddField::CodexImpersonateClaudeCode
| ProviderAddField::CodexPromptCacheRouting
Expand Down Expand Up @@ -764,6 +766,7 @@ impl ProviderAddFormState {
ProviderAddField::HermesRateLimitDelay => Some(&mut self.hermes_rate_limit_delay),
ProviderAddField::CodexOAuthAccount
| ProviderAddField::CodexFastMode
| ProviderAddField::ClaudeAnthropicApiKeyField
| ProviderAddField::CodexAnthropicApiKeyField
| ProviderAddField::CodexImpersonateClaudeCode
| ProviderAddField::CodexPromptCacheRouting
Expand Down
74 changes: 74 additions & 0 deletions src-tauri/src/cli/tui/form/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2663,6 +2663,57 @@ fn provider_add_form_claude_builds_env_settings() {
);
}

#[test]
fn provider_add_form_claude_has_auth_field_selector() {
let form = ProviderAddFormState::new(AppType::Claude);
let fields = form.fields();
assert!(
fields.contains(&ProviderAddField::ClaudeAnthropicApiKeyField),
"Claude custom provider should expose the auth field selector"
);
}

#[test]
fn provider_add_form_claude_api_key_field_switches_env_and_meta() {
let mut form = ProviderAddFormState::new(AppType::Claude);
form.id.set("p1");
form.name.set("Provider One");
form.claude_base_url.set("https://kimi.example");
form.claude_api_key.set("sk-kimi");
form.claude_api_key_field = ClaudeApiKeyField::ApiKey;

let provider = form.to_provider_json_value();
assert_eq!(
provider["settingsConfig"]["env"]["ANTHROPIC_API_KEY"],
"sk-kimi"
);
assert!(
provider["settingsConfig"]["env"]
.get("ANTHROPIC_AUTH_TOKEN")
.is_none(),
"ANTHROPIC_AUTH_TOKEN should be removed when ApiKey is selected"
);
assert_eq!(provider["meta"]["apiKeyField"], "ANTHROPIC_API_KEY");

// Switch back to the default auth token.
form.claude_api_key_field = ClaudeApiKeyField::AuthToken;
let provider = form.to_provider_json_value();
assert_eq!(
provider["settingsConfig"]["env"]["ANTHROPIC_AUTH_TOKEN"],
"sk-kimi"
);
assert!(
provider["settingsConfig"]["env"]
.get("ANTHROPIC_API_KEY")
.is_none(),
"ANTHROPIC_API_KEY should be removed when AuthToken is selected"
);
assert!(
provider["meta"].get("apiKeyField").is_none(),
"default AuthToken should not write apiKeyField meta"
);
}

#[test]
fn provider_add_form_claude_api_format_writes_openai_chat_meta() {
let mut form = ProviderAddFormState::new(AppType::Claude);
Expand Down Expand Up @@ -7059,6 +7110,29 @@ fn provider_edit_form_infers_claude_api_key_field_from_env_when_meta_missing() {
);
}

#[test]
fn provider_edit_form_claude_exposes_auth_field_selector_for_api_key_env() {
let provider_value = json!({
"id": "provider-1",
"name": "Provider One",
"settingsConfig": {
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com",
"ANTHROPIC_API_KEY": "sk-api-key"
}
}
});
let provider: Provider = serde_json::from_value(provider_value).expect("provider json valid");

let form = ProviderAddFormState::from_provider(AppType::Claude, &provider);
assert_eq!(form.claude_api_key_field, ClaudeApiKeyField::ApiKey);
assert!(
form.fields()
.contains(&ProviderAddField::ClaudeAnthropicApiKeyField),
"edit form should expose the Claude auth field selector"
);
}

#[test]
fn provider_add_form_does_not_write_usage_script_until_touched() {
let mut form = ProviderAddFormState::new(AppType::Claude);
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/cli/tui/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,13 @@ fn provider_field_help(app_type: AppType, field: ProviderAddField) -> HelpConten
"Choose which header carries the API key: ANTHROPIC_AUTH_TOKEN sends Authorization: Bearer; ANTHROPIC_API_KEY sends x-api-key. Only one is sent.",
),
),
ProviderAddField::ClaudeAnthropicApiKeyField => HelpContent::new(
texts::tui_label_claude_auth_field(),
help_lines(
"选择 Claude Code 写入 settings.json 的 API Key 字段名:ANTHROPIC_AUTH_TOKEN(默认)或 ANTHROPIC_API_KEY。切换时会迁移已填写的 key。",
"Choose the API key field name written to Claude Code's settings.json: ANTHROPIC_AUTH_TOKEN (default) or ANTHROPIC_API_KEY. The entered key is migrated when switching.",
),
),
ProviderAddField::CodexImpersonateClaudeCode => HelpContent::new(
texts::tui_label_codex_impersonate_claude_code(),
help_lines(
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/cli/tui/ui/forms/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,9 @@ pub(crate) fn provider_field_label_and_value(
}
}
ProviderAddField::ClaudeApiKey => texts::tui_label_api_key().to_string(),
ProviderAddField::ClaudeAnthropicApiKeyField => {
texts::tui_label_claude_auth_field().to_string()
}
ProviderAddField::ClaudeModelConfig => texts::tui_label_claude_model_config().to_string(),
ProviderAddField::ClaudeFallbackModel => {
texts::tui_label_claude_fallback_model().to_string()
Expand Down Expand Up @@ -1966,6 +1969,10 @@ pub(crate) fn provider_field_label_and_value(

let value = match field {
ProviderAddField::ClaudeApiFormat => provider_api_format_label(provider),
ProviderAddField::ClaudeAnthropicApiKeyField => {
texts::tui_claude_auth_field_value(provider.claude_api_key_field.as_env_key())
.to_string()
}
ProviderAddField::CodexAnthropicApiKeyField => {
texts::tui_codex_anthropic_auth_field_value(provider.claude_api_key_field.as_env_key())
.to_string()
Expand Down
66 changes: 57 additions & 9 deletions src-tauri/src/cli/tui/ui/forms/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ pub(crate) fn add_form_key_items(
ProviderAddField::GeminiAuthType
| ProviderAddField::CodexPromptCacheRouting
| ProviderAddField::OpenClawApiProtocol
| ProviderAddField::HermesApiMode,
| ProviderAddField::HermesApiMode
| ProviderAddField::ClaudeAnthropicApiKeyField
| ProviderAddField::CodexAnthropicApiKeyField,
) => texts::tui_key_select(),
_ => texts::tui_key_edit_mode(),
};
Expand Down Expand Up @@ -251,22 +253,68 @@ pub(crate) fn render_form_template_chips(
frame.render_widget(template_block.clone(), area);
let template_inner = template_block.inner(area);

let mut spans: Vec<Span<'static>> = Vec::new();
for (idx, label) in labels.iter().enumerate() {
if template_inner.width == 0 || labels.is_empty() {
return;
}

let selected_idx = selected_idx.min(labels.len().saturating_sub(1));
let viewport = template_inner.width as usize;

// Build chip metadata: each chip renders as " {label} " followed by a
// trailing space, so its total advance is label width + 3.
let chip_widths: Vec<usize> = labels
.iter()
.map(|label| UnicodeWidthStr::width(*label).saturating_add(3))
.collect();
let total_width = chip_widths
.iter()
.fold(0usize, |acc, width| acc.saturating_add(*width));

// Decide the horizontal window so that the selected chip is always fully
// visible. When space permits, also show preceding and following chips.
let (first_visible, last_visible) = if total_width <= viewport {
(0, labels.len().saturating_sub(1))
} else {
let mut first = selected_idx;
let mut last = selected_idx;
let mut used = chip_widths[selected_idx];
while first > 0 {
let prev_width = chip_widths[first.saturating_sub(1)];
if used.saturating_add(prev_width) > viewport {
break;
}
used = used.saturating_add(prev_width);
first = first.saturating_sub(1);
}
while last.saturating_add(1) < labels.len() {
let next_width = chip_widths[last.saturating_add(1)];
if used.saturating_add(next_width) > viewport {
break;
}
used = used.saturating_add(next_width);
last = last.saturating_add(1);
}
(first, last)
};

let mut visible_spans: Vec<Span<'static>> = Vec::new();
for (idx, label) in labels
.iter()
.enumerate()
.skip(first_visible)
.take(last_visible.saturating_sub(first_visible).saturating_add(1))
{
let selected = idx == selected_idx;
let style = if selected {
active_chip_style(theme)
} else {
inactive_chip_style(theme)
};
spans.push(Span::styled(format!(" {label} "), style));
spans.push(Span::raw(" "));
visible_spans.push(Span::styled(format!(" {label} "), style));
visible_spans.push(Span::raw(" "));
}

frame.render_widget(
Paragraph::new(Line::from(spans)).wrap(Wrap { trim: false }),
template_inner,
);
frame.render_widget(Paragraph::new(Line::from(visible_spans)), template_inner);
}

pub(crate) fn visible_text_window(text: &str, cursor: usize, width: usize) -> (String, u16) {
Expand Down
Loading