feat(tui/core): add account switching and skills settings

This commit is contained in:
James Peter
2025-12-22 15:17:35 +10:00
parent 095c8401cc
commit b8ea3d3f03
27 changed files with 1888 additions and 138 deletions
+346
View File
@@ -0,0 +1,346 @@
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::Path;
use chrono::{DateTime, Utc};
use code_app_server_protocol::AuthMode;
use crate::account_usage;
use crate::auth_accounts;
#[derive(Debug, Default)]
pub(crate) struct RateLimitSwitchState {
tried_accounts: HashSet<String>,
limited_chatgpt_accounts: HashSet<String>,
blocked_until: HashMap<String, DateTime<Utc>>,
}
impl RateLimitSwitchState {
pub(crate) fn mark_limited(
&mut self,
account_id: &str,
mode: AuthMode,
blocked_until: Option<DateTime<Utc>>,
) {
self.tried_accounts.insert(account_id.to_string());
if mode == AuthMode::ChatGPT {
self.limited_chatgpt_accounts
.insert(account_id.to_string());
}
if let Some(until) = blocked_until {
self.blocked_until
.entry(account_id.to_string())
.and_modify(|existing| {
if until > *existing {
*existing = until;
}
})
.or_insert(until);
}
}
fn blocked_until(&self, account_id: &str) -> Option<DateTime<Utc>> {
self.blocked_until.get(account_id).copied()
}
fn has_tried(&self, account_id: &str) -> bool {
self.tried_accounts.contains(account_id)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct CandidateScore {
used_percent: f64,
}
fn account_has_credentials(account: &auth_accounts::StoredAccount) -> bool {
match account.mode {
AuthMode::ChatGPT => account.tokens.is_some(),
AuthMode::ApiKey => account.openai_api_key.is_some(),
}
}
fn usage_reset_blocked_until(
snapshot: &account_usage::StoredRateLimitSnapshot,
) -> Option<DateTime<Utc>> {
snapshot
.primary_next_reset_at
.into_iter()
.chain(snapshot.secondary_next_reset_at)
.max()
}
fn usage_used_percent(snapshot: &account_usage::StoredRateLimitSnapshot) -> Option<f64> {
let snapshot = snapshot.snapshot.as_ref()?;
let used = snapshot
.primary_used_percent
.max(snapshot.secondary_used_percent);
if used.is_finite() {
Some(used)
} else {
None
}
}
fn is_blocked(now: DateTime<Utc>, blocked_until: Option<DateTime<Utc>>) -> bool {
blocked_until.is_some_and(|until| until > now)
}
pub(crate) fn select_next_account_id(
code_home: &Path,
state: &RateLimitSwitchState,
allow_api_key_fallback: bool,
now: DateTime<Utc>,
) -> io::Result<Option<String>> {
let current = auth_accounts::get_active_account_id(code_home)?;
let accounts = auth_accounts::list_accounts(code_home)?;
let snapshots = account_usage::list_rate_limit_snapshots(code_home).unwrap_or_default();
let snapshot_map: HashMap<String, account_usage::StoredRateLimitSnapshot> = snapshots
.into_iter()
.map(|snap| (snap.account_id.clone(), snap))
.collect();
let mut chatgpt_accounts: Vec<&auth_accounts::StoredAccount> = accounts
.iter()
.filter(|acc| acc.mode == AuthMode::ChatGPT)
.filter(|acc| account_has_credentials(acc))
.collect();
let mut api_key_accounts: Vec<&auth_accounts::StoredAccount> = accounts
.iter()
.filter(|acc| acc.mode == AuthMode::ApiKey)
.filter(|acc| account_has_credentials(acc))
.collect();
// Prefer deterministic ordering.
chatgpt_accounts.sort_by(|a, b| a.id.cmp(&b.id));
api_key_accounts.sort_by(|a, b| a.id.cmp(&b.id));
let current = current.as_deref();
let mut best_chatgpt: Option<(&auth_accounts::StoredAccount, CandidateScore)> = None;
for account in &chatgpt_accounts {
if current.is_some_and(|id| id == account.id) {
continue;
}
if state.has_tried(&account.id) {
continue;
}
let blocked_until = state
.blocked_until(&account.id)
.into_iter()
.chain(snapshot_map.get(&account.id).and_then(usage_reset_blocked_until))
.max();
if is_blocked(now, blocked_until) {
continue;
}
let used_percent = snapshot_map
.get(&account.id)
.and_then(usage_used_percent)
.unwrap_or(0.0);
let score = CandidateScore { used_percent };
match best_chatgpt {
None => best_chatgpt = Some((*account, score)),
Some((_, best_score)) => {
if score.used_percent < best_score.used_percent {
best_chatgpt = Some((*account, score));
}
}
}
}
if let Some((account, _)) = best_chatgpt {
return Ok(Some(account.id.clone()));
}
if !allow_api_key_fallback {
return Ok(None);
}
// Only allow API key fallback when every ChatGPT account is either blocked
// or has already been tried and still rate/usage limited.
let all_chatgpt_unavailable = chatgpt_accounts.iter().all(|account| {
let blocked_until = state
.blocked_until(&account.id)
.into_iter()
.chain(snapshot_map.get(&account.id).and_then(usage_reset_blocked_until))
.max();
let blocked = is_blocked(now, blocked_until);
let exhausted = state.limited_chatgpt_accounts.contains(&account.id);
let tried = state.has_tried(&account.id);
blocked || (tried && exhausted)
});
if !chatgpt_accounts.is_empty() && !all_chatgpt_unavailable {
return Ok(None);
}
for account in &api_key_accounts {
if current.is_some_and(|id| id == account.id) {
continue;
}
if state.has_tried(&account.id) {
continue;
}
return Ok(Some(account.id.clone()));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::token_data::{IdTokenInfo, TokenData};
use base64::Engine;
use chrono::TimeZone;
use serde::Serialize;
use tempfile::tempdir;
fn fake_jwt(email: &str, plan: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = serde_json::json!({
"email": email,
"https://api.openai.com/auth": {
"chatgpt_plan_type": plan,
}
});
fn b64url_no_pad(bytes: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).expect("header"));
let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).expect("payload"));
let signature_b64 = b64url_no_pad(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn chatgpt_tokens(account_id: &str, email: &str) -> TokenData {
TokenData {
id_token: IdTokenInfo {
email: Some(email.to_string()),
chatgpt_plan_type: None,
raw_jwt: fake_jwt(email, "pro"),
},
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
account_id: Some(account_id.to_string()),
}
}
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2025, 12, 22, 12, 0, 0).unwrap()
}
#[test]
fn selects_another_chatgpt_account_when_available() {
let home = tempdir().expect("tmp");
let a = auth_accounts::upsert_chatgpt_account(
home.path(),
chatgpt_tokens("acct-a", "[email protected]"),
Utc::now(),
None,
true,
)
.expect("insert a");
let b = auth_accounts::upsert_chatgpt_account(
home.path(),
chatgpt_tokens("acct-b", "[email protected]"),
Utc::now(),
None,
false,
)
.expect("insert b");
let mut state = RateLimitSwitchState::default();
state.mark_limited(&a.id, AuthMode::ChatGPT, None);
let next = select_next_account_id(home.path(), &state, false, fixed_now())
.expect("select");
assert_eq!(next.as_deref(), Some(b.id.as_str()));
}
#[test]
fn skips_chatgpt_accounts_blocked_by_reset_time() {
let home = tempdir().expect("tmp");
let a = auth_accounts::upsert_chatgpt_account(
home.path(),
chatgpt_tokens("acct-a", "[email protected]"),
Utc::now(),
None,
true,
)
.expect("insert a");
let b = auth_accounts::upsert_chatgpt_account(
home.path(),
chatgpt_tokens("acct-b", "[email protected]"),
Utc::now(),
None,
false,
)
.expect("insert b");
let now = fixed_now();
let reset_in = Some(60 * 60);
account_usage::record_usage_limit_hint(home.path(), &b.id, Some("Pro"), reset_in, now)
.expect("hint");
let mut state = RateLimitSwitchState::default();
state.mark_limited(&a.id, AuthMode::ChatGPT, None);
let next = select_next_account_id(home.path(), &state, false, now).expect("select");
assert!(next.is_none());
}
#[test]
fn api_key_fallback_requires_all_chatgpt_limited() {
let home = tempdir().expect("tmp");
let a = auth_accounts::upsert_chatgpt_account(
home.path(),
chatgpt_tokens("acct-a", "[email protected]"),
Utc::now(),
None,
true,
)
.expect("insert a");
let b = auth_accounts::upsert_chatgpt_account(
home.path(),
chatgpt_tokens("acct-b", "[email protected]"),
Utc::now(),
None,
false,
)
.expect("insert b");
let api = auth_accounts::upsert_api_key_account(
home.path(),
"sk-test".to_string(),
None,
false,
)
.expect("insert api");
let now = fixed_now();
let mut state = RateLimitSwitchState::default();
state.mark_limited(&a.id, AuthMode::ChatGPT, None);
let next = select_next_account_id(home.path(), &state, true, now).expect("select");
assert_eq!(next.as_deref(), Some(b.id.as_str()));
// After both ChatGPT accounts are exhausted, allow API key fallback.
state.mark_limited(&b.id, AuthMode::ChatGPT, None);
let next = select_next_account_id(home.path(), &state, true, now).expect("select");
assert_eq!(next.as_deref(), Some(api.id.as_str()));
}
}
+260 -76
View File
@@ -7,6 +7,9 @@ use std::time::Duration;
use crate::AuthManager;
use crate::RefreshTokenError;
use crate::account_usage;
use crate::auth;
use crate::auth_accounts;
use bytes::Bytes;
use code_app_server_protocol::AuthMode;
use code_protocol::models::ResponseItem;
@@ -27,7 +30,7 @@ use tracing::debug;
use tracing::trace;
use tracing::warn;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
const AUTH_REQUIRED_MESSAGE: &str = "Authentication required. Run `code login` to continue.";
@@ -568,6 +571,7 @@ impl ModelClient {
let mut attempt = 0;
let max_retries = self.provider.request_max_retries();
let mut request_id = String::new();
let mut rate_limit_switch_state = crate::account_switching::RateLimitSwitchState::default();
// Compute endpoint with the latest available auth (may be None at this point).
let endpoint = self
@@ -803,6 +807,126 @@ impl ModelClient {
let body_text = res.text().await.unwrap_or_default();
let body = serde_json::from_str::<ErrorResponse>(&body_text).ok();
if status == StatusCode::TOO_MANY_REQUESTS
&& self.config.auto_switch_accounts_on_rate_limit
&& auth_manager.is_some()
&& auth::read_code_api_key_from_env().is_none()
{
if let Ok(Some(current_account_id)) =
auth_accounts::get_active_account_id(self.code_home())
{
let mut retry_after_delay = retry_after_hint.clone();
if retry_after_delay.is_none() {
if let Some(ErrorResponse { ref error }) = body {
retry_after_delay = try_parse_retry_after(error, now);
}
}
let current_auth_mode = auth
.as_ref()
.map(|a| a.mode)
.unwrap_or(AuthMode::ApiKey);
let switch_reason = match body
.as_ref()
.and_then(|err| err.error.r#type.as_deref())
{
Some("usage_limit_reached") => "usage_limit_reached",
Some("usage_not_included") => "usage_not_included",
_ => "http_429",
};
let (blocked_until, should_record_usage_limit) = match body.as_ref() {
Some(ErrorResponse { error })
if error.r#type.as_deref() == Some("usage_limit_reached") =>
{
(
error
.resets_in_seconds
.map(|seconds| now + ChronoDuration::seconds(seconds as i64)),
true,
)
}
_ => (retry_after_delay.as_ref().map(|info| info.resume_at), false),
};
rate_limit_switch_state.mark_limited(
&current_account_id,
current_auth_mode,
blocked_until,
);
if let Ok(Some(next_account_id)) =
crate::account_switching::select_next_account_id(
self.code_home(),
&rate_limit_switch_state,
self.config.api_key_fallback_on_all_accounts_limited,
now,
)
{
if should_record_usage_limit {
let plan_type = body
.as_ref()
.and_then(|err| err.error.plan_type.as_deref())
.map(|s| s.to_string());
let resets_in_seconds =
body.as_ref().and_then(|err| err.error.resets_in_seconds);
let code_home = self.code_home().to_path_buf();
let account_id = current_account_id.clone();
tokio::task::spawn_blocking(move || {
let observed_at = Utc::now();
if let Err(err) = account_usage::record_usage_limit_hint(
&code_home,
&account_id,
plan_type.as_deref(),
resets_in_seconds,
observed_at,
) {
tracing::warn!("Failed to persist usage limit hint: {err}");
}
});
}
tracing::info!(
from_account_id = %current_account_id,
to_account_id = %next_account_id,
reason = switch_reason,
"rate limit hit; auto-switching active account"
);
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"account_switch",
&serde_json::json!({
"reason": switch_reason,
"from_account_id": current_account_id.clone(),
"to_account_id": next_account_id.clone(),
"status": status.as_u16(),
}),
);
}
if let Err(err) =
auth::activate_account(self.code_home(), &next_account_id)
{
tracing::warn!(
from_account_id = %current_account_id,
to_account_id = %next_account_id,
error = %err,
"failed to activate account after rate limit"
);
} else {
if let Some(manager) = auth_manager.as_ref() {
manager.reload();
}
attempt = 0;
continue;
}
}
}
}
if status == StatusCode::BAD_REQUEST {
if let Some(ErrorResponse { ref error }) = body {
if !self.reasoning_summary_disabled.load(Ordering::Relaxed)
@@ -1031,38 +1155,7 @@ impl ModelClient {
}
let auth_manager = self.auth_manager.clone();
let auth = auth_manager.as_ref().and_then(|m| m.auth());
let mut request = self
.provider
.create_compact_request_builder(&self.client, &auth)
.await?;
// Ensure Responses API beta header is present for compact calls. Mirror the
// streaming path: use the public "responses=v1" header for the public OpenAI
// endpoint and fall back to "responses=experimental" for other providers.
let has_beta_header = request
.try_clone()
.and_then(|builder| builder.build().ok())
.map_or(false, |req| req.headers().contains_key("OpenAI-Beta"));
if !has_beta_header {
let beta_value = if self.provider.is_public_openai_responses_endpoint() {
RESPONSES_BETA_HEADER_V1
} else {
RESPONSES_BETA_HEADER_EXPERIMENTAL
};
request = request.header("OpenAI-Beta", beta_value);
}
request = attach_openai_subagent_header(request);
request = attach_codex_beta_features_header(request, &self.config);
if let Some(auth) = auth.as_ref()
&& auth.mode == AuthMode::ChatGPT
&& let Some(account_id) = auth.get_account_id()
{
request = request.header("chatgpt-account-id", account_id);
}
let mut rate_limit_switch_state = crate::account_switching::RateLimitSwitchState::default();
let model_slug = prompt
.model_override
@@ -1084,52 +1177,143 @@ impl ModelClient {
"input": payload.input,
"instructions": instructions,
});
request = request.json(&payload);
let header_snapshot = request
.try_clone()
.and_then(|builder| builder.build().ok())
.map(|req| header_map_to_json(req.headers()));
let mut request_id = String::new();
if let Ok(logger) = self.debug_logger.lock() {
let endpoint = self
loop {
let auth = auth_manager.as_ref().and_then(|m| m.auth());
let mut request = self
.provider
.get_compact_url(&auth)
.unwrap_or_else(|| self.provider.get_full_url(&auth));
request_id = logger
.start_request_log(&endpoint, &payload_json, header_snapshot.as_ref(), Some("compact_remote"))
.unwrap_or_default();
.create_compact_request_builder(&self.client, &auth)
.await?;
// Ensure Responses API beta header is present for compact calls. Mirror the
// streaming path: use the public "responses=v1" header for the public OpenAI
// endpoint and fall back to "responses=experimental" for other providers.
let has_beta_header = request
.try_clone()
.and_then(|builder| builder.build().ok())
.map_or(false, |req| req.headers().contains_key("OpenAI-Beta"));
if !has_beta_header {
let beta_value = if self.provider.is_public_openai_responses_endpoint() {
RESPONSES_BETA_HEADER_V1
} else {
RESPONSES_BETA_HEADER_EXPERIMENTAL
};
request = request.header("OpenAI-Beta", beta_value);
}
request = attach_openai_subagent_header(request);
request = attach_codex_beta_features_header(request, &self.config);
if let Some(auth) = auth.as_ref()
&& auth.mode == AuthMode::ChatGPT
&& let Some(account_id) = auth.get_account_id()
{
request = request.header("chatgpt-account-id", account_id);
}
request = request.json(&payload);
let header_snapshot = request
.try_clone()
.and_then(|builder| builder.build().ok())
.map(|req| header_map_to_json(req.headers()));
if request_id.is_empty() {
if let Ok(logger) = self.debug_logger.lock() {
let endpoint = self
.provider
.get_compact_url(&auth)
.unwrap_or_else(|| self.provider.get_full_url(&auth));
request_id = logger
.start_request_log(
&endpoint,
&payload_json,
header_snapshot.as_ref(),
Some("compact_remote"),
)
.unwrap_or_default();
}
}
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
if status == StatusCode::TOO_MANY_REQUESTS
&& self.config.auto_switch_accounts_on_rate_limit
&& auth_manager.is_some()
&& auth::read_code_api_key_from_env().is_none()
{
let now = Utc::now();
if let Ok(Some(current_account_id)) =
auth_accounts::get_active_account_id(self.code_home())
{
let current_auth_mode = auth
.as_ref()
.map(|a| a.mode)
.unwrap_or(AuthMode::ApiKey);
rate_limit_switch_state.mark_limited(
&current_account_id,
current_auth_mode,
None,
);
if let Ok(Some(next_account_id)) =
crate::account_switching::select_next_account_id(
self.code_home(),
&rate_limit_switch_state,
self.config.api_key_fallback_on_all_accounts_limited,
now,
)
{
tracing::info!(
from_account_id = %current_account_id,
to_account_id = %next_account_id,
"rate limit hit during compact; auto-switching active account"
);
if let Err(err) = auth::activate_account(self.code_home(), &next_account_id) {
tracing::warn!(
from_account_id = %current_account_id,
to_account_id = %next_account_id,
error = %err,
"failed to activate account after rate limit during compact"
);
} else {
if let Some(manager) = auth_manager.as_ref() {
manager.reload();
}
continue;
}
}
}
}
if let Ok(logger) = self.debug_logger.lock() {
let response_body: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|_| serde_json::json!({ "raw": body }));
let _ = logger.append_response_event(
&request_id,
"compact_response",
&serde_json::json!({
"status_code": status.as_u16(),
"body": response_body,
}),
);
let _ = logger.end_request_log(&request_id);
}
if !status.is_success() {
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body,
request_id: None,
}));
}
let CompactHistoryResponse { output } = serde_json::from_str(&body)?;
return Ok(output);
}
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
if let Ok(logger) = self.debug_logger.lock() {
let response_body: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|_| serde_json::json!({ "raw": body }));
let _ = logger.append_response_event(
&request_id,
"compact_response",
&serde_json::json!({
"status_code": status.as_u16(),
"body": response_body,
}),
);
let _ = logger.end_request_log(&request_id);
}
if !status.is_success() {
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body,
request_id: None,
}));
}
let CompactHistoryResponse { output } = serde_json::from_str(&body)?;
Ok(output)
}
}
+42
View File
@@ -998,6 +998,7 @@ use crate::protocol::EventMsg;
use crate::protocol::ExitedReviewModeEvent;
use crate::protocol::ReviewSnapshotInfo;
use crate::protocol::ListCustomPromptsResponseEvent;
use crate::protocol::ListSkillsResponseEvent;
use crate::protocol::{BrowserSnapshotEvent, EnvironmentContextDeltaEvent, EnvironmentContextFullEvent};
use crate::protocol::ExecApprovalRequestEvent;
use crate::protocol::ExecCommandBeginEvent;
@@ -5324,6 +5325,47 @@ async fn submission_loop(
sess.send_event(event).await;
}
Op::ListSkills => {
let sess = match sess.as_ref() {
Some(sess) => Arc::clone(sess),
None => {
send_no_session_event(sub.id).await;
continue;
}
};
let config_for_skills = Arc::clone(&config);
let skill_load_outcome = tokio::task::spawn_blocking(move || {
crate::skills::load_skills(&config_for_skills)
})
.await
.unwrap_or_default();
let skills: Vec<code_protocol::skills::Skill> = skill_load_outcome
.skills
.into_iter()
.map(|skill| code_protocol::skills::Skill {
name: skill.name,
description: skill.description,
path: skill.path,
scope: match skill.scope {
crate::skills::model::SkillScope::Repo => code_protocol::skills::SkillScope::Repo,
crate::skills::model::SkillScope::User => code_protocol::skills::SkillScope::User,
crate::skills::model::SkillScope::System => code_protocol::skills::SkillScope::System,
},
content: skill.content,
})
.collect();
let event = Event {
id: sub.id.clone(),
event_seq: 0,
msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }),
order: None,
};
sess.send_event(event).await;
}
Op::Compact => {
let sess = match sess.as_ref() {
Some(sess) => sess,
+30
View File
@@ -427,6 +427,14 @@ pub struct Config {
/// Whether we're using ChatGPT authentication (affects feature availability)
pub using_chatgpt_auth: bool,
/// When true, automatically switch to another connected account when the
/// current account hits a rate/usage limit.
pub auto_switch_accounts_on_rate_limit: bool,
/// When true, fall back to an API key account only if every connected
/// ChatGPT account is rate/usage limited.
pub api_key_fallback_on_all_accounts_limited: bool,
/// GitHub integration configuration.
pub github: GithubConfig,
@@ -2265,6 +2273,16 @@ pub struct ConfigToml {
/// If set to `true`, the API key will be signed with the `originator` header.
pub preferred_auth_method: Option<AuthMode>,
/// When true, automatically switch to another connected account when the
/// current account hits a rate/usage limit.
#[serde(default)]
pub auto_switch_accounts_on_rate_limit: Option<bool>,
/// When true, fall back to an API key account only if every connected
/// ChatGPT account is rate/usage limited.
#[serde(default)]
pub api_key_fallback_on_all_accounts_limited: Option<bool>,
/// Nested tools section for feature toggles
pub tools: Option<ToolsToml>,
@@ -2748,6 +2766,16 @@ impl Config {
// Determine auth mode early so defaults like model selection can depend on it.
let using_chatgpt_auth = Self::is_using_chatgpt_auth(&code_home);
let auto_switch_accounts_on_rate_limit = config_profile
.auto_switch_accounts_on_rate_limit
.or(cfg.auto_switch_accounts_on_rate_limit)
.unwrap_or(true);
let api_key_fallback_on_all_accounts_limited = config_profile
.api_key_fallback_on_all_accounts_limited
.or(cfg.api_key_fallback_on_all_accounts_limited)
.unwrap_or(false);
let default_model_slug = if using_chatgpt_auth {
GPT_5_CODEX_MEDIUM_MODEL
} else {
@@ -3114,6 +3142,8 @@ impl Config {
debug: debug.unwrap_or(false),
// Already computed before moving code_home
using_chatgpt_auth,
auto_switch_accounts_on_rate_limit,
api_key_fallback_on_all_accounts_limited,
github: cfg.github.unwrap_or_default(),
validation: cfg.validation.unwrap_or_default(),
subagent_commands: cfg
+8
View File
@@ -39,4 +39,12 @@ pub struct ConfigProfile {
pub experimental_instructions_file: Option<PathBuf>,
pub compact_prompt_override: Option<String>,
pub compact_prompt_override_file: Option<PathBuf>,
/// When true, automatically switch to another connected account when the
/// current account hits a rate/usage limit.
pub auto_switch_accounts_on_rate_limit: Option<bool>,
/// When true, fall back to an API key account only if every connected
/// ChatGPT account is rate/usage limited.
pub api_key_fallback_on_all_accounts_limited: Option<bool>,
}
+1
View File
@@ -11,6 +11,7 @@ mod fs_sanitize;
pub mod auth;
pub mod auth_accounts;
pub mod account_usage;
mod account_switching;
pub mod bash;
mod auto_drive_pid;
mod chat_completions;
+9
View File
@@ -39,6 +39,8 @@ pub use code_protocol::protocol::RolloutItem;
pub use code_protocol::protocol::RolloutLine;
pub use code_protocol::protocol::ConversationPathResponseEvent;
pub use code_protocol::protocol::ListCustomPromptsResponseEvent;
pub use code_protocol::protocol::ListSkillsResponseEvent;
pub use code_protocol::skills::Skill;
pub use code_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
pub use code_protocol::protocol::ExitedReviewModeEvent;
pub use code_protocol::protocol::ReviewSnapshotInfo;
@@ -232,6 +234,10 @@ pub enum Op {
/// Reply is delivered via `EventMsg::ListCustomPromptsResponse`.
ListCustomPrompts,
/// Request the list of available skills.
/// Reply is delivered via `EventMsg::ListSkillsResponse`.
ListSkills,
/// Request the agent to summarize the current conversation context.
/// The agent will use its existing context (either conversation history or previous response id)
/// to generate a summary which will be returned as an AgentMessage event.
@@ -795,6 +801,9 @@ pub enum EventMsg {
/// List of custom prompts available to the agent.
ListCustomPromptsResponse(ListCustomPromptsResponseEvent),
/// List of skills available to the agent.
ListSkillsResponse(ListSkillsResponseEvent),
PlanUpdate(UpdatePlanArgs),
/// Browser screenshot has been captured and is ready for display
+1
View File
@@ -235,6 +235,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
description,
path: resolved_path,
scope,
content: contents,
})
}
+1 -1
View File
@@ -13,6 +13,7 @@ pub struct SkillMetadata {
pub description: String,
pub path: PathBuf,
pub scope: SkillScope,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -26,4 +27,3 @@ pub struct SkillLoadOutcome {
pub skills: Vec<SkillMetadata>,
pub errors: Vec<SkillError>,
}
@@ -201,7 +201,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::EnvironmentContextFull(_)
| EventMsg::EnvironmentContextDelta(_)
| EventMsg::BrowserSnapshot(_)
| EventMsg::ListCustomPromptsResponse(_) => {
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_) => {
// Environment context events are consumed by the TUI; the CLI runner
// does not surface them alongside the human-readable transcript.
}
@@ -290,6 +290,7 @@ async fn run_code_tool_session_inner(
| EventMsg::EnvironmentContextFull(_)
| EventMsg::EnvironmentContextDelta(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::AgentStatusUpdate(_)
| EventMsg::CompactionCheckpointWarning(_)
| EventMsg::TurnAborted(_)
+1
View File
@@ -1,5 +1,6 @@
pub mod config_types;
pub mod custom_prompts;
pub mod skills;
pub mod mcp_protocol;
pub mod message_history;
pub mod models;
+14
View File
@@ -14,6 +14,7 @@ use crate::ConversationId;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::custom_prompts::CustomPrompt;
use crate::skills::Skill;
use crate::message_history::HistoryEntry;
use crate::models::ContentItem;
use crate::models::ResponseItem;
@@ -191,6 +192,10 @@ pub enum Op {
/// Request the list of available custom prompts.
ListCustomPrompts,
/// Request the list of available skills.
/// Reply is delivered via `EventMsg::ListSkillsResponse`.
ListSkills,
/// Request the agent to summarize the current conversation context.
/// The agent will use its existing context (either conversation history or previous response id)
/// to generate a summary which will be returned as an AgentMessage event.
@@ -591,6 +596,9 @@ pub enum EventMsg {
/// List of custom prompts available to the agent.
ListCustomPromptsResponse(ListCustomPromptsResponseEvent),
/// List of skills available to the agent.
ListSkillsResponse(ListSkillsResponseEvent),
PlanUpdate(UpdatePlanArgs),
TurnAborted(TurnAbortedEvent),
@@ -1357,6 +1365,12 @@ pub struct ListCustomPromptsResponseEvent {
pub custom_prompts: Vec<CustomPrompt>,
}
/// Response payload for `Op::ListSkills`.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ListSkillsResponseEvent {
pub skills: Vec<Skill>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, TS)]
pub struct SessionConfiguredEvent {
/// Name left as session_id instead of conversation_id for backwards compatibility.
+20
View File
@@ -0,0 +1,20 @@
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
use ts_rs::TS;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)]
pub enum SkillScope {
Repo,
User,
System,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: PathBuf,
pub scope: SkillScope,
pub content: String,
}
+12
View File
@@ -2002,6 +2002,18 @@ impl App<'_> {
self.config.auto_upgrade_enabled = enabled;
}
}
AppEvent::SetAutoSwitchAccountsOnRateLimit(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_auto_switch_accounts_on_rate_limit(enabled);
}
self.config.auto_switch_accounts_on_rate_limit = enabled;
}
AppEvent::SetApiKeyFallbackOnAllAccountsLimited(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_api_key_fallback_on_all_accounts_limited(enabled);
}
self.config.api_key_fallback_on_all_accounts_limited = enabled;
}
AppEvent::ShowAutoDriveSettings => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_auto_drive_settings();
+2
View File
@@ -624,6 +624,8 @@ pub(crate) enum AppEvent {
latest_version: Option<String>,
},
SetAutoUpgradeEnabled(bool),
SetAutoSwitchAccountsOnRateLimit(bool),
SetApiKeyFallbackOnAllAccountsLimited(bool),
RequestAgentInstall { name: String, selected_index: usize },
AgentsOverviewSelectionChanged { index: usize },
/// Add or update an agent's settings (enabled, params, instructions)
@@ -0,0 +1,186 @@
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::colors;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
pub(crate) struct AccountSwitchSettingsView {
app_event_tx: AppEventSender,
selected_index: usize,
auto_switch_enabled: bool,
api_key_fallback_enabled: bool,
is_complete: bool,
}
impl AccountSwitchSettingsView {
pub(crate) fn new(
app_event_tx: AppEventSender,
auto_switch_enabled: bool,
api_key_fallback_enabled: bool,
) -> Self {
Self {
app_event_tx,
selected_index: 0,
auto_switch_enabled,
api_key_fallback_enabled,
is_complete: false,
}
}
fn option_count() -> usize {
3
}
fn toggle_auto_switch(&mut self) {
self.auto_switch_enabled = !self.auto_switch_enabled;
self.app_event_tx
.send(AppEvent::SetAutoSwitchAccountsOnRateLimit(
self.auto_switch_enabled,
));
}
fn toggle_api_key_fallback(&mut self) {
self.api_key_fallback_enabled = !self.api_key_fallback_enabled;
self.app_event_tx
.send(AppEvent::SetApiKeyFallbackOnAllAccountsLimited(
self.api_key_fallback_enabled,
));
}
fn close(&mut self) {
self.is_complete = true;
}
fn activate_selected(&mut self) {
match self.selected_index {
0 => self.toggle_auto_switch(),
1 => self.toggle_api_key_fallback(),
2 => self.close(),
_ => {}
}
}
fn info_lines(&self) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(Line::from(vec![Span::styled(
"Accounts",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
let highlight = Style::default()
.fg(colors::primary())
.add_modifier(Modifier::BOLD);
let normal = Style::default().fg(colors::text());
let dim = Style::default().fg(colors::text_dim());
let row = |idx: usize, label: &str, enabled: bool| -> Line<'static> {
let selected = idx == self.selected_index;
let indicator = if selected { ">" } else { " " };
let style = if selected { highlight } else { normal };
let state_style = if enabled {
Style::default().fg(colors::success())
} else {
Style::default().fg(colors::text_dim())
};
Line::from(vec![
Span::styled(format!("{indicator} "), style),
Span::styled(label.to_string(), style),
Span::raw(" "),
Span::styled(
format!("[{}]", if enabled { "x" } else { " " }),
state_style,
),
])
};
lines.push(row(
0,
"Auto-switch on rate/usage limit",
self.auto_switch_enabled,
));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"Switches to another connected account on 429/usage_limit.",
dim,
),
]));
lines.push(row(
1,
"API key fallback when all accounts limited",
self.api_key_fallback_enabled,
));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"Only used if every connected ChatGPT account is limited.",
dim,
),
]));
lines.push(Line::from(""));
let close_selected = self.selected_index == 2;
let close_style = if close_selected { highlight } else { normal };
let indicator = if close_selected { ">" } else { " " };
lines.push(Line::from(vec![
Span::styled(format!("{indicator} "), close_style),
Span::styled("Close", close_style),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Up/Down", Style::default().fg(colors::function())),
Span::styled(" Navigate ", dim),
Span::styled("Enter", Style::default().fg(colors::success())),
Span::styled(" Toggle ", dim),
Span::styled("Esc", Style::default().fg(colors::error())),
Span::styled(" Close", dim),
]));
lines
}
pub(crate) fn render_without_frame(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
Paragraph::new(self.info_lines())
.wrap(Wrap { trim: true })
.style(Style::default().bg(colors::background()).fg(colors::text()))
.render(area, buf);
}
pub(crate) fn handle_key_event_direct(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Esc => self.close(),
KeyCode::Up => {
self.selected_index = self.selected_index.saturating_sub(1);
}
KeyCode::Down | KeyCode::Tab => {
self.selected_index = (self.selected_index + 1) % Self::option_count();
}
KeyCode::BackTab => {
if self.selected_index == 0 {
self.selected_index = Self::option_count() - 1;
} else {
self.selected_index = self.selected_index.saturating_sub(1);
}
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate_selected(),
_ => {}
}
}
pub(crate) fn is_view_complete(&self) -> bool {
self.is_complete
}
}
+14
View File
@@ -11,6 +11,7 @@ pub(crate) use bottom_pane_view::BottomPaneView;
pub(crate) use bottom_pane_view::ConditionalUpdate;
use crate::util::buffer::fill_rect;
use code_protocol::custom_prompts::CustomPrompt;
use code_protocol::skills::Skill;
use code_core::protocol::TokenUsage;
use code_file_search::FileMatch;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
@@ -24,6 +25,7 @@ mod approval_modal_view;
mod approval_ui;
mod auto_coordinator_view;
mod auto_drive_settings_view;
mod account_switch_settings_view;
mod bottom_pane_view;
mod chat_composer;
mod chat_composer_history;
@@ -51,6 +53,7 @@ mod login_accounts_view;
mod textarea;
pub mod form_text_field;
pub mod prompts_settings_view;
pub mod skills_settings_view;
mod theme_selection_view;
mod planning_settings_view;
mod verbosity_selection_view;
@@ -79,6 +82,7 @@ pub(crate) use auto_coordinator_view::{
CountdownState,
};
pub(crate) use auto_drive_settings_view::AutoDriveSettingsView;
pub(crate) use account_switch_settings_view::AccountSwitchSettingsView;
pub(crate) use login_accounts_view::{
LoginAccountsState,
LoginAccountsView,
@@ -141,6 +145,7 @@ pub(crate) struct BottomPane<'a> {
auto_drive_active: bool,
custom_prompts: Vec<CustomPrompt>,
skills: Vec<Skill>,
}
@@ -178,6 +183,7 @@ impl BottomPane<'_> {
auto_drive_variant: params.auto_drive_variant,
auto_drive_active: false,
custom_prompts: Vec::new(),
skills: Vec::new(),
}
}
@@ -553,6 +559,14 @@ impl BottomPane<'_> {
&self.custom_prompts
}
pub(crate) fn set_skills(&mut self, skills: Vec<Skill>) {
self.skills = skills;
}
pub(crate) fn skills(&self) -> &[Skill] {
&self.skills
}
/// Enable or disable compact compose mode. When enabled, the spacer line
/// above the input composer is removed so the history can scroll into that
/// row. This is typically toggled when the user scrolls up.
+36 -24
View File
@@ -3,8 +3,10 @@ pub(crate) enum SettingsSection {
Model,
Theme,
Updates,
Accounts,
Agents,
Prompts,
Skills,
AutoDrive,
Review,
Planning,
@@ -16,12 +18,14 @@ pub(crate) enum SettingsSection {
}
impl SettingsSection {
pub(crate) const ALL: [SettingsSection; 13] = [
pub(crate) const ALL: [SettingsSection; 15] = [
SettingsSection::Model,
SettingsSection::Theme,
SettingsSection::Updates,
SettingsSection::Accounts,
SettingsSection::Agents,
SettingsSection::Prompts,
SettingsSection::Skills,
SettingsSection::AutoDrive,
SettingsSection::Review,
SettingsSection::Planning,
@@ -36,17 +40,19 @@ impl SettingsSection {
match self {
SettingsSection::Model => "Model",
SettingsSection::Theme => "Theme",
SettingsSection::Planning => "Planning",
SettingsSection::Updates => "Updates",
SettingsSection::Agents => "Agents",
SettingsSection::AutoDrive => "Auto Drive",
SettingsSection::Review => "Review",
SettingsSection::Planning => "Planning",
SettingsSection::Updates => "Updates",
SettingsSection::Accounts => "Accounts",
SettingsSection::Agents => "Agents",
SettingsSection::AutoDrive => "Auto Drive",
SettingsSection::Review => "Review",
SettingsSection::Validation => "Validation",
SettingsSection::Limits => "Limits",
SettingsSection::Chrome => "Chrome",
SettingsSection::Mcp => "MCP",
SettingsSection::Notifications => "Notifications",
SettingsSection::Prompts => "Prompts",
SettingsSection::Mcp => "MCP",
SettingsSection::Notifications => "Notifications",
SettingsSection::Prompts => "Prompts",
SettingsSection::Skills => "Skills",
}
}
@@ -54,17 +60,19 @@ impl SettingsSection {
match self {
SettingsSection::Model => "Choose the language model used for new completions.",
SettingsSection::Theme => "Switch between preset color palettes and adjust contrast.",
SettingsSection::Planning => "Choose the model used in Plan Mode (Read Only).",
SettingsSection::Updates => "Control CLI auto-update cadence and release channels.",
SettingsSection::Agents => "Configure linked agents and default task permissions.",
SettingsSection::AutoDrive => "Manage Auto Drive defaults for review and cadence.",
SettingsSection::Review => "Adjust Auto Review and Auto Resolve automation for /review.",
SettingsSection::Planning => "Choose the model used in Plan Mode (Read Only).",
SettingsSection::Updates => "Control CLI auto-update cadence and release channels.",
SettingsSection::Accounts => "Configure account switching behavior under rate and usage limits.",
SettingsSection::Agents => "Configure linked agents and default task permissions.",
SettingsSection::AutoDrive => "Manage Auto Drive defaults for review and cadence.",
SettingsSection::Review => "Adjust Auto Review and Auto Resolve automation for /review.",
SettingsSection::Validation => "Toggle validation groups and tool availability.",
SettingsSection::Limits => "Inspect API usage, rate limits, and reset windows.",
SettingsSection::Chrome => "Connect to Chrome or switch browser integrations.",
SettingsSection::Mcp => "Enable and manage local MCP servers for tooling.",
SettingsSection::Notifications => "Adjust desktop and terminal notification preferences.",
SettingsSection::Prompts => "Create and edit custom prompt snippets.",
SettingsSection::Mcp => "Enable and manage local MCP servers for tooling.",
SettingsSection::Notifications => "Adjust desktop and terminal notification preferences.",
SettingsSection::Prompts => "Create and edit custom prompt snippets.",
SettingsSection::Skills => "Manage project-scoped and global skills.",
}
}
@@ -72,26 +80,30 @@ impl SettingsSection {
match self {
SettingsSection::Model => "Model settings coming soon.",
SettingsSection::Theme => "Theme settings coming soon.",
SettingsSection::Planning => "Planning settings coming soon.",
SettingsSection::Updates => "Upgrade Codex and manage automatic updates.",
SettingsSection::Agents => "Agents configuration coming soon.",
SettingsSection::AutoDrive => "Auto Drive controls coming soon.",
SettingsSection::Planning => "Planning settings coming soon.",
SettingsSection::Updates => "Upgrade Codex and manage automatic updates.",
SettingsSection::Accounts => "Account switching settings coming soon.",
SettingsSection::Agents => "Agents configuration coming soon.",
SettingsSection::AutoDrive => "Auto Drive controls coming soon.",
SettingsSection::Review => "Adjust Auto Review and Auto Resolve automation for /review.",
SettingsSection::Validation => "Toggle validation groups and tools.",
SettingsSection::Limits => "Limits usage visualization coming soon.",
SettingsSection::Chrome => "Chrome integration settings coming soon.",
SettingsSection::Mcp => "MCP server management coming soon.",
SettingsSection::Notifications => "Notification preferences coming soon.",
SettingsSection::Prompts => "Manage custom prompts.",
SettingsSection::Mcp => "MCP server management coming soon.",
SettingsSection::Notifications => "Notification preferences coming soon.",
SettingsSection::Prompts => "Manage custom prompts.",
SettingsSection::Skills => "Manage skills.",
}
}
pub(crate) fn from_hint(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"model" | "models" => Some(SettingsSection::Model),
"skill" | "skills" => Some(SettingsSection::Skills),
"theme" | "themes" => Some(SettingsSection::Theme),
"planning" | "plan" => Some(SettingsSection::Planning),
"update" | "updates" => Some(SettingsSection::Updates),
"account" | "accounts" | "auth" => Some(SettingsSection::Accounts),
"agent" | "agents" => Some(SettingsSection::Agents),
"auto" | "autodrive" | "drive" => Some(SettingsSection::AutoDrive),
"review" | "reviews" => Some(SettingsSection::Review),
@@ -0,0 +1,529 @@
use std::fs;
use code_core::config::find_code_home;
use code_core::protocol::Op;
use code_protocol::skills::{Skill, SkillScope};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::prelude::Widget;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::colors;
use super::form_text_field::{FormTextField, InputFilter};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
List,
Name,
Body,
Save,
Delete,
Cancel,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mode {
List,
Edit,
}
pub(crate) struct SkillsSettingsView {
skills: Vec<Skill>,
selected: usize,
focus: Focus,
name_field: FormTextField,
body_field: FormTextField,
status: Option<(String, Style)>,
app_event_tx: AppEventSender,
is_complete: bool,
mode: Mode,
}
impl SkillsSettingsView {
pub fn new(skills: Vec<Skill>, app_event_tx: AppEventSender) -> Self {
let mut name_field = FormTextField::new_single_line();
name_field.set_filter(InputFilter::Id);
let body_field = FormTextField::new_multi_line();
Self {
skills,
selected: 0,
focus: Focus::List,
name_field,
body_field,
status: None,
app_event_tx,
is_complete: false,
mode: Mode::List,
}
}
pub fn handle_key_event_direct(&mut self, key: KeyEvent) -> bool {
if self.is_complete {
return true;
}
match self.mode {
Mode::List => match key {
KeyEvent { code: KeyCode::Esc, .. } => {
self.is_complete = true;
true
}
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => {
self.enter_editor();
true
}
KeyEvent { code: KeyCode::Char('n'), modifiers, .. }
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.start_new_skill();
true
}
other => self.handle_list_key(other),
},
Mode::Edit => match key {
KeyEvent { code: KeyCode::Esc, .. } => {
self.mode = Mode::List;
self.focus = Focus::List;
self.status = None;
true
}
KeyEvent { code: KeyCode::Tab, .. } => {
self.cycle_focus(true);
true
}
KeyEvent { code: KeyCode::BackTab, .. } => {
self.cycle_focus(false);
true
}
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => {
match self.focus {
Focus::Save => self.save_current(),
Focus::Delete => self.delete_current(),
Focus::Cancel => {
self.mode = Mode::List;
self.focus = Focus::List;
self.status = None;
}
_ => {}
}
true
}
KeyEvent { code: KeyCode::Char('n'), modifiers, .. }
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.start_new_skill();
true
}
_ => match self.focus {
Focus::Name => {
self.name_field.handle_key(key);
true
}
Focus::Body => {
self.body_field.handle_key(key);
true
}
Focus::Save | Focus::Delete | Focus::Cancel => false,
Focus::List => self.handle_list_key(key),
},
},
}
}
pub fn is_complete(&self) -> bool {
self.is_complete
}
pub fn render(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
self.render_body(area, buf);
}
fn render_body(&self, area: Rect, buf: &mut Buffer) {
match self.mode {
Mode::List => self.render_list(area, buf),
Mode::Edit => self.render_form(area, buf),
}
}
fn render_list(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = Vec::new();
for (idx, skill) in self.skills.iter().enumerate() {
let arrow = if idx == self.selected { ">" } else { " " };
let name_style = if idx == self.selected {
Style::default().fg(colors::primary()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors::text())
};
let scope_text = match skill.scope {
SkillScope::Repo => " [repo]",
SkillScope::User => " [user]",
SkillScope::System => " [system]",
};
let name_span = Span::styled(format!("{arrow} {name}", name = skill.name), name_style);
let scope_span = Span::styled(scope_text, Style::default().fg(colors::text_dim()));
let desc_span = Span::styled(
format!(" {desc}", desc = skill.description),
Style::default().fg(colors::text_dim()),
);
lines.push(Line::from(vec![name_span, scope_span, desc_span]));
}
if lines.is_empty() {
lines.push(Line::from("No skills yet. Press Ctrl+N to create."));
}
let add_arrow = if self.selected == self.skills.len() { ">" } else { " " };
let add_style = if self.selected == self.skills.len() {
Style::default().fg(colors::primary()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors::success()).add_modifier(Modifier::BOLD)
};
lines.push(Line::from(vec![Span::styled(
format!("{add_arrow} Add new..."),
add_style,
)]));
let title = Paragraph::new(vec![Line::from(Span::styled(
"Skills are reusable instruction bundles stored as SKILL.md files. Edit the frontmatter to update name and description.",
Style::default().fg(colors::text_dim()),
))])
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.style(Style::default().bg(colors::background()));
let list = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(colors::background()));
let outer = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(colors::background()));
let inner = outer.inner(area);
outer.render(area, buf);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(inner);
title.render(chunks[0], buf);
list.render(chunks[1], buf);
}
fn render_form(&self, area: Rect, buf: &mut Buffer) {
let outer = Block::default()
.borders(Borders::ALL)
.title("Skill")
.style(Style::default().bg(colors::background()));
let inner = outer.inner(area);
outer.render(area, buf);
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(6),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let name_label = Span::styled("Name", Style::default().fg(colors::text_dim()));
let name_line = Line::from(vec![name_label]);
Paragraph::new(name_line).render(vertical[0], buf);
self.name_field
.render(vertical[0], buf, matches!(self.focus, Focus::Name));
self.body_field
.render(vertical[1], buf, matches!(self.focus, Focus::Body));
let save_label = if self.focus == Focus::Save { "Save" } else { "Save" };
let delete_label = if self.focus == Focus::Delete { "Delete" } else { "Delete" };
let cancel_label = if self.focus == Focus::Cancel { "Cancel" } else { "Cancel" };
let btn_span = |label: &str, focus: Focus, color: Style| {
if self.focus == focus {
Span::styled(label.to_string(), color.bg(colors::primary()).fg(colors::background()))
} else {
Span::styled(label.to_string(), color)
}
};
let line = Line::from(vec![
btn_span(save_label, Focus::Save, Style::default().fg(colors::success()).add_modifier(Modifier::BOLD)),
Span::raw(" "),
btn_span(delete_label, Focus::Delete, Style::default().fg(colors::error()).add_modifier(Modifier::BOLD)),
Span::raw(" "),
btn_span(cancel_label, Focus::Cancel, Style::default().fg(colors::text_dim()).add_modifier(Modifier::BOLD)),
Span::raw(" Tab cycle - Enter activates"),
]);
Paragraph::new(line).render(vertical[2], buf);
if let Some((msg, style)) = &self.status {
Paragraph::new(Line::from(Span::styled(msg.clone(), *style)))
.alignment(Alignment::Left)
.render(vertical[3], buf);
}
}
fn handle_list_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Up => {
if self.selected > 0 {
self.selected -= 1;
}
return true;
}
KeyCode::Down => {
let max = self.skills.len();
if self.selected < max {
self.selected += 1;
}
return true;
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.start_new_skill();
return true;
}
_ => {}
}
false
}
fn start_new_skill(&mut self) {
self.selected = self.skills.len();
self.name_field.set_text("");
self.body_field.set_text("---\nname: Example Skill\ndescription: Describe this skill\n---\n");
self.focus = Focus::Name;
self.status = Some(("New skill".to_string(), Style::default().fg(colors::info())));
self.mode = Mode::Edit;
}
fn load_selected_into_form(&mut self) {
if let Some(skill) = self.skills.get(self.selected) {
self.name_field.set_text(&skill_slug(skill));
self.body_field.set_text(&skill.content);
self.focus = Focus::Name;
}
}
fn enter_editor(&mut self) {
if self.selected >= self.skills.len() {
self.start_new_skill();
} else {
self.load_selected_into_form();
self.mode = Mode::Edit;
}
}
fn cycle_focus(&mut self, forward: bool) {
let order = [Focus::List, Focus::Name, Focus::Body, Focus::Save, Focus::Delete, Focus::Cancel];
let mut idx = order.iter().position(|f| *f == self.focus).unwrap_or(0);
if forward {
idx = (idx + 1) % order.len();
} else {
idx = idx.checked_sub(1).unwrap_or(order.len() - 1);
}
self.focus = order[idx];
}
fn validate_name(&self, name: &str) -> Result<(), String> {
let slug = name.trim();
if slug.is_empty() {
return Err("Name is required".to_string());
}
if !slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err("Name must use letters, numbers, '-', '_' or '.'".to_string());
}
let dup = self
.skills
.iter()
.enumerate()
.any(|(idx, skill)| idx != self.selected && skill_slug(skill).eq_ignore_ascii_case(slug));
if dup {
return Err("A skill with this name already exists".to_string());
}
Ok(())
}
fn validate_frontmatter(&self, body: &str) -> Result<(), String> {
let Some(frontmatter) = extract_frontmatter(body) else {
return Err("SKILL.md must start with YAML frontmatter".to_string());
};
if frontmatter_value(&frontmatter, "name").is_none() {
return Err("Frontmatter must include name".to_string());
}
if frontmatter_value(&frontmatter, "description").is_none() {
return Err("Frontmatter must include description".to_string());
}
Ok(())
}
fn save_current(&mut self) {
if let Some(skill) = self.skills.get(self.selected) {
if skill.scope != SkillScope::User {
self.status = Some((
"Only user skills can be saved".to_string(),
Style::default().fg(colors::error()),
));
return;
}
}
let name = self.name_field.text().trim().to_string();
let body = self.body_field.text().to_string();
if let Err(msg) = self.validate_name(&name) {
self.status = Some((msg, Style::default().fg(colors::error())));
return;
}
if let Err(msg) = self.validate_frontmatter(&body) {
self.status = Some((msg, Style::default().fg(colors::error())));
return;
}
let code_home = match find_code_home() {
Ok(path) => path,
Err(err) => {
self.status = Some((
format!("CODE_HOME unavailable: {err}"),
Style::default().fg(colors::error()),
));
return;
}
};
let mut dir = code_home;
dir.push("skills");
dir.push(&name);
if let Err(err) = fs::create_dir_all(&dir) {
self.status = Some((
format!("Failed to create skill dir: {err}"),
Style::default().fg(colors::error()),
));
return;
}
let mut path = dir;
path.push("SKILL.md");
if let Err(err) = fs::write(&path, &body) {
self.status = Some((
format!("Failed to save: {err}"),
Style::default().fg(colors::error()),
));
return;
}
let description = frontmatter_value(&body, "description")
.unwrap_or_else(|| "No description".to_string());
let display_name = frontmatter_value(&body, "name").unwrap_or_else(|| name.clone());
let mut updated = self.skills.clone();
let new_entry = Skill {
name: display_name,
path,
description,
scope: SkillScope::User,
content: body.clone(),
};
if self.selected < updated.len() {
updated[self.selected] = new_entry;
} else {
updated.push(new_entry);
self.selected = updated.len() - 1;
}
self.skills = updated;
self.status = Some(("Saved.".to_string(), Style::default().fg(colors::success())));
self.app_event_tx.send(AppEvent::CodexOp(Op::ListSkills));
}
fn delete_current(&mut self) {
if self.selected >= self.skills.len() {
self.status = Some(("Nothing to delete".to_string(), Style::default().fg(colors::warning())));
self.mode = Mode::List;
self.focus = Focus::List;
return;
}
let skill = self.skills[self.selected].clone();
if skill.scope != SkillScope::User {
self.status = Some((
"Only user skills can be deleted".to_string(),
Style::default().fg(colors::error()),
));
return;
}
if let Err(err) = fs::remove_file(&skill.path) {
if err.kind() != std::io::ErrorKind::NotFound {
self.status = Some((
format!("Delete failed: {err}"),
Style::default().fg(colors::error()),
));
return;
}
}
if let Some(parent) = skill.path.parent() {
let _ = fs::remove_dir(parent);
}
if self.selected < self.skills.len() {
self.skills.remove(self.selected);
if self.selected >= self.skills.len() && !self.skills.is_empty() {
self.selected = self.skills.len() - 1;
}
}
self.mode = Mode::List;
self.focus = Focus::List;
self.status = Some(("Deleted.".to_string(), Style::default().fg(colors::success())));
self.app_event_tx.send(AppEvent::CodexOp(Op::ListSkills));
}
}
fn skill_slug(skill: &Skill) -> String {
skill
.path
.parent()
.and_then(|path| path.file_name())
.and_then(|name| name.to_str())
.map(str::to_string)
.unwrap_or_else(|| skill.name.clone())
}
fn extract_frontmatter(body: &str) -> Option<String> {
let mut lines = body.lines();
if lines.next()? != "---" {
return None;
}
let mut frontmatter = String::new();
for line in lines {
if line.trim() == "---" {
return Some(frontmatter);
}
frontmatter.push_str(line);
frontmatter.push('\n');
}
None
}
fn frontmatter_value(body: &str, key: &str) -> Option<String> {
let frontmatter = extract_frontmatter(body)?;
for line in frontmatter.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(&format!("{key}:")) {
let value = rest.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
+143 -1
View File
@@ -264,6 +264,7 @@ use crate::bottom_pane::{
CountdownState,
AgentHintLabel, AutoReviewFooterStatus, AutoReviewPhase,
prompts_settings_view::PromptsSettingsView,
skills_settings_view::SkillsSettingsView,
McpSettingsView,
ModelSelectionView,
NotificationsMode,
@@ -2589,6 +2590,7 @@ use self::diff_ui::DiffConfirm;
use self::diff_ui::DiffOverlay;
use self::settings_overlay::{
AgentOverviewRow,
AccountsSettingsContent,
AutoDriveSettingsContent,
AgentsSettingsContent,
LimitsSettingsContent,
@@ -2598,6 +2600,7 @@ use self::settings_overlay::{
PlanningSettingsContent,
NotificationsSettingsContent,
PromptsSettingsContent,
SkillsSettingsContent,
ReviewSettingsContent,
ThemeSettingsContent,
UpdatesSettingsContent,
@@ -12268,6 +12271,7 @@ impl ChatWidget<'_> {
// Ask core for custom prompts so the slash menu can show them.
self.submit_op(Op::ListCustomPrompts);
self.submit_op(Op::ListSkills);
if self.resume_placeholder_visible && event.history_entry_count == 0 {
self.replace_resume_placeholder_with_notice(RESUME_NO_HISTORY_NOTICE);
@@ -13673,6 +13677,12 @@ impl ChatWidget<'_> {
debug!("received {len} custom prompts");
self.bottom_pane.set_custom_prompts(ev.custom_prompts);
}
EventMsg::ListSkillsResponse(ev) => {
let len = ev.skills.len();
debug!("received {len} skills");
self.bottom_pane.set_skills(ev.skills);
self.refresh_settings_overview_rows();
}
EventMsg::ShutdownComplete => {
self.push_background_tail("🟡 ShutdownComplete".to_string());
self.app_event_tx.send(AppEvent::ExitRequest);
@@ -15251,6 +15261,15 @@ impl ChatWidget<'_> {
.map(UpdatesSettingsContent::new)
}
fn build_accounts_settings_content(&self) -> AccountsSettingsContent {
let view = crate::bottom_pane::AccountSwitchSettingsView::new(
self.app_event_tx.clone(),
self.config.auto_switch_accounts_on_rate_limit,
self.config.api_key_fallback_on_all_accounts_limited,
);
AccountsSettingsContent::new(view)
}
fn build_validation_settings_content(&mut self) -> ValidationSettingsContent {
let groups = vec![
(
@@ -22082,6 +22101,96 @@ Have we met every part of this goal and is there no further work to do?"#
self.request_redraw();
}
pub(crate) fn set_auto_switch_accounts_on_rate_limit(&mut self, enabled: bool) {
if self.config.auto_switch_accounts_on_rate_limit == enabled {
return;
}
self.config.auto_switch_accounts_on_rate_limit = enabled;
let code_home = self.config.code_home.clone();
let profile = self.config.active_profile.clone();
tokio::spawn(async move {
if let Err(err) = code_core::config_edit::persist_overrides(
&code_home,
profile.as_deref(),
&[(&["auto_switch_accounts_on_rate_limit"], if enabled { "true" } else { "false" })],
)
.await
{
tracing::warn!("failed to persist account auto-switch setting: {err}");
}
});
let notice = if enabled {
"Auto-switch accounts enabled"
} else {
"Auto-switch accounts disabled"
};
self.bottom_pane.flash_footer_notice(notice.to_string());
let should_refresh_accounts = matches!(
self.settings
.overlay
.as_ref()
.map(|overlay| overlay.active_section()),
Some(SettingsSection::Accounts)
);
if should_refresh_accounts {
let content = self.build_accounts_settings_content();
if let Some(overlay) = self.settings.overlay.as_mut() {
overlay.set_accounts_content(content);
}
}
self.refresh_settings_overview_rows();
self.request_redraw();
}
pub(crate) fn set_api_key_fallback_on_all_accounts_limited(&mut self, enabled: bool) {
if self.config.api_key_fallback_on_all_accounts_limited == enabled {
return;
}
self.config.api_key_fallback_on_all_accounts_limited = enabled;
let code_home = self.config.code_home.clone();
let profile = self.config.active_profile.clone();
tokio::spawn(async move {
if let Err(err) = code_core::config_edit::persist_overrides(
&code_home,
profile.as_deref(),
&[(&["api_key_fallback_on_all_accounts_limited"], if enabled { "true" } else { "false" })],
)
.await
{
tracing::warn!("failed to persist API key fallback setting: {err}");
}
});
let notice = if enabled {
"API key fallback enabled"
} else {
"API key fallback disabled"
};
self.bottom_pane.flash_footer_notice(notice.to_string());
let should_refresh_accounts = matches!(
self.settings
.overlay
.as_ref()
.map(|overlay| overlay.active_section()),
Some(SettingsSection::Accounts)
);
if should_refresh_accounts {
let content = self.build_accounts_settings_content();
if let Some(overlay) = self.settings.overlay.as_mut() {
overlay.set_accounts_content(content);
}
}
self.refresh_settings_overview_rows();
self.request_redraw();
}
/// Forward file-search results to the bottom pane.
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.bottom_pane.on_file_search_result(query, matches);
@@ -22115,8 +22224,10 @@ Have we met every part of this goal and is there no further work to do?"#
if let Some(update_content) = self.build_updates_settings_content() {
overlay.set_updates_content(update_content);
}
overlay.set_accounts_content(self.build_accounts_settings_content());
overlay.set_notifications_content(self.build_notifications_settings_content());
overlay.set_prompts_content(self.build_prompts_settings_content());
overlay.set_skills_content(self.build_skills_settings_content());
if let Some(mcp_content) = self.build_mcp_settings_content() {
overlay.set_mcp_content(mcp_content);
}
@@ -22200,6 +22311,12 @@ Have we met every part of this goal and is there no further work to do?"#
PromptsSettingsContent::new(view)
}
fn build_skills_settings_content(&mut self) -> SkillsSettingsContent {
let skills = self.bottom_pane.skills().to_vec();
let view = SkillsSettingsView::new(skills, self.app_event_tx.clone());
SkillsSettingsContent::new(view)
}
fn build_chrome_settings_content(&self, port: Option<u16>) -> ChromeSettingsContent {
ChromeSettingsContent::new(self.app_event_tx.clone(), port)
}
@@ -22483,8 +22600,10 @@ Have we met every part of this goal and is there no further work to do?"#
SettingsSection::Theme => self.settings_summary_theme(),
SettingsSection::Planning => self.settings_summary_planning(),
SettingsSection::Updates => self.settings_summary_updates(),
SettingsSection::Accounts => self.settings_summary_accounts(),
SettingsSection::Agents => self.settings_summary_agents(),
SettingsSection::Prompts => self.settings_summary_prompts(),
SettingsSection::Skills => self.settings_summary_skills(),
SettingsSection::AutoDrive => self.settings_summary_auto_drive(),
SettingsSection::Review => self.settings_summary_review(),
SettingsSection::Validation => self.settings_summary_validation(),
@@ -22565,6 +22684,22 @@ Have we met every part of this goal and is there no further work to do?"#
Some(parts.join(" · "))
}
fn settings_summary_accounts(&self) -> Option<String> {
let auto_switch = if self.config.auto_switch_accounts_on_rate_limit {
"Auto-switch: On"
} else {
"Auto-switch: Off"
};
let api_key_fallback = if self.config.api_key_fallback_on_all_accounts_limited {
"API key fallback: On"
} else {
"API key fallback: Off"
};
Some(format!("{auto_switch} · {api_key_fallback}"))
}
fn settings_summary_agents(&self) -> Option<String> {
let (enabled, total) = agent_summary_counts(&self.config.agents);
let commands = self.config.subagent_commands.len();
@@ -22709,6 +22844,11 @@ Have we met every part of this goal and is there no further work to do?"#
Some(format!("Prompts enabled: {}", count))
}
fn settings_summary_skills(&self) -> Option<String> {
let count = self.bottom_pane.skills().len();
Some(format!("Skills loaded: {count}"))
}
fn refresh_settings_overview_rows(&mut self) {
if self.settings.overlay.is_none() {
return;
@@ -22855,7 +22995,9 @@ Have we met every part of this goal and is there no further work to do?"#
| SettingsSection::AutoDrive
| SettingsSection::Mcp
| SettingsSection::Notifications
| SettingsSection::Prompts => false,
| SettingsSection::Prompts
| SettingsSection::Accounts
| SettingsSection::Skills => false,
SettingsSection::Agents => {
self.show_agents_overview_ui();
false
+94 -1
View File
@@ -11,6 +11,7 @@ use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::{
agent_editor_view::AgentEditorView,
agents_settings_view::SubagentEditorView,
AccountSwitchSettingsView,
AutoDriveSettingsView,
BottomPaneView,
ConditionalUpdate,
@@ -19,6 +20,7 @@ use crate::bottom_pane::{
ModelSelectionView,
NotificationsSettingsView,
prompts_settings_view::PromptsSettingsView,
skills_settings_view::SkillsSettingsView,
PlanningSettingsView,
SettingsSection,
ThemeSelectionView,
@@ -144,7 +146,13 @@ impl SettingsHelpOverlay {
Line::from(vec![Span::styled("• Tab Cycle sections", hint)]),
Line::from(vec![Span::styled("• Shift+Tab Cycle backwards", hint)]),
];
if matches!(section, SettingsSection::Agents | SettingsSection::Mcp) {
if matches!(
section,
SettingsSection::Agents
| SettingsSection::Mcp
| SettingsSection::Accounts
| SettingsSection::Skills
) {
lines.push(Line::from(vec![Span::styled(
"• Enter Activate focused action",
hint,
@@ -314,6 +322,55 @@ impl SettingsContent for UpdatesSettingsContent {
}
}
pub(crate) struct AccountsSettingsContent {
view: AccountSwitchSettingsView,
}
impl AccountsSettingsContent {
pub(crate) fn new(view: AccountSwitchSettingsView) -> Self {
Self { view }
}
}
impl SettingsContent for AccountsSettingsContent {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.view.render_without_frame(area, buf);
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
self.view.handle_key_event_direct(key);
true
}
fn is_complete(&self) -> bool {
self.view.is_view_complete()
}
}
pub(crate) struct SkillsSettingsContent {
view: SkillsSettingsView,
}
impl SkillsSettingsContent {
pub(crate) fn new(view: SkillsSettingsView) -> Self {
Self { view }
}
}
impl SettingsContent for SkillsSettingsContent {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.view.render(area, buf);
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
self.view.handle_key_event_direct(key)
}
fn is_complete(&self) -> bool {
self.view.is_complete()
}
}
pub(crate) struct ValidationSettingsContent {
view: ValidationSettingsView,
}
@@ -1249,7 +1306,9 @@ pub(crate) struct SettingsOverlayView {
theme_content: Option<ThemeSettingsContent>,
updates_content: Option<UpdatesSettingsContent>,
notifications_content: Option<NotificationsSettingsContent>,
accounts_content: Option<AccountsSettingsContent>,
prompts_content: Option<PromptsSettingsContent>,
skills_content: Option<SkillsSettingsContent>,
mcp_content: Option<McpSettingsContent>,
agents_content: Option<AgentsSettingsContent>,
review_content: Option<ReviewSettingsContent>,
@@ -1272,7 +1331,9 @@ impl SettingsOverlayView {
theme_content: None,
updates_content: None,
notifications_content: None,
accounts_content: None,
prompts_content: None,
skills_content: None,
mcp_content: None,
agents_content: None,
review_content: None,
@@ -1356,10 +1417,18 @@ impl SettingsOverlayView {
self.notifications_content = Some(content);
}
pub(crate) fn set_accounts_content(&mut self, content: AccountsSettingsContent) {
self.accounts_content = Some(content);
}
pub(crate) fn set_prompts_content(&mut self, content: PromptsSettingsContent) {
self.prompts_content = Some(content);
}
pub(crate) fn set_skills_content(&mut self, content: SkillsSettingsContent) {
self.skills_content = Some(content);
}
pub(crate) fn set_mcp_content(&mut self, content: McpSettingsContent) {
self.mcp_content = Some(content);
}
@@ -1853,7 +1922,9 @@ impl SettingsOverlayView {
SettingsSection::Theme => "Theme Settings",
SettingsSection::Planning => "Planning Settings",
SettingsSection::Updates => "Upgrade",
SettingsSection::Accounts => "Account Switching",
SettingsSection::Agents => "Agents",
SettingsSection::Skills => "Skills",
SettingsSection::AutoDrive => "Auto Drive Settings",
SettingsSection::Review => "Review Settings",
SettingsSection::Validation => "Validation Settings",
@@ -2172,6 +2243,13 @@ impl SettingsOverlayView {
}
self.render_placeholder(area, buf, SettingsSection::Agents.placeholder());
}
SettingsSection::Accounts => {
if let Some(content) = self.accounts_content.as_ref() {
content.render(area, buf);
return;
}
self.render_placeholder(area, buf, SettingsSection::Accounts.placeholder());
}
SettingsSection::Prompts => {
if let Some(content) = self.prompts_content.as_ref() {
content.render(area, buf);
@@ -2179,6 +2257,13 @@ impl SettingsOverlayView {
}
self.render_placeholder(area, buf, SettingsSection::Prompts.placeholder());
}
SettingsSection::Skills => {
if let Some(content) = self.skills_content.as_ref() {
content.render(area, buf);
return;
}
self.render_placeholder(area, buf, SettingsSection::Skills.placeholder());
}
SettingsSection::AutoDrive => {
if let Some(content) = self.auto_drive_content.as_ref() {
content.render(area, buf);
@@ -2264,10 +2349,18 @@ impl SettingsOverlayView {
.agents_content
.as_mut()
.map(|content| content as &mut dyn SettingsContent),
SettingsSection::Accounts => self
.accounts_content
.as_mut()
.map(|content| content as &mut dyn SettingsContent),
SettingsSection::Prompts => self
.prompts_content
.as_mut()
.map(|content| content as &mut dyn SettingsContent),
SettingsSection::Skills => self
.skills_content
.as_mut()
.map(|content| content as &mut dyn SettingsContent),
SettingsSection::AutoDrive => self
.auto_drive_content
.as_mut()
+36 -4
View File
@@ -1,5 +1,6 @@
use super::card_style::{
agent_card_style,
ansi16_inverse_color,
fill_card_background,
hint_text_style,
primary_text_style,
@@ -14,6 +15,7 @@ use super::card_style::{
};
use super::{HistoryCell, HistoryCellType, ToolCellStatus};
use crate::colors;
use crate::theme::{palette_mode, PaletteMode};
use code_common::elapsed::format_duration_digital;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -355,6 +357,9 @@ impl AgentRunCell {
}
fn accent_style(style: &CardStyle) -> Style {
if palette_mode() == PaletteMode::Ansi16 {
return Style::default().fg(ansi16_inverse_color());
}
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
Style::default().fg(dim)
}
@@ -367,6 +372,22 @@ impl AgentRunCell {
secondary_text_style(style)
}
fn title_border_style(style: &CardStyle) -> Style {
if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(ansi16_inverse_color())
} else {
title_text_style(style)
}
}
fn secondary_title_border_style(style: &CardStyle) -> Style {
if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(ansi16_inverse_color())
} else {
secondary_text_style(style)
}
}
fn top_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let mut segments = Vec::new();
if body_width == 0 {
@@ -406,7 +427,10 @@ impl AgentRunCell {
};
let agents_width = string_width(agents_segment.as_str());
if !agents_segment.is_empty() {
segments.push(CardSegment::new(agents_segment, secondary_text_style(style)));
segments.push(CardSegment::new(
agents_segment,
Self::secondary_title_border_style(style),
));
}
remaining = remaining.saturating_sub(agents_width);
@@ -440,7 +464,10 @@ impl AgentRunCell {
let trimmed_name = truncated.trim_end().to_string();
let name_width = string_width(trimmed_name.as_str());
if !trimmed_name.is_empty() {
segments.push(CardSegment::new(trimmed_name, title_text_style(style)));
segments.push(CardSegment::new(
trimmed_name,
Self::title_border_style(style),
));
}
available = available.saturating_sub(name_width);
@@ -463,7 +490,7 @@ impl AgentRunCell {
}
fn mode_label_style(style: &CardStyle) -> Style {
secondary_text_style(style)
Self::secondary_title_border_style(style)
}
fn blank_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
@@ -549,7 +576,12 @@ impl AgentRunCell {
" [Ctrl+A] Expand".to_string()
};
let text = truncate_with_ellipsis(text_value.as_str(), body_width);
let segment = CardSegment::new(text, hint_text_style(style));
let hint_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(ansi16_inverse_color())
} else {
hint_text_style(style)
};
let segment = CardSegment::new(text, hint_style);
CardRow::new(BORDER_BOTTOM.to_string(), Self::accent_style(style), vec![segment], None)
}
+22 -4
View File
@@ -206,6 +206,9 @@ impl AutoDriveCardCell {
}
fn accent_style(style: &CardStyle) -> Style {
if palette_mode() == PaletteMode::Ansi16 {
return Style::default().fg(colors::warning());
}
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
Style::default().fg(dim)
}
@@ -304,9 +307,19 @@ impl AutoDriveCardCell {
format!(" · {}", self.status.label())
};
let combined = format!("{title_text}{status_text}");
let title_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(colors::warning())
} else {
title_text_style(style)
};
let status_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(colors::warning())
} else {
secondary_text_style(style)
};
if UnicodeWidthStr::width(combined.as_str()) <= body_width {
let mut bold_title = title_text_style(style);
let mut bold_title = title_style;
bold_title = bold_title.add_modifier(Modifier::BOLD);
segments.push(CardSegment::new(
title_text.to_string(),
@@ -314,11 +327,11 @@ impl AutoDriveCardCell {
));
segments.push(CardSegment::new(
status_text,
secondary_text_style(style),
status_style,
));
} else {
let display = truncate_with_ellipsis(title_text, body_width);
let mut bold_title = title_text_style(style);
let mut bold_title = title_style;
bold_title = bold_title.add_modifier(Modifier::BOLD);
segments.push(CardSegment::new(display, bold_title));
}
@@ -1068,7 +1081,12 @@ impl AutoDriveCardCell {
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let text = truncate_with_ellipsis(HINT_TEXT, body_width);
let mut segment = CardSegment::new(text, hint_text_style(style));
let hint_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(colors::warning())
} else {
hint_text_style(style)
};
let mut segment = CardSegment::new(text, hint_style);
segment.inherit_background = true;
CardRow::new(
BORDER_BOTTOM.to_string(),
+19 -3
View File
@@ -1,4 +1,5 @@
use super::card_style::{
ansi16_inverse_color,
browser_card_style,
fill_card_background,
hint_text_style,
@@ -14,6 +15,7 @@ use super::card_style::{
};
use super::{HistoryCell, HistoryCellType, ToolCellStatus};
use crate::colors;
use crate::theme::{palette_mode, PaletteMode};
use code_common::elapsed::format_duration_digital;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -295,6 +297,9 @@ impl BrowserSessionCell {
}
fn accent_style(style: &CardStyle) -> Style {
if palette_mode() == PaletteMode::Ansi16 {
return Style::default().fg(ansi16_inverse_color());
}
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
Style::default().fg(dim)
}
@@ -332,11 +337,17 @@ impl BrowserSessionCell {
);
}
segments.push(CardSegment::new(" ".to_string(), title_text_style(style)));
let title_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(ansi16_inverse_color())
} else {
title_text_style(style)
};
segments.push(CardSegment::new(" ".to_string(), title_style));
let remaining = body_width.saturating_sub(1);
let text = truncate_with_ellipsis(self.header_summary_text().as_str(), remaining);
if !text.is_empty() {
segments.push(CardSegment::new(text, title_text_style(style)));
segments.push(CardSegment::new(text, title_style));
}
CardRow::new(BORDER_TOP.to_string(), Self::accent_style(style), segments, None)
}
@@ -389,7 +400,12 @@ impl BrowserSessionCell {
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let text_value = format!(" [Ctrl+B] View · [Esc] Stop");
let text = truncate_with_ellipsis(text_value.as_str(), body_width);
let segment = CardSegment::new(text, hint_text_style(style));
let hint_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(ansi16_inverse_color())
} else {
hint_text_style(style)
};
let segment = CardSegment::new(text, hint_style);
CardRow::new(BORDER_BOTTOM.to_string(), Self::accent_style(style), vec![segment], None)
}
+35 -19
View File
@@ -62,10 +62,9 @@ impl CardRow {
pub(crate) const CARD_ACCENT_WIDTH: usize = 2;
pub(crate) fn agent_card_style(write_enabled: Option<bool>) -> CardStyle {
pub(crate) fn agent_card_style(_write_enabled: Option<bool>) -> CardStyle {
// Agent batches share the calmer green theme in full-color terminals.
// In ANSI-16 mode we swap to solid backgrounds for legibility (green for read,
// yellow when write mode is enabled).
// In ANSI-16 mode we keep the surface transparent and rely on inverted text.
let is_dark = is_dark_theme_active();
let definition = if is_dark {
card_theme::agent_read_only_dark_theme()
@@ -75,18 +74,7 @@ pub(crate) fn agent_card_style(write_enabled: Option<bool>) -> CardStyle {
let mut style = style_from_theme(definition, is_dark);
if palette_mode() == PaletteMode::Ansi16 {
let bg = if write_enabled == Some(true) {
if is_dark {
Color::Yellow
} else {
Color::LightYellow
}
} else if is_dark {
Color::Green
} else {
Color::LightGreen
};
apply_ansi16_background(&mut style, bg, is_dark);
strip_ansi16_background(&mut style);
}
style
@@ -102,8 +90,7 @@ pub(crate) fn browser_card_style() -> CardStyle {
let mut style = style_from_theme(definition, is_dark);
if palette_mode() == PaletteMode::Ansi16 {
let bg = if is_dark { Color::Red } else { Color::LightRed };
apply_ansi16_background(&mut style, bg, is_dark);
strip_ansi16_background(&mut style);
}
style
@@ -119,7 +106,7 @@ pub(crate) fn auto_drive_card_style() -> CardStyle {
let mut style = style_from_theme(definition, is_dark);
if palette_mode() == PaletteMode::Ansi16 {
let bg = if is_dark { Color::Magenta } else { Color::LightMagenta };
let bg = if is_dark { Color::Blue } else { Color::LightBlue };
apply_ansi16_background(&mut style, bg, is_dark);
}
@@ -147,7 +134,29 @@ pub(crate) fn web_search_card_style() -> CardStyle {
} else {
card_theme::search_light_theme()
};
style_from_theme(definition, is_dark)
let mut style = style_from_theme(definition, is_dark);
if palette_mode() == PaletteMode::Ansi16 {
strip_ansi16_background(&mut style);
}
style
}
pub(crate) fn ansi16_inverse_color() -> Color {
if is_dark_theme_active() {
Color::White
} else {
Color::Black
}
}
fn strip_ansi16_background(style: &mut CardStyle) {
style.gradient = GradientSpec {
left: Color::Reset,
right: Color::Reset,
bias: 0.0,
};
}
fn style_from_theme(definition: CardThemeDefinition, is_dark: bool) -> CardStyle {
@@ -200,6 +209,13 @@ fn adjust_gradient(gradient: GradientSpec, _is_dark: bool) -> GradientSpec {
}
pub(crate) fn fill_card_background(buf: &mut Buffer, area: Rect, style: &CardStyle) {
if palette_mode() == PaletteMode::Ansi16
&& matches!(style.gradient.left, Color::Reset)
&& matches!(style.gradient.right, Color::Reset)
{
return;
}
if palette_mode() == PaletteMode::Ansi16
&& style.gradient.left == style.gradient.right
&& !matches!(style.gradient.left, Color::Rgb(_, _, _))
+24 -4
View File
@@ -1,4 +1,5 @@
use super::card_style::{
ansi16_inverse_color,
fill_card_background,
hint_text_style,
primary_text_style,
@@ -14,6 +15,7 @@ use super::card_style::{
};
use super::{HistoryCell, HistoryCellType, ToolCellStatus};
use crate::colors;
use crate::theme::{palette_mode, PaletteMode};
use code_common::elapsed::format_duration_digital;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -158,6 +160,9 @@ impl WebSearchSessionCell {
}
fn accent_style(style: &CardStyle) -> Style {
if palette_mode() == PaletteMode::Ansi16 {
return Style::default().fg(ansi16_inverse_color());
}
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
Style::default().fg(dim)
}
@@ -193,19 +198,29 @@ impl WebSearchSessionCell {
let title_text = " Web Search";
let status_text = format!(" · {}", self.status_label());
let total = format!("{title_text}{status_text}");
let title_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(ansi16_inverse_color())
} else {
title_text_style(style)
};
let status_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(ansi16_inverse_color())
} else {
secondary_text_style(style)
};
if UnicodeWidthStr::width(total.as_str()) <= body_width {
segments.push(CardSegment::new(
title_text.to_string(),
title_text_style(style),
title_style,
));
segments.push(CardSegment::new(
status_text,
secondary_text_style(style),
status_style,
));
} else {
let display = truncate_with_ellipsis(title_text, body_width);
segments.push(CardSegment::new(display, title_text_style(style)));
segments.push(CardSegment::new(display, title_style));
}
CardRow::new(
@@ -400,7 +415,12 @@ impl WebSearchSessionCell {
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let text = truncate_with_ellipsis(HINT_TEXT, body_width);
let mut segment = CardSegment::new(text, hint_text_style(style));
let hint_style = if palette_mode() == PaletteMode::Ansi16 {
Style::default().fg(ansi16_inverse_color())
} else {
hint_text_style(style)
};
let mut segment = CardSegment::new(text, hint_style);
segment.inherit_background = true;
CardRow::new(
BORDER_BOTTOM.to_string(),