mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/core): add account switching and skills settings
This commit is contained in:
@@ -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
@@ -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(
|
||||
¤t_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(
|
||||
¤t_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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -235,6 +235,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
|
||||
description,
|
||||
path: resolved_path,
|
||||
scope,
|
||||
content: contents,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_, _, _))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user