F3: Kimi inference pipeline + full grok cloud-surface excision
Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
dialect (thinking-field mapping, model_id stripping, empty-content
tool-call message fix, stream_options.include_usage), with kimi-cli
source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)
Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
share command, credit bar: deleted (single local session lane;
paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
gate/tier/team/ZDR fields, app/subscription.rs watch loop,
dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
RPC; Kigi is zero-egress — nothing to share or retain remotely)
Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
(https://www.kimi.com/code/authorize_device?user_code=..., verified
against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
wiremock fixture URLs into the real browser (root cause of the
"garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
This commit is contained in:
@@ -80,8 +80,6 @@ pub(crate) fn handle_ask_user_question(
|
||||
let cmd = match kind {
|
||||
LocalQuestionKind::Fork { .. } => "/fork",
|
||||
LocalQuestionKind::NewSession => "/new",
|
||||
LocalQuestionKind::CreditLimitUpsell => "credit-limit upsell",
|
||||
LocalQuestionKind::FreeUsageUpsell => "SuperGrok upsell",
|
||||
LocalQuestionKind::AgentTypeMismatch { .. } => "model switch",
|
||||
LocalQuestionKind::ProjectSelect { .. } => "project select",
|
||||
};
|
||||
|
||||
@@ -67,8 +67,6 @@ use prompt_origin::{push_wake_end_marker, viewer_turn_anchor, wake_turn_elapsed}
|
||||
pub(crate) use subagent_activity::finalize_killed_subagent;
|
||||
use subagent_activity::{subagent_activity_label, sync_subagent_activity};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use session_notification::apply_session_event_for_test;
|
||||
use session_notification::{
|
||||
advance_reconnect_cursor, confirm_context_used, detect_plan_mode_change,
|
||||
drop_unexpected_replay, handle_session_notification,
|
||||
|
||||
@@ -342,8 +342,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
bg_tasks: std::collections::BTreeMap::new(),
|
||||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||||
scheduled_tasks: std::collections::HashMap::new(),
|
||||
@@ -363,7 +361,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
||||
child_view.set_input_mode(InputMode::Vim);
|
||||
child_view.is_subagent_view = true;
|
||||
child_view.active_pane = crate::views::agent::ActivePane::Scrollback;
|
||||
child_view.set_sharing_enabled(agent.sharing_enabled);
|
||||
let usage_visible = agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
@@ -1087,18 +1084,6 @@ pub(super) fn handle_child_session_notification(
|
||||
/// Apply a compaction or retry event to a session's activity state and scrollback.
|
||||
///
|
||||
/// Shared between the root agent and child (subagent) notification paths.
|
||||
/// Test-only shim so dispatch-level tests can replay real notification
|
||||
/// sequences (e.g. `RetryState::Retrying` → `Exhausted`) through the
|
||||
/// production handler — the Retrying arm clears the `in_flight_prompt`
|
||||
/// rewind stash, which a fixture setting fields directly would miss.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_session_event_for_test(
|
||||
update: &XaiSessionUpdate,
|
||||
session: &mut AgentSession,
|
||||
scrollback: &mut crate::scrollback::state::ScrollbackState,
|
||||
) -> bool {
|
||||
apply_session_event(update, session, scrollback, false)
|
||||
}
|
||||
pub(super) fn apply_session_event(
|
||||
update: &XaiSessionUpdate,
|
||||
session: &mut AgentSession,
|
||||
@@ -1215,7 +1200,6 @@ pub(super) fn apply_retry_state(
|
||||
scrollback: &mut crate::scrollback::state::ScrollbackState,
|
||||
is_api_key_auth: bool,
|
||||
) {
|
||||
let mut is_credit_limit = false;
|
||||
let mut is_reauth = false;
|
||||
use kigi_shell::extensions::notification::RetryState;
|
||||
match retry {
|
||||
@@ -1238,14 +1222,7 @@ pub(super) fn apply_retry_state(
|
||||
session.set_retry_activity(None);
|
||||
session.rate_limited = *rate_limited;
|
||||
|
||||
is_credit_limit = super::super::dispatch::is_credit_limit_error(None, reason);
|
||||
let is_free_usage =
|
||||
*rate_limited && super::super::dispatch::is_free_usage_exhausted_error(reason);
|
||||
if is_credit_limit {
|
||||
session.credit_limit_blocked = true;
|
||||
} else if is_free_usage {
|
||||
session.free_usage_blocked = true;
|
||||
} else if !*rate_limited && is_reauthable_failure(None, reason) {
|
||||
if !*rate_limited && is_reauthable_failure(None, reason) {
|
||||
is_reauth = true;
|
||||
scrollback.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
|
||||
} else {
|
||||
@@ -1268,10 +1245,7 @@ pub(super) fn apply_retry_state(
|
||||
if error_type == "encrypted_content_mismatch" {
|
||||
session.model_incompatible = true;
|
||||
}
|
||||
is_credit_limit = super::super::dispatch::is_credit_limit_error(None, message);
|
||||
if is_credit_limit {
|
||||
session.credit_limit_blocked = true;
|
||||
} else if is_reauthable_failure(Some(error_type.as_str()), message) {
|
||||
if is_reauthable_failure(Some(error_type.as_str()), message) {
|
||||
is_reauth = true;
|
||||
scrollback.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
|
||||
} else if error_type == "context_length" {
|
||||
@@ -1287,8 +1261,7 @@ pub(super) fn apply_retry_state(
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_credit_limit {
|
||||
} else if !is_reauth {
|
||||
if !is_reauth {
|
||||
session.in_flight_prompt = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,23 +107,6 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
||||
if let Some(v) = update.show_resolved_model {
|
||||
app.show_resolved_model = v;
|
||||
}
|
||||
if let Some(v) = update.sharing_enabled {
|
||||
app.sharing_enabled = v;
|
||||
// Propagate to existing agents so slash-command registries stay
|
||||
// in sync (same fan-out pattern used when creating new agents).
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.set_sharing_enabled(v);
|
||||
}
|
||||
}
|
||||
// Always recompute is_api_key_auth from the tier so a later Free/SuperGrok
|
||||
// stamp does not leave API-key bypass / hidden `/usage` stuck.
|
||||
if let Some(v) = update.subscription_tier_display {
|
||||
let is_key = super::super::app_view::is_api_key_label(&v);
|
||||
app.is_api_key_auth = is_key;
|
||||
app.usage_visible = !is_key && app.team_name.is_none();
|
||||
app.subscription_tier = Some(v);
|
||||
app.apply_tier_restrictions();
|
||||
}
|
||||
// TODO: extract resolve_session_picker_grouped helper (duplicates event_loop.rs:143-160)
|
||||
// Respect env var > config > remote precedence (mirrors event_loop.rs startup).
|
||||
if let Some(remote_val) = update.session_picker_grouped {
|
||||
@@ -142,33 +125,6 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
||||
.unwrap_or(remote_val);
|
||||
app.session_picker_grouped = resolved;
|
||||
}
|
||||
if let Some(v) = update.subscription_watch_interval_secs {
|
||||
app.subscription_watch_interval_secs = Some(v);
|
||||
}
|
||||
|
||||
// Gate update logic:
|
||||
// - allow_access == Some(true): explicitly granted → lift the gate
|
||||
// - gate_message.is_some(): server sent a new message → impose/update
|
||||
// - Neither condition met: don't touch the gate. In particular,
|
||||
// allow_access=Some(false) without a gate_message must NOT clear the
|
||||
// gate (gate_from_settings returns None when gate_message is absent,
|
||||
// which would incorrectly lift an existing gate).
|
||||
if update.allow_access == Some(true) {
|
||||
let effs = app.lift_gate();
|
||||
app.pending_effects.extend(effs);
|
||||
} else if let Some(msg) = update.gate_message.as_ref()
|
||||
&& !msg.is_empty()
|
||||
{
|
||||
// (An empty gate_message would only clear the gate message text, NOT
|
||||
// access, so it intentionally does not touch the gate here.)
|
||||
let effs = app.impose_gate(kigi_shell::auth::GateInfo {
|
||||
message: msg.clone(),
|
||||
url: update.gate_url.clone(),
|
||||
label: update.gate_label.clone(),
|
||||
});
|
||||
app.pending_effects.extend(effs);
|
||||
}
|
||||
|
||||
// Load config layers once for tips + group_tool_verbs +
|
||||
// collapsed_edit_blocks resolution. Loaded unconditionally: the UI flags
|
||||
// re-resolve on every update (see below), and updates are rare (post-auth
|
||||
@@ -361,22 +317,10 @@ pub(super) struct PagerSettingsUpdate {
|
||||
#[serde(default)]
|
||||
show_resolved_model: Option<bool>,
|
||||
#[serde(default)]
|
||||
sharing_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
session_picker_grouped: Option<bool>,
|
||||
#[serde(default)]
|
||||
tips: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
gate_message: Option<String>,
|
||||
#[serde(default)]
|
||||
gate_url: Option<String>,
|
||||
#[serde(default)]
|
||||
gate_label: Option<String>,
|
||||
#[serde(default)]
|
||||
allow_access: Option<bool>,
|
||||
#[serde(default)]
|
||||
subscription_tier_display: Option<String>,
|
||||
#[serde(default)]
|
||||
auto_permission_mode_enabled: Option<bool>,
|
||||
/// Soft-default permission mode. Presence-aware: omit = no update,
|
||||
/// `null` = recompute with remote=None, string = that soft-default.
|
||||
@@ -389,8 +333,6 @@ pub(super) struct PagerSettingsUpdate {
|
||||
group_tool_verbs: Option<bool>,
|
||||
#[serde(default)]
|
||||
collapsed_edit_blocks: Option<bool>,
|
||||
#[serde(default)]
|
||||
subscription_watch_interval_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Presence-aware string: omit → `None` (`#[serde(default)]`), null →
|
||||
|
||||
@@ -33,8 +33,6 @@ pub(super) fn make_session(session_id: Option<&str>) -> AgentSession {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
@@ -257,17 +255,6 @@ pub(super) fn follow_ups_ext_with_prompt(
|
||||
std::sync::Arc::from(serde_json::value::to_raw_value(¶ms).unwrap()),
|
||||
)
|
||||
}
|
||||
pub(super) fn tier_settings_update(tier: &str) -> acp::ExtNotification {
|
||||
acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
std::sync::Arc::from(
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!({ "subscription_tier_display" : tier }),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
)
|
||||
}
|
||||
pub(super) fn group_tool_verbs_settings_update(
|
||||
value: Option<bool>,
|
||||
) -> acp::ExtNotification {
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
|
||||
entries: crate::views::modal::default_palette_entries(),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
|
||||
@@ -1751,24 +1751,7 @@
|
||||
}
|
||||
|
||||
/// The credit-limit early return discards the popped adoption's buffer.
|
||||
#[test]
|
||||
fn credit_limit_response_discards_adoption_buffer() {
|
||||
let mut app = app_with_running_p1_and_stashed_b1();
|
||||
let id = AgentId(0);
|
||||
send_tool_call_update(&mut app, "b1", "bash-mode-1", None);
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.session
|
||||
.credit_limit_blocked = true;
|
||||
|
||||
prompt_response(&mut app, "p1");
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(!app.pending_running_adoptions.contains_key(&id));
|
||||
assert!(agent.pending_adoption_updates.is_empty());
|
||||
}
|
||||
|
||||
/// A stash whose pid replayed a durable terminal is discarded, never adopted.
|
||||
/// A stash whose pid replayed a durable terminal is discarded, never adopted.
|
||||
#[test]
|
||||
fn terminal_in_replay_stash_is_discarded_not_adopted() {
|
||||
let mut app = app_with_running_p1_and_stashed_b1();
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
apply_retry_state(&exhausted, &mut session, &mut scrollback, false);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::RetryFailed { error, .. }) => {
|
||||
assert_eq!(error, RATE_LIMITED_USER_MESSAGE_OAUTH);
|
||||
assert_eq!(error, RATE_LIMITED_USER_MESSAGE_OAUTH.as_str());
|
||||
}
|
||||
other => panic!("expected OAuth rate-limit RetryFailed, got {other:?}"),
|
||||
}
|
||||
@@ -194,140 +194,8 @@
|
||||
);
|
||||
}
|
||||
|
||||
/// A rate-limit exhaustion whose flattened reason carries the
|
||||
/// free-usage code sets both flags and pushes NO generic block (the
|
||||
/// driver shows the paywall modal on PromptResponse; viewers keep no
|
||||
/// marker).
|
||||
#[test]
|
||||
fn retry_exhausted_free_usage_sets_paywall_flag_without_marker() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "try me again".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 0,
|
||||
reason: "API error (status 429 Too Many Requests): \
|
||||
subscription:free-usage-exhausted: You have used all your free usage."
|
||||
.into(),
|
||||
is_rate_limited: true,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.rate_limited,
|
||||
"free-usage keeps rate_limited (TurnFailed/toast suppression)"
|
||||
);
|
||||
assert!(session.free_usage_blocked);
|
||||
assert_eq!(
|
||||
scrollback.len(),
|
||||
0,
|
||||
"no RetryFailed marker — the paywall modal shows instead"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"free-usage exhaustion clears in_flight_prompt like other failures"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_credit_limit_exhausted_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "stash me".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "status 403: run out of credits".into(),
|
||||
is_rate_limited: false,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for credit-limit 403"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved so PromptResponse handler can stash it"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "stash me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_credit_limit_failed_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "stash me too".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(3),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "proxy_error".into(),
|
||||
message: "status 403: run out of credits".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for credit-limit 403"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved so PromptResponse handler can stash it"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "stash me too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_pool_402_sets_credit_limit_blocked() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "pool blocked".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(5),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "proxy_error".into(),
|
||||
message:
|
||||
"API error (status 402 Payment Required): Grok Build usage balance exhausted"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for pool 402 balance exhausted"
|
||||
);
|
||||
assert!(session.in_flight_prompt.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_non_credit_limit_failed_clears_in_flight_prompt() {
|
||||
fn apply_retry_state_generic_failed_clears_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
@@ -345,13 +213,9 @@
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.credit_limit_blocked,
|
||||
"credit_limit_blocked must NOT be set for non-credit-limit errors"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"in_flight_prompt must be cleared for non-credit-limit errors"
|
||||
"in_flight_prompt must be cleared for generic errors"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -400,7 +264,6 @@
|
||||
),
|
||||
"auth 401 must surface the actionable re-auth prompt"
|
||||
);
|
||||
assert!(!session.credit_limit_blocked);
|
||||
}
|
||||
|
||||
/// A recoverable auth failure preserves `in_flight_prompt` so the
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_non_api_key_tier_clears_stale_api_key_flag() {
|
||||
let mut app = make_app_with_agent("sess-stale-key");
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("API Key"),
|
||||
&mut app
|
||||
));
|
||||
assert!(app.is_api_key_auth);
|
||||
assert!(!app.usage_visible);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
|
||||
// Later personal Free stamp must not keep the API-key bypass.
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("Free"),
|
||||
&mut app
|
||||
));
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.usage_visible);
|
||||
// Tier gating no longer exists; nothing gets re-restricted.
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
|
||||
// A paid tier after API Key clears the api-key flag and tier limits.
|
||||
let mut app = make_app_with_agent("sess-paid-tier");
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("API Key"),
|
||||
&mut app
|
||||
));
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("SuperGrok"),
|
||||
&mut app
|
||||
));
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_clearing_group_tool_verbs_reverts_to_default() {
|
||||
// Expected values come from the same chain the handler resolves, so the
|
||||
|
||||
@@ -56,10 +56,6 @@ pub enum Action {
|
||||
ExitSession,
|
||||
/// Exit session without double-press confirmation (e.g., from command palette).
|
||||
ExitSessionConfirmed,
|
||||
/// Open grok.com in the browser for SuperGrok subscription upsell.
|
||||
OpenSupergrokUrl,
|
||||
/// Re-check subscription status via the shell's `x.ai/auth/check_subscription`.
|
||||
CheckSubscription,
|
||||
/// Open an arbitrary URL in the system browser (with scheme validation).
|
||||
OpenUrl(String),
|
||||
/// Open grok.com managed connectors, appending session teamId when set.
|
||||
@@ -587,8 +583,6 @@ pub enum Action {
|
||||
TrustFolder,
|
||||
/// A spawned task completed.
|
||||
TaskComplete(TaskResult),
|
||||
/// Share the current session via URL.
|
||||
ShareSession,
|
||||
/// Show session info (ID, cwd, model, context usage) instantly.
|
||||
ShowSessionInfo,
|
||||
/// Show release notes in a modal.
|
||||
@@ -602,7 +596,7 @@ pub enum Action {
|
||||
},
|
||||
/// Show detailed context usage (progress bar, token breakdown, stats).
|
||||
ShowContextInfo,
|
||||
/// Show credit usage via /usage command.
|
||||
/// Show Kimi usage/quota via the /usage command.
|
||||
ShowUsage,
|
||||
/// Commit a read-only list of the queued prompts as a system block
|
||||
/// (`/queue`). The surface minimal mode uses in place of the `QueuePane`.
|
||||
@@ -663,11 +657,6 @@ pub enum Action {
|
||||
TriggerDeepSearch,
|
||||
/// Force an immediate deep content search, skipping the debounce.
|
||||
ForceDeepSearch,
|
||||
/// Show privacy and data retention status.
|
||||
ShowPrivacyInfo,
|
||||
SetCodingDataSharing {
|
||||
opted_in: bool,
|
||||
},
|
||||
/// `/fork` slash command: parsed args produced by
|
||||
/// [`crate::slash::commands::fork::parse_fork_args`]. The dispatcher
|
||||
/// resolves the worktree question (via flag or the local
|
||||
@@ -1698,11 +1687,6 @@ pub enum Effect {
|
||||
tool_name: String,
|
||||
enabled: bool,
|
||||
},
|
||||
/// Share the current session via URL.
|
||||
ShareSession {
|
||||
agent_id: AgentId,
|
||||
session_id: acp::SessionId,
|
||||
},
|
||||
/// Fetch and display session info via x.ai/session/info.
|
||||
ShowSessionInfo {
|
||||
agent_id: AgentId,
|
||||
@@ -1777,19 +1761,6 @@ pub enum Effect {
|
||||
},
|
||||
/// Log out via `x.ai/auth/logout` (shell clears auth.json + in-memory state).
|
||||
Logout,
|
||||
/// Re-check subscription status via `x.ai/auth/check_subscription`.
|
||||
/// `verify` scopes the result to a deferred-gate verification (see
|
||||
/// [`crate::app::subscription`]); `None` for generic checks.
|
||||
CheckSubscription { verify: Option<u64> },
|
||||
/// One-shot subscription re-check triggered by a credit-limit 403.
|
||||
/// If the tier changed, the stashed prompt is retried instead of
|
||||
/// showing the upsell modal.
|
||||
CreditLimitRecheck { agent_id: AgentId },
|
||||
/// Schedule a 5s timer that fires `TaskResult::PaywallCheckTick`.
|
||||
SchedulePaywallCheck,
|
||||
/// Schedule `TaskResult::GateVerifyTimeout { generation }` after
|
||||
/// [`crate::app::subscription::GATE_VERIFY_TIMEOUT`].
|
||||
ScheduleGateVerifyTimeout { generation: u64 },
|
||||
/// Log out then authenticate sequentially in one task.
|
||||
SwitchAccount {
|
||||
request_seq: u64,
|
||||
@@ -1808,13 +1779,6 @@ pub enum Effect {
|
||||
UnregisterActiveSession { session_id: acp::SessionId },
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
/// Toggle coding data sharing via ACP.
|
||||
SetCodingDataSharing {
|
||||
agent_id: AgentId,
|
||||
opted_in: bool,
|
||||
/// Pre-toggle value to revert to on failure.
|
||||
rollback_to_opted_in: bool,
|
||||
},
|
||||
/// Rename the current session.
|
||||
RenameSession {
|
||||
agent_id: AgentId,
|
||||
@@ -1871,16 +1835,9 @@ pub enum Effect {
|
||||
target_prompt_index: usize,
|
||||
mode: crate::views::rewind::RewindMode,
|
||||
},
|
||||
/// Fetch billing/credit usage from the agent's `x.ai/billing` extension.
|
||||
/// When `silent` is true the result updates `credit_balance` without
|
||||
/// pushing a system message into scrollback (used for automatic refreshes
|
||||
/// on session init and after each turn).
|
||||
FetchBilling { agent_id: AgentId, silent: bool },
|
||||
/// Fetch billing data at the app level (no agent required).
|
||||
/// Used on startup to populate the welcome-screen credit warning.
|
||||
FetchAppBilling,
|
||||
/// Re-fetch remote settings to check subscription gate.
|
||||
RefreshGate,
|
||||
/// Fetch Kimi usage/quota rows from the agent's `x.ai/billing`
|
||||
/// extension (`GET {base}/usages` shell-side) for the `/usage` view.
|
||||
FetchUsage { agent_id: AgentId },
|
||||
/// Spawn a debounce sleep task for shell suggestions. `agent_id` rides
|
||||
/// to the expiry so the fetch is built from the arming agent, not
|
||||
/// whatever view is active when the timer fires.
|
||||
@@ -2019,9 +1976,6 @@ pub enum TaskResult {
|
||||
/// Session list fetched for the welcome screen picker.
|
||||
SessionListLoaded {
|
||||
sessions: Vec<crate::app::app_view::SessionPickerEntry>,
|
||||
/// Degraded conversations lane (`_meta["x.ai/partial"]`), surfaced
|
||||
/// as an actionable picker notice instead of a silent empty list.
|
||||
partial: Option<crate::app::effects::ConversationsPartial>,
|
||||
/// Echo of [`Effect::FetchSessionList::seq`]; stale results are dropped.
|
||||
seq: u64,
|
||||
/// Echo of [`Effect::FetchSessionList::query`]. `Some` marks the
|
||||
@@ -2256,16 +2210,6 @@ pub enum TaskResult {
|
||||
agent_id: AgentId,
|
||||
result: Result<(), String>,
|
||||
},
|
||||
/// Share session completed successfully.
|
||||
ShareSessionComplete {
|
||||
agent_id: AgentId,
|
||||
share_url: String,
|
||||
},
|
||||
/// Share session failed.
|
||||
ShareSessionFailed {
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
},
|
||||
/// Session info fetched successfully.
|
||||
SessionInfoComplete {
|
||||
agent_id: AgentId,
|
||||
@@ -2277,17 +2221,6 @@ pub enum TaskResult {
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
},
|
||||
/// Coding data sharing preference updated.
|
||||
CodingDataSharingUpdated {
|
||||
agent_id: AgentId,
|
||||
opted_in: bool,
|
||||
},
|
||||
/// Coding data sharing update failed.
|
||||
CodingDataSharingFailed {
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
rollback_to_opted_in: bool,
|
||||
},
|
||||
/// Session rename completed successfully.
|
||||
RenameSessionComplete {
|
||||
agent_id: AgentId,
|
||||
@@ -2405,25 +2338,6 @@ pub enum TaskResult {
|
||||
},
|
||||
/// Shell acknowledged logout (auth cleared).
|
||||
LogoutComplete,
|
||||
/// Shell responded to `x.ai/auth/check_subscription`. `verify` echoes
|
||||
/// the generation from `Effect::CheckSubscription` for deferred-gate
|
||||
/// verifications.
|
||||
CheckSubscriptionComplete {
|
||||
verify: Option<u64>,
|
||||
meta: Option<serde_json::Value>,
|
||||
},
|
||||
/// Result of the credit-limit subscription re-check. If the tier
|
||||
/// changed the stashed prompt is retried; otherwise the upsell is shown.
|
||||
CreditLimitRecheckComplete {
|
||||
agent_id: AgentId,
|
||||
meta: Option<serde_json::Value>,
|
||||
},
|
||||
/// 5s paywall check timer fired -- time to send another check.
|
||||
PaywallCheckTick,
|
||||
/// The deferred-gate verification window expired.
|
||||
GateVerifyTimeout {
|
||||
generation: u64,
|
||||
},
|
||||
/// The 2-second "copied!" display timer expired.
|
||||
AuthCopiedTimeout,
|
||||
DeepSearchResults {
|
||||
@@ -2470,30 +2384,10 @@ pub enum TaskResult {
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
},
|
||||
/// Billing data fetched from the agent.
|
||||
BillingFetched {
|
||||
/// Kimi usage/quota rows fetched for the `/usage` view.
|
||||
UsageFetched {
|
||||
agent_id: AgentId,
|
||||
balance: Option<crate::views::credit_bar::CreditBalance>,
|
||||
/// When true, update `credit_balance` silently (no scrollback message).
|
||||
silent: bool,
|
||||
/// Subscription tier piggybacked from remote settings.
|
||||
subscription_tier: Option<String>,
|
||||
/// Auto top-up rule fetch result; `Unchanged` keeps any cached rule.
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch,
|
||||
},
|
||||
/// App-level billing data (welcome screen).
|
||||
AppBillingFetched {
|
||||
balance: Option<crate::views::credit_bar::CreditBalance>,
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch,
|
||||
},
|
||||
GateRefreshed {
|
||||
settings: Option<kigi_shell::util::config::RemoteSettings>,
|
||||
},
|
||||
BillingError {
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
/// When true, swallow the error silently (background refresh).
|
||||
silent: bool,
|
||||
result: Result<Vec<kigi_shell::extensions::billing::UsageRow>, String>,
|
||||
},
|
||||
/// Debounce timer for shell suggestions expired. Routed by the arming
|
||||
/// `agent_id`.
|
||||
|
||||
@@ -653,16 +653,6 @@ pub struct AgentSession {
|
||||
/// fires, so the subsequent `TurnFailed` can be suppressed (the retry handler
|
||||
/// already displayed a user-friendly message). Cleared on `finish_turn`.
|
||||
pub model_incompatible: bool,
|
||||
/// Set when a `RetryState::Failed` carries a 403 credit-limit error, so
|
||||
/// the error message is suppressed in favour of the upsell modal.
|
||||
/// Cleared on `finish_turn`.
|
||||
pub credit_limit_blocked: bool,
|
||||
/// Set when a rate-limit `RetryState::Exhausted` carries the
|
||||
/// `subscription:free-usage-exhausted` code, so the PromptResponse
|
||||
/// handler shows the free-usage paywall instead of the generic
|
||||
/// rate-limit message. Always set together with [`Self::rate_limited`].
|
||||
/// Cleared on `finish_turn`.
|
||||
pub free_usage_blocked: bool,
|
||||
pub(crate) tracker: AcpUpdateTracker,
|
||||
/// ACP-advertised slash commands. Seeded from `InitializeResponse.meta`,
|
||||
/// updated by `AvailableCommandsUpdate`. The prompt-side registry syncs
|
||||
@@ -796,8 +786,6 @@ impl AgentSession {
|
||||
self.state = AgentState::Idle;
|
||||
self.rate_limited = false;
|
||||
self.model_incompatible = false;
|
||||
self.credit_limit_blocked = false;
|
||||
self.free_usage_blocked = false;
|
||||
self.in_flight_prompt = None;
|
||||
self.current_prompt_id = None;
|
||||
}
|
||||
@@ -999,8 +987,6 @@ mod tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -678,7 +678,6 @@ impl AgentView {
|
||||
.hit_plan_approval_status
|
||||
.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_context.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_credits.update_hover(mouse.column, mouse.row);
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if self.hit_plan_button.contains(mouse.column, mouse.row) {
|
||||
@@ -943,7 +942,7 @@ impl AgentView {
|
||||
|| (key.code == KeyCode::Char('/') && key.modifiers.contains(KeyModifiers::SHIFT)))
|
||||
{
|
||||
self.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(self.sharing_enabled),
|
||||
entries: crate::views::modal::default_palette_entries(),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
@@ -1016,7 +1015,7 @@ impl AgentView {
|
||||
}
|
||||
ActionId::CommandPalette => {
|
||||
self.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(self.sharing_enabled),
|
||||
entries: crate::views::modal::default_palette_entries(),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
@@ -1462,7 +1461,7 @@ mod focus_gained_restore_tests {
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
with_permission(&mut agent);
|
||||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(false),
|
||||
entries: crate::views::modal::default_palette_entries(),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
|
||||
@@ -1261,8 +1261,6 @@ mod cancel_turn_mouse_tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -727,10 +727,6 @@ pub struct AgentView {
|
||||
/// Stashed normal prompt state while editing a queued prompt.
|
||||
/// Restored when editing ends.
|
||||
pub stashed_prompt: Option<StashedPrompt>,
|
||||
/// Complete prompt stashed from a credit-limit-blocked turn. Used by
|
||||
/// `CreditLimitRecheckComplete` to retry the prompt after a tier
|
||||
/// upgrade instead of showing a stale upsell.
|
||||
pub credit_limit_stashed_prompt: Option<crate::app::agent::InFlightPrompt>,
|
||||
/// Complete prompt stashed from a turn that failed because the login
|
||||
/// expired (401 / re-auth). Used by the `AuthComplete` handler to
|
||||
/// auto-resubmit the prompt after a successful mid-session re-auth so
|
||||
@@ -754,10 +750,6 @@ pub struct AgentView {
|
||||
/// Unlike `chat_kind`, stays `false` for a `/chat` one-shot session in
|
||||
/// a Build process, whose picker still lists local sessions.
|
||||
pub app_chat_mode: bool,
|
||||
/// Mocked credit balance for the status bar indicator.
|
||||
pub credit_balance: Option<crate::views::credit_bar::CreditBalance>,
|
||||
/// Auto top-up rule paired with `credit_balance` for the prompt warning.
|
||||
pub auto_topup: Option<crate::views::credit_bar::AutoTopupInfo>,
|
||||
/// Current goal orchestration state. Set by `GoalUpdated` session
|
||||
/// notifications, cleared when a new session starts.
|
||||
pub goal_state: Option<super::agent::GoalDisplayState>,
|
||||
@@ -947,7 +939,6 @@ pub struct AgentView {
|
||||
pub hovered_prompt: bool,
|
||||
pub hit_badge: HitArea,
|
||||
pub hit_context: HitArea,
|
||||
pub hit_credits: HitArea,
|
||||
pub hit_todo_close: HitArea,
|
||||
pub hit_bg_close: HitArea,
|
||||
pub hit_subagent_close: HitArea,
|
||||
@@ -1235,8 +1226,6 @@ pub struct AgentView {
|
||||
/// Hit area for the [✗] close button in the subagent frame title bar.
|
||||
pub hit_subagent_frame_close: HitArea,
|
||||
/// Whether the `/share` slash command is available (mirrors
|
||||
/// `AppView::sharing_enabled`). Used to gate palette entries.
|
||||
pub sharing_enabled: bool,
|
||||
/// Input flight recorder — rolling buffer of recent key events.
|
||||
/// Dumped to file via Esc→d combo for debugging.
|
||||
pub(crate) input_log: crate::input_log::InputRingBuffer,
|
||||
@@ -1512,23 +1501,6 @@ fn translate_local_submit(
|
||||
persist_mode,
|
||||
})
|
||||
}
|
||||
LocalQuestionKind::CreditLimitUpsell => {
|
||||
let q = qv.questions.first();
|
||||
let url = q
|
||||
.and_then(|q| q.options.get(*idx))
|
||||
.and_then(|o| o.id.as_deref())
|
||||
.unwrap_or(super::dispatch::UPSELL_URL_PAYG);
|
||||
InputOutcome::Action(Action::OpenUrl(url.to_string()))
|
||||
}
|
||||
LocalQuestionKind::FreeUsageUpsell => {
|
||||
let url = qv
|
||||
.questions
|
||||
.first()
|
||||
.and_then(|q| q.options.get(*idx))
|
||||
.and_then(|o| o.id.as_deref())
|
||||
.unwrap_or(super::dispatch::UPSELL_URL_UPGRADE);
|
||||
InputOutcome::Action(Action::OpenUrl(url.to_string()))
|
||||
}
|
||||
LocalQuestionKind::AgentTypeMismatch { model_id, effort } => {
|
||||
let start_new = *idx == 0;
|
||||
InputOutcome::Action(Action::AgentTypeMismatchAnswered {
|
||||
@@ -2217,8 +2189,6 @@ pub(super) mod test_fixtures {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
@@ -2278,8 +2248,6 @@ pub(super) mod test_fixtures {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
@@ -3010,8 +2978,6 @@ pub(crate) fn test_agent_view(session_id: Option<&str>, cwd: std::path::PathBuf)
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -454,8 +454,6 @@ pub(super) mod paste_key_tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -703,8 +703,6 @@ mod plan_chip_tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -329,7 +329,6 @@ impl AgentView {
|
||||
let thinking_label = self.scrollback.thinking_fold_label();
|
||||
let selected_is_user_prompt = selected_entry.is_some_and(|e| e.block.is_user_prompt());
|
||||
let selected_is_agent_message = selected_entry.is_some_and(|e| e.block.is_agent_message());
|
||||
let selected_is_credit_limit = selected_entry.is_some_and(|e| e.block.is_credit_limit());
|
||||
let mut hints = agent::build_hints(
|
||||
self.active_pane,
|
||||
&self.prompt,
|
||||
@@ -355,7 +354,6 @@ impl AgentView {
|
||||
!self.visible_queue_is_empty(),
|
||||
selected_is_user_prompt,
|
||||
selected_is_agent_message,
|
||||
selected_is_credit_limit,
|
||||
crate::terminal::terminal_context().shift_enter_unavailable(),
|
||||
self.scrollback_search.as_ref(),
|
||||
);
|
||||
@@ -1300,7 +1298,6 @@ impl AgentView {
|
||||
self.hit_bg_status.rect = areas.get("bg_tasks").copied();
|
||||
self.hit_goal_status.rect = areas.get("goal").copied();
|
||||
self.hit_context.rect = areas.get("context").copied();
|
||||
self.hit_credits.rect = areas.get("credits").copied();
|
||||
self.hit_plan_button.rect = areas.get("plan").copied();
|
||||
self.hit_queue_badge.rect = areas.get("queue").copied();
|
||||
self.hit_badge.rect = areas.get("badge").copied();
|
||||
@@ -2041,23 +2038,6 @@ impl AgentView {
|
||||
}
|
||||
let mode_flags: &[PromptFlag] = &mode_flags_vec;
|
||||
let multiline = self.multiline_mode;
|
||||
let usage_visible = self
|
||||
.prompt
|
||||
.slash_controller
|
||||
.registry()
|
||||
.get("usage")
|
||||
.is_some();
|
||||
let warning = self.credit_balance.as_ref().and_then(|bal| {
|
||||
crate::views::credit_bar::usage_warning_for_session(
|
||||
bal,
|
||||
self.auto_topup.as_ref(),
|
||||
usage_visible,
|
||||
self.chat_kind,
|
||||
)
|
||||
});
|
||||
let usage_warning_text: Option<String> = warning.as_ref().map(|(t, _)| t.clone());
|
||||
let usage_warning = usage_warning_text.as_deref();
|
||||
let usage_warning_critical = warning.is_some_and(|(_, critical)| critical);
|
||||
let model_label = match self.session.models.reasoning_effort {
|
||||
Some(eff) => format!("{model_id} ({eff})"),
|
||||
None => model_id,
|
||||
@@ -2067,8 +2047,6 @@ impl AgentView {
|
||||
model_name: &model_label,
|
||||
flags: mode_flags,
|
||||
multiline,
|
||||
usage_warning,
|
||||
usage_warning_critical,
|
||||
},
|
||||
PromptMode::EditingQueued { id, .. } => {
|
||||
let pos = self.session.queue_position(*id).map(|i| i + 1).unwrap_or(1);
|
||||
@@ -2077,8 +2055,6 @@ impl AgentView {
|
||||
model_name: &editing_label,
|
||||
flags: mode_flags,
|
||||
multiline,
|
||||
usage_warning,
|
||||
usage_warning_critical,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2087,8 +2063,6 @@ impl AgentView {
|
||||
model_name: label,
|
||||
flags: &[],
|
||||
multiline: false,
|
||||
usage_warning,
|
||||
usage_warning_critical,
|
||||
}
|
||||
} else {
|
||||
info
|
||||
|
||||
@@ -206,8 +206,6 @@ mod sync_rewind_anchor_to_picker_tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Session lifecycle: bind/reload/replay bookkeeping, turn activity
|
||||
//! resolution, context/credit updates, and app-scoped gates.
|
||||
//! resolution, context updates, and app-scoped gates.
|
||||
#[cfg(test)]
|
||||
use super::test_agent_view;
|
||||
use super::{
|
||||
@@ -85,7 +85,6 @@ impl AgentView {
|
||||
bash_turn: false,
|
||||
cron_task_id: None,
|
||||
stashed_prompt: None,
|
||||
credit_limit_stashed_prompt: None,
|
||||
reauth_stashed_prompt: None,
|
||||
active_modal: None,
|
||||
modal_buttons: Vec::new(),
|
||||
@@ -93,8 +92,6 @@ impl AgentView {
|
||||
context_state: None,
|
||||
chat_kind: false,
|
||||
app_chat_mode: false,
|
||||
credit_balance: None,
|
||||
auto_topup: None,
|
||||
goal_state: None,
|
||||
parked_wait_marker_for: None,
|
||||
end_work_announced: false,
|
||||
@@ -152,7 +149,6 @@ impl AgentView {
|
||||
hovered_prompt: false,
|
||||
hit_badge: Default::default(),
|
||||
hit_context: Default::default(),
|
||||
hit_credits: Default::default(),
|
||||
hit_todo_close: Default::default(),
|
||||
hit_bg_close: Default::default(),
|
||||
hit_subagent_close: Default::default(),
|
||||
@@ -257,7 +253,6 @@ impl AgentView {
|
||||
active_subagent: None,
|
||||
is_subagent_view: false,
|
||||
hit_subagent_frame_close: Default::default(),
|
||||
sharing_enabled: false,
|
||||
input_log: crate::input_log::InputRingBuffer::new(),
|
||||
esc_pressed_at: None,
|
||||
pending_first_prompt: None,
|
||||
@@ -740,21 +735,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Apply Build coding-credit balance only for non-chat agents.
|
||||
/// Gateway/chat-kind sessions keep credits unset so bars/warnings stay off.
|
||||
pub fn apply_credit_balance(
|
||||
&mut self,
|
||||
balance: Option<crate::views::credit_bar::CreditBalance>,
|
||||
auto_topup: Option<crate::views::credit_bar::AutoTopupInfo>,
|
||||
) {
|
||||
if self.chat_kind {
|
||||
self.credit_balance = None;
|
||||
self.auto_topup = None;
|
||||
return;
|
||||
}
|
||||
self.credit_balance = balance;
|
||||
self.auto_topup = auto_topup;
|
||||
}
|
||||
/// Record a key event to the input flight recorder.
|
||||
///
|
||||
/// Zero heap allocations — stores raw `Copy` types in the ring buffer.
|
||||
@@ -801,18 +781,6 @@ impl AgentView {
|
||||
textarea_changed: delta.textarea_changed,
|
||||
});
|
||||
}
|
||||
/// Set the sharing-enabled flag on this view and propagate it to the
|
||||
/// slash-command registry so the `/share` entry stays hidden/visible in
|
||||
/// lockstep with `AgentView::sharing_enabled`. Use this instead of
|
||||
/// mutating `sharing_enabled` directly when a new agent is created or a
|
||||
/// session is loaded, so the field and registry can't drift.
|
||||
pub fn set_sharing_enabled(&mut self, enabled: bool) {
|
||||
self.sharing_enabled = enabled;
|
||||
self.prompt
|
||||
.slash_controller
|
||||
.registry_mut()
|
||||
.set_share_visible(enabled);
|
||||
}
|
||||
/// Show or hide the `/usage` slash command in this agent's registry.
|
||||
pub fn set_usage_visible(&mut self, visible: bool) {
|
||||
self.prompt
|
||||
@@ -839,13 +807,11 @@ impl AgentView {
|
||||
/// One place for the app-scoped gates a new/adopted session inherits so the session-creation sites cannot drift.
|
||||
pub(crate) fn apply_app_scoped_gates(
|
||||
&mut self,
|
||||
sharing_enabled: bool,
|
||||
usage_visible: bool,
|
||||
chat_mode: bool,
|
||||
screen_mode: crate::app::ScreenMode,
|
||||
restricted_commands: &[String],
|
||||
) {
|
||||
self.set_sharing_enabled(sharing_enabled);
|
||||
self.set_usage_visible(usage_visible);
|
||||
self.app_chat_mode = chat_mode;
|
||||
self.prompt.set_screen_mode(screen_mode);
|
||||
|
||||
@@ -386,28 +386,6 @@ fn parse_esc_ttl(raw: Option<String>) -> Duration {
|
||||
.map(|ms| Duration::from_millis(ms.min(ESC_DOUBLE_PRESS_TEST_MS)))
|
||||
.unwrap_or(PendingAction::ESC_DOUBLE_PRESS_TTL)
|
||||
}
|
||||
/// Slash commands unavailable on the free and X Basic subscription tiers.
|
||||
///
|
||||
/// To restrict another command for these tiers, add its canonical name
|
||||
/// (no leading `/`) here — matching covers aliases automatically via
|
||||
/// [`crate::slash::registry::CommandRegistry::set_restricted_commands`].
|
||||
///
|
||||
/// Current set:
|
||||
/// - `usage` — coding credit / billing UI (alias: `/cost`)
|
||||
/// - `imagine` — image generation entry point
|
||||
/// - `imagine-video` — video generation entry point
|
||||
pub(crate) const TIER_RESTRICTED_COMMANDS: &[&str] = &["usage", "imagine", "imagine-video"];
|
||||
/// Whether a subscription-tier display name is a tier with restricted
|
||||
/// commands: the free tier (no subscription ⇒ `None`, or an explicit
|
||||
/// "Free") and X Basic (CCP display name "X Basic"; JWT claim fallback
|
||||
/// "x_basic"). Everything else — paid tiers and unknown future names —
|
||||
/// is unrestricted (fail-open).
|
||||
///
|
||||
/// Tier gating was an xAI concept; the Kimi Code subscription has no
|
||||
/// client-visible tier, so nothing is ever restricted.
|
||||
fn is_restricted_tier(_tier: Option<&str>) -> bool {
|
||||
false
|
||||
}
|
||||
/// True for API-key labels from shell/CCP: `"ApiKey"`, `"API Key"`, `"api_key"`.
|
||||
pub(crate) fn is_api_key_label(s: &str) -> bool {
|
||||
s.trim().to_ascii_lowercase().replace([' ', '_', '-'], "") == "apikey"
|
||||
@@ -519,19 +497,9 @@ pub struct AppView {
|
||||
pub tip: Option<String>,
|
||||
/// Whether to show the resolved model ID in /session-info output.
|
||||
pub show_resolved_model: bool,
|
||||
/// Whether the `/share` slash command is available. Gated by
|
||||
/// `RemoteSettings.sharing_enabled`; defaults to `false` when remote
|
||||
/// settings are unavailable or the field is absent.
|
||||
pub sharing_enabled: bool,
|
||||
/// Whether the `/usage` slash command is available. Hidden for team
|
||||
/// (`team_name.is_some()`) and API-key auth.
|
||||
pub usage_visible: bool,
|
||||
/// Slash commands denied for the current subscription tier
|
||||
/// ([`TIER_RESTRICTED_COMMANDS`] when the user is on the free / X Basic
|
||||
/// tier, empty otherwise). Recomputed by [`Self::apply_tier_restrictions`]
|
||||
/// and fanned out to every slash registry (welcome prompt, agents,
|
||||
/// dashboard); deny wins over all other visibility gates.
|
||||
pub tier_restricted_commands: Vec<String>,
|
||||
/// Whether the pager is connected via a leader (leader mode). The Agent
|
||||
/// Dashboard entry points (`/dashboard`, `Ctrl+\`, `grok dashboard`, the
|
||||
/// startup hook) are only meaningful when a leader is coordinating a
|
||||
@@ -539,13 +507,6 @@ pub struct AppView {
|
||||
/// `event_loop::run` from `connection.leader_status_rx.is_some()`;
|
||||
/// defaults to `false` (non-leader, dashboard hidden).
|
||||
pub leader_mode: bool,
|
||||
/// App-level credit balance used to show the usage warning on the
|
||||
/// welcome screen before any agent session exists.
|
||||
pub credit_balance: Option<crate::views::credit_bar::CreditBalance>,
|
||||
/// App-level auto top-up rule paired with `credit_balance` for the warning.
|
||||
pub auto_topup: Option<crate::views::credit_bar::AutoTopupInfo>,
|
||||
/// Periodic billing poll requested (credits >= 99%).
|
||||
pub billing_poll_wanted: bool,
|
||||
/// Leader-mode session roster (FleetView dashboard). Populated from
|
||||
/// `x.ai/sessions/list` polls and `x.ai/sessions/changed` broadcasts.
|
||||
/// Empty in non-leader mode, which naturally gates roster rendering.
|
||||
@@ -667,9 +628,7 @@ pub struct AppView {
|
||||
/// Hit-test rect for the "show full URL" fallback link.
|
||||
pub welcome_auth_fallback_rect: Option<ratatui::layout::Rect>,
|
||||
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
|
||||
pub welcome_refresh_rect: Option<ratatui::layout::Rect>,
|
||||
/// Hit-test rect for the gate URL link on the paywall CTA.
|
||||
pub welcome_gate_url_rect: Option<ratatui::layout::Rect>,
|
||||
/// Hit-test rect for the clickable changelog info block (opens release notes).
|
||||
pub welcome_changelog_cta_rect: Option<ratatui::layout::Rect>,
|
||||
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
||||
@@ -833,16 +792,6 @@ pub struct AppView {
|
||||
pub auth_use_oauth: bool,
|
||||
/// Whether the last clipboard copy during auth succeeded.
|
||||
pub auth_clipboard_copied: bool,
|
||||
/// Team principal UUID from auth (`None` for personal sessions).
|
||||
pub team_id: Option<String>,
|
||||
/// Team name from auth (displayed in the shortcuts bar).
|
||||
pub team_name: Option<String>,
|
||||
/// Whether the user's team has enterprise Zero Data Retention enabled.
|
||||
pub is_zdr: bool,
|
||||
/// Team role (e.g. "Admin", "Member", "Read Only") for access-control checks.
|
||||
pub team_role: Option<String>,
|
||||
/// Whether the user has opted out of coding data retention.
|
||||
pub coding_data_retention_opt_out: bool,
|
||||
/// Persisted `[cli].show_tips` mirror. `None` = no override (default `true`).
|
||||
pub show_tips: Option<bool>,
|
||||
/// Persisted `[cli].auto_update` mirror. `None` = no override (default `true`).
|
||||
@@ -851,31 +800,6 @@ pub struct AppView {
|
||||
/// from the effective TOML merge like `show_tips`. `None` = unset in TOML
|
||||
/// (default `true`); toggles write the user layer.
|
||||
pub ask_user_question_timeout_enabled: Option<bool>,
|
||||
/// Whether ZDR users are allowed to use the product.
|
||||
/// Server-controlled via RemoteSettings (remote settings). Default `false` (blocked) during beta.
|
||||
pub zdr_access_enabled: bool,
|
||||
/// When set, `/usage` shows a link to this URL instead of fetching billing
|
||||
/// data from the backend. Server-controlled via RemoteSettings (remote settings
|
||||
/// `grok_build_usage_redirect_url`, targeted at personal-team users).
|
||||
/// `None` (default) fetches usage from the backend.
|
||||
pub usage_billing_redirect_url: Option<String>,
|
||||
pub access_gate_shown_logged: bool,
|
||||
/// Access gate from `grok_build_access_gate`. `Some` = blocked.
|
||||
pub gate: Option<kigi_shell::auth::GateInfo>,
|
||||
/// User-friendly subscription tier name (e.g. "SuperGrok", "Free").
|
||||
pub subscription_tier: Option<String>,
|
||||
/// When the pager started auto-checking subscriptions (for 10-min timeout).
|
||||
pub paywall_check_started: Option<std::time::Instant>,
|
||||
/// Debounce stamp for watch/focus subscription checks (see
|
||||
/// [`super::subscription`]).
|
||||
pub last_subscription_check_at: Option<std::time::Instant>,
|
||||
/// Server override (seconds) for the subscription-watch cadence.
|
||||
pub subscription_watch_interval_secs: Option<u64>,
|
||||
/// A stale-source gate held out of `gate` while a live check verifies
|
||||
/// it (see [`super::subscription`]).
|
||||
pub pending_gate_verification: Option<kigi_shell::auth::GateInfo>,
|
||||
/// Generation stamp of the current gate verification.
|
||||
pub gate_verify_gen: u64,
|
||||
/// Whether a leader reconnect is in progress (blocks prompt submission).
|
||||
pub reconnect_pending: bool,
|
||||
/// Structured startup warnings collected from the terminal diagnostics
|
||||
@@ -923,17 +847,6 @@ pub struct AppView {
|
||||
pub(crate) keyboard_normalizer: KeyboardNormalizer,
|
||||
}
|
||||
impl AppView {
|
||||
pub fn is_zdr_blocked(&self) -> bool {
|
||||
self.is_zdr && !self.zdr_access_enabled
|
||||
}
|
||||
/// User is not gated (no gate from remote settings or subscription fallback).
|
||||
pub fn has_access(&self) -> bool {
|
||||
self.gate.is_none()
|
||||
}
|
||||
/// True when the user should not see the prompt (gate, subscription, or ZDR).
|
||||
pub fn is_access_blocked(&self) -> bool {
|
||||
!self.has_access() || self.is_zdr_blocked()
|
||||
}
|
||||
/// Whether deferred session-startup actions may run: both auth AND folder
|
||||
/// trust must be resolved. Mirrors the auth gate at the session-creating
|
||||
/// startup sites; trust is gated AFTER auth so a pending trust question
|
||||
@@ -941,33 +854,12 @@ impl AppView {
|
||||
pub fn session_startup_allowed(&self) -> bool {
|
||||
matches!(self.auth_state, AuthState::Done) && matches!(self.trust_state, TrustState::Done)
|
||||
}
|
||||
/// Extract `GateInfo` from `RemoteSettings`.
|
||||
pub fn gate_from_settings(
|
||||
rs: &kigi_shell::util::config::RemoteSettings,
|
||||
) -> Option<kigi_shell::auth::GateInfo> {
|
||||
let msg = rs.gate_message.as_ref()?;
|
||||
if msg.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(kigi_shell::auth::GateInfo {
|
||||
message: msg.clone(),
|
||||
url: rs.gate_url.clone(),
|
||||
label: rs.gate_label.clone(),
|
||||
})
|
||||
}
|
||||
/// Apply typed auth metadata from the shell. The Kimi auth model carries
|
||||
/// no team/tier/gate info; those fields only ever come from remote
|
||||
/// settings now.
|
||||
/// Apply typed auth metadata from the shell: auth mode (drives the
|
||||
/// API-key badge and `/usage` visibility) and the resolved-model display
|
||||
/// preference. The Kimi auth model carries no team/tier/gate info.
|
||||
pub fn apply_auth_meta(&mut self, meta: &kigi_shell::auth::AuthMeta) {
|
||||
self.pending_gate_verification = None;
|
||||
let was_gated = self.gate.is_some();
|
||||
self.gate = None;
|
||||
if was_gated {
|
||||
self.paywall_check_started = None;
|
||||
}
|
||||
self.is_api_key_auth = meta.auth_mode.as_deref().is_some_and(is_api_key_label);
|
||||
self.usage_visible = !self.is_api_key_auth;
|
||||
self.apply_tier_restrictions();
|
||||
if let Some(show) = meta.show_resolved_model {
|
||||
self.show_resolved_model = show;
|
||||
}
|
||||
@@ -1038,8 +930,6 @@ impl AppView {
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_refresh_rect: None,
|
||||
welcome_gate_url_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
@@ -1098,24 +988,9 @@ impl AppView {
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
auth_clipboard_copied: false,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
is_zdr: false,
|
||||
team_role: None,
|
||||
coding_data_retention_opt_out: false,
|
||||
show_tips: None,
|
||||
auto_update: None,
|
||||
ask_user_question_timeout_enabled: None,
|
||||
zdr_access_enabled: false,
|
||||
usage_billing_redirect_url: None,
|
||||
access_gate_shown_logged: false,
|
||||
gate: None,
|
||||
subscription_tier: None,
|
||||
paywall_check_started: None,
|
||||
last_subscription_check_at: None,
|
||||
subscription_watch_interval_secs: None,
|
||||
pending_gate_verification: None,
|
||||
gate_verify_gen: 0,
|
||||
reconnect_pending: false,
|
||||
startup_warnings: Vec::new(),
|
||||
is_api_key_auth: false,
|
||||
@@ -1129,13 +1004,8 @@ impl AppView {
|
||||
welcome_doc_viewer: None,
|
||||
screen_mode: ScreenMode::Inline,
|
||||
show_resolved_model: true,
|
||||
sharing_enabled: false,
|
||||
usage_visible: true,
|
||||
tier_restricted_commands: Vec::new(),
|
||||
leader_mode: false,
|
||||
credit_balance: None,
|
||||
auto_topup: None,
|
||||
billing_poll_wanted: false,
|
||||
leader_roster: Vec::new(),
|
||||
dashboard_local_sessions: Vec::new(),
|
||||
dashboard_sessions_loading: false,
|
||||
@@ -1178,35 +1048,6 @@ impl AppView {
|
||||
dashboard.set_auto_mode_available(available);
|
||||
}
|
||||
}
|
||||
/// Recompute the tier-restricted slash commands from the current auth
|
||||
/// state and sync the deny list into every slash surface (welcome
|
||||
/// prompt, all agents, dashboard) so restricted commands hide/show in
|
||||
/// lockstep.
|
||||
///
|
||||
/// Called from [`Self::apply_auth_meta`] (startup / login) and from the
|
||||
/// `x.ai/settings/update` handler when the subscription tier changes, so
|
||||
/// a mid-session upgrade lifts the restrictions without a restart.
|
||||
pub fn apply_tier_restrictions(&mut self) {
|
||||
let restricted = self.team_name.is_none()
|
||||
&& !self.is_api_key_auth
|
||||
&& is_restricted_tier(self.subscription_tier.as_deref());
|
||||
let names: Vec<String> = if restricted {
|
||||
TIER_RESTRICTED_COMMANDS
|
||||
.iter()
|
||||
.map(|n| (*n).to_string())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for agent in self.agents.values_mut() {
|
||||
agent.set_restricted_commands(&names);
|
||||
}
|
||||
self.welcome_prompt.set_restricted_commands(&names);
|
||||
if let Some(dashboard) = self.dashboard.as_mut() {
|
||||
dashboard.set_restricted_commands(&names);
|
||||
}
|
||||
self.tier_restricted_commands = names;
|
||||
}
|
||||
/// Session ID of the active agent, if one exists and has an established session.
|
||||
pub fn active_session_id(&self) -> Option<&str> {
|
||||
match self.active_view {
|
||||
@@ -1667,8 +1508,6 @@ impl AppView {
|
||||
);
|
||||
if is_mouse_action {}
|
||||
}
|
||||
let zdr_blocked = self.is_zdr_blocked();
|
||||
let has_access = self.has_access();
|
||||
let has_foreign_resume = self.foreign_resume_hint().is_some();
|
||||
let outcome = match self.active_view {
|
||||
ActiveView::Welcome => handle_welcome_input(
|
||||
@@ -1684,27 +1523,20 @@ impl AppView {
|
||||
new_worktree_dialog: &mut self.new_worktree_dialog,
|
||||
menu_index: &mut self.welcome_menu_index,
|
||||
menu_rects: &self.welcome_menu_rects,
|
||||
menu_count: if zdr_blocked {
|
||||
2
|
||||
} else {
|
||||
3 + if self.has_claude_import { 1 } else { 0 }
|
||||
+ if self.welcome_show_changelog_action {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
},
|
||||
menu_count: 3
|
||||
+ if self.has_claude_import { 1 } else { 0 }
|
||||
+ if self.welcome_show_changelog_action {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
prompt_rect: self.welcome_prompt_rect.as_ref(),
|
||||
import_banner_rect: self.welcome_import_banner_rect.as_ref(),
|
||||
auth_url_rect: self.welcome_auth_url_rect.as_ref(),
|
||||
auth_fallback_rect: self.welcome_auth_fallback_rect.as_ref(),
|
||||
refresh_rect: self.welcome_refresh_rect.as_ref(),
|
||||
gate_url_rect: self.welcome_gate_url_rect.as_ref(),
|
||||
changelog_cta_rect: self.welcome_changelog_cta_rect.as_ref(),
|
||||
on_changelog_cta: &mut self.welcome_on_changelog_cta,
|
||||
show_raw_url: &mut self.auth_show_raw_url,
|
||||
has_access,
|
||||
is_zdr_blocked: zdr_blocked,
|
||||
sp_entries: &mut self.session_picker_entries,
|
||||
sp_state: &mut self.session_picker_state,
|
||||
sp_content_results: &self.session_picker_content_results,
|
||||
@@ -2228,15 +2060,11 @@ struct WelcomeInputCtx<'a> {
|
||||
import_banner_rect: Option<&'a ratatui::layout::Rect>,
|
||||
auth_url_rect: Option<&'a ratatui::layout::Rect>,
|
||||
auth_fallback_rect: Option<&'a ratatui::layout::Rect>,
|
||||
refresh_rect: Option<&'a ratatui::layout::Rect>,
|
||||
gate_url_rect: Option<&'a ratatui::layout::Rect>,
|
||||
/// Hit-test rect for the clickable changelog info block (opens release notes).
|
||||
changelog_cta_rect: Option<&'a ratatui::layout::Rect>,
|
||||
/// Sticky hover flag for the changelog block (redraw on enter/leave).
|
||||
on_changelog_cta: &'a mut bool,
|
||||
show_raw_url: &'a mut bool,
|
||||
has_access: bool,
|
||||
is_zdr_blocked: bool,
|
||||
sp_entries: &'a mut Option<Vec<SessionPickerEntry>>,
|
||||
sp_state: &'a mut crate::views::picker::PickerState,
|
||||
sp_content_results: &'a Option<Vec<kigi_shell::extensions::session_search::SearchSessionHit>>,
|
||||
@@ -2360,8 +2188,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if matches!(ctx.auth_state, AuthState::Done)
|
||||
&& ctx.has_access
|
||||
&& !ctx.is_zdr_blocked
|
||||
&& matches!(ctx.trust_state, TrustState::Pending { .. })
|
||||
{
|
||||
if let Event::Key(key) = ev {
|
||||
@@ -2603,22 +2429,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
if key.kind == KeyEventKind::Release {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if ctx.is_zdr_blocked && matches!(ctx.auth_state, AuthState::Done) {
|
||||
return handle_menu_shortcuts(
|
||||
key,
|
||||
ctx.menu_index,
|
||||
&['l', 'q'],
|
||||
dispatch_zdr_menu_action,
|
||||
);
|
||||
}
|
||||
if !ctx.has_access && matches!(ctx.auth_state, AuthState::Done) {
|
||||
return handle_menu_shortcuts(
|
||||
key,
|
||||
ctx.menu_index,
|
||||
&['g', 'l', 'q'],
|
||||
dispatch_access_gate_menu_action,
|
||||
);
|
||||
}
|
||||
if matches!(ctx.auth_state, AuthState::Done)
|
||||
&& key!(Enter).matches(key)
|
||||
&& key.modifiers.is_empty()
|
||||
@@ -2760,9 +2570,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
if let Event::Paste(text) = ev {
|
||||
match ctx.auth_state {
|
||||
AuthState::Done => {
|
||||
if !ctx.has_access || ctx.is_zdr_blocked {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
return InputOutcome::ActionThenForward(Action::NewSession);
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
@@ -2792,12 +2599,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
if matches!(ctx.auth_state, AuthState::Pending { .. }) {
|
||||
return dispatch_pending_menu_action(i);
|
||||
}
|
||||
if ctx.is_zdr_blocked {
|
||||
return dispatch_zdr_menu_action(i);
|
||||
}
|
||||
if !ctx.has_access {
|
||||
return dispatch_access_gate_menu_action(i);
|
||||
}
|
||||
if ctx.has_claude_import
|
||||
&& i == 0
|
||||
&& mouse.column >= rect.x + rect.width.saturating_sub(4)
|
||||
@@ -2813,16 +2614,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(rect) = ctx.refresh_rect
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
{
|
||||
return InputOutcome::Action(Action::CheckSubscription);
|
||||
}
|
||||
if let Some(rect) = ctx.gate_url_rect
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
{
|
||||
return InputOutcome::Action(Action::OpenSupergrokUrl);
|
||||
}
|
||||
if let Some(rect) = ctx.changelog_cta_rect
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
&& let Some(md) = ctx.changelog_markdown.as_deref()
|
||||
@@ -2901,32 +2692,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
/// Handle Up/Down arrow key cycling through a menu of `count` items.
|
||||
fn is_quit_signal(key: &crossterm::event::KeyEvent) -> bool {
|
||||
key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key)
|
||||
}
|
||||
/// `shortcuts[i]` triggers `menu_dispatch(i)`.
|
||||
fn handle_menu_shortcuts(
|
||||
key: &crossterm::event::KeyEvent,
|
||||
menu_index: &mut Option<usize>,
|
||||
shortcuts: &[char],
|
||||
menu_dispatch: fn(usize) -> InputOutcome,
|
||||
) -> InputOutcome {
|
||||
if is_quit_signal(key) {
|
||||
return InputOutcome::Action(Action::Quit);
|
||||
}
|
||||
for (i, &ch) in shortcuts.iter().enumerate() {
|
||||
if key.code == KeyCode::Char(ch) {
|
||||
return menu_dispatch(i);
|
||||
}
|
||||
}
|
||||
if key!(Enter).matches(key) {
|
||||
return menu_dispatch(menu_index.unwrap_or(0));
|
||||
}
|
||||
if let Some(outcome) = handle_menu_nav(key, menu_index, shortcuts.len()) {
|
||||
return outcome;
|
||||
}
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
fn handle_menu_nav(
|
||||
key: &crossterm::event::KeyEvent,
|
||||
index: &mut Option<usize>,
|
||||
@@ -2959,25 +2724,6 @@ fn dispatch_pending_menu_action(index: usize) -> InputOutcome {
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
/// Dispatch an action for a welcome menu item when ZDR-blocked.
|
||||
/// Menu layout: 0 = Switch account, 1 = Quit.
|
||||
fn dispatch_zdr_menu_action(index: usize) -> InputOutcome {
|
||||
match index {
|
||||
0 => InputOutcome::Action(Action::SwitchAccount),
|
||||
1 => InputOutcome::Action(Action::Quit),
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
/// Menu actions when user is access-gated: 0 = Subscribe CTA, 1 = Logout, 2 = Quit.
|
||||
/// "Refresh" (ctrl-r) is handled as a direct key shortcut, not a menu item.
|
||||
fn dispatch_access_gate_menu_action(index: usize) -> InputOutcome {
|
||||
match index {
|
||||
0 => InputOutcome::Action(Action::OpenSupergrokUrl),
|
||||
1 => InputOutcome::Action(Action::Logout),
|
||||
2 => InputOutcome::Action(Action::Quit),
|
||||
_ => InputOutcome::Unchanged,
|
||||
}
|
||||
}
|
||||
/// Dispatch an action for a welcome menu item by index.
|
||||
///
|
||||
/// Menu order: `[Import]`, New worktree, Resume session, `[Changelog]`, Quit.
|
||||
@@ -3242,8 +2988,6 @@ impl AppView {
|
||||
layout_cfg.eff_outer_vpad(compact),
|
||||
)
|
||||
};
|
||||
let zdr_blocked_for_draw = self.is_zdr_blocked();
|
||||
let has_access = self.has_access();
|
||||
let scroll_debug_panel = self.scroll_debug_panel();
|
||||
let dev_fps_rows = self.dev_fps_rows();
|
||||
let fps_overlay = self.fps_hud.overlay(dev_fps_rows);
|
||||
@@ -3337,11 +3081,8 @@ impl AppView {
|
||||
model_name: &model_name,
|
||||
flags: &flags_vec,
|
||||
selected: self.welcome_menu_index,
|
||||
team_name: self.team_name.as_deref(),
|
||||
has_access,
|
||||
has_claude_import: self.has_claude_import,
|
||||
mouse_pos: self.last_mouse_pos,
|
||||
is_zdr_blocked: zdr_blocked_for_draw,
|
||||
session_picker: self.session_picker_entries.as_deref(),
|
||||
session_picker_loading: self.session_picker_entries.is_none()
|
||||
&& (self.session_picker_loading
|
||||
@@ -3359,14 +3100,9 @@ impl AppView {
|
||||
.session_picker_entries_query
|
||||
.as_deref(),
|
||||
welcome_tick: self.welcome_tick,
|
||||
gate: self.gate.as_ref(),
|
||||
subscription_tier: self.subscription_tier.as_deref(),
|
||||
session_picker_grouped: self.session_picker_grouped,
|
||||
session_picker_source_filter: self.session_picker_source_filter,
|
||||
chat_mode: self.chat_mode,
|
||||
credit_balance: self.credit_balance.as_ref(),
|
||||
auto_topup: self.auto_topup.as_ref(),
|
||||
usage_visible: self.usage_visible,
|
||||
is_api_key_auth: self.is_api_key_auth,
|
||||
changelog_bullets: &self.changelog_bullets,
|
||||
changelog_has_full_notes: self.changelog_markdown.is_some(),
|
||||
@@ -3384,8 +3120,6 @@ impl AppView {
|
||||
self.welcome_import_banner_rect = result.import_banner_rect;
|
||||
self.welcome_auth_url_rect = result.auth_url_rect;
|
||||
self.welcome_auth_fallback_rect = result.auth_fallback_rect;
|
||||
self.welcome_refresh_rect = result.refresh_rect;
|
||||
self.welcome_gate_url_rect = result.gate_url_rect;
|
||||
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
|
||||
self.session_picker_state.hit_areas = result.session_picker_hit_areas;
|
||||
if let Some(modal) = self.import_claude_modal.as_mut() {
|
||||
@@ -3427,9 +3161,6 @@ impl AppView {
|
||||
&theme,
|
||||
);
|
||||
}
|
||||
if !has_access && !self.access_gate_shown_logged {
|
||||
self.access_gate_shown_logged = true;
|
||||
}
|
||||
if let Some(fps) = &fps_overlay {
|
||||
fps.render(full_area, f.buffer_mut());
|
||||
}
|
||||
@@ -4474,24 +4205,9 @@ pub(crate) mod tests {
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
auth_clipboard_copied: false,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
is_zdr: false,
|
||||
team_role: None,
|
||||
coding_data_retention_opt_out: false,
|
||||
show_tips: None,
|
||||
auto_update: None,
|
||||
ask_user_question_timeout_enabled: None,
|
||||
zdr_access_enabled: false,
|
||||
usage_billing_redirect_url: None,
|
||||
access_gate_shown_logged: false,
|
||||
gate: None,
|
||||
subscription_tier: None,
|
||||
paywall_check_started: None,
|
||||
last_subscription_check_at: None,
|
||||
subscription_watch_interval_secs: None,
|
||||
pending_gate_verification: None,
|
||||
gate_verify_gen: 0,
|
||||
bundle_state: BundleState::default(),
|
||||
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
|
||||
fps_hud: crate::views::fps_hud::FpsHud::new(),
|
||||
@@ -4513,8 +4229,6 @@ pub(crate) mod tests {
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_refresh_rect: None,
|
||||
welcome_gate_url_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
@@ -4555,13 +4269,8 @@ pub(crate) mod tests {
|
||||
minimal_state: crate::minimal_api::MinimalState::default(),
|
||||
reconnect_pending: false,
|
||||
show_resolved_model: true,
|
||||
sharing_enabled: false,
|
||||
usage_visible: true,
|
||||
tier_restricted_commands: Vec::new(),
|
||||
leader_mode: true,
|
||||
credit_balance: None,
|
||||
auto_topup: None,
|
||||
billing_poll_wanted: false,
|
||||
leader_roster: Vec::new(),
|
||||
dashboard_local_sessions: Vec::new(),
|
||||
dashboard_sessions_loading: false,
|
||||
@@ -4600,8 +4309,6 @@ pub(crate) mod tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
@@ -4791,8 +4498,6 @@ pub(crate) mod tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
@@ -5590,58 +5295,6 @@ pub(crate) mod tests {
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.usage_visible);
|
||||
}
|
||||
/// Make every tier-restricted command visible on the welcome prompt so the
|
||||
/// present/absent assertions exercise the deny list, not incidental
|
||||
/// fail-closed hiding:
|
||||
/// - `/imagine`, `/imagine-video` are `required_tools()`-gated, so advertise
|
||||
/// their tools (otherwise the registry fail-closes them).
|
||||
fn advertise_media_tools(app: &mut AppView) {
|
||||
app.welcome_prompt
|
||||
.slash_controller
|
||||
.registry_mut()
|
||||
.set_available_tools(
|
||||
["image_gen", "image_to_video"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
fn assert_tier_restricted_commands_present(app: &AppView) {
|
||||
let reg = app.welcome_prompt.slash_controller.registry();
|
||||
for name in TIER_RESTRICTED_COMMANDS {
|
||||
assert!(
|
||||
reg.get(name).is_some(),
|
||||
"/{name} must be available when not tier-restricted (tools advertised)"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn apply_auth_meta_never_restricts_tiers() {
|
||||
let mut app = test_app();
|
||||
advertise_media_tools(&mut app);
|
||||
app.apply_auth_meta(&kigi_shell::auth::AuthMeta::default());
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
assert_tier_restricted_commands_present(&app);
|
||||
}
|
||||
#[test]
|
||||
fn is_restricted_tier_never_restricts() {
|
||||
assert!(!is_restricted_tier(None));
|
||||
assert!(!is_restricted_tier(Some("Free")));
|
||||
assert!(!is_restricted_tier(Some("SomeFutureTier")));
|
||||
}
|
||||
#[test]
|
||||
fn apply_auth_meta_clears_gate_on_login() {
|
||||
let mut app = test_app();
|
||||
app.gate = Some(kigi_shell::auth::GateInfo {
|
||||
message: "Subscribe".into(),
|
||||
url: None,
|
||||
label: None,
|
||||
});
|
||||
assert!(app.is_access_blocked());
|
||||
app.apply_auth_meta(&kigi_shell::auth::AuthMeta::default());
|
||||
assert!(app.gate.is_none());
|
||||
assert!(app.has_access());
|
||||
}
|
||||
#[test]
|
||||
fn welcome_ctrl_q_requires_confirmation() {
|
||||
let mut app = test_app();
|
||||
|
||||
@@ -38,9 +38,6 @@ pub enum Command {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Share a session and print the share URL
|
||||
#[command(hide = true)]
|
||||
Share(crate::share_cmd::ShareArgs),
|
||||
/// Run any command with local clipboard support (OSC 52 → system clipboard).
|
||||
#[cfg_attr(not(any(unix, windows)), command(hide = true))]
|
||||
#[command(long_about = "\
|
||||
@@ -268,8 +265,8 @@ pub struct AgentArgs {
|
||||
#[arg(long, conflicts_with = "leader")]
|
||||
pub no_leader: bool,
|
||||
/// Override the CLI chat proxy base URL.
|
||||
#[arg(long = "cli-chat-proxy-base-url")]
|
||||
pub cli_chat_proxy_base_url: Option<String>,
|
||||
#[arg(long = "coding-api-base-url")]
|
||||
pub coding_api_base_url: Option<String>,
|
||||
/// Override the public xAI API base URL.
|
||||
#[arg(long = "xai-api-base-url")]
|
||||
pub xai_api_base_url: Option<String>,
|
||||
|
||||
@@ -274,7 +274,7 @@ pub(super) fn handle_auth_complete(
|
||||
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.welcome_prompt_focused = !app.is_access_blocked();
|
||||
app.welcome_prompt_focused = true;
|
||||
app.auth_code_input.clear();
|
||||
|
||||
// Mid-session re-auth (`/login` or a 401 prompt): restore the
|
||||
@@ -312,9 +312,6 @@ pub(super) fn handle_auth_complete(
|
||||
}
|
||||
}
|
||||
let mut effects = dispatch(Action::RequestBundleStatus, app);
|
||||
if app.usage_visible {
|
||||
effects.push(Effect::FetchAppBilling);
|
||||
}
|
||||
effects.extend(retry_effects);
|
||||
return effects;
|
||||
}
|
||||
@@ -322,27 +319,9 @@ pub(super) fn handle_auth_complete(
|
||||
// status only; shell auto-syncs post-auth
|
||||
let mut effects = dispatch(Action::RequestBundleStatus, app);
|
||||
|
||||
// Start auto-checking subscription if gated.
|
||||
// Check immediately (don't wait 5s) then schedule the timer.
|
||||
if !app.has_access() {
|
||||
app.paywall_check_started = Some(std::time::Instant::now());
|
||||
effects.push(Effect::CheckSubscription { verify: None });
|
||||
effects.push(Effect::SchedulePaywallCheck);
|
||||
}
|
||||
// Fetch billing so the welcome screen can show a credit warning.
|
||||
if app.usage_visible {
|
||||
effects.push(Effect::FetchAppBilling);
|
||||
}
|
||||
// Fetch changelog (mirrors startup path for interactive login).
|
||||
effects.push(Effect::FetchChangelog);
|
||||
|
||||
// ZDR-blocked users stay on the welcome screen — discard any
|
||||
// deferred startup (they cannot start a session).
|
||||
if app.is_zdr_blocked() {
|
||||
clear_startup_actions(app);
|
||||
return effects;
|
||||
}
|
||||
|
||||
// Replay deferred session startup once BOTH gates are open. Auth
|
||||
// is now Done, so `session_startup_allowed()` here means "is trust
|
||||
// also resolved?" -- if trust is still Pending its question renders
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
//! Subscription tier checks, credit-limit upsells, and auto-topup handling.
|
||||
|
||||
use super::queue::maybe_drain_queue;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::AppView;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long the pager auto-checks subscription status before stopping.
|
||||
/// After this, the user can still manually check via the [Refresh] button.
|
||||
pub(super) const PAYWALL_AUTO_CHECK_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// Whether the user is at the highest subscription tier (SuperGrok Heavy).
|
||||
///
|
||||
/// Returns `true` only when `subscription_tier` **positively matches** a
|
||||
/// known max-tier identifier. When the tier is unknown (`None`) or any
|
||||
/// other value, returns `false` — the user gets the Q&A modal so lower-
|
||||
/// tier users always see the upgrade option.
|
||||
pub(super) fn is_max_tier(subscription_tier: Option<&str>) -> bool {
|
||||
let Some(t) = subscription_tier else {
|
||||
return false; // Unknown — default to Q&A.
|
||||
};
|
||||
// Normalize: lowercase + spaces→underscores to match both JWT-derived
|
||||
// keys ("supergrok_heavy") and CCP display names ("SuperGrok Heavy").
|
||||
t.to_ascii_lowercase().replace(' ', "_") == "supergrok_heavy"
|
||||
}
|
||||
|
||||
/// URL for upgrading the subscription tier.
|
||||
pub(crate) const UPSELL_URL_UPGRADE: &str = "https://grok.com/supergrok?referrer=grok-build";
|
||||
|
||||
/// URL for managing pay-as-you-go / on-demand spending / purchasing credits.
|
||||
pub(crate) const UPSELL_URL_PAYG: &str = "https://grok.com?_s=usage";
|
||||
|
||||
/// Billing mode for credit-limit upsell copy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum CreditLimitUpsellMode {
|
||||
/// Unified usage pool — suggest purchasing prepaid credits.
|
||||
UnifiedCredits,
|
||||
/// Legacy on-demand / PAYG (`enabled` = on-demand cap already active).
|
||||
LegacyPayg { enabled: bool },
|
||||
}
|
||||
|
||||
/// Resolve upsell copy mode from credits config.
|
||||
///
|
||||
/// Prefers explicit `is_unified_billing_user` (`Option` — do not treat a
|
||||
/// missing field as legacy). Positive `pay_as_you_go` (on-demand cap > 0)
|
||||
/// only selects legacy when the unified flag is absent. Unknown defaults to
|
||||
/// unified (buy credits) so pool users never get “enable on-demand” wrongly.
|
||||
pub(super) fn credit_limit_upsell_mode(
|
||||
balance: Option<&crate::views::credit_bar::CreditBalance>,
|
||||
) -> CreditLimitUpsellMode {
|
||||
match balance {
|
||||
Some(b) if b.is_unified_billing_user == Some(true) => CreditLimitUpsellMode::UnifiedCredits,
|
||||
Some(b) if b.is_unified_billing_user == Some(false) => CreditLimitUpsellMode::LegacyPayg {
|
||||
enabled: b.pay_as_you_go,
|
||||
},
|
||||
// Flag absent: only treat as legacy PAYG when we have a positive
|
||||
// on-demand cap (pay_as_you_go is derived from cap > 0).
|
||||
Some(b) if b.pay_as_you_go => CreditLimitUpsellMode::LegacyPayg { enabled: true },
|
||||
_ => CreditLimitUpsellMode::UnifiedCredits,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an API / retry error is a credit-limit / spend-block denial.
|
||||
///
|
||||
/// - **402** Payment Required — always credit/spend block on this surface
|
||||
/// (Build pool and IC spend blocks); no message filter.
|
||||
/// - **403** — only when the body contains "run out of credits" (legacy IC
|
||||
/// spend wording); other 403s (content-safety, ZDR, …) are excluded.
|
||||
pub(crate) fn is_credit_limit_error(http_status: Option<u16>, message: &str) -> bool {
|
||||
let m = message.to_ascii_lowercase();
|
||||
let legacy = m.contains("run out of credits");
|
||||
match http_status {
|
||||
Some(402) => true,
|
||||
Some(403) if legacy => true,
|
||||
// Retry notifications embed "status 402" / "status 403" in the body
|
||||
// without a separate status field.
|
||||
None | Some(_) => m.contains("status 402") || (m.contains("status 403") && legacy),
|
||||
}
|
||||
}
|
||||
|
||||
/// Well-known error code CCP returns (HTTP 429, flat body
|
||||
/// `{"code": "...", "error": "..."}`) when a free-tier user exhausts the
|
||||
/// free usage quota. Kept in sync with the shared well-known error code
|
||||
/// `SUBSCRIPTION_FREE_USAGE_EXHAUSTED`. sampling-types' `parse_error_bytes` prepends the flat
|
||||
/// `code` to the flattened message, so the code reaches the pager embedded
|
||||
/// in `RetryState::Exhausted.reason` and the -32003 error's data string.
|
||||
pub(crate) const FREE_USAGE_EXHAUSTED_ERROR_CODE: &str = "subscription:free-usage-exhausted";
|
||||
|
||||
/// Whether a rate-limit error is the free-usage-quota exhaustion (paywall)
|
||||
/// rather than transient throttling. Text-sniff on the flattened message,
|
||||
/// same precedent as [`is_credit_limit_error`].
|
||||
pub(crate) fn is_free_usage_exhausted_error(reason: &str) -> bool {
|
||||
reason.contains(FREE_USAGE_EXHAUSTED_ERROR_CODE)
|
||||
}
|
||||
|
||||
/// Whether a rate-limited (-32003) ACP error is the free-usage exhaustion.
|
||||
/// `data` may be a bare string or the `{message, promptUsage?}` object
|
||||
/// `attach_prompt_usage` produces — always read via the shared detail helper.
|
||||
pub(crate) fn acp_error_is_free_usage_exhausted(err: &agent_client_protocol::Error) -> bool {
|
||||
err.data
|
||||
.as_ref()
|
||||
.and_then(kigi_shell::sampling::error::error_detail_from_data)
|
||||
.as_deref()
|
||||
.is_some_and(is_free_usage_exhausted_error)
|
||||
}
|
||||
|
||||
/// User-facing message for free-usage exhaustion. Shown by headless mode and
|
||||
/// `format_acp_error` in place of auth-aware rate-limit copy. Deliberately
|
||||
/// promises no reset duration — the quota window is backend-config-driven.
|
||||
pub(crate) const FREE_USAGE_USER_MESSAGE: &str = "You\u{2019}ve reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits, or try again later: https://grok.com/supergrok?referrer=grok-build";
|
||||
|
||||
/// Open the credit-limit upsell on the given agent.
|
||||
///
|
||||
/// **`max_tier = false`** (default): shows the Q&A question modal with
|
||||
/// two options ("Upgrade tier" + buy-credits or PAYG). Each option's `id`
|
||||
/// carries the target URL so the submit handler is position-independent.
|
||||
///
|
||||
/// **`max_tier = true`** (positively identified as SuperGrok Heavy):
|
||||
/// pushes an inline scrollback card (`CreditLimitBlock`) with a single
|
||||
/// continue action. No Q&A modal — the user can't upgrade further.
|
||||
pub(super) fn open_credit_limit_upsell(
|
||||
agent: &mut AgentView,
|
||||
mode: CreditLimitUpsellMode,
|
||||
max_tier: bool,
|
||||
) {
|
||||
use crate::scrollback::blocks::CreditLimitCardAction;
|
||||
|
||||
let (heading, upgrade_tier_desc, secondary_label, secondary_desc, card_action): (
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
CreditLimitCardAction,
|
||||
) = match mode {
|
||||
CreditLimitUpsellMode::UnifiedCredits => (
|
||||
"You hit your weekly limit.",
|
||||
"Upgrade to a higher tier for more usage",
|
||||
"Buy more credits",
|
||||
"Purchase credits to keep using Grok Build",
|
||||
CreditLimitCardAction::PurchaseCredits,
|
||||
),
|
||||
CreditLimitUpsellMode::LegacyPayg { enabled: true } => (
|
||||
"You\u{2019}ve hit your spending cap.",
|
||||
"Upgrade to a higher tier for more credits",
|
||||
"Increase limit",
|
||||
"Raise your pay-as-you-go spending cap",
|
||||
CreditLimitCardAction::IncreasePaygLimit,
|
||||
),
|
||||
CreditLimitUpsellMode::LegacyPayg { enabled: false } => (
|
||||
"You\u{2019}ve hit the credit limit for your plan.",
|
||||
"Upgrade to a higher tier for more credits",
|
||||
"Pay as you go",
|
||||
"Enable pay-as-you-go credits for on-demand usage",
|
||||
CreditLimitCardAction::EnablePayg,
|
||||
),
|
||||
};
|
||||
|
||||
// ── Max tier: inline scrollback card ─────────────────────────
|
||||
if max_tier {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
agent.scrollback.push_block(RenderBlock::credit_limit_card(
|
||||
heading,
|
||||
card_action,
|
||||
UPSELL_URL_PAYG,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Default: Q&A question modal with two options ────────────────
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
|
||||
|
||||
if agent.question_view.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let question = Question {
|
||||
question: heading.into(),
|
||||
options: vec![
|
||||
QuestionOption {
|
||||
label: "Upgrade tier".into(),
|
||||
description: upgrade_tier_desc.into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
QuestionOption {
|
||||
label: secondary_label.into(),
|
||||
description: secondary_desc.into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_PAYG.into()),
|
||||
},
|
||||
],
|
||||
multi_select: Some(false),
|
||||
id: None,
|
||||
};
|
||||
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("credit-limit-upsell-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::CreditLimitUpsell)
|
||||
.with_no_freeform();
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
}
|
||||
|
||||
/// Open the free-usage paywall on the given agent: a Q&A modal in the
|
||||
/// [`open_credit_limit_upsell`] style with two upgrade options. Each
|
||||
/// option's `id` carries its target URL so the submit handler is
|
||||
/// position-independent.
|
||||
///
|
||||
/// Driver-only by construction (called from the PromptResponse handler,
|
||||
/// which viewers never receive).
|
||||
pub(super) fn open_free_usage_upsell(agent: &mut AgentView) {
|
||||
open_supergrok_upsell(agent, UpsellReason::FreeUsageLimit);
|
||||
}
|
||||
|
||||
/// Open the SuperGrok upsell for a tier-restricted slash command
|
||||
/// (`/usage`, `/imagine`, …). Returns whether the modal opened (`false`
|
||||
/// when another question modal is already up) so the caller can decide
|
||||
/// whether to consume the input that triggered it.
|
||||
pub(super) fn open_restricted_command_upsell(agent: &mut AgentView) -> bool {
|
||||
open_supergrok_upsell(agent, UpsellReason::RestrictedCommand)
|
||||
}
|
||||
|
||||
/// Which situation opened the SuperGrok upsell modal. Controls the heading.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum UpsellReason {
|
||||
/// Free-usage quota exhausted (429 paywall).
|
||||
FreeUsageLimit,
|
||||
/// A tier-restricted slash command was invoked.
|
||||
RestrictedCommand,
|
||||
}
|
||||
|
||||
/// Shared builder behind [`open_free_usage_upsell`] /
|
||||
/// [`open_restricted_command_upsell`]: a Q&A modal in the
|
||||
/// [`open_credit_limit_upsell`] style. Upgrade options carry their target
|
||||
/// URL in the option `id` (position-independent submit handling).
|
||||
fn open_supergrok_upsell(agent: &mut AgentView, reason: UpsellReason) -> bool {
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
|
||||
|
||||
// Never displace an already-open question modal. Callers that consume
|
||||
// input on open must check this `false` and keep the input instead.
|
||||
if agent.question_view.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (heading, modal_id_prefix) = match reason {
|
||||
UpsellReason::FreeUsageLimit => ("You hit your free usage limit.", "free-usage-upsell"),
|
||||
UpsellReason::RestrictedCommand => (
|
||||
"Unlock all features with SuperGrok.",
|
||||
"restricted-command-upsell",
|
||||
),
|
||||
};
|
||||
|
||||
let options = vec![
|
||||
QuestionOption {
|
||||
label: "Upgrade to SuperGrok".into(),
|
||||
description: "For everyday coding and productivity tasks".into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Upgrade to SuperGrok Heavy".into(),
|
||||
description: "Get the most out of Grok Build. Highest usage limits.".into(),
|
||||
preview: None,
|
||||
// No Heavy-specific URL exists; the /supergrok page lists
|
||||
// both plans, so both upgrade options land there.
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
];
|
||||
let question = Question {
|
||||
question: heading.into(),
|
||||
options,
|
||||
multi_select: Some(false),
|
||||
id: None,
|
||||
};
|
||||
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("{modal_id_prefix}-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::FreeUsageUpsell)
|
||||
.with_no_freeform();
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply an [`AutoTopupFetch`] outcome to a cached `auto_topup` slot: `Resolved`
|
||||
/// sets it, `Cleared` resets it to "unknown" (no credits), and `Unchanged` keeps
|
||||
/// the last-known-good value (the fetch failed).
|
||||
pub(super) fn apply_auto_topup(
|
||||
slot: &mut Option<crate::views::credit_bar::AutoTopupInfo>,
|
||||
fetch: &crate::views::credit_bar::AutoTopupFetch,
|
||||
) {
|
||||
use crate::views::credit_bar::AutoTopupFetch;
|
||||
match fetch {
|
||||
AutoTopupFetch::Resolved(rule) => *slot = Some(rule.clone()),
|
||||
AutoTopupFetch::Cleared => *slot = None,
|
||||
AutoTopupFetch::Unchanged => {}
|
||||
}
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_billing_fetched(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
balance: Option<crate::views::credit_bar::CreditBalance>,
|
||||
silent: bool,
|
||||
subscription_tier: Option<String>,
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch,
|
||||
) -> Vec<Effect> {
|
||||
// Parse/transport failures route to `BillingError`, so a `None`
|
||||
// balance here means the response carried no billing config. Clear
|
||||
// the cached balance + polling so the status bar agrees with the
|
||||
// "No billing data available." message rather than showing a stale
|
||||
// value.
|
||||
app.credit_balance = balance.clone();
|
||||
// `Resolved` updates the cached rule, `Cleared` resets it to unknown
|
||||
// (no credits), `Unchanged` keeps the last-known-good (fetch failed).
|
||||
apply_auto_topup(&mut app.auto_topup, &autotopup);
|
||||
app.billing_poll_wanted = balance
|
||||
.as_ref()
|
||||
.map(|b| b.usage_pct >= 99.0)
|
||||
.unwrap_or(false);
|
||||
if let Some(tier) = subscription_tier {
|
||||
app.subscription_tier = Some(tier);
|
||||
}
|
||||
// Render the `/usage` summary from the now-current cached rule.
|
||||
let summary_topup = app.auto_topup.clone();
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
// Gateway/chat-kind: do not attach Build coding credits.
|
||||
let mut topup = agent.auto_topup.clone();
|
||||
apply_auto_topup(&mut topup, &autotopup);
|
||||
agent.apply_credit_balance(balance.clone(), topup);
|
||||
if !silent && !agent.chat_kind {
|
||||
let msg = match &balance {
|
||||
Some(bal) => {
|
||||
crate::views::credit_bar::format_usage_summary(bal, summary_topup.as_ref())
|
||||
}
|
||||
None => "No billing data available.".to_string(),
|
||||
};
|
||||
agent.scrollback.push_block(RenderBlock::System(
|
||||
crate::scrollback::blocks::SystemMessageBlock::new(msg),
|
||||
));
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_gate_refreshed(
|
||||
app: &mut AppView,
|
||||
settings: Option<kigi_shell::util::config::RemoteSettings>,
|
||||
) -> Vec<Effect> {
|
||||
let Some(rs) = settings else {
|
||||
return vec![];
|
||||
};
|
||||
app.usage_billing_redirect_url = rs.usage_billing_redirect_url.clone();
|
||||
if let Some(secs) = rs.subscription_watch_interval_secs {
|
||||
app.subscription_watch_interval_secs = Some(secs);
|
||||
}
|
||||
match AppView::gate_from_settings(&rs) {
|
||||
Some(gate) => app.impose_gate(gate),
|
||||
None => app.lift_gate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `x.ai/auth/check_subscription` completed. Meta is authoritative
|
||||
/// (`apply_auth_meta` also drops any deferred gate). A failed check only
|
||||
/// promotes the deferred gate it was verifying (`verify` generation);
|
||||
/// generic watch/focus/paywall-chain failures never touch it.
|
||||
pub(super) fn handle_check_subscription_complete(
|
||||
app: &mut AppView,
|
||||
verify: Option<u64>,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Vec<Effect> {
|
||||
let was_blocked = !app.has_access();
|
||||
let applied = match meta {
|
||||
Some(meta_val) => {
|
||||
match serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val) {
|
||||
Ok(auth_meta) => {
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// Shell sent meta we can't decode — a protocol bug, not
|
||||
// a transient failure. The check result is lost, so a
|
||||
// verify deferral falls through to promotion below.
|
||||
crate::unified_log::error(
|
||||
"subscription.check.meta_parse_failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"verify": verify,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
// meta: None = shell reports "not authenticated" or the check RPC
|
||||
// failed (already logged as subscription.check.rpc_failed).
|
||||
None => false,
|
||||
};
|
||||
if !applied && let Some(generation) = verify {
|
||||
app.promote_deferred_gate(generation, "check_failed");
|
||||
}
|
||||
crate::unified_log::info(
|
||||
"subscription.check.complete",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"verify": verify,
|
||||
"meta_applied": applied,
|
||||
"was_blocked": was_blocked,
|
||||
"gated": !app.has_access(),
|
||||
"tier": app.subscription_tier,
|
||||
})),
|
||||
);
|
||||
maybe_start_paywall_chain(app, was_blocked)
|
||||
}
|
||||
|
||||
/// Safety net for a hung verification check: show the still-pending
|
||||
/// deferred gate (err on blocking).
|
||||
pub(super) fn handle_gate_verify_timeout(app: &mut AppView, generation: u64) -> Vec<Effect> {
|
||||
let was_blocked = !app.has_access();
|
||||
app.promote_deferred_gate(generation, "verify_timeout");
|
||||
maybe_start_paywall_chain(app, was_blocked)
|
||||
}
|
||||
|
||||
/// Arm the 5s paywall auto-check chain on an ungated→gated transition, so a
|
||||
/// paywall shown by verify-before-paywall self-lifts exactly like the
|
||||
/// login-path one. Guarded so steady-state paywall-poller responses and
|
||||
/// repeated checks can't fan out extra timers.
|
||||
fn maybe_start_paywall_chain(app: &mut AppView, was_blocked: bool) -> Vec<Effect> {
|
||||
if !was_blocked && !app.has_access() && app.paywall_check_started.is_none() {
|
||||
app.paywall_check_started = Some(std::time::Instant::now());
|
||||
return vec![Effect::SchedulePaywallCheck];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_credit_limit_recheck_complete(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Vec<Effect> {
|
||||
if let Some(meta_val) = meta
|
||||
&& let Ok(auth_meta) = serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val)
|
||||
{
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
}
|
||||
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// If the user already submitted another prompt while the
|
||||
// recheck was in flight, don't show the upsell — they've moved on.
|
||||
let user_moved_on = !agent.session.state.is_idle() || !agent.session.pending_prompts.is_empty();
|
||||
|
||||
if !user_moved_on {
|
||||
let balance = agent
|
||||
.credit_balance
|
||||
.as_ref()
|
||||
.or(app.credit_balance.as_ref());
|
||||
let mode = credit_limit_upsell_mode(balance);
|
||||
let max_tier = is_max_tier(app.subscription_tier.as_deref());
|
||||
open_credit_limit_upsell(agent, mode, max_tier);
|
||||
}
|
||||
// Either way, drop the stashed prompt.
|
||||
agent.credit_limit_stashed_prompt = None;
|
||||
|
||||
let mut effects = maybe_drain_queue(agent);
|
||||
effects.push(Effect::FetchBilling {
|
||||
agent_id,
|
||||
silent: true,
|
||||
});
|
||||
effects
|
||||
}
|
||||
|
||||
// Action handlers.
|
||||
|
||||
pub(super) fn dispatch_open_supergrok_url(app: &mut AppView) -> Vec<Effect> {
|
||||
let url = app
|
||||
.gate
|
||||
.as_ref()
|
||||
.and_then(|g| g.url.as_deref())
|
||||
.unwrap_or("https://grok.com/supergrok?referrer=grok-build");
|
||||
// Funnel attribution: tag CLI-originated SuperGrok upsell clicks
|
||||
// with `referrer=grok-build`, matching the OAuth consent flow and
|
||||
// x.ai/cli marketing links. Applied even when the URL came from
|
||||
// remote settings's `gate_url`, so we don't depend on the remote flag
|
||||
// being correctly configured. If the URL already specifies a
|
||||
// referrer it's left alone.
|
||||
let url = crate::app::link_opener::ensure_query_param(url, "referrer", "grok-build");
|
||||
crate::app::link_opener::open_url(&url);
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn free_usage_dual_read_string_and_wrapped_object_data() {
|
||||
let free = "subscription:free-usage-exhausted quota hit";
|
||||
let string_err = agent_client_protocol::Error::new(-32003, "Rate limited").data(free);
|
||||
assert!(acp_error_is_free_usage_exhausted(&string_err));
|
||||
|
||||
// attach_prompt_usage wraps string data as {"message": ..., "promptUsage": ...}.
|
||||
let wrapped =
|
||||
agent_client_protocol::Error::new(-32003, "Rate limited").data(serde_json::json!({
|
||||
"message": free,
|
||||
"promptUsage": { "inputTokens": 1, "outputTokens": 0, "numTurns": 1 }
|
||||
}));
|
||||
assert!(acp_error_is_free_usage_exhausted(&wrapped));
|
||||
assert!(!wrapped.data.as_ref().unwrap().is_string());
|
||||
|
||||
let other = agent_client_protocol::Error::new(-32003, "Rate limited").data("throttled");
|
||||
assert!(!acp_error_is_free_usage_exhausted(&other));
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ pub(super) fn ensure_dashboard_state(app: &mut AppView) {
|
||||
state.adopt_slash_mru(app.slash_mru.clone());
|
||||
state.set_screen_mode(app.screen_mode);
|
||||
state.set_recap_visible(app.session_recap_available);
|
||||
state.set_restricted_commands(&app.tier_restricted_commands);
|
||||
state.set_restricted_commands(&[]);
|
||||
app.dashboard = Some(state);
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
|
||||
// Subsequent reopen — just gc dead ids; in-memory state stays.
|
||||
d.gc_stale_refs(&dashboard_alive_fn(&app.agents));
|
||||
d.set_recap_visible(app.session_recap_available);
|
||||
d.set_restricted_commands(&app.tier_restricted_commands);
|
||||
d.set_restricted_commands(&[]);
|
||||
}
|
||||
// Refresh each local agent's git context (branch / worktree / label)
|
||||
// from disk so the row subtitles show the LATEST branch and worktree
|
||||
@@ -1207,7 +1207,6 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
@@ -1231,23 +1230,6 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
||||
};
|
||||
let reg = dashboard.dispatch.slash_controller.registry();
|
||||
|
||||
// Tier-restricted commands stay visible for discoverability but must
|
||||
// not execute — and must not fall through to the unknown-command
|
||||
// path below (which would spawn a session with the raw slash text as
|
||||
// its first prompt). The dashboard has no question-modal surface, so
|
||||
// upsell via the feedback toast.
|
||||
if reg.is_restricted(invocation.token) {
|
||||
let token = invocation.token.to_string();
|
||||
if let Some(d) = app.dashboard.as_mut() {
|
||||
d.dispatch.set_text("");
|
||||
d.set_error_toast(&format!(
|
||||
"/{token} requires SuperGrok — upgrade at {}",
|
||||
super::billing::UPSELL_URL_UPGRADE
|
||||
));
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(command) = reg.get(invocation.token).cloned() else {
|
||||
// Unknown command. Fall back to the regular dispatch
|
||||
// path so the text becomes a new session's prompt.
|
||||
@@ -1295,7 +1277,6 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
||||
.iter()
|
||||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||||
.collect(),
|
||||
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
|
||||
plan_mode_active: false,
|
||||
show_tips: show_tips_from_app,
|
||||
auto_update: auto_update_from_app,
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
//! otherwise); tests/ shares a fixture prelude via `use super::*;`.
|
||||
|
||||
mod auth;
|
||||
mod billing;
|
||||
mod ctx;
|
||||
mod dashboard;
|
||||
mod import_claude;
|
||||
@@ -33,10 +32,6 @@ mod task_result;
|
||||
mod transcript;
|
||||
mod turn;
|
||||
|
||||
pub(crate) use billing::{
|
||||
FREE_USAGE_USER_MESSAGE, UPSELL_URL_PAYG, UPSELL_URL_UPGRADE,
|
||||
acp_error_is_free_usage_exhausted, is_credit_limit_error, is_free_usage_exhausted_error,
|
||||
};
|
||||
pub(crate) use modes::{downgrade_displayed_auto_if_gated, effective_auto};
|
||||
pub(crate) use notes::{recap_unavailable_toast, scrollback_has_user_messages};
|
||||
pub(crate) use permissions::resolve_permission_queue_transition;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Prompt and bash-command submission dispatchers and reload-window helpers.
|
||||
|
||||
use super::auth::{scrollback_has_recent_context_too_large, scrollback_has_recent_reauth_prompt};
|
||||
use super::billing::is_credit_limit_error;
|
||||
use super::ctx::with_active_agent;
|
||||
use super::interject;
|
||||
use super::permissions::drain_permission_queue;
|
||||
@@ -292,7 +291,6 @@ pub(super) fn dispatch_send_prompt_inner(
|
||||
return vec![];
|
||||
};
|
||||
// Capture app-level fields before the mut-borrow on `agent`.
|
||||
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
@@ -324,37 +322,6 @@ pub(super) fn dispatch_send_prompt_inner(
|
||||
|
||||
let mut effects = Vec::new();
|
||||
|
||||
// ── Tier-restricted command upsell ─────────────────────────────
|
||||
// Restricted commands (`/usage`, `/imagine`, …) are hidden from the
|
||||
// registry's `get()`, so a typed invocation would otherwise fall
|
||||
// through the unknown-command path below and leak to the model as a
|
||||
// raw prompt. Upsell instead; genuinely unknown commands still pass
|
||||
// through (shell/ACP commands depend on that).
|
||||
if !literal
|
||||
&& trimmed.starts_with('/')
|
||||
&& let Some(invocation) = crate::slash::parse_invocation(trimmed)
|
||||
&& agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
.registry()
|
||||
.is_restricted(invocation.token)
|
||||
{
|
||||
// Only consume the composer when the upsell can actually open: with
|
||||
// another question modal already up, `open_supergrok_upsell` would
|
||||
// no-op and wiping the composer here would silently drop the typed
|
||||
// text. Keep it instead so the user can resubmit after closing the
|
||||
// modal — and never fall through to passthrough for restricted
|
||||
// commands.
|
||||
if agent.question_view.is_none() {
|
||||
if consume_input {
|
||||
agent.prompt.set_text("");
|
||||
}
|
||||
let opened = super::billing::open_restricted_command_upsell(agent);
|
||||
debug_assert!(opened, "no modal was open, so the upsell must open");
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// ── Registry-based slash command execution ─────────────────────
|
||||
// If the text starts with `/`, run it through the slash registry.
|
||||
// The registry resolves builtins, ACP-advertised commands, and
|
||||
@@ -384,7 +351,6 @@ pub(super) fn dispatch_send_prompt_inner(
|
||||
.iter()
|
||||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||||
.collect(),
|
||||
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
|
||||
// Prefer optimistic pending over confirmed active.
|
||||
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
|
||||
show_tips: show_tips_from_app,
|
||||
@@ -1004,29 +970,11 @@ pub(super) fn handle_prompt_response(
|
||||
None => expected_send_now.is_some(),
|
||||
};
|
||||
let rate_limited = agent.session.rate_limited;
|
||||
// Fallback mirroring the credit-limit race guard below: if the retry
|
||||
// notification lost the race with (or never reached) this
|
||||
// PromptResponse, detect the free-usage code from the prompt error
|
||||
// itself — the flattened 429 body embeds it.
|
||||
let free_usage_blocked = agent.session.free_usage_blocked
|
||||
|| result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|e| super::billing::is_free_usage_exhausted_error(e));
|
||||
let model_incompatible = agent.session.model_incompatible;
|
||||
// Context overflow: the RetryState handler already pushed the actionable
|
||||
// block, so the generic TurnFailed + error toast are redundant. Derived
|
||||
// from the scrollback (mirrors reauth), not a session flag.
|
||||
let context_overflow = scrollback_has_recent_context_too_large(&agent.scrollback);
|
||||
// Fallback: if the retry notification didn't set the flag,
|
||||
// detect credit-limit denials (legacy 403 or pool 402) from
|
||||
// the PromptResponse error + HTTP status. Covers races where
|
||||
// the retry notification arrives after the PromptResponse.
|
||||
let credit_limit_blocked = agent.session.credit_limit_blocked
|
||||
|| result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|e| is_credit_limit_error(http_status, e));
|
||||
// A 401/auth failure already surfaced an actionable
|
||||
// `ReAuthRequired` prompt via the RetryState handler (which
|
||||
// runs before this PromptResponse). Suppress the redundant
|
||||
@@ -1055,12 +1003,7 @@ pub(super) fn handle_prompt_response(
|
||||
);
|
||||
}
|
||||
|
||||
// Stash the complete in-flight prompt before finish_turn clears it.
|
||||
// Used by CreditLimitRecheckComplete to retry after a tier upgrade.
|
||||
if credit_limit_blocked {
|
||||
agent.credit_limit_stashed_prompt = agent.session.in_flight_prompt.clone();
|
||||
}
|
||||
// Likewise, stash the prompt from a turn that failed on an
|
||||
// Stash the prompt from a turn that failed on an
|
||||
// expired login (401 / re-auth). The AuthComplete handler
|
||||
// auto-resubmits it after a successful mid-session re-auth.
|
||||
// A non-rewindable turn (None) must not clobber an earlier stash.
|
||||
@@ -1110,17 +1053,11 @@ pub(super) fn handle_prompt_response(
|
||||
elapsed: Some(elapsed.unwrap_or_default()),
|
||||
}),
|
||||
(Err(_), _)
|
||||
if rate_limited
|
||||
|| free_usage_blocked
|
||||
|| model_incompatible
|
||||
|| credit_limit_blocked
|
||||
|| reauth_prompted
|
||||
|| context_overflow =>
|
||||
if rate_limited || model_incompatible || reauth_prompted || context_overflow =>
|
||||
{
|
||||
// Skip TurnFailed when a dedicated prompt/modal shows instead
|
||||
// (rate limit, free-usage paywall, model incompatibility,
|
||||
// credit 403, 401 re-auth, or a terminal context-window
|
||||
// overflow).
|
||||
// (rate limit, model incompatibility, 401 re-auth, or a
|
||||
// terminal context-window overflow).
|
||||
None
|
||||
}
|
||||
(Err(err), _) => Some(SessionEvent::TurnFailed {
|
||||
@@ -1147,9 +1084,7 @@ pub(super) fn handle_prompt_response(
|
||||
}
|
||||
(Err(err), _)
|
||||
if !rate_limited
|
||||
&& !free_usage_blocked
|
||||
&& !model_incompatible
|
||||
&& !credit_limit_blocked
|
||||
&& !reauth_prompted
|
||||
&& !context_overflow =>
|
||||
{
|
||||
@@ -1256,7 +1191,7 @@ pub(super) fn handle_prompt_response(
|
||||
|
||||
// Predicted-next-prompt (tab autocomplete): wipe any stale suggestion
|
||||
// at every turn boundary. This must run before the reconnect /
|
||||
// credit-limit early returns below, which skip the fetch gate
|
||||
// paywall early returns below, which skip the fetch gate
|
||||
// entirely — a prior ghost would otherwise survive those paths.
|
||||
agent.prompt.prompt_suggestion.clear();
|
||||
|
||||
@@ -1271,60 +1206,6 @@ pub(super) fn handle_prompt_response(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Credit-limit (403 legacy / 402 pool): strip stale error
|
||||
// blocks, then do a one-shot subscription re-check. If the
|
||||
// tier changed (user upgraded mid-session), the stashed
|
||||
// prompt is retried automatically; otherwise the upsell
|
||||
// is shown.
|
||||
if credit_limit_blocked {
|
||||
// Strip stale "Retry failed" / "Turn failed" error blocks
|
||||
// that were pushed before the credit-limit was detected.
|
||||
// Walk backwards from the end and remove matching events.
|
||||
let mut to_remove = Vec::new();
|
||||
for idx in (0..agent.scrollback.len()).rev() {
|
||||
match agent.scrollback.entry(idx).map(|e| &e.block) {
|
||||
Some(crate::scrollback::block::RenderBlock::SessionEvent(ev))
|
||||
if matches!(
|
||||
&ev.event,
|
||||
SessionEvent::RetryFailed { .. } | SessionEvent::TurnFailed { .. }
|
||||
) =>
|
||||
{
|
||||
to_remove.push(idx);
|
||||
}
|
||||
// Stop at the first non-error block.
|
||||
Some(
|
||||
crate::scrollback::block::RenderBlock::SessionEvent(_)
|
||||
| crate::scrollback::block::RenderBlock::System(_),
|
||||
) => continue,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
for idx in to_remove {
|
||||
agent.scrollback.remove_from(idx);
|
||||
}
|
||||
|
||||
// Defer the upsell until the subscription re-check
|
||||
// completes. Queue drain + billing fetch happen in the
|
||||
// CreditLimitRecheckComplete handler.
|
||||
if let Some(p) = pending_adoption {
|
||||
agent.discard_pending_adoption_updates(&p.prompt_id);
|
||||
}
|
||||
return vec![Effect::CreditLimitRecheck { agent_id }];
|
||||
}
|
||||
|
||||
// Free-usage paywall (429 + subscription:free-usage-exhausted): the
|
||||
// RetryState handler set the flag and suppressed the generic
|
||||
// rate-limit block; show the upsell modal. Driver-only by
|
||||
// construction — viewers never receive a PromptResponse. No queue
|
||||
// drain: queued prompts would fail on the same exhausted quota.
|
||||
if free_usage_blocked {
|
||||
super::billing::open_free_usage_upsell(agent);
|
||||
if let Some(p) = pending_adoption {
|
||||
agent.discard_pending_adoption_updates(&p.prompt_id);
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// FIFO handoff: if a server-authoritative prompt drained
|
||||
// into the running slot during this turn's teardown, adopt it
|
||||
// now (finish_turn cleared current_prompt_id) and run the
|
||||
@@ -1372,10 +1253,6 @@ pub(super) fn handle_prompt_response(
|
||||
});
|
||||
}
|
||||
|
||||
effects.push(Effect::FetchBilling {
|
||||
agent_id,
|
||||
silent: true,
|
||||
});
|
||||
return effects;
|
||||
}
|
||||
vec![]
|
||||
|
||||
@@ -876,13 +876,9 @@ mod tests {
|
||||
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||||
};
|
||||
|
||||
// Turn ends → should NOT drain "second" (user is editing it), only FetchBilling.
|
||||
// Turn ends → should NOT drain "second" (user is editing it).
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
assert!(effects.is_empty(), "drain should be blocked: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_idle());
|
||||
// "second" should still be in the queue.
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 2);
|
||||
@@ -908,14 +904,10 @@ mod tests {
|
||||
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||||
};
|
||||
|
||||
// Turn ends → should drain "second" (front, not being edited) + FetchBilling.
|
||||
// Turn ends → should drain "second" (front, not being edited).
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert_eq!(effects.len(), 2);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "second"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
// "third" should still be in queue.
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 1);
|
||||
assert_eq!(app.agents[&id].session.pending_prompts[0].text, "third");
|
||||
@@ -1809,13 +1801,9 @@ mod tests {
|
||||
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||||
};
|
||||
|
||||
// End turn for p2 → should NOT drain p3 (being edited), only FetchBilling.
|
||||
// End turn for p2 → should NOT drain p3 (being edited).
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(&effects[0], Effect::FetchBilling { silent: true, .. }),
|
||||
"drain should be blocked, only billing refresh"
|
||||
);
|
||||
assert!(effects.is_empty(), "drain should be blocked: {effects:?}");
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 2); // p3, p4
|
||||
|
||||
// Simulate user saving edited text.
|
||||
|
||||
@@ -3,7 +3,6 @@ use super::auth::{
|
||||
dispatch_cancel_login, dispatch_login, dispatch_logout, dispatch_submit_auth_code,
|
||||
dispatch_switch_account,
|
||||
};
|
||||
use super::billing::dispatch_open_supergrok_url;
|
||||
use super::ctx::{
|
||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||
sync_sleep_inhibitor, with_active_agent, with_scrollback,
|
||||
@@ -93,10 +92,9 @@ use super::settings::ui::{
|
||||
dispatch_toggle_vim_mode,
|
||||
};
|
||||
use super::status::{
|
||||
dispatch_copy_session_id, dispatch_open_gboom, dispatch_share_session,
|
||||
dispatch_show_context_info, dispatch_show_privacy_info, dispatch_show_queue,
|
||||
dispatch_copy_session_id, dispatch_open_gboom, dispatch_show_context_info, dispatch_show_queue,
|
||||
dispatch_show_release_notes, dispatch_show_session_info, dispatch_show_tasks,
|
||||
dispatch_show_usage, set_coding_data_sharing,
|
||||
dispatch_show_usage,
|
||||
};
|
||||
use super::task_result::{dispatch_task_result, unregister_all_active_sessions};
|
||||
use super::transcript::{
|
||||
@@ -551,23 +549,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
if group_toggled {
|
||||
return vec![];
|
||||
}
|
||||
let mut credit_card: Option<String> = None;
|
||||
with_scrollback(app, |s| {
|
||||
if let Some(idx) = s.selected()
|
||||
&& let Some(entry) = s.entry(idx)
|
||||
&& let crate::scrollback::block::RenderBlock::CreditLimit(ref blk) = entry.block
|
||||
{
|
||||
credit_card = Some(blk.url.clone());
|
||||
}
|
||||
});
|
||||
if let Some(url) = credit_card {
|
||||
crate::app::link_opener::open_url_if_safe(
|
||||
&url,
|
||||
crate::terminal::hyperlinks::SchemeFilter::Standard,
|
||||
);
|
||||
} else {
|
||||
dispatch_open_block_viewer(app);
|
||||
}
|
||||
dispatch_open_block_viewer(app);
|
||||
vec![]
|
||||
}
|
||||
Action::OpenExtensionsModal { tab } => {
|
||||
@@ -789,7 +771,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
vec![Effect::FetchCatalogEntry { kind, name }]
|
||||
}
|
||||
Action::CycleMode => dispatch_cycle_mode(app),
|
||||
Action::ShareSession => dispatch_share_session(app),
|
||||
Action::ShowSessionInfo => dispatch_show_session_info(app),
|
||||
Action::ShowReleaseNotes { title, content } => {
|
||||
dispatch_show_release_notes(app, title, content)
|
||||
@@ -809,8 +790,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
|
||||
Action::SendBtw(question) => dispatch_send_btw(app, question),
|
||||
Action::SendRecap { auto } => dispatch_send_recap(app, auto),
|
||||
Action::ShowPrivacyInfo => dispatch_show_privacy_info(app),
|
||||
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(app, opted_in),
|
||||
Action::ToggleYolo => dispatch_toggle_yolo(app),
|
||||
Action::ToggleMultiline => dispatch_toggle_multiline(app),
|
||||
Action::ToggleCompactMode => dispatch_toggle_compact_mode(app),
|
||||
@@ -873,8 +852,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
Action::PermissionCancel => dispatch_permission_cancel(app),
|
||||
Action::Logout => dispatch_logout(app),
|
||||
Action::SwitchAccount => dispatch_switch_account(app),
|
||||
Action::CheckSubscription => vec![Effect::CheckSubscription { verify: None }],
|
||||
Action::OpenSupergrokUrl => dispatch_open_supergrok_url(app),
|
||||
Action::OpenUrl(url) => {
|
||||
use crate::terminal::hyperlinks::SchemeFilter;
|
||||
if url.starts_with("file://") {
|
||||
@@ -894,7 +871,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
}
|
||||
Action::OpenManagedConnectors => {
|
||||
use crate::terminal::hyperlinks::SchemeFilter;
|
||||
let url = crate::views::mcps_modal::managed_connectors_url(app.team_id.as_deref());
|
||||
let url = crate::views::mcps_modal::managed_connectors_url(None);
|
||||
crate::app::link_opener::open_url_if_safe(&url, SchemeFilter::Standard);
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::app_view::{AppView, SessionPickerEntry};
|
||||
use crate::app::dispatch::ctx::get_active_agent_mut;
|
||||
use crate::app::effects::ConversationsPartial;
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::picker::PickerState;
|
||||
use crate::views::session_picker::{
|
||||
@@ -61,7 +60,6 @@ impl PickerSurface<'_> {
|
||||
query: Option<String>,
|
||||
chat_mode: bool,
|
||||
empty_notice: String,
|
||||
partial_notice: Option<&'static str>,
|
||||
) -> Option<String> {
|
||||
let anchor = self.capture_selection();
|
||||
let is_search = query.is_some();
|
||||
@@ -88,11 +86,7 @@ impl PickerSurface<'_> {
|
||||
}
|
||||
} else {
|
||||
self.lanes.pending_notice = None;
|
||||
if chat_mode {
|
||||
partial_notice.map(str::to_owned)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
None
|
||||
};
|
||||
self.restore_selection(anchor);
|
||||
notice
|
||||
@@ -188,7 +182,6 @@ pub(in crate::app::dispatch) fn dispatch_fetch_session_list(app: &mut AppView) -
|
||||
pub(in crate::app::dispatch) fn handle_session_list_loaded(
|
||||
app: &mut AppView,
|
||||
sessions: Vec<SessionPickerEntry>,
|
||||
partial: Option<ConversationsPartial>,
|
||||
seq: u64,
|
||||
query: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
@@ -196,18 +189,7 @@ pub(in crate::app::dispatch) fn handle_session_list_loaded(
|
||||
return vec![];
|
||||
}
|
||||
app.session_picker_detail_generation += 1;
|
||||
if let Some(partial) = partial {
|
||||
crate::unified_log::warn(
|
||||
"session.list.partial",
|
||||
None,
|
||||
Some(serde_json::json!({ "reason": format!("{partial:?}") })),
|
||||
);
|
||||
}
|
||||
let empty_notice = partial.map_or_else(
|
||||
|| "No sessions found for this directory".to_owned(),
|
||||
|partial| partial.picker_notice().to_owned(),
|
||||
);
|
||||
let partial_notice = partial.map(ConversationsPartial::picker_notice);
|
||||
let empty_notice = "No sessions found for this directory".to_owned();
|
||||
let chat_mode = app.chat_mode;
|
||||
let mut sessions = Some(sessions);
|
||||
let mut notice = None;
|
||||
@@ -242,7 +224,6 @@ pub(in crate::app::dispatch) fn handle_session_list_loaded(
|
||||
query.clone(),
|
||||
chat_mode,
|
||||
empty_notice.clone(),
|
||||
partial_notice,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -260,7 +241,7 @@ pub(in crate::app::dispatch) fn handle_session_list_loaded(
|
||||
grouped: app.session_picker_grouped,
|
||||
current_repo,
|
||||
}
|
||||
.native_loaded(sessions, query, chat_mode, empty_notice, partial_notice);
|
||||
.native_loaded(sessions, query, chat_mode, empty_notice);
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
app.show_toast(¬ice);
|
||||
|
||||
@@ -206,15 +206,8 @@ pub(in crate::app::dispatch) fn dispatch_fork_resolved(
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
agent.set_session_recap_available(app.session_recap_available);
|
||||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.tier_restricted_commands,
|
||||
);
|
||||
agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
|
||||
agent.chat_kind = parent_chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
@@ -353,7 +346,6 @@ pub(in crate::app::dispatch) fn dispatch_project_selected(
|
||||
let chat_kind = consume_chat_kind(app);
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
agent.chat_kind = chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
}
|
||||
effects.push(Effect::CreateSession {
|
||||
agent_id: id,
|
||||
@@ -398,8 +390,6 @@ fn build_fork_placeholder(
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: app.bootstrap_acp_commands.clone(),
|
||||
available_commands_generation: 1,
|
||||
available_tools: None,
|
||||
@@ -543,7 +533,6 @@ pub(in crate::app::dispatch) fn handle_worktree_forked(
|
||||
}
|
||||
let effective_chat = conversation_entry || app.chat_mode;
|
||||
agent.chat_kind = effective_chat;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
return vec![Effect::LoadSession {
|
||||
agent_id,
|
||||
session_id: session_id_str,
|
||||
|
||||
@@ -304,8 +304,6 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: app.bootstrap_acp_commands.clone(),
|
||||
available_commands_generation: 1,
|
||||
available_tools: None,
|
||||
@@ -330,14 +328,7 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
agent.set_session_recap_available(app.session_recap_available);
|
||||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.tier_restricted_commands,
|
||||
);
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
|
||||
agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
@@ -353,7 +344,6 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
|
||||
let chat_kind = consume_chat_kind(app);
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent.chat_kind = chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
@@ -402,7 +392,7 @@ pub(in crate::app::dispatch) fn dispatch_trust_folder(app: &mut AppView) -> Vec<
|
||||
/// `AuthComplete` uses, so whichever gate resolves last drains exactly once.
|
||||
pub(in crate::app::dispatch) fn finish_trust(app: &mut AppView) -> Vec<Effect> {
|
||||
app.trust_state = TrustState::Done;
|
||||
app.welcome_prompt_focused = !app.is_access_blocked();
|
||||
app.welcome_prompt_focused = true;
|
||||
if app.session_startup_allowed() {
|
||||
drain_startup_actions(app)
|
||||
} else {
|
||||
@@ -629,8 +619,6 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: app.bootstrap_acp_commands.clone(),
|
||||
available_commands_generation: 1,
|
||||
available_tools: None,
|
||||
@@ -667,15 +655,8 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
agent.set_session_recap_available(app.session_recap_available);
|
||||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.tier_restricted_commands,
|
||||
);
|
||||
agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
|
||||
agent.chat_kind = chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
@@ -764,7 +745,6 @@ pub(in crate::app::dispatch) fn skip_picker_and_create_session(
|
||||
let chat_kind = consume_chat_kind(app);
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent.chat_kind = chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
@@ -836,10 +816,6 @@ pub(in crate::app::dispatch) fn handle_session_created(
|
||||
session_id: session_id_clone.clone(),
|
||||
});
|
||||
effects.push(Effect::RefreshAvailableCommands { agent_id, cwd });
|
||||
effects.push(Effect::FetchBilling {
|
||||
agent_id,
|
||||
silent: true,
|
||||
});
|
||||
if let Some((model_id, effort)) = deferred {
|
||||
effects.push(Effect::SwitchModel {
|
||||
agent_id,
|
||||
@@ -916,10 +892,6 @@ pub(in crate::app::dispatch) fn handle_worktree_session_created(
|
||||
session_id: session_id_clone.clone(),
|
||||
});
|
||||
effects.push(Effect::RefreshAvailableCommands { agent_id, cwd });
|
||||
effects.push(Effect::FetchBilling {
|
||||
agent_id,
|
||||
silent: true,
|
||||
});
|
||||
if let Some((model_id, effort)) = deferred {
|
||||
effects.push(Effect::SwitchModel {
|
||||
agent_id,
|
||||
|
||||
@@ -162,8 +162,6 @@ fn dispatch_load_session_ungated(
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: app.bootstrap_acp_commands.clone(),
|
||||
available_commands_generation: 1,
|
||||
available_tools: None,
|
||||
@@ -195,15 +193,8 @@ fn dispatch_load_session_ungated(
|
||||
agent_mut.session.start_command(AgentCommand::RestoreCode);
|
||||
agent_mut.turn_started_at = Some(std::time::Instant::now());
|
||||
}
|
||||
agent_mut.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.tier_restricted_commands,
|
||||
);
|
||||
agent_mut.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
|
||||
agent_mut.chat_kind = chat_kind || app.chat_mode;
|
||||
agent_mut.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent_mut
|
||||
.prompt
|
||||
.slash_controller
|
||||
@@ -808,8 +799,6 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: app.bootstrap_acp_commands.clone(),
|
||||
available_commands_generation: 1,
|
||||
available_tools: None,
|
||||
@@ -836,15 +825,8 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
agent.set_session_recap_available(app.session_recap_available);
|
||||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.tier_restricted_commands,
|
||||
);
|
||||
agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
|
||||
agent.chat_kind = app.chat_mode;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
@@ -958,10 +940,6 @@ pub(in crate::app::dispatch) fn handle_session_loaded(
|
||||
agent_id,
|
||||
session_id: hydrate_sid.clone(),
|
||||
});
|
||||
effects.push(Effect::FetchBilling {
|
||||
agent_id,
|
||||
silent: true,
|
||||
});
|
||||
if let Some((model_id, effort)) = deferred {
|
||||
agent.session.model_switch_pending = true;
|
||||
effects.push(Effect::SwitchModel {
|
||||
@@ -1110,7 +1088,6 @@ pub(in crate::app::dispatch) fn handle_session_restored(
|
||||
supersede_open_reload_window(agent, agent_id, "SessionRestored");
|
||||
agent.bind_session_id(sid);
|
||||
agent.chat_kind = app.chat_mode;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Session restored. Loading {local_session_id}..."
|
||||
)));
|
||||
|
||||
@@ -44,7 +44,6 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
|
||||
}
|
||||
let ui_snapshot = app.current_ui.clone();
|
||||
// Capture app-level fields before the mut-borrow loop.
|
||||
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
@@ -76,7 +75,6 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
|
||||
.iter()
|
||||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||||
.collect(),
|
||||
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
|
||||
// Prefer optimistic pending over confirmed active.
|
||||
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
|
||||
show_tips: show_tips_from_app,
|
||||
@@ -110,7 +108,7 @@ pub(in crate::app::dispatch) fn dispatch_open_command_palette(app: &mut AppView)
|
||||
return vec![];
|
||||
}
|
||||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
|
||||
entries: crate::views::modal::default_palette_entries(),
|
||||
// Type-to-find: open in input mode (matches Ctrl+P).
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
@@ -149,7 +147,6 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(app: &mut AppView) -> Vec
|
||||
let registry = app.settings_registry.clone();
|
||||
let ui_snapshot = app.current_ui.clone();
|
||||
// Capture app-level fields before the mut-borrow on the agent.
|
||||
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
@@ -187,7 +184,6 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(app: &mut AppView) -> Vec
|
||||
.iter()
|
||||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||||
.collect(),
|
||||
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
|
||||
// Prefer optimistic pending over confirmed active.
|
||||
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
|
||||
show_tips: show_tips_from_app,
|
||||
@@ -657,7 +653,6 @@ pub(crate) fn build_pager_snapshot(app: &AppView) -> crate::settings::PagerLocal
|
||||
auto_mode: agent_auto_mode(app),
|
||||
current_model_name: agent_current_model_name(app),
|
||||
available_models: agent_available_models(app),
|
||||
coding_data_sharing_opt_out: app.coding_data_retention_opt_out,
|
||||
plan_mode_active: agent_plan_mode(app),
|
||||
show_tips: app.show_tips,
|
||||
auto_update: app.auto_update,
|
||||
@@ -793,14 +788,6 @@ pub(in crate::app::dispatch) fn action_for_reset(
|
||||
}
|
||||
// max_thoughts_width: direct round-trip.
|
||||
("max_thoughts_width", SettingValue::Int(i)) => Some(Action::SetMaxThoughtsWidth(*i)),
|
||||
// coding_data_sharing: "opt-in" / "opt-out" → bool.
|
||||
// "opt-out" arm is a skew guard (default is "opt-in").
|
||||
("coding_data_sharing", SettingValue::Enum("opt-in")) => {
|
||||
Some(Action::SetCodingDataSharing { opted_in: true })
|
||||
}
|
||||
("coding_data_sharing", SettingValue::Enum("opt-out")) => {
|
||||
Some(Action::SetCodingDataSharing { opted_in: false })
|
||||
}
|
||||
// plan_mode: "on" / "off" → PlanModeKind.
|
||||
// "on" arm is a skew guard (default is "off").
|
||||
("plan_mode", SettingValue::Enum("off")) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Session status, sharing, privacy, usage, and info dispatchers.
|
||||
//! Session status, privacy, usage, and info dispatchers.
|
||||
|
||||
use super::ctx::get_active_agent;
|
||||
use super::settings::ui::refresh_open_settings_modals;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
@@ -9,39 +8,6 @@ use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::notifications::{NotificationEvent, NotificationEventKind};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
/// Toggle YOLO mode (auto-approve all permissions).
|
||||
///
|
||||
/// When turning ON: auto-approve all currently queued permissions and
|
||||
/// restore the stashed prompt. Future incoming permissions will be
|
||||
/// auto-approved in `handle_permission_request`.
|
||||
///
|
||||
/// Share the current session via a public URL.
|
||||
///
|
||||
/// Produces Effect::ShareSession which spawns an async ACP ext request.
|
||||
/// On completion, TaskResult::ShareSessionComplete shows the URL in scrollback.
|
||||
pub(super) fn dispatch_share_session(app: &mut AppView) -> Vec<Effect> {
|
||||
if !app.sharing_enabled {
|
||||
app.show_toast("Sharing is disabled");
|
||||
return vec![];
|
||||
}
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
// No active session — error should have been caught by slash command,
|
||||
// but guard here just in case.
|
||||
return vec![];
|
||||
};
|
||||
|
||||
vec![Effect::ShareSession {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Show session info: fetch via x.ai/session/info and display in scrollback.
|
||||
///
|
||||
/// Produces Effect::ShowSessionInfo which spawns an async ACP ext request.
|
||||
@@ -66,131 +32,6 @@ pub(super) fn dispatch_show_session_info(app: &mut AppView) -> Vec<Effect> {
|
||||
}]
|
||||
}
|
||||
|
||||
/// Show privacy and data retention status as a system message in scrollback.
|
||||
///
|
||||
/// Three-state display: Enterprise ZDR, coding data sharing opted out,
|
||||
/// or opted in. Labels align with `CODING_DATA_SHARING_CHOICES` in
|
||||
/// `settings/defs.rs` and the `coding_data_sharing_toast` format.
|
||||
pub(super) fn dispatch_show_privacy_info(app: &mut AppView) -> Vec<Effect> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if app.is_zdr {
|
||||
// Enterprise ZDR -- the team has disabled retention entirely.
|
||||
lines.push(" Zero Data Retention: enabled");
|
||||
lines.push(" Your data is not retained or used for training (ZDR enabled).");
|
||||
} else if app.coding_data_retention_opt_out {
|
||||
// Coding data sharing opted out -- matches desktop's "Privacy mode" state.
|
||||
lines.push(" Privacy: privacy mode");
|
||||
lines.push(" Your code data will not be trained on or used to improve the product.");
|
||||
lines.push("");
|
||||
lines.push(" Use /privacy opt-in to share data and help improve the product.");
|
||||
} else {
|
||||
// Coding data sharing opted in -- matches desktop's "Share data" state.
|
||||
lines.push(" Privacy: share data");
|
||||
lines.push(" Usage and code data may be used by SpaceXAI to improve the product.");
|
||||
lines.push("");
|
||||
lines.push(" Use /privacy opt-out to enable privacy mode.");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(" Learn more: https://x.ai/legal");
|
||||
let text = lines.join("\n");
|
||||
push_system_to_any_agent(app, &text);
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// State-only mutation for `coding_data_sharing`. SHELL-owned.
|
||||
pub(super) fn set_coding_data_sharing_inner(app: &mut AppView, opted_in: bool) {
|
||||
app.coding_data_retention_opt_out = !opted_in;
|
||||
}
|
||||
|
||||
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
|
||||
/// (persists via ACP ext-request, NOT `~/.kigi/config.toml`).
|
||||
pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<Effect> {
|
||||
// ── Guard 1: Enterprise ZDR ──────────────────────────────────────
|
||||
if app.is_zdr {
|
||||
app.show_toast("\u{2717} Cannot change: Zero Data Retention enabled");
|
||||
return vec![];
|
||||
}
|
||||
// ── Guard 2: Non-admin team member ───────────────────────────────
|
||||
if app.team_name.is_some() {
|
||||
let is_admin = app
|
||||
.team_role
|
||||
.as_deref()
|
||||
.is_some_and(|r| r.eq_ignore_ascii_case("admin"));
|
||||
if !is_admin {
|
||||
app.show_toast("\u{2717} Data sharing is controlled by your team admin");
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
// ── Guard 3: an agent must exist to thread the ACP call through ──
|
||||
let agent_id = match app.active_view {
|
||||
crate::app::app_view::ActiveView::Agent(id) => id,
|
||||
_ => match app.agents.keys().next().copied() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "settings",
|
||||
key = "coding_data_sharing",
|
||||
opted_in,
|
||||
"set_coding_data_sharing called with no agents — unreachable in \
|
||||
practice; returning empty (no toast: app.show_toast would no-op)",
|
||||
);
|
||||
return vec![];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let prev = !app.coding_data_retention_opt_out;
|
||||
|
||||
// ── Idempotent path: toast but skip the ACP round-trip. ──────────
|
||||
if prev == opted_in {
|
||||
app.show_toast(&coding_data_sharing_toast(opted_in));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// ── Optimistic mutation: state, then UI feedback, then effect. ───
|
||||
set_coding_data_sharing_inner(app, opted_in);
|
||||
refresh_open_settings_modals(app);
|
||||
app.show_toast(&coding_data_sharing_toast(opted_in));
|
||||
|
||||
tracing::info!(
|
||||
target: "settings",
|
||||
key = "coding_data_sharing",
|
||||
opted_in,
|
||||
"setting changed",
|
||||
);
|
||||
|
||||
vec![Effect::SetCodingDataSharing {
|
||||
agent_id,
|
||||
opted_in,
|
||||
rollback_to_opted_in: prev,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Format the `Coding data sharing` toast. Asymmetric: opt-in
|
||||
/// (privacy-degrading) uses ⚠ + consequence text; opt-out (safe
|
||||
/// default) uses ✓. Uses display names from the registry catalog.
|
||||
pub(super) fn coding_data_sharing_toast(opted_in: bool) -> String {
|
||||
let display = display_for_coding_data_sharing_canonical(opted_in);
|
||||
if opted_in {
|
||||
// Privacy-degrading: warn glyph + spelled-out consequence.
|
||||
format!(
|
||||
"\u{26A0} Coding data sharing: {display} \u{2014} code samples may be retained \
|
||||
for training"
|
||||
)
|
||||
} else {
|
||||
// Safe default — uniform ✓ glyph.
|
||||
format!("\u{2713} Coding data sharing: {display}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Display string for the canonical bool. Keep aligned with
|
||||
/// `CODING_DATA_SHARING_CHOICES` in `settings/defs.rs`.
|
||||
fn display_for_coding_data_sharing_canonical(opted_in: bool) -> &'static str {
|
||||
if opted_in { "Opt in" } else { "Opt out" }
|
||||
}
|
||||
|
||||
/// Scrub an untrusted error string for toast display. Substitutes a
|
||||
/// generic placeholder when the input exceeds 120 chars or contains
|
||||
/// control / bidi-override characters (prevents escape-sequence
|
||||
@@ -208,21 +49,6 @@ pub(super) fn scrub_error_for_toast(error: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a system message to the active agent's scrollback, or to any available
|
||||
/// agent if on the welcome screen.
|
||||
fn push_system_to_any_agent(app: &mut AppView, msg: &str) {
|
||||
let block = crate::scrollback::block::RenderBlock::system(msg.to_string());
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
{
|
||||
agent.scrollback.push_block(block);
|
||||
return;
|
||||
}
|
||||
if let Some(agent) = app.agents.values_mut().next() {
|
||||
agent.scrollback.push_block(block);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show context info: fetch via x.ai/session/info and display rich breakdown.
|
||||
///
|
||||
/// Produces Effect::ShowContextInfo which spawns an async ACP ext request.
|
||||
@@ -244,32 +70,73 @@ pub(super) fn dispatch_show_context_info(app: &mut AppView) -> Vec<Effect> {
|
||||
}]
|
||||
}
|
||||
|
||||
/// Show credit usage: fetch billing data and display inline.
|
||||
/// `/usage` — fetch Kimi usage/quota rows and display them inline.
|
||||
///
|
||||
/// When the remote settings `grok_build_usage_redirect_url` flag is set (delivered via
|
||||
/// RemoteSettings, targeted at personal-team users), skip the backend fetch and
|
||||
/// just point the user at that URL instead. This is a kill switch for the
|
||||
/// personal-team billing path while it is unreliable.
|
||||
/// Produces [`Effect::FetchUsage`], which asks the shell's `x.ai/billing`
|
||||
/// extension (`GET {base}/usages`); [`handle_usage_fetched`] renders the
|
||||
/// rows as a system block in scrollback.
|
||||
pub(super) fn dispatch_show_usage(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
if let Some(url) = app.usage_billing_redirect_url.clone() {
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
agent.scrollback.push_block(RenderBlock::System(
|
||||
crate::scrollback::blocks::SystemMessageBlock::new(format!(
|
||||
"Please check your usage on {url}"
|
||||
)),
|
||||
));
|
||||
}
|
||||
return vec![];
|
||||
vec![Effect::FetchUsage { agent_id: id }]
|
||||
}
|
||||
|
||||
/// Render the `/usage` result: the fetched quota rows (kimi-cli
|
||||
/// `usage.py` semantics — label, remaining-quota bar, percent left, reset
|
||||
/// hint), "No usage data available." for an empty list, or the error.
|
||||
pub(super) fn handle_usage_fetched(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
result: Result<Vec<kigi_shell::extensions::billing::UsageRow>, String>,
|
||||
) -> Vec<Effect> {
|
||||
let msg = match &result {
|
||||
Ok(rows) if rows.is_empty() => "No usage data available.".to_string(),
|
||||
Ok(rows) => format_usage_rows(rows),
|
||||
Err(e) => format!("Couldn't fetch usage: {e}"),
|
||||
};
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent.scrollback.push_block(RenderBlock::system(msg));
|
||||
}
|
||||
// Non-silent fetch: the effect also pulls the auto top-up rule so the
|
||||
// summary can render usage, prepaid credits, and auto top-up together.
|
||||
vec![Effect::FetchBilling {
|
||||
agent_id: id,
|
||||
silent: false,
|
||||
}]
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Width of the remaining-quota bar, matching kimi-cli's usage panel.
|
||||
const USAGE_BAR_WIDTH: usize = 20;
|
||||
|
||||
/// Format usage rows as aligned text lines (kimi-cli `_format_row`
|
||||
/// parity): `label [bar] N% left (reset hint)`. The percentage is
|
||||
/// derived from `used`/`limit` only — a row without a positive limit
|
||||
/// renders as 0% left with an empty bar, exactly like kimi-cli.
|
||||
fn format_usage_rows(rows: &[kigi_shell::extensions::billing::UsageRow]) -> String {
|
||||
let label_width = rows
|
||||
.iter()
|
||||
.map(|r| r.label.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(6);
|
||||
let mut lines = vec!["API Usage".to_string()];
|
||||
for row in rows {
|
||||
let ratio = if row.limit <= 0 {
|
||||
0.0
|
||||
} else {
|
||||
(row.limit - row.used).clamp(0, row.limit) as f64 / row.limit as f64
|
||||
};
|
||||
let filled = (ratio * USAGE_BAR_WIDTH as f64).round() as usize;
|
||||
let filled = filled.min(USAGE_BAR_WIDTH);
|
||||
let bar: String = "\u{2588}".repeat(filled) + &"\u{2591}".repeat(USAGE_BAR_WIDTH - filled);
|
||||
let mut line = format!(
|
||||
" {:<width$} [{bar}] {:.0}% left",
|
||||
row.label,
|
||||
ratio * 100.0,
|
||||
width = label_width,
|
||||
);
|
||||
if let Some(hint) = &row.reset_hint {
|
||||
line.push_str(&format!(" ({hint})"));
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Commit a one-line "update available" notice into the active agent's
|
||||
@@ -366,58 +233,6 @@ pub(super) fn notify_session_ready(
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_coding_data_sharing_updated(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
opted_in: bool,
|
||||
) -> Vec<Effect> {
|
||||
// Re-anchor mirror to server-confirmed value (defense-in-
|
||||
// depth against server reshaping the boolean). `agent_id`
|
||||
// discarded — privacy is app-level, not per-agent.
|
||||
set_coding_data_sharing_inner(app, opted_in);
|
||||
refresh_open_settings_modals(app);
|
||||
// Re-toast on confirmation. Without this, a slow ACP
|
||||
// round-trip would leave the user with only the
|
||||
// optimistic toast (already faded) and no
|
||||
// server-confirmed feedback.
|
||||
app.show_toast(&coding_data_sharing_toast(opted_in));
|
||||
tracing::info!(
|
||||
target: "settings",
|
||||
key = "coding_data_sharing",
|
||||
?agent_id,
|
||||
opted_in,
|
||||
"ACP update confirmed; mirror re-anchored",
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_coding_data_sharing_failed(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
rollback_to_opted_in: bool,
|
||||
) -> Vec<Effect> {
|
||||
// Revert optimistic mutation: inner → refresh → toast.
|
||||
//
|
||||
// `agent_id` discarded — privacy is global.
|
||||
set_coding_data_sharing_inner(app, rollback_to_opted_in);
|
||||
refresh_open_settings_modals(app);
|
||||
// Scrub long/unsafe error strings before toasting.
|
||||
let scrubbed = scrub_error_for_toast(&error);
|
||||
app.show_toast(&format!(
|
||||
"\u{2717} Couldn't update coding data sharing: {scrubbed}"
|
||||
));
|
||||
tracing::warn!(
|
||||
target: "settings",
|
||||
key = "coding_data_sharing",
|
||||
?agent_id,
|
||||
rollback_to_opted_in,
|
||||
%error,
|
||||
"ACP update failed; reverted optimistic mutation",
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_context_info_complete(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
use super::auth::{
|
||||
ensure_login_method, handle_auth_complete, handle_auth_url_ready, handle_mcp_auth_trigger_done,
|
||||
};
|
||||
use super::billing::{
|
||||
PAYWALL_AUTO_CHECK_TIMEOUT, apply_auto_topup, handle_billing_fetched,
|
||||
handle_check_subscription_complete, handle_credit_limit_recheck_complete,
|
||||
handle_gate_refreshed, handle_gate_verify_timeout,
|
||||
};
|
||||
use super::ctx::{find_agent_by_session_id, get_active_agent_mut};
|
||||
use super::notes::{handle_btw_response, handle_memory_note_saved};
|
||||
use super::prompt::{
|
||||
@@ -34,10 +29,7 @@ use super::session::load::{
|
||||
handle_session_search_debounce_expired, remove_session_from_pickers,
|
||||
};
|
||||
use super::settings::ui::apply_setting_rollback;
|
||||
use super::status::{
|
||||
handle_coding_data_sharing_failed, handle_coding_data_sharing_updated,
|
||||
handle_context_info_complete, scrub_error_for_toast,
|
||||
};
|
||||
use super::status::{handle_context_info_complete, handle_usage_fetched, scrub_error_for_toast};
|
||||
use super::transcript::{
|
||||
handle_hooks_list_loaded, handle_mcp_toggle_done, handle_plugins_list_loaded,
|
||||
handle_skills_toggle_done,
|
||||
@@ -224,33 +216,9 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
TaskResult::ForkSessionFailed { agent_id, error } => {
|
||||
handle_fork_session_failed(app, agent_id, error)
|
||||
}
|
||||
TaskResult::BillingFetched {
|
||||
agent_id,
|
||||
balance,
|
||||
silent,
|
||||
subscription_tier,
|
||||
autotopup,
|
||||
} => handle_billing_fetched(app, agent_id, balance, silent, subscription_tier, autotopup),
|
||||
TaskResult::BillingError {
|
||||
agent_id,
|
||||
error,
|
||||
silent,
|
||||
} => {
|
||||
if !silent && let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent.scrollback.push_block(RenderBlock::System(
|
||||
crate::scrollback::blocks::SystemMessageBlock::new(format!(
|
||||
"Billing error: {error}"
|
||||
)),
|
||||
));
|
||||
}
|
||||
vec![]
|
||||
TaskResult::UsageFetched { agent_id, result } => {
|
||||
handle_usage_fetched(app, agent_id, result)
|
||||
}
|
||||
TaskResult::AppBillingFetched { balance, autotopup } => {
|
||||
app.credit_balance = balance;
|
||||
apply_auto_topup(&mut app.auto_topup, &autotopup);
|
||||
vec![]
|
||||
}
|
||||
TaskResult::GateRefreshed { settings } => handle_gate_refreshed(app, settings),
|
||||
TaskResult::SessionLoaded {
|
||||
agent_id,
|
||||
session_id,
|
||||
@@ -287,10 +255,9 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
} => handle_session_load_failed(app, agent_id, session_id, error),
|
||||
TaskResult::SessionListLoaded {
|
||||
sessions,
|
||||
partial,
|
||||
seq,
|
||||
query,
|
||||
} => handle_session_list_loaded(app, sessions, partial, seq, query),
|
||||
} => handle_session_list_loaded(app, sessions, seq, query),
|
||||
TaskResult::ForeignSessionsScanned { entries, seq } => {
|
||||
handle_foreign_sessions_scanned(app, entries, seq)
|
||||
}
|
||||
@@ -606,29 +573,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
TaskResult::SkillsToggleDone { agent_id, result } => {
|
||||
handle_skills_toggle_done(app, agent_id, result)
|
||||
}
|
||||
TaskResult::ShareSessionComplete {
|
||||
agent_id,
|
||||
share_url,
|
||||
} => {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(format!(
|
||||
"Session shared: {share_url}"
|
||||
)));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::ShareSessionFailed { agent_id, error } => {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(format!(
|
||||
"Couldn't share session: {error}"
|
||||
)));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::SessionAgentNameResolved {
|
||||
agent_id,
|
||||
agent_name,
|
||||
@@ -668,14 +612,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::CodingDataSharingUpdated { agent_id, opted_in } => {
|
||||
handle_coding_data_sharing_updated(app, agent_id, opted_in)
|
||||
}
|
||||
TaskResult::CodingDataSharingFailed {
|
||||
agent_id,
|
||||
error,
|
||||
rollback_to_opted_in,
|
||||
} => handle_coding_data_sharing_failed(app, agent_id, error, rollback_to_opted_in),
|
||||
TaskResult::RenameSessionComplete { agent_id, title } => {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
let safe = crate::views::session_title::sanitize_display_text(&title);
|
||||
@@ -873,32 +809,8 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
app.auth_clipboard_copied = false;
|
||||
vec![]
|
||||
}
|
||||
TaskResult::PaywallCheckTick => {
|
||||
let timed_out = app
|
||||
.paywall_check_started
|
||||
.is_some_and(|t| t.elapsed() >= PAYWALL_AUTO_CHECK_TIMEOUT);
|
||||
if !app.has_access() && !timed_out {
|
||||
vec![
|
||||
Effect::CheckSubscription { verify: None },
|
||||
Effect::SchedulePaywallCheck,
|
||||
]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
TaskResult::CheckSubscriptionComplete { verify, meta } => {
|
||||
handle_check_subscription_complete(app, verify, meta)
|
||||
}
|
||||
TaskResult::GateVerifyTimeout { generation } => handle_gate_verify_timeout(app, generation),
|
||||
TaskResult::CreditLimitRecheckComplete { agent_id, meta } => {
|
||||
handle_credit_limit_recheck_complete(app, agent_id, meta)
|
||||
}
|
||||
TaskResult::LogoutComplete => {
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
app.access_gate_shown_logged = false;
|
||||
app.gate = None;
|
||||
app.pending_gate_verification = None;
|
||||
app.last_subscription_check_at = None;
|
||||
app.login_method_id = None;
|
||||
ensure_login_method(app);
|
||||
app.auth_clipboard_copied = false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1270,37 +1270,6 @@ fn dashboard_slash_model_stages_pending_model() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A tier-restricted command typed into the dashboard dispatch input must
|
||||
/// upsell via the feedback toast — not execute, and (crucially) not fall
|
||||
/// through the unknown-command path, which would spawn a session whose
|
||||
/// first prompt is the raw slash text.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_slash_restricted_command_upsells_via_toast() {
|
||||
let mut app = test_app();
|
||||
app.tier_restricted_commands = vec!["imagine".to_string()];
|
||||
open_dashboard(&mut app);
|
||||
|
||||
let effects = dispatch_dashboard_dispatch_slash(&mut app, "/imagine a sunset".into());
|
||||
|
||||
assert!(effects.is_empty(), "restricted command must not dispatch");
|
||||
assert!(
|
||||
app.agents.is_empty(),
|
||||
"no session may be spawned for the raw slash text"
|
||||
);
|
||||
let toast = app
|
||||
.dashboard
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.error_toast
|
||||
.as_deref()
|
||||
.expect("restricted command must set the upsell toast");
|
||||
assert!(
|
||||
toast.contains("/imagine") && toast.contains("SuperGrok"),
|
||||
"toast must carry the upsell: {toast}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A slash command that fails (`CommandResult::Error`) surfaces on
|
||||
/// the dashboard with the `✗` error prefix — command error strings
|
||||
/// carry no glyph of their own, and the feedback badge paints the
|
||||
|
||||
@@ -15,10 +15,6 @@ mod status;
|
||||
mod task_result;
|
||||
mod transcript;
|
||||
mod turn;
|
||||
use super::billing::{
|
||||
CreditLimitUpsellMode, credit_limit_upsell_mode, is_max_tier, open_credit_limit_upsell,
|
||||
open_free_usage_upsell,
|
||||
};
|
||||
use super::ctx::{find_agent_by_session_id, get_active_agent, get_active_agent_mut};
|
||||
use super::dashboard::{
|
||||
apply_pending_dispatch_config, dispatch_dashboard_attach, dispatch_dashboard_begin_rename,
|
||||
@@ -133,24 +129,9 @@ fn test_app() -> AppView {
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
auth_clipboard_copied: false,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
is_zdr: false,
|
||||
team_role: None,
|
||||
coding_data_retention_opt_out: false,
|
||||
show_tips: None,
|
||||
auto_update: None,
|
||||
ask_user_question_timeout_enabled: None,
|
||||
zdr_access_enabled: false,
|
||||
usage_billing_redirect_url: None,
|
||||
access_gate_shown_logged: false,
|
||||
gate: None,
|
||||
subscription_tier: None,
|
||||
paywall_check_started: None,
|
||||
last_subscription_check_at: None,
|
||||
subscription_watch_interval_secs: None,
|
||||
pending_gate_verification: None,
|
||||
gate_verify_gen: 0,
|
||||
bundle_state: crate::app::bundle::BundleState::default(),
|
||||
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
|
||||
fps_hud: crate::views::fps_hud::FpsHud::new(),
|
||||
@@ -172,8 +153,6 @@ fn test_app() -> AppView {
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_refresh_rect: None,
|
||||
welcome_gate_url_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
@@ -213,13 +192,8 @@ fn test_app() -> AppView {
|
||||
minimal_state: crate::minimal_api::MinimalState::default(),
|
||||
reconnect_pending: false,
|
||||
show_resolved_model: true,
|
||||
sharing_enabled: false,
|
||||
usage_visible: true,
|
||||
tier_restricted_commands: Vec::new(),
|
||||
leader_mode: true,
|
||||
credit_balance: None,
|
||||
auto_topup: None,
|
||||
billing_poll_wanted: false,
|
||||
leader_roster: Vec::new(),
|
||||
dashboard_local_sessions: Vec::new(),
|
||||
dashboard_sessions_loading: false,
|
||||
@@ -262,8 +236,6 @@ fn make_test_agent_session(app: &AppView, id: AgentId, sid: &str) -> AgentSessio
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
@@ -446,8 +418,6 @@ fn insert_placeholder_agent(app: &mut AppView, id: AgentId) {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
@@ -591,8 +561,6 @@ fn two_agent_app_with_bg_task() -> AppView {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
@@ -895,17 +863,3 @@ fn reset_mouse_capture_enabled(on: bool) {
|
||||
fn mouse_capture_is_enabled() -> bool {
|
||||
crate::app::MOUSE_CAPTURE_ENABLED.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
/// Build a minimal `CreditBalance` for billing dispatch tests.
|
||||
fn test_bal(usage_pct: f64) -> crate::views::credit_bar::CreditBalance {
|
||||
crate::views::credit_bar::CreditBalance {
|
||||
usage_pct,
|
||||
effective_usage_pct: usage_pct,
|
||||
period_end_display: None,
|
||||
pay_as_you_go: false,
|
||||
on_demand_cap_cents: None,
|
||||
on_demand_used_cents: None,
|
||||
prepaid_balance_cents: None,
|
||||
period_type: None,
|
||||
is_unified_billing_user: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,13 +733,8 @@ fn turn_end_drains_next_queued_prompt() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// No re-send (the prompt was already sent at enqueue time): only the
|
||||
// billing refresh effect.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
// No re-send (the prompt was already sent at enqueue time).
|
||||
assert!(effects.is_empty(), "no effects expected: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_turn_running());
|
||||
// current_prompt_id was handed off to the second prompt for correlation.
|
||||
assert_eq!(
|
||||
@@ -772,12 +767,8 @@ fn turn_end_with_empty_queue_stays_idle() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Silent billing refresh after turn completion.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
// Turn completion produces no follow-up effects.
|
||||
assert!(effects.is_empty(), "no effects expected: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_idle());
|
||||
// Session event "Worked for" added.
|
||||
assert_eq!(app.agents[&id].scrollback.len(), 1);
|
||||
@@ -806,31 +797,19 @@ fn multiple_queued_prompts_drain_one_per_turn() {
|
||||
})
|
||||
};
|
||||
|
||||
// Turn end → drain "b" + FetchBilling.
|
||||
// Turn end → drain "b".
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "b"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 1);
|
||||
|
||||
// Turn end → drain "c" + FetchBilling.
|
||||
// Turn end → drain "c".
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "c"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 0);
|
||||
|
||||
// Turn end → FetchBilling only.
|
||||
// Turn end with an empty queue → nothing to drain.
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
assert!(effects.is_empty(), "no effects expected: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_idle());
|
||||
}
|
||||
|
||||
@@ -852,12 +831,8 @@ fn prompt_response_resets_turn_state() {
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
// Silent billing refresh after turn completion.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
// Turn completion produces no follow-up effects.
|
||||
assert!(effects.is_empty(), "no effects expected: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_idle());
|
||||
assert!(app.agents[&id].turn_started_at.is_none());
|
||||
// mark_turn_finished must stamp the activity anchor used by the
|
||||
@@ -892,7 +867,7 @@ fn turn_end_fetches_prompt_suggestion_when_enabled() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(effects.len(), 2, "suggestion fetch + billing: {effects:?}");
|
||||
assert_eq!(effects.len(), 1, "suggestion fetch: {effects:?}");
|
||||
let Effect::FetchPromptSuggestion {
|
||||
agent_id,
|
||||
generation,
|
||||
@@ -1249,12 +1224,8 @@ fn turn_complete_notification_suppressed_when_queue_non_empty() {
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
// No re-send; only billing refresh. The second prompt is adopted.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
// No re-send. The second prompt is adopted.
|
||||
assert!(effects.is_empty(), "no effects expected: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_turn_running());
|
||||
assert!(
|
||||
app.deferred_notification.is_none(),
|
||||
@@ -1536,12 +1507,8 @@ fn prompt_response_resets_cancelling_to_idle() {
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
// Silent billing refresh after turn completion.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
// Turn completion produces no follow-up effects.
|
||||
assert!(effects.is_empty(), "no effects expected: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_idle());
|
||||
// Cancellation produces a "Turn cancelled" session event.
|
||||
assert_eq!(app.agents[&id].scrollback.len(), 1);
|
||||
@@ -1578,12 +1545,8 @@ fn cancel_with_queued_prompt_drains_on_completion() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(effects.len(), 2);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
assert!(app.agents[&id].session.state.is_turn_running());
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 0);
|
||||
}
|
||||
@@ -1605,12 +1568,8 @@ fn cancel_with_empty_queue_stays_idle() {
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
// Silent billing refresh after turn completion.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
// Turn completion produces no follow-up effects.
|
||||
assert!(effects.is_empty(), "no effects expected: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_idle());
|
||||
}
|
||||
|
||||
@@ -1660,12 +1619,8 @@ fn cancel_with_multiple_queued_prompts_drains_only_front_prompt() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(effects.len(), 2);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued-1"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
assert!(app.agents[&id].session.state.is_turn_running());
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 1);
|
||||
assert_eq!(app.agents[&id].session.pending_prompts[0].text, "queued-2");
|
||||
@@ -1706,12 +1661,8 @@ fn cancel_drain_is_blocked_when_editing_front_prompt() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Drain blocked but billing refresh still happens.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
// Drain blocked — no effects.
|
||||
assert!(effects.is_empty(), "drain should be blocked: {effects:?}");
|
||||
assert!(app.agents[&id].session.state.is_idle());
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 2);
|
||||
assert_eq!(app.agents[&id].session.pending_prompts[0].text, "queued-1");
|
||||
|
||||
@@ -220,7 +220,6 @@ fn native_empty_waits_for_foreign_and_foreign_only_rows_survive() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![],
|
||||
partial: None,
|
||||
seq: 1,
|
||||
query: None,
|
||||
}),
|
||||
@@ -267,7 +266,6 @@ fn foreign_empty_then_native_empty_finishes_once_without_resurrecting() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![],
|
||||
partial: None,
|
||||
seq: 3,
|
||||
query: None,
|
||||
}),
|
||||
@@ -331,7 +329,6 @@ fn modal_empty_notice_waits_until_both_lanes_are_empty() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![],
|
||||
partial: None,
|
||||
seq: 9,
|
||||
query: None,
|
||||
}),
|
||||
@@ -435,7 +432,6 @@ fn modal_selection_survives_native_and_foreign_completion_races() {
|
||||
at(make_picker_entry("a", "/repo"), 20),
|
||||
at(make_picker_entry("b", "/repo"), 10),
|
||||
],
|
||||
partial: None,
|
||||
seq: 2,
|
||||
query: None,
|
||||
}),
|
||||
|
||||
@@ -723,27 +723,14 @@ fn dispatch_fork_stashes_directive_in_pending_first_prompt() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_fork_inherits_appearance_sharing_and_plugin_visibility() {
|
||||
fn dispatch_fork_inherits_appearance_and_plugin_visibility() {
|
||||
let mut app = fork_test_app();
|
||||
// Tweak app-level state so we can verify the sweep applied it.
|
||||
app.appearance.prompt.compact = true;
|
||||
app.sharing_enabled = false;
|
||||
app.usage_visible = false;
|
||||
app.appearance.disable_plugins = true;
|
||||
// Cached billing state must be inherited so the credits warning is
|
||||
// correct from the first frame (not just after a billing fetch).
|
||||
app.credit_balance = Some(crate::views::credit_bar::CreditBalance {
|
||||
prepaid_balance_cents: Some(1500),
|
||||
..test_bal(50.0)
|
||||
});
|
||||
app.auto_topup = Some(crate::views::credit_bar::AutoTopupInfo {
|
||||
enabled: true,
|
||||
topup_amount_cents: Some(2000),
|
||||
max_amount_cents: None,
|
||||
});
|
||||
dispatch(Action::Fork(fork_args(Some(false), None)), &mut app);
|
||||
let new_agent = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(!new_agent.sharing_enabled);
|
||||
assert!(
|
||||
new_agent
|
||||
.prompt
|
||||
@@ -752,14 +739,6 @@ fn dispatch_fork_inherits_appearance_sharing_and_plugin_visibility() {
|
||||
.get("usage")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
new_agent
|
||||
.credit_balance
|
||||
.as_ref()
|
||||
.and_then(|b| b.prepaid_balance_cents),
|
||||
Some(1500)
|
||||
);
|
||||
assert!(new_agent.auto_topup.as_ref().is_some_and(|at| at.enabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -50,7 +50,7 @@ fn session_created_sets_session_id() {
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(effects.len(), 5);
|
||||
assert_eq!(effects.len(), 4);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::FetchPromptHistory { session_id, .. } if
|
||||
session_id == "new-session-123")
|
||||
@@ -60,11 +60,7 @@ fn session_created_sets_session_id() {
|
||||
&effects[2],
|
||||
Effect::RefreshAvailableCommands { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
&effects[3],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
));
|
||||
assert!(matches!(&effects[4], Effect::RegisterActiveSession { .. }));
|
||||
assert!(matches!(&effects[3], Effect::RegisterActiveSession { .. }));
|
||||
assert_eq!(
|
||||
app.agents[&id]
|
||||
.session
|
||||
@@ -198,11 +194,6 @@ fn worktree_session_created_sets_session_and_cwd() {
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchSessionAgentName { .. }))
|
||||
);
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchBilling { silent: true, .. }))
|
||||
);
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
|
||||
@@ -1836,7 +1836,6 @@ fn stale_session_list_responses_are_dropped() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-stale-1")],
|
||||
partial: None,
|
||||
seq: 1,
|
||||
query: None,
|
||||
}),
|
||||
@@ -1861,7 +1860,6 @@ fn stale_session_list_responses_are_dropped() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-fresh-2")],
|
||||
partial: None,
|
||||
seq: 2,
|
||||
query: Some("abcd".into()),
|
||||
}),
|
||||
@@ -1906,7 +1904,6 @@ fn modal_search_response_lands_and_stale_is_dropped() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-hit-1")],
|
||||
partial: None,
|
||||
seq: 1,
|
||||
query: Some("hit".into()),
|
||||
}),
|
||||
@@ -1944,7 +1941,6 @@ fn modal_search_response_lands_and_stale_is_dropped() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-stale-m")],
|
||||
partial: None,
|
||||
seq: 1,
|
||||
query: Some("hit".into()),
|
||||
}),
|
||||
@@ -1999,7 +1995,6 @@ fn modal_close_drops_in_flight_search_response() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-late-1")],
|
||||
partial: None,
|
||||
seq,
|
||||
query: Some("hit".into()),
|
||||
}),
|
||||
@@ -2045,7 +2040,6 @@ fn modal_pick_drops_in_flight_search_response() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-late-p")],
|
||||
partial: None,
|
||||
seq,
|
||||
query: Some("hit".into()),
|
||||
}),
|
||||
@@ -2089,7 +2083,6 @@ fn welcome_esc_drops_in_flight_fetch_response() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-late-w")],
|
||||
partial: None,
|
||||
seq,
|
||||
query: None,
|
||||
}),
|
||||
@@ -2119,7 +2112,6 @@ fn build_mode_modal_close_does_not_invalidate_plain_fetch() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_picker_entry("build-late-1", "/tmp/repo")],
|
||||
partial: None,
|
||||
seq,
|
||||
query: None,
|
||||
}),
|
||||
@@ -2142,7 +2134,6 @@ fn zero_hit_search_shows_empty_list_without_toast() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![],
|
||||
partial: None,
|
||||
seq: 1,
|
||||
query: Some("zzz".into()),
|
||||
}),
|
||||
@@ -2163,7 +2154,6 @@ fn zero_hit_search_shows_empty_list_without_toast() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![],
|
||||
partial: None,
|
||||
seq: 2,
|
||||
query: None,
|
||||
}),
|
||||
@@ -2347,7 +2337,6 @@ fn build_mode_list_response_preserves_deep_search_spinner() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_picker_entry("local-1", "/r")],
|
||||
partial: None,
|
||||
seq: app.session_picker_list_seq,
|
||||
query: None,
|
||||
}),
|
||||
@@ -2400,7 +2389,6 @@ fn build_mode_rapid_plain_fetches_keep_last_write_wins() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_picker_entry("build-first", "/r")],
|
||||
partial: None,
|
||||
seq: 0,
|
||||
query: None,
|
||||
}),
|
||||
@@ -2416,7 +2404,6 @@ fn build_mode_rapid_plain_fetches_keep_last_write_wins() {
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_picker_entry("build-second", "/r")],
|
||||
partial: None,
|
||||
seq: 0,
|
||||
query: None,
|
||||
}),
|
||||
|
||||
@@ -1209,9 +1209,6 @@ fn move_setting_away_from_default(app: &mut AppView, key: crate::settings::Setti
|
||||
"max_thoughts_width" => {
|
||||
let _ = dispatch(Action::SetMaxThoughtsWidth(200), app);
|
||||
}
|
||||
"coding_data_sharing" => {
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, app);
|
||||
}
|
||||
"plan_mode" => {
|
||||
let _ = dispatch(
|
||||
Action::SetPlanMode(crate::app::actions::PlanModeKind::On),
|
||||
@@ -1388,8 +1385,6 @@ fn set_simple_mode_propagates_to_every_agent() {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Tests for session status, sharing, privacy, and coding-data-sharing dispatchers.
|
||||
//! Tests for session status and sharing dispatchers.
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -60,659 +60,6 @@ fn send_while_idle_with_nonempty_shared_queue_routes_to_server() {
|
||||
assert_eq!(q.last().map(|e| e.text.as_str()), Some("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_privacy_info_zdr() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.is_zdr = true;
|
||||
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(text.contains("Zero Data Retention"));
|
||||
}
|
||||
|
||||
/// `/privacy` info-print uses the desktop-aligned "privacy mode" /
|
||||
/// "share data" labels from the user's intentional rewrite.
|
||||
#[test]
|
||||
fn show_privacy_info_opted_out() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("Privacy: privacy mode"),
|
||||
"info-print must use 'Privacy: privacy mode' (desktop-aligned label): {text}",
|
||||
);
|
||||
assert!(text.contains("/privacy opt-in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_privacy_info_opted_in() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("Privacy: share data"),
|
||||
"info-print must use 'Privacy: share data' (desktop-aligned label): {text}",
|
||||
);
|
||||
assert!(text.contains("/privacy opt-out"));
|
||||
}
|
||||
|
||||
/// The info-print uses desktop-aligned labels ("privacy mode" /
|
||||
/// "share data"). This test pins those labels to catch accidental
|
||||
/// regressions to the registry's "Opt in" / "Opt out" display
|
||||
/// strings.
|
||||
#[test]
|
||||
fn show_privacy_info_does_not_use_old_desktop_labels() {
|
||||
// opted-out → "Privacy: privacy mode"
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("privacy mode"),
|
||||
"[opted-out] info-print must contain 'privacy mode': {text:?}",
|
||||
);
|
||||
|
||||
// opted-in → "Privacy: share data"
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("share data"),
|
||||
"[opted-in] info-print must contain 'share data': {text:?}",
|
||||
);
|
||||
}
|
||||
|
||||
// ── coding_data_sharing dispatch tests ───
|
||||
//
|
||||
// The dispatcher uses **optimistic + rollback + toast**, matching the
|
||||
// `set_yolo_mode` pattern. These tests pin the contract:
|
||||
// - Guards (ZDR, non-admin team) toast and short-circuit.
|
||||
// - Idempotent dispatch toasts but emits no Effect.
|
||||
// - Optimistic mutation flips `app.coding_data_retention_opt_out`
|
||||
// BEFORE the Effect is emitted.
|
||||
// - `Effect::SetCodingDataSharing` carries
|
||||
// `rollback_to_opted_in = previous_value`.
|
||||
// - `TaskResult::CodingDataSharingFailed` reverts the optimistic
|
||||
// mutation; `TaskResult::CodingDataSharingUpdated` re-anchors
|
||||
// to the server-confirmed value.
|
||||
|
||||
/// Idempotent re-dispatch when already opted-in toasts but emits
|
||||
/// no Effect (avoids a wasted ACP round-trip).
|
||||
///
|
||||
/// Toast uses the **display name** ("Opt in", not the
|
||||
/// snake-case canonical "opt-in") AND the **destructive `⚠`
|
||||
/// glyph** on the opt-in direction (privacy-degrading).
|
||||
#[test]
|
||||
fn set_coding_data_sharing_idempotent_opt_in() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false; // currently opted-in
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"idempotent re-dispatch must NOT emit Effect"
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Opt in"),
|
||||
"toast must show display name 'Opt in' (PR 9 R1, General-3 Issue 6): {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains("opt-in"),
|
||||
"toast must NOT use snake-case canonical 'opt-in' — display name only: {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{26A0}'),
|
||||
"idempotent opt-in toast uses ⚠ destructive-warning glyph (PR 9 R1, \
|
||||
General-3 Issue 5): {toast}",
|
||||
);
|
||||
// State unchanged.
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"idempotent path must not mutate state",
|
||||
);
|
||||
}
|
||||
|
||||
/// Idempotent re-dispatch when already opted-out toasts but emits
|
||||
/// no Effect.
|
||||
///
|
||||
/// Opt-out direction uses the **uniform `✓` glyph**
|
||||
/// (restoring the safe default) and the display name "Opt out".
|
||||
#[test]
|
||||
fn set_coding_data_sharing_idempotent_opt_out() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true; // currently opted-out
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"idempotent re-dispatch must NOT emit Effect"
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Opt out"),
|
||||
"toast must show display name 'Opt out': {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{2713}'),
|
||||
"idempotent opt-out toast uses ✓ safe-default glyph: {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains('\u{26A0}'),
|
||||
"opt-out is the safe direction — must NOT use ⚠: {toast}",
|
||||
);
|
||||
// State unchanged.
|
||||
assert!(
|
||||
app.coding_data_retention_opt_out,
|
||||
"idempotent path must not mutate state",
|
||||
);
|
||||
}
|
||||
|
||||
/// ZDR teams are blocked from toggling. The blocked path
|
||||
/// toasts (not scrollback) and short-circuits with no Effect.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_blocked_by_zdr() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.is_zdr = true;
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert!(effects.is_empty(), "ZDR block must NOT emit Effect");
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Zero Data Retention"),
|
||||
"ZDR toast must surface the policy: {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{2717}'),
|
||||
"blocked toast uses ✗ glyph: {toast}"
|
||||
);
|
||||
// State unchanged — the user was blocked, the optimistic
|
||||
// mutation never happened.
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"ZDR block must not mutate state",
|
||||
);
|
||||
}
|
||||
|
||||
/// ZDR block fires even when the toggle would be a no-op
|
||||
/// (defense-in-depth: don't quietly accept a same-value toggle
|
||||
/// from a user the policy says shouldn't be touching this).
|
||||
#[test]
|
||||
fn set_coding_data_sharing_blocked_by_zdr_even_if_idempotent() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.is_zdr = true;
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
assert!(read_toast(&app).contains("Zero Data Retention"));
|
||||
}
|
||||
|
||||
/// Non-admin team members are blocked from toggling (matches
|
||||
/// desktop). The blocked path toasts and short-circuits.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_blocked_non_admin() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.team_name = Some("Acme".into());
|
||||
app.team_role = Some("Member".into());
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("team admin"),
|
||||
"non-admin toast must mention team admin: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Admin team members CAN toggle. The admin-allowed path produces
|
||||
/// an Effect carrying the rollback value.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_allowed_for_admin() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.team_name = Some("Acme".into());
|
||||
app.team_role = Some("Admin".into());
|
||||
app.coding_data_retention_opt_out = false; // currently opted-in
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
match &effects[0] {
|
||||
Effect::SetCodingDataSharing {
|
||||
opted_in,
|
||||
rollback_to_opted_in,
|
||||
..
|
||||
} => {
|
||||
assert!(!*opted_in, "Effect must carry opted_in=false");
|
||||
assert!(
|
||||
*rollback_to_opted_in,
|
||||
"rollback_to_opted_in must capture pre-toggle opt-in=true",
|
||||
);
|
||||
}
|
||||
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
|
||||
}
|
||||
// Optimistic mutation already applied.
|
||||
assert!(
|
||||
app.coding_data_retention_opt_out,
|
||||
"admin-allowed dispatch must optimistically flip state",
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-idempotent dispatch emits one Effect AND mutates state
|
||||
/// optimistically AND toasts.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false; // currently opted-in
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert_eq!(effects.len(), 1, "non-idempotent dispatch emits one Effect");
|
||||
match &effects[0] {
|
||||
Effect::SetCodingDataSharing {
|
||||
agent_id,
|
||||
opted_in,
|
||||
rollback_to_opted_in,
|
||||
} => {
|
||||
assert_eq!(*agent_id, AgentId(0));
|
||||
assert!(!*opted_in);
|
||||
assert!(
|
||||
*rollback_to_opted_in,
|
||||
"rollback_to_opted_in must be pre-toggle value (true == opted-in)",
|
||||
);
|
||||
}
|
||||
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
|
||||
}
|
||||
// Optimistic mutation applied.
|
||||
assert!(
|
||||
app.coding_data_retention_opt_out,
|
||||
"dispatch must optimistically mutate state",
|
||||
);
|
||||
// Toast on every dispatch (SHELL setter contract).
|
||||
assert!(app.agents[&AgentId(0)].toast.is_some());
|
||||
}
|
||||
|
||||
/// `TaskResult::CodingDataSharingUpdated` re-anchors state to the
|
||||
/// server-confirmed value (defense-in-depth) and re-toasts.
|
||||
#[test]
|
||||
fn coding_data_sharing_updated_re_anchors_state_and_re_toasts() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Simulate post-optimistic state: opted-out.
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
// Server confirms opt-out (same as optimistic).
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
|
||||
agent_id: id,
|
||||
opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty(), "TaskResult arm must NOT emit Effect");
|
||||
// State re-anchored (was already true, stays true).
|
||||
assert!(app.coding_data_retention_opt_out);
|
||||
// Re-toast on confirmation uses display name + ✓.
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Opt out"),
|
||||
"confirmation toast must use display name 'Opt out': {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{2713}'),
|
||||
"opt-out confirmation toast uses ✓: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `TaskResult::CodingDataSharingUpdated` corrects the in-memory
|
||||
/// state if the server reshapes the boolean (e.g. policy
|
||||
/// override). Pins the defense-in-depth re-anchor contract.
|
||||
#[test]
|
||||
fn coding_data_sharing_updated_corrects_state_if_server_disagrees() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Optimistic mutation said "opt-out" — but the server
|
||||
// overrides to "opt-in" (e.g. policy that prevents opt-out).
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
|
||||
agent_id: id,
|
||||
opted_in: true, // server says opted-in
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
// State corrected to match server.
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"server-confirmed opt-in must overwrite optimistic opt-out",
|
||||
);
|
||||
// Server-correction toast uses the destructive ⚠
|
||||
// pattern for the opt-in direction (the privacy-degrading
|
||||
// override deserves the warning glyph even if the SERVER, not
|
||||
// the user, made the call).
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Opt in"),
|
||||
"post-correction toast uses display name 'Opt in': {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{26A0}'),
|
||||
"opt-in direction always uses ⚠ glyph, even on server-correction path: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `TaskResult::CodingDataSharingFailed` REVERTS the optimistic
|
||||
/// mutation and surfaces a failure toast. Pins the rollback
|
||||
/// contract.
|
||||
///
|
||||
/// Failure toast uses the standardised "coding data sharing"
|
||||
/// wording.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_rolls_back_and_toasts_error() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Simulate post-optimistic state: user picked opt-out, state
|
||||
// was flipped, then the ACP call failed. The pre-toggle value
|
||||
// was opt-in (true), so `rollback_to_opted_in = true`.
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: "server error".into(),
|
||||
rollback_to_opted_in: true,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty(), "rollback path must NOT emit Effect");
|
||||
// State reverted to pre-toggle (opted-in).
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"rollback must revert optimistic mutation",
|
||||
);
|
||||
// Failure toast surfaces the error using full label.
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("coding data sharing"),
|
||||
"PR 9 R1: failure toast wording standardised to include 'coding data sharing' \
|
||||
(G2 Issue 2): {toast}",
|
||||
);
|
||||
assert!(toast.contains("server error"), "error in toast: {toast}");
|
||||
assert!(toast.contains('\u{2717}'), "failure toast uses ✗: {toast}");
|
||||
}
|
||||
|
||||
/// `TaskResult::CodingDataSharingFailed` reverts in the OTHER
|
||||
/// direction too (the pre-toggle state could have been either).
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_rolls_back_to_opt_out() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Post-optimistic: opted-in (user picked opt-in, server
|
||||
// failed, pre-toggle was opt-out).
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let id = AgentId(0);
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: "network timeout".into(),
|
||||
rollback_to_opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
// Reverted to pre-toggle opt-out.
|
||||
assert!(
|
||||
app.coding_data_retention_opt_out,
|
||||
"rollback to opt-out must set state=true",
|
||||
);
|
||||
}
|
||||
|
||||
/// Optimistic mutation refreshes any open settings modal.
|
||||
/// Without this refresh, the modal indicator would stay at the
|
||||
/// pre-toggle value until manual re-render.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_refreshes_open_modal_snapshot() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false;
|
||||
// Open a settings modal (capture initial snapshot).
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
// Verify snapshot reads opted-in.
|
||||
let agent_id = AgentId(0);
|
||||
{
|
||||
let state = match &app.agents[&agent_id].active_modal {
|
||||
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
|
||||
_ => panic!("expected Settings modal open after OpenSettings dispatch"),
|
||||
};
|
||||
assert!(
|
||||
!state.pager_snapshot.coding_data_sharing_opt_out,
|
||||
"initial snapshot must read opt_out=false (opted-in)",
|
||||
);
|
||||
}
|
||||
// Dispatch the toggle.
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
// Snapshot now reflects the optimistic mutation.
|
||||
let state = match &app.agents[&agent_id].active_modal {
|
||||
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
|
||||
_ => panic!("Settings modal must still be open after SetCodingDataSharing dispatch"),
|
||||
};
|
||||
assert!(
|
||||
state.pager_snapshot.coding_data_sharing_opt_out,
|
||||
"snapshot must refresh to reflect opt_out=true (opted-out) after dispatch",
|
||||
);
|
||||
}
|
||||
|
||||
/// Rollback also refreshes the modal — the user sees the
|
||||
/// reverted value, not the stale optimistic one.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_refreshes_open_modal_snapshot() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
// Optimistic flip.
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
// ACP failure.
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: AgentId(0),
|
||||
error: "x".into(),
|
||||
rollback_to_opted_in: true,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let state = match &app.agents[&AgentId(0)].active_modal {
|
||||
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
|
||||
_ => panic!("Settings modal must still be open after rollback TaskResult"),
|
||||
};
|
||||
assert!(
|
||||
!state.pager_snapshot.coding_data_sharing_opt_out,
|
||||
"rollback must refresh snapshot back to opt_out=false (opted-in)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── coding_data_sharing toast tests ─────────────
|
||||
|
||||
/// The opt-in transition
|
||||
/// uses the **`⚠` destructive-warning glyph** + spelled-out
|
||||
/// consequence text — mirroring `yolo_toast`'s
|
||||
/// "Always-approve ON: all tool actions auto-run" pattern. The
|
||||
/// consequence text is verbatim-pinned because the toast is the
|
||||
/// only post-commit feedback for a privacy-degrading transition;
|
||||
/// a future PR that softens the wording silently degrades the
|
||||
/// safety affordance.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_opt_in_renders_destructive_warning_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true; // currently opted-out
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
|
||||
assert_eq!(effects.len(), 1, "non-idempotent opt-in must emit Effect");
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains('\u{26A0}'),
|
||||
"opt-in toast MUST use ⚠ glyph (PR 9 R1, General-3 Issue 5 — \
|
||||
privacy-degrading transition deserves destructive-warning glyph): {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains('\u{2713}'),
|
||||
"opt-in toast MUST NOT use the uniform ✓ glyph — that's the \
|
||||
safe-default toast for opt-out: {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains("Opt in"),
|
||||
"destructive toast still uses display name 'Opt in': {toast}",
|
||||
);
|
||||
// Consequence text pinned: a future PR softening this loses
|
||||
// the safety affordance.
|
||||
assert!(
|
||||
toast.contains("code samples"),
|
||||
"destructive toast must spell out the consequence \
|
||||
(mention 'code samples'): {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains("training"),
|
||||
"destructive toast must spell out the consequence \
|
||||
(mention 'training'): {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// The opt-out transition uses the
|
||||
/// uniform `✓` glyph (safe default), NOT the destructive `⚠`.
|
||||
/// Mirrors `yolo_toast(false)` precedent — restoring the safe
|
||||
/// default doesn't warrant the heavier visual.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_opt_out_renders_safe_default_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false; // currently opted-in
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains('\u{2713}'),
|
||||
"opt-out toast uses ✓ safe-default glyph: {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains('\u{26A0}'),
|
||||
"opt-out toast MUST NOT use ⚠ — that's reserved for the privacy-degrading \
|
||||
direction (PR 9 R1): {toast}",
|
||||
);
|
||||
assert!(toast.contains("Opt out"));
|
||||
}
|
||||
|
||||
/// The toast renders
|
||||
/// the registered `EnumChoice.display` ("Opt in" / "Opt out"),
|
||||
/// NOT the persisted canonical ("opt-in" / "opt-out"). Mirrors
|
||||
/// the `set_theme_toast_format_uses_display_name` contract.
|
||||
/// The display strings here are pinned by the
|
||||
/// `coding_data_sharing_choices_use_canonical_strings` e2e test
|
||||
/// (registry side) AND
|
||||
/// `pr9_coding_data_sharing_choices_use_canonical_strings` (which
|
||||
/// also pins the display labels via the same EnumChoice
|
||||
/// entries).
|
||||
#[test]
|
||||
fn coding_data_sharing_toast_format_uses_display_name() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Opt-in direction.
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
|
||||
let opt_in_toast = read_toast(&app);
|
||||
assert!(
|
||||
opt_in_toast.contains("Opt in"),
|
||||
"opt-in toast uses display 'Opt in', not canonical 'opt-in': {opt_in_toast}",
|
||||
);
|
||||
// Clear and test opt-out direction.
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
let opt_out_toast = read_toast(&app);
|
||||
assert!(
|
||||
opt_out_toast.contains("Opt out"),
|
||||
"opt-out toast uses display 'Opt out', not canonical 'opt-out': {opt_out_toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure toast
|
||||
/// substitutes a generic placeholder when the error string is
|
||||
/// too long OR contains control characters / newlines. Pins the
|
||||
/// scrub contract.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_scrubs_long_error_messages() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
// ~500-char error simulating a stack trace / HTML 502 page.
|
||||
let huge_error = "a".repeat(500);
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: huge_error.clone(),
|
||||
rollback_to_opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
!toast.contains(&huge_error),
|
||||
"long error MUST be scrubbed from the toast: {} chars",
|
||||
toast.len(),
|
||||
);
|
||||
assert!(
|
||||
toast.contains("see logs"),
|
||||
"scrubbed toast must point at the log for full details: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Control characters (CR/LF/NUL)
|
||||
/// in the error trigger the scrub path even on short strings —
|
||||
/// preserves the toast's single-line layout.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_scrubs_control_chars_in_error() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
// Short message with embedded newlines.
|
||||
let multiline = "line1\nline2\nline3".to_string();
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: multiline.clone(),
|
||||
rollback_to_opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
!toast.contains('\n'),
|
||||
"newlines MUST be scrubbed from the toast (would break single-line layout): \
|
||||
{toast:?}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains("see logs"),
|
||||
"control-char-scrubbed toast points at logs: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// The scrub path preserves short,
|
||||
/// sanitised error messages verbatim — the typical happy-path
|
||||
/// shell-side error string stays unscrubbed.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_preserves_short_clean_error_message() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
let short_clean = "network timeout".to_string();
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: short_clean.clone(),
|
||||
rollback_to_opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains(&short_clean),
|
||||
"short clean error must appear verbatim in the toast: {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains("see logs"),
|
||||
"short clean error must NOT trigger the scrub fallback: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Direct unit test of the `scrub_error_for_toast` helper —
|
||||
/// pins the threshold and the fallback string against drift.
|
||||
#[test]
|
||||
@@ -766,31 +113,6 @@ fn scrub_error_for_toast_unit() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The no-agent path
|
||||
/// returns empty cleanly — no toast (the show_toast call would
|
||||
/// no-op anyway), no panic, no Effect emitted. A "✗ No active
|
||||
/// session" toast would be dead UX (no agent = no toast surface
|
||||
/// to render on), so this path emits a tracing::warn! instead.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_no_agents_returns_empty_without_panic() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Remove every agent so the dispatcher hits the no-agent path.
|
||||
app.agents.clear();
|
||||
// Force the view off Agent so the dispatcher falls through to
|
||||
// app.agents.keys().next() which is now empty.
|
||||
app.active_view = ActiveView::Welcome;
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"no-agent path must return empty (no Effect to fire)",
|
||||
);
|
||||
// State unchanged (we never reach the optimistic mutation).
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"no-agent path must NOT mutate state",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_rename_session_updates_display_name_locally() {
|
||||
let mut app = test_app_with_agent();
|
||||
@@ -898,27 +220,6 @@ fn show_usage_on_welcome_screen_is_noop() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_usage_with_redirect_url_shows_link_and_skips_fetch() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.usage_billing_redirect_url = Some("https://billing.example.com/me".to_string());
|
||||
let before = agent_scrollback_len(&app);
|
||||
let effects = dispatch(Action::ShowUsage, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"with a redirect URL set, ShowUsage should not fetch (billing or auto-topup), got: {effects:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
agent_scrollback_len(&app),
|
||||
before + 1,
|
||||
"redirect path should push one system message with the billing link"
|
||||
);
|
||||
assert!(
|
||||
last_system_text(&app, AgentId(0)).contains("https://billing.example.com/me"),
|
||||
"redirect message should use the remote settings-provided URL"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Minimal update-notice tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1494,391 +1494,6 @@ fn rename_session_failed_keeps_local_display_name_and_pushes_system_block() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── GateRefreshed subscription flow ─────────────────────────────
|
||||
|
||||
/// Regression: when the 30s gate poll detects the subscription gate has
|
||||
/// been lifted, it must emit `CheckSubscription` so the shell refreshes
|
||||
/// the JWT. Without this the auth token still lacks the subscription
|
||||
/// claim and all API calls return 403.
|
||||
#[test]
|
||||
fn gate_refreshed_emits_check_subscription_on_gate_lift() {
|
||||
let mut app = test_app();
|
||||
// User starts gated (no subscription).
|
||||
app.gate = Some(kigi_shell::auth::GateInfo {
|
||||
message: "SuperGrok subscription required".into(),
|
||||
url: Some("https://grok.com/supergrok".into()),
|
||||
label: Some("Subscribe".into()),
|
||||
});
|
||||
assert!(!app.has_access());
|
||||
|
||||
// Server-side settings now show no gate (user purchased subscription).
|
||||
let settings = kigi_shell::util::config::RemoteSettings::default();
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::GateRefreshed {
|
||||
settings: Some(settings),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Gate must be lifted.
|
||||
assert!(app.has_access(), "gate should be lifted");
|
||||
assert!(app.welcome_prompt_focused, "prompt should be focused");
|
||||
|
||||
// Must emit CheckSubscription to trigger shell-side JWT refresh.
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CheckSubscription { verify: None })),
|
||||
"must emit CheckSubscription to refresh JWT; got: {effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the gate poll returns settings that still have a gate, no
|
||||
/// effects should be emitted and the user stays blocked.
|
||||
#[test]
|
||||
fn gate_refreshed_no_effect_when_still_gated() {
|
||||
let mut app = test_app();
|
||||
app.gate = Some(kigi_shell::auth::GateInfo {
|
||||
message: "Subscribe".into(),
|
||||
url: None,
|
||||
label: None,
|
||||
});
|
||||
|
||||
let settings = kigi_shell::util::config::RemoteSettings {
|
||||
gate_message: Some("Subscribe".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::GateRefreshed {
|
||||
settings: Some(settings),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!app.has_access(), "gate should remain");
|
||||
assert!(effects.is_empty(), "no effects when still gated");
|
||||
}
|
||||
|
||||
/// When the user was never gated, GateRefreshed is a no-op.
|
||||
#[test]
|
||||
fn gate_refreshed_no_effect_when_already_unblocked() {
|
||||
let mut app = test_app();
|
||||
assert!(app.has_access()); // no gate
|
||||
|
||||
let settings = kigi_shell::util::config::RemoteSettings::default();
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::GateRefreshed {
|
||||
settings: Some(settings),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(effects.is_empty(), "no effects when already unblocked");
|
||||
}
|
||||
|
||||
/// A gate newly imposed by the 30s settings poll (possibly stale) must be
|
||||
/// deferred for live verification instead of painting the paywall directly:
|
||||
/// the gate is held out of `app.gate` and a `CheckSubscription` +
|
||||
/// verify-timeout pair is emitted.
|
||||
#[test]
|
||||
fn gate_refreshed_newly_blocked_defers_gate_for_verification() {
|
||||
let mut app = test_app();
|
||||
assert!(app.has_access()); // ungated
|
||||
|
||||
let settings = kigi_shell::util::config::RemoteSettings {
|
||||
gate_message: Some("Subscribe".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::GateRefreshed {
|
||||
settings: Some(settings),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.has_access(),
|
||||
"deferred gate must not show as paywall before verification"
|
||||
);
|
||||
assert!(app.pending_gate_verification.is_some());
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CheckSubscription { verify: Some(_) })),
|
||||
"must live-check before showing the paywall; got: {effects:?}"
|
||||
);
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::ScheduleGateVerifyTimeout { .. })),
|
||||
"must arm the verification timeout; got: {effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stale-gate verification resolution ──────────────────────────
|
||||
|
||||
fn test_gate() -> kigi_shell::auth::GateInfo {
|
||||
kigi_shell::auth::GateInfo {
|
||||
message: "Subscribe".into(),
|
||||
url: None,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The live check confirmed access (meta without a gate): the deferred
|
||||
/// stale gate is dropped and the paywall never shows.
|
||||
#[test]
|
||||
fn verify_check_with_meta_resolves_pending_gate() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
assert!(app.has_access());
|
||||
|
||||
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap();
|
||||
dispatch_task_result(
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify: Some(app.gate_verify_gen),
|
||||
meta: Some(meta),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(app.has_access(), "live check says subscribed — no paywall");
|
||||
assert!(app.pending_gate_verification.is_none());
|
||||
}
|
||||
|
||||
/// The verification's own check failed (meta None) while its stale gate
|
||||
/// was deferred: err on blocking — the deferred gate is promoted.
|
||||
#[test]
|
||||
fn verify_check_failure_promotes_pending_gate() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify: Some(app.gate_verify_gen),
|
||||
meta: None,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!app.has_access(), "check failed — deferred gate must show");
|
||||
assert!(app.pending_gate_verification.is_none());
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SchedulePaywallCheck)),
|
||||
"freshly shown gate must arm the 5s auto-lift chain; got: {effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed GENERIC check (watch / focus / paywall chain — no generation)
|
||||
/// must never promote a deferred gate: only the deferral's own
|
||||
/// generation-scoped check or timeout may (a superseded or unrelated check
|
||||
/// failing is not evidence about the current verification).
|
||||
#[test]
|
||||
fn check_subscription_complete_failure_leaves_pending_gate_untouched() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify: None,
|
||||
meta: None,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
assert!(
|
||||
app.has_access(),
|
||||
"generic check failure must not promote the deferred gate"
|
||||
);
|
||||
assert!(
|
||||
app.pending_gate_verification.is_some(),
|
||||
"verification must stay in flight"
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed verification check from a SUPERSEDED deferral (older
|
||||
/// generation) must not promote the newer pending gate.
|
||||
#[test]
|
||||
fn verify_check_stale_generation_failure_is_ignored() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
let stale_gen = app.gate_verify_gen;
|
||||
// Second deferral supersedes the first (its check is in flight).
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify: Some(stale_gen),
|
||||
meta: None,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
assert!(
|
||||
app.has_access(),
|
||||
"superseded verification failure must not promote the newer gate"
|
||||
);
|
||||
assert!(app.pending_gate_verification.is_some());
|
||||
}
|
||||
|
||||
/// A check failure with no deferred gate (the plain paywall-poller path)
|
||||
/// must not invent a gate.
|
||||
#[test]
|
||||
fn check_subscription_complete_failure_without_pending_gate_is_noop() {
|
||||
let mut app = test_app();
|
||||
dispatch_task_result(
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify: None,
|
||||
meta: None,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert!(app.has_access());
|
||||
}
|
||||
|
||||
/// The verification window expired before the live check resolved:
|
||||
/// err on blocking — the deferred gate is promoted, and the freshly shown
|
||||
/// paywall gets the 5s auto-lift chain.
|
||||
#[test]
|
||||
fn gate_verify_timeout_promotes_pending_gate() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
assert!(app.has_access());
|
||||
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::GateVerifyTimeout {
|
||||
generation: app.gate_verify_gen,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!app.has_access(), "timeout — deferred gate must show");
|
||||
assert!(app.pending_gate_verification.is_none());
|
||||
assert!(
|
||||
app.paywall_check_started.is_some(),
|
||||
"promoted gate must arm the paywall auto-check chain"
|
||||
);
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SchedulePaywallCheck)),
|
||||
"promoted gate must schedule the 5s chain; got: {effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The timeout fires after the check already resolved the gate: no-op.
|
||||
#[test]
|
||||
fn gate_verify_timeout_noop_when_already_resolved() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
let generation = app.gate_verify_gen;
|
||||
// Live check resolved first (access confirmed).
|
||||
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap();
|
||||
dispatch_task_result(
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify: None,
|
||||
meta: Some(meta),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
dispatch_task_result(TaskResult::GateVerifyTimeout { generation }, &mut app);
|
||||
assert!(
|
||||
app.has_access(),
|
||||
"stale timeout must not re-impose the gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// A timeout from a SUPERSEDED verification (older generation) must not
|
||||
/// promote a newer deferred gate whose own live check is still in flight.
|
||||
#[test]
|
||||
fn gate_verify_timeout_stale_generation_is_ignored() {
|
||||
let mut app = test_app();
|
||||
// First deferral resolves (access confirmed) ...
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
let stale_gen = app.gate_verify_gen;
|
||||
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap();
|
||||
dispatch_task_result(
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify: None,
|
||||
meta: Some(meta),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
// ... then a SECOND gate is deferred (check in flight).
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
assert!(app.has_access());
|
||||
|
||||
// The FIRST deferral's timer fires now — it must not promote the
|
||||
// second deferral's pending gate.
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::GateVerifyTimeout {
|
||||
generation: stale_gen,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
assert!(
|
||||
app.has_access(),
|
||||
"stale-generation timer must not promote the newer pending gate"
|
||||
);
|
||||
assert!(
|
||||
app.pending_gate_verification.is_some(),
|
||||
"the newer verification must stay in flight"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GateRefreshed` with gate-free settings while a deferred gate awaits
|
||||
/// verification must drop the pending copy — the fresh settings are newer
|
||||
/// than the stale snapshot that produced it — and still run the lift
|
||||
/// bookkeeping (`CheckSubscription` for the JWT refresh), since the pending
|
||||
/// deferral means the user was conceptually blocked.
|
||||
#[test]
|
||||
fn gate_refreshed_without_gate_clears_pending_verification() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
let generation = app.gate_verify_gen;
|
||||
|
||||
let settings = kigi_shell::util::config::RemoteSettings::default();
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::GateRefreshed {
|
||||
settings: Some(settings),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(app.pending_gate_verification.is_none());
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CheckSubscription { verify: None })),
|
||||
"settings-confirmed lift of a pending gate must refresh the JWT; got: {effects:?}"
|
||||
);
|
||||
// The still-armed timer must find nothing to promote.
|
||||
dispatch_task_result(TaskResult::GateVerifyTimeout { generation }, &mut app);
|
||||
assert!(
|
||||
app.has_access(),
|
||||
"cleared pending gate must not resurface via the timer"
|
||||
);
|
||||
}
|
||||
|
||||
/// Logout clears any deferred gate and the check debounce.
|
||||
#[test]
|
||||
fn logout_clears_pending_gate_verification() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(test_gate());
|
||||
|
||||
dispatch_task_result(TaskResult::LogoutComplete, &mut app);
|
||||
|
||||
assert!(app.pending_gate_verification.is_none());
|
||||
assert!(app.last_subscription_check_at.is_none());
|
||||
}
|
||||
|
||||
/// `apply_setting_rollback` on a known key reverts the in-memory
|
||||
/// cache without emitting any new effects.
|
||||
#[test]
|
||||
@@ -2002,38 +1617,16 @@ fn rollback_to_always_approve_blocked_by_policy_pin() {
|
||||
assert!(!app.default_yolo);
|
||||
}
|
||||
|
||||
// -- Degraded conversations lane (SessionListLoaded.partial) ----------
|
||||
// -- SessionListLoaded ------------------------------------------------
|
||||
|
||||
/// A degraded conversations lane surfaces an actionable notice instead of
|
||||
/// the misleading "No sessions found" toast.
|
||||
/// Canary: an empty list surfaces the generic "no sessions" toast.
|
||||
#[test]
|
||||
fn session_list_partial_no_oauth_surfaces_login_hint() {
|
||||
fn session_list_empty_shows_generic_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
open_session_picker_with(&mut app, vec![]);
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![],
|
||||
partial: Some(crate::app::effects::ConversationsPartial::NoOauth),
|
||||
seq: 0,
|
||||
query: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
read_toast(&app).contains("/login"),
|
||||
"no_oauth must point at /login"
|
||||
);
|
||||
}
|
||||
|
||||
/// Canary: an empty list without a degraded lane keeps the generic toast.
|
||||
#[test]
|
||||
fn session_list_empty_without_partial_keeps_generic_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
open_session_picker_with(&mut app, vec![]);
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![],
|
||||
partial: None,
|
||||
seq: 0,
|
||||
query: None,
|
||||
}),
|
||||
@@ -2041,95 +1634,3 @@ fn session_list_empty_without_partial_keeps_generic_toast() {
|
||||
);
|
||||
assert!(read_toast(&app).contains("No sessions found"));
|
||||
}
|
||||
|
||||
/// Non-empty degraded list under chat mode (welcome-fallback branch):
|
||||
/// entries land AND the retry notice surfaces; Build mode stays silent.
|
||||
#[test]
|
||||
fn session_list_nonempty_partial_toasts_retry_in_chat_mode_only() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.chat_mode = true;
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-part-1")],
|
||||
partial: Some(crate::app::effects::ConversationsPartial::Timeout),
|
||||
seq: 0,
|
||||
query: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.session_picker_entries.is_some(),
|
||||
"entries must still land on a degraded lane"
|
||||
);
|
||||
assert!(
|
||||
read_toast(&app).contains("retry"),
|
||||
"timeout must surface the retry notice"
|
||||
);
|
||||
|
||||
// Build-mode canary: stays silent on a degraded lane.
|
||||
let mut app = test_app_with_agent();
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_picker_entry("local-part-1", "/r")],
|
||||
partial: Some(crate::app::effects::ConversationsPartial::Timeout),
|
||||
seq: 0,
|
||||
query: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].toast.is_none(),
|
||||
"Build-mode non-empty degraded list stays silent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Modal variant of the non-empty degraded-lane notice: same chat-mode-only
|
||||
/// gating as the welcome-fallback branch.
|
||||
#[test]
|
||||
fn session_list_nonempty_partial_modal_toasts_in_chat_mode_only() {
|
||||
use crate::views::modal::ActiveModal;
|
||||
let mut app = test_app_with_agent();
|
||||
app.chat_mode = true;
|
||||
open_session_picker_with(&mut app, vec![]);
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_conversation_entry("conv-part-m1")],
|
||||
partial: Some(crate::app::effects::ConversationsPartial::Timeout),
|
||||
seq: 0,
|
||||
query: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let agent = get_active_agent(&app).expect("active agent");
|
||||
assert!(
|
||||
matches!(
|
||||
agent.active_modal.as_ref(),
|
||||
Some(ActiveModal::SessionPicker {
|
||||
entries: Some(list),
|
||||
..
|
||||
}) if list.len() == 1
|
||||
),
|
||||
"entries must land in the open modal on a degraded lane"
|
||||
);
|
||||
assert!(
|
||||
read_toast(&app).contains("retry"),
|
||||
"chat-mode modal must surface the retry notice"
|
||||
);
|
||||
|
||||
// Build-mode canary: the open modal stays silent.
|
||||
let mut app = test_app_with_agent();
|
||||
open_session_picker_with(&mut app, vec![]);
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
sessions: vec![make_picker_entry("local-part-m1", "/r")],
|
||||
partial: Some(crate::app::effects::ConversationsPartial::Timeout),
|
||||
seq: 0,
|
||||
query: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].toast.is_none(),
|
||||
"Build-mode modal non-empty degraded list stays silent"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -372,8 +372,7 @@ pub(super) fn dispatch_open_extensions_modal(
|
||||
|
||||
// Mutual exclusivity: close agents modal when opening extensions.
|
||||
agent.agents_modal = None;
|
||||
let mut modal = ExtensionsModalState::new(tab);
|
||||
modal.session_team_id = app.team_id.clone();
|
||||
let modal = ExtensionsModalState::new(tab);
|
||||
agent.extensions_modal = Some(modal);
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
|
||||
@@ -43,9 +43,6 @@ pub(super) const SESSION_SEARCH_DEBOUNCE_MS: u64 = 250;
|
||||
/// All other errors are sanitized to remove internal service names and jargon.
|
||||
pub(super) fn format_acp_error(err: &acp::Error, is_api_key_auth: bool) -> String {
|
||||
if i32::from(err.code) == RATE_LIMITED_ERROR_CODE {
|
||||
if super::dispatch::acp_error_is_free_usage_exhausted(err) {
|
||||
return super::dispatch::FREE_USAGE_USER_MESSAGE.into();
|
||||
}
|
||||
return rate_limited_user_message(is_api_key_auth).into();
|
||||
}
|
||||
if err.code == acp::ErrorCode::InvalidParams && let Some(data) = &err.data
|
||||
@@ -132,8 +129,6 @@ pub(crate) fn sanitize_user_error(raw: &str) -> String {
|
||||
return "Out of disk space.".to_string();
|
||||
}
|
||||
static REPLACEMENTS: &[(&str, &str)] = &[
|
||||
("cli-chat-proxy", "server"),
|
||||
("cli_chat_proxy", "server"),
|
||||
("inference-api", "server"),
|
||||
("inference_api", "server"),
|
||||
("research-api", "server"),
|
||||
@@ -365,41 +360,6 @@ pub(super) fn count_chat_history_stats(history_path: &Path) -> (usize, usize) {
|
||||
}
|
||||
(turn_count, tool_call_count)
|
||||
}
|
||||
/// Degraded conversations lane on `x.ai/session/list`, parsed from the
|
||||
/// response's `_meta["x.ai/partial"]` envelope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConversationsPartial {
|
||||
NoOauth,
|
||||
Timeout,
|
||||
Error,
|
||||
}
|
||||
impl ConversationsPartial {
|
||||
/// Actionable picker notice for a degraded conversations lane.
|
||||
pub(crate) fn picker_notice(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoOauth => "Couldn't load your chats \u{2014} log in with /login",
|
||||
Self::Timeout | Self::Error => "Couldn't load conversations \u{2014} retry",
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Read `_meta["x.ai/partial"]` from a session-list payload. `None` when the
|
||||
/// conversations lane completed (or was skipped); unknown reasons degrade to
|
||||
/// [`ConversationsPartial::Error`].
|
||||
pub(super) fn parse_session_list_partial(
|
||||
payload: &serde_json::Value,
|
||||
) -> Option<ConversationsPartial> {
|
||||
let partial = payload.get("_meta")?.get("x.ai/partial")?;
|
||||
if partial.get("conversations").and_then(|v| v.as_bool()) != Some(true) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
match partial.get("reason").and_then(|v| v.as_str()) {
|
||||
Some("no_oauth") => ConversationsPartial::NoOauth,
|
||||
Some("timeout") => ConversationsPartial::Timeout,
|
||||
_ => ConversationsPartial::Error,
|
||||
},
|
||||
)
|
||||
}
|
||||
/// Parse the `x.ai/session/list` response payload (the unwrapped
|
||||
/// `{ "sessions": [...] }` object) into [`SessionPickerEntry`] rows.
|
||||
///
|
||||
@@ -597,73 +557,6 @@ pub(super) async fn send_logout(tx: &AcpAgentTx) {
|
||||
tracing::warn!(error = % e, "logout failed");
|
||||
}
|
||||
}
|
||||
pub(super) async fn send_check_subscription(
|
||||
tx: &AcpAgentTx,
|
||||
verify: Option<u64>,
|
||||
) -> TaskResult {
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/auth/check_subscription",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize check_subscription params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(req, tx).await {
|
||||
Ok(resp) => {
|
||||
let meta = serde_json::from_str::<serde_json::Value>(resp.0.get())
|
||||
.ok()
|
||||
.and_then(|v| v.get("meta").cloned());
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = % e, "check_subscription failed");
|
||||
crate::unified_log::warn(
|
||||
"subscription.check.rpc_failed",
|
||||
None,
|
||||
Some(serde_json::json!({ "verify" : verify, "error" : e.to_string(), })),
|
||||
);
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One-shot subscription re-check for the credit-limit retry flow.
|
||||
/// Same ACP call as `send_check_subscription` but returns a
|
||||
/// `CreditLimitRecheckComplete` so the dispatch layer can decide
|
||||
/// whether to retry the stashed prompt or show the upsell.
|
||||
pub(super) async fn send_credit_limit_recheck(
|
||||
tx: &AcpAgentTx,
|
||||
agent_id: AgentId,
|
||||
) -> TaskResult {
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/auth/check_subscription",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize check_subscription params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(req, tx).await {
|
||||
Ok(resp) => {
|
||||
let meta = serde_json::from_str::<serde_json::Value>(resp.0.get())
|
||||
.ok()
|
||||
.and_then(|v| v.get("meta").cloned());
|
||||
TaskResult::CreditLimitRecheckComplete {
|
||||
agent_id,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = % e, "credit_limit_recheck failed");
|
||||
TaskResult::CreditLimitRecheckComplete {
|
||||
agent_id,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) async fn send_authenticate(
|
||||
tx: &AcpAgentTx,
|
||||
request_seq: u64,
|
||||
@@ -1199,125 +1092,16 @@ pub(super) fn persist_hint(
|
||||
TaskResult::CancelComplete
|
||||
});
|
||||
}
|
||||
/// Map a billing config into a [`CreditBalance`].
|
||||
///
|
||||
/// Prefers the newer credits-config fields (`credit_usage_percent`,
|
||||
/// `current_period`) and falls back to the deprecated
|
||||
/// `monthly_limit`/`used`/`billing_period_end`. Shared by `Effect::FetchBilling`
|
||||
/// and `Effect::FetchAppBilling` so every pager UI path derives identical usage
|
||||
/// values from the same config.
|
||||
pub(super) fn credit_balance_from_config(
|
||||
c: kigi_shell::extensions::billing::BillingConfig,
|
||||
) -> crate::views::credit_bar::CreditBalance {
|
||||
let limit = c.monthly_limit.map(|v| v.val).unwrap_or(0);
|
||||
let used = c.used.map(|v| v.val).unwrap_or(0);
|
||||
let has_credit_pct = c.credit_usage_percent.is_some();
|
||||
let usage_pct = match c.credit_usage_percent {
|
||||
Some(pct) => pct.clamp(0.0, 100.0),
|
||||
None if limit > 0 => (used as f64 / limit as f64 * 100.0).min(100.0),
|
||||
None => 0.0,
|
||||
};
|
||||
let period_end_display = c
|
||||
.current_period
|
||||
.as_ref()
|
||||
.and_then(|p| p.end.clone())
|
||||
.or(c.billing_period_end)
|
||||
.and_then(|s| {
|
||||
chrono::DateTime::parse_from_rfc3339(&s)
|
||||
.ok()
|
||||
.map(|dt| {
|
||||
dt.with_timezone(&chrono::Local).format("%B %-d, %H:%M").to_string()
|
||||
})
|
||||
});
|
||||
let on_demand_val = c.on_demand_cap.map(|v| v.val).unwrap_or(0);
|
||||
let pay_as_you_go = on_demand_val > 0;
|
||||
let on_demand_cap_cents = if on_demand_val > 0 { Some(on_demand_val) } else { None };
|
||||
let on_demand_used_cents = c
|
||||
.on_demand_used
|
||||
.map(|v| v.val)
|
||||
.unwrap_or_else(|| (used - limit).max(0));
|
||||
let effective_usage_pct = if on_demand_val > 0 {
|
||||
if usage_pct >= 100.0 {
|
||||
(on_demand_used_cents as f64 / on_demand_val as f64 * 100.0).min(100.0)
|
||||
} else if has_credit_pct {
|
||||
usage_pct
|
||||
} else {
|
||||
let total_budget = limit + on_demand_val;
|
||||
if total_budget > 0 {
|
||||
(used as f64 / total_budget as f64 * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
usage_pct
|
||||
};
|
||||
let period_type = c.current_period.as_ref().and_then(|p| p.period_type.clone());
|
||||
crate::views::credit_bar::CreditBalance {
|
||||
usage_pct,
|
||||
effective_usage_pct,
|
||||
period_end_display,
|
||||
pay_as_you_go,
|
||||
on_demand_cap_cents,
|
||||
on_demand_used_cents: Some(on_demand_used_cents),
|
||||
prepaid_balance_cents: c.prepaid_balance.map(|v| v.val),
|
||||
period_type,
|
||||
is_unified_billing_user: c.is_unified_billing_user,
|
||||
}
|
||||
}
|
||||
/// Whether the balance carries a non-zero prepaid credit balance (signed cents).
|
||||
pub(super) fn has_prepaid_credits(
|
||||
balance: Option<&crate::views::credit_bar::CreditBalance>,
|
||||
) -> bool {
|
||||
balance.and_then(|b| b.prepaid_balance_cents).map(i64::abs).is_some_and(|c| c > 0)
|
||||
}
|
||||
/// Fetch the user's auto top-up rule via the `x.ai/auto-topup-rule` extension.
|
||||
/// A transport failure yields [`AutoTopupFetch::Unchanged`] so the caller keeps
|
||||
/// any cached rule rather than treating the blip as "no auto top-up".
|
||||
pub(super) async fn fetch_auto_topup_info(
|
||||
tx: &kigi_acp_lib::AcpAgentTx,
|
||||
) -> crate::views::credit_bar::AutoTopupFetch {
|
||||
use crate::views::credit_bar::AutoTopupFetch;
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/auto-topup-rule",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize auto-topup params")
|
||||
.into(),
|
||||
);
|
||||
let Ok(resp) = acp_send(req, tx).await else {
|
||||
return AutoTopupFetch::Unchanged;
|
||||
};
|
||||
let wrapper: serde_json::Value = serde_json::from_str(resp.0.get())
|
||||
.unwrap_or_default();
|
||||
let result = wrapper.get("result").unwrap_or(&wrapper);
|
||||
parse_auto_topup_response(result)
|
||||
}
|
||||
/// Map an `x.ai/auto-topup-rule` payload to an [`AutoTopupFetch`]. A body that
|
||||
/// fails to deserialize is a fetch error (→ `Unchanged`, keep the cached rule),
|
||||
/// not a definitive "no rule", so a malformed response can't silently flip the
|
||||
/// credits warning.
|
||||
pub(super) fn parse_auto_topup_response(
|
||||
/// Parse an `x.ai/billing` ext response body (the unwrapped `result`
|
||||
/// payload) into Kimi usage rows. A body that fails to deserialize is an
|
||||
/// error, not an empty quota list, so a malformed response can't render
|
||||
/// as "no usage data".
|
||||
pub(super) fn parse_usage_response(
|
||||
result: &serde_json::Value,
|
||||
) -> crate::views::credit_bar::AutoTopupFetch {
|
||||
use crate::views::credit_bar::{AutoTopupFetch, AutoTopupInfo};
|
||||
use kigi_shell::extensions::billing::GetAutoTopupRuleResponse;
|
||||
match serde_json::from_value::<GetAutoTopupRuleResponse>(result.clone()) {
|
||||
Ok(parsed) => {
|
||||
AutoTopupFetch::Resolved(
|
||||
parsed
|
||||
.rule
|
||||
.map_or_else(
|
||||
AutoTopupInfo::disabled,
|
||||
|rule| AutoTopupInfo {
|
||||
enabled: rule.enabled,
|
||||
topup_amount_cents: rule.topup_amount.map(|c| c.val),
|
||||
max_amount_cents: rule.max_amount_per_month.map(|c| c.val),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
Err(_) => AutoTopupFetch::Unchanged,
|
||||
}
|
||||
) -> Result<Vec<kigi_shell::extensions::billing::UsageRow>, String> {
|
||||
serde_json::from_value::<kigi_shell::extensions::billing::UsageResponse>(result.clone())
|
||||
.map(|usage| usage.rows)
|
||||
.map_err(|e| format!("Parse error: {e}"))
|
||||
}
|
||||
/// A blocking flock on the shared, possibly-network `~/.kigi` lock must never
|
||||
/// stall the event-loop thread (and would hang exit on `/quit`); the registry
|
||||
|
||||
@@ -9,7 +9,6 @@ mod helpers;
|
||||
use super::actions;
|
||||
#[allow(unused_imports)]
|
||||
use super::{agent, dispatch};
|
||||
pub use helpers::ConversationsPartial;
|
||||
pub(super) use helpers::parse_session_load_running_prompt_id;
|
||||
pub(crate) use helpers::{
|
||||
EffectMeta, RestoreProgressMsg, SessionFlags, persist_permission_mode_and_notify,
|
||||
@@ -79,31 +78,6 @@ pub(crate) fn execute(
|
||||
TaskResult::LogoutComplete
|
||||
});
|
||||
}
|
||||
Effect::CheckSubscription { verify } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks.spawn(async move { send_check_subscription(&tx, verify).await });
|
||||
}
|
||||
Effect::CreditLimitRecheck { agent_id } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks.spawn(async move { send_credit_limit_recheck(&tx, agent_id).await });
|
||||
}
|
||||
Effect::SchedulePaywallCheck => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
TaskResult::PaywallCheckTick
|
||||
});
|
||||
}
|
||||
Effect::ScheduleGateVerifyTimeout { generation } => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
tokio::time::sleep(crate::app::subscription::GATE_VERIFY_TIMEOUT)
|
||||
.await;
|
||||
TaskResult::GateVerifyTimeout {
|
||||
generation,
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::SwitchAccount { request_seq, method_id, use_oauth } => {
|
||||
let tx = acp_tx.clone();
|
||||
let abort_handle = tasks
|
||||
@@ -717,10 +691,8 @@ pub(crate) fn execute(
|
||||
}
|
||||
let payload = wrapper.get("result").unwrap_or(&wrapper);
|
||||
let sessions = parse_session_picker_entries(payload);
|
||||
let partial = parse_session_list_partial(payload);
|
||||
TaskResult::SessionListLoaded {
|
||||
sessions,
|
||||
partial,
|
||||
seq,
|
||||
query,
|
||||
}
|
||||
@@ -2414,66 +2386,6 @@ pub(crate) fn execute(
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::ShareSession { agent_id, session_id } => {
|
||||
use kigi_shell::session::{ShareSessionRequest, ShareSessionResponse};
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let request = acp::ExtRequest::new(
|
||||
"x.ai/share_session",
|
||||
serde_json::value::to_raw_value(
|
||||
&ShareSessionRequest {
|
||||
session_id: session_id.0.to_string(),
|
||||
},
|
||||
)
|
||||
.expect("serialize share session params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(request, &tx).await {
|
||||
Ok(resp) => {
|
||||
let wrapper: serde_json::Value = serde_json::from_str(
|
||||
resp.0.get(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
if let Some(err) = wrapper.get("error") {
|
||||
let msg = err
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| "unknown error".to_string());
|
||||
return TaskResult::ShareSessionFailed {
|
||||
agent_id,
|
||||
error: msg,
|
||||
};
|
||||
}
|
||||
let inner = wrapper.get("result").unwrap_or(&wrapper);
|
||||
match serde_json::from_value::<
|
||||
ShareSessionResponse,
|
||||
>(inner.clone()) {
|
||||
Ok(share_resp) => {
|
||||
TaskResult::ShareSessionComplete {
|
||||
agent_id,
|
||||
share_url: share_resp.share_url,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
TaskResult::ShareSessionFailed {
|
||||
agent_id,
|
||||
error: "couldn't share session".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
TaskResult::ShareSessionFailed {
|
||||
agent_id,
|
||||
error: sanitize_user_error(
|
||||
&format!("couldn't share session: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::FetchSessionAgentName { agent_id, session_id } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
@@ -2638,68 +2550,6 @@ pub(crate) fn execute(
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::SetCodingDataSharing { agent_id, opted_in, rollback_to_opted_in } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let request = acp::ExtRequest::new(
|
||||
"x.ai/privacy/setCodingDataRetention",
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!(
|
||||
{ "codingDataRetentionOptOut" : ! opted_in }
|
||||
),
|
||||
)
|
||||
.expect("serialize params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(request, &tx).await {
|
||||
Ok(resp) => {
|
||||
let wrapper: serde_json::Value = match serde_json::from_str(
|
||||
resp.0.get(),
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return TaskResult::CodingDataSharingFailed {
|
||||
agent_id,
|
||||
error: format!("malformed response: {e}"),
|
||||
rollback_to_opted_in,
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Some(err) = wrapper
|
||||
.get("error")
|
||||
.filter(|v| !v.is_null())
|
||||
{
|
||||
let msg = err
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| err.to_string());
|
||||
return TaskResult::CodingDataSharingFailed {
|
||||
agent_id,
|
||||
error: msg,
|
||||
rollback_to_opted_in,
|
||||
};
|
||||
}
|
||||
let confirmed_opted_in = wrapper
|
||||
.get("codingDataRetentionOptOut")
|
||||
.and_then(|v| v.as_bool())
|
||||
.map(|opt_out| !opt_out)
|
||||
.unwrap_or(opted_in);
|
||||
TaskResult::CodingDataSharingUpdated {
|
||||
agent_id,
|
||||
opted_in: confirmed_opted_in,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
TaskResult::CodingDataSharingFailed {
|
||||
agent_id,
|
||||
error: format!("{e}"),
|
||||
rollback_to_opted_in,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::ShowContextInfo { agent_id, session_id } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
@@ -3453,150 +3303,30 @@ pub(crate) fn execute(
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::FetchBilling { agent_id, silent } => {
|
||||
Effect::FetchUsage { agent_id } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
use kigi_shell::extensions::billing::BillingConfigResponse;
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/billing",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize billing params")
|
||||
.expect("serialize usage params")
|
||||
.into(),
|
||||
);
|
||||
let parsed = match acp_send(req, &tx).await {
|
||||
let result = match acp_send(req, &tx).await {
|
||||
Ok(resp) => {
|
||||
let wrapper: serde_json::Value = serde_json::from_str(
|
||||
resp.0.get(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let result = wrapper.get("result").unwrap_or(&wrapper);
|
||||
serde_json::from_value::<
|
||||
BillingConfigResponse,
|
||||
>(result.clone())
|
||||
}
|
||||
Err(e) => {
|
||||
return TaskResult::BillingError {
|
||||
agent_id,
|
||||
error: sanitize_user_error(&format!("{e}")),
|
||||
silent,
|
||||
};
|
||||
let payload = wrapper.get("result").unwrap_or(&wrapper);
|
||||
parse_usage_response(payload)
|
||||
}
|
||||
Err(e) => Err(sanitize_user_error(&format!("{e}"))),
|
||||
};
|
||||
let billing = match parsed {
|
||||
Ok(billing) => billing,
|
||||
Err(e) => {
|
||||
return TaskResult::BillingError {
|
||||
agent_id,
|
||||
error: format!("Parse error: {e}"),
|
||||
silent,
|
||||
};
|
||||
}
|
||||
};
|
||||
let subscription_tier = billing.subscription_tier;
|
||||
let balance = billing.config.map(credit_balance_from_config);
|
||||
let autotopup = if has_prepaid_credits(balance.as_ref()) {
|
||||
fetch_auto_topup_info(&tx).await
|
||||
} else {
|
||||
crate::views::credit_bar::AutoTopupFetch::Cleared
|
||||
};
|
||||
TaskResult::BillingFetched {
|
||||
TaskResult::UsageFetched {
|
||||
agent_id,
|
||||
balance,
|
||||
silent,
|
||||
subscription_tier,
|
||||
autotopup,
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::RefreshGate => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let settings = tokio::task::spawn_blocking(|| {
|
||||
if !kigi_shell::util::config::resolve_remote_fetch_enabled() {
|
||||
return None;
|
||||
}
|
||||
let kigi_home = kigi_shell::util::kigi_home::kigi_home();
|
||||
let store = kigi_shell::auth::read_auth_json(
|
||||
&kigi_home.join("auth.json"),
|
||||
)
|
||||
.ok()?;
|
||||
let scope = kigi_shell::auth::KimiCodeConfig::default()
|
||||
.auth_scope();
|
||||
let auth = kigi_shell::auth::lookup_auth(
|
||||
&store,
|
||||
&scope,
|
||||
)?;
|
||||
let proxy_base = std::env::var(
|
||||
"KIGI_CLI_CHAT_PROXY_BASE_URL",
|
||||
)
|
||||
.unwrap_or_else(|_| kigi_env::coding_api_base_url());
|
||||
kigi_shell::remote::fetch_settings_blocking(
|
||||
&proxy_base,
|
||||
&auth,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
TaskResult::GateRefreshed {
|
||||
settings,
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::FetchAppBilling => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
use kigi_shell::extensions::billing::BillingConfigResponse;
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/billing",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize billing params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(req, &tx).await {
|
||||
Ok(resp) => {
|
||||
let wrapper: serde_json::Value = serde_json::from_str(
|
||||
resp.0.get(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let result = wrapper.get("result").unwrap_or(&wrapper);
|
||||
match serde_json::from_value::<
|
||||
BillingConfigResponse,
|
||||
>(result.clone()) {
|
||||
Ok(billing) => {
|
||||
let balance = billing
|
||||
.config
|
||||
.map(|c| crate::views::credit_bar::CreditBalance {
|
||||
period_end_display: None,
|
||||
..credit_balance_from_config(c)
|
||||
});
|
||||
let autotopup = if has_prepaid_credits(balance.as_ref()) {
|
||||
fetch_auto_topup_info(&tx).await
|
||||
} else {
|
||||
crate::views::credit_bar::AutoTopupFetch::Cleared
|
||||
};
|
||||
TaskResult::AppBillingFetched {
|
||||
balance,
|
||||
autotopup,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
TaskResult::AppBillingFetched {
|
||||
balance: None,
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch::Unchanged,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
TaskResult::AppBillingFetched {
|
||||
balance: None,
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch::Unchanged,
|
||||
}
|
||||
}
|
||||
result,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
use kigi_shell::extensions::billing::{BillingConfig, Cent, UsagePeriod};
|
||||
/// The invalid-params server detail survives `attach_prompt_usage`
|
||||
/// wrapping `error.data` as `{message, promptUsage}`.
|
||||
#[test]
|
||||
@@ -23,7 +22,7 @@ fn format_acp_error_rate_limit_is_auth_aware() {
|
||||
RATE_LIMITED_USER_MESSAGE_OAUTH,
|
||||
};
|
||||
let err = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited").data("slow down");
|
||||
assert_eq!(format_acp_error(& err, false), RATE_LIMITED_USER_MESSAGE_OAUTH);
|
||||
assert_eq!(format_acp_error(&err, false), RATE_LIMITED_USER_MESSAGE_OAUTH.as_str());
|
||||
assert_eq!(format_acp_error(& err, true), RATE_LIMITED_USER_MESSAGE_API_KEY);
|
||||
}
|
||||
/// Non-empty token ranges ride the wire block meta as `skillTokenRanges`
|
||||
@@ -138,43 +137,6 @@ fn picker_still_drops_build_row_with_empty_summary() {
|
||||
assert!(entries.is_empty(), "empty-summary Build rows stay dropped");
|
||||
}
|
||||
#[test]
|
||||
fn session_list_partial_parses_reasons() {
|
||||
let payload = |reason: &str| {
|
||||
serde_json::json!(
|
||||
{ "sessions" : [], "_meta" : { "x.ai/partial" : { "conversations" : true,
|
||||
"reason" : reason } } }
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
parse_session_list_partial(& payload("no_oauth")),
|
||||
Some(ConversationsPartial::NoOauth)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_list_partial(& payload("timeout")),
|
||||
Some(ConversationsPartial::Timeout)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_list_partial(& payload("error")), Some(ConversationsPartial::Error)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_list_partial(& payload("something_new")),
|
||||
Some(ConversationsPartial::Error)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn session_list_partial_absent_for_healthy_or_meta_less_responses() {
|
||||
let healthy = serde_json::json!(
|
||||
{ "sessions" : [], "_meta" : { "x.ai/partial" : { "conversations" : false } } }
|
||||
);
|
||||
assert_eq!(parse_session_list_partial(& healthy), None);
|
||||
let legacy = serde_json::json!({ "sessions" : [] });
|
||||
assert_eq!(parse_session_list_partial(& legacy), None);
|
||||
}
|
||||
/// The agent serializes `ExtMethodResult<KillTaskResponse>`: the outcome
|
||||
/// lives at `result.outcome`. Probing the top level (the pre-fix code)
|
||||
/// was why the tasks-pane ✗ never removed stale (`not_found`) rows after
|
||||
/// a session resume.
|
||||
#[test]
|
||||
fn parse_kill_outcome_reads_result_envelope() {
|
||||
use kigi_tools::types::KillOutcome;
|
||||
let resp = r#"{"result":{"taskId":"t-1","outcome":"not_found"}}"#;
|
||||
@@ -315,250 +277,61 @@ fn interject_params_carry_content_when_blocks_present() {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0] ["text"], "look at [Image #1]");
|
||||
}
|
||||
/// A billing config with every field unset, for use as a base in
|
||||
/// `credit_balance_from_config` tests via struct-update syntax.
|
||||
fn empty_billing_config() -> BillingConfig {
|
||||
BillingConfig {
|
||||
credit_usage_percent: None,
|
||||
current_period: None,
|
||||
monthly_limit: None,
|
||||
used: None,
|
||||
on_demand_cap: None,
|
||||
on_demand_used: None,
|
||||
prepaid_balance: None,
|
||||
is_unified_billing_user: None,
|
||||
billing_period_start: None,
|
||||
billing_period_end: None,
|
||||
history: vec![],
|
||||
}
|
||||
}
|
||||
/// `x.ai/billing` ext result (a serialized shell `UsageResponse`) parses
|
||||
/// into typed rows; unknown labels/reset hints survive the round trip.
|
||||
#[test]
|
||||
fn credit_balance_prefers_credit_usage_percent_over_limit_used() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(42.0),
|
||||
monthly_limit: Some(Cent { val: 10_000 }),
|
||||
used: Some(Cent { val: 9_000 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).usage_pct, 42.0);
|
||||
fn parse_usage_response_reads_rows_from_fixture() {
|
||||
let fixture = serde_json::json!({
|
||||
"rows": [
|
||||
{
|
||||
"label": "Weekly limit",
|
||||
"used": 250,
|
||||
"limit": 1000,
|
||||
"resetHint": "resets in 2d 1h"
|
||||
},
|
||||
{ "label": "5h limit", "used": 20, "limit": 50 }
|
||||
]
|
||||
});
|
||||
let rows = parse_usage_response(&fixture).expect("fixture must parse");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].label, "Weekly limit");
|
||||
assert_eq!(rows[0].used, 250);
|
||||
assert_eq!(rows[0].limit, 1000);
|
||||
assert_eq!(rows[0].reset_hint.as_deref(), Some("resets in 2d 1h"));
|
||||
assert_eq!(rows[1].label, "5h limit");
|
||||
assert!(rows[1].reset_hint.is_none());
|
||||
}
|
||||
/// Round-trip through the shell's own serializer: what the billing
|
||||
/// extension emits must parse back to the same typed rows.
|
||||
#[test]
|
||||
fn credit_balance_forwards_is_unified_billing_user() {
|
||||
let c = BillingConfig {
|
||||
is_unified_billing_user: Some(true),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).is_unified_billing_user, Some(true));
|
||||
assert_eq!(
|
||||
credit_balance_from_config(empty_billing_config()).is_unified_billing_user, None
|
||||
);
|
||||
fn parse_usage_response_round_trips_shell_serialization() {
|
||||
use kigi_shell::extensions::billing::{UsageResponse, UsageRow};
|
||||
let wire = serde_json::to_value(&UsageResponse {
|
||||
rows: vec![UsageRow {
|
||||
label: "RPM".into(),
|
||||
used: 12,
|
||||
limit: 60,
|
||||
reset_hint: None,
|
||||
}],
|
||||
})
|
||||
.expect("serialize");
|
||||
let rows = parse_usage_response(&wire).expect("round trip");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].label, "RPM");
|
||||
assert_eq!(rows[0].used, 12);
|
||||
assert_eq!(rows[0].limit, 60);
|
||||
}
|
||||
/// Empty rows parse to an empty list (→ "No usage data available." in the
|
||||
/// dispatch layer); a malformed body is an error, not an empty quota list.
|
||||
#[test]
|
||||
fn credit_balance_falls_back_to_limit_used_when_percent_absent() {
|
||||
let c = BillingConfig {
|
||||
monthly_limit: Some(Cent { val: 10_000 }),
|
||||
used: Some(Cent { val: 2_500 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).usage_pct, 25.0);
|
||||
}
|
||||
/// Match production: RFC 3339 → user's local wall-clock (no zone label).
|
||||
fn expected_period_end_display(rfc3339: &str) -> String {
|
||||
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
||||
.expect("test fixture is valid RFC 3339")
|
||||
.with_timezone(&chrono::Local)
|
||||
.format("%B %-d, %H:%M")
|
||||
.to_string()
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_prefers_current_period_end_over_billing_period_end() {
|
||||
let end = "2026-06-08T20:00:00Z";
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(10.0),
|
||||
current_period: Some(UsagePeriod {
|
||||
period_type: Some("USAGE_PERIOD_TYPE_WEEKLY".into()),
|
||||
start: Some("2026-06-01T00:00:00Z".into()),
|
||||
end: Some(end.into()),
|
||||
}),
|
||||
billing_period_end: Some("2026-07-01T20:00:00Z".into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(
|
||||
credit_balance_from_config(c).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(end).as_str())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_period_end_uses_local_timezone() {
|
||||
let winter = "2026-01-15T20:00:00Z";
|
||||
let summer = "2026-07-15T20:00:00Z";
|
||||
let winter_cfg = BillingConfig {
|
||||
billing_period_end: Some(winter.into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let summer_cfg = BillingConfig {
|
||||
billing_period_end: Some(summer.into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(
|
||||
credit_balance_from_config(winter_cfg).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(winter).as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
credit_balance_from_config(summer_cfg).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(summer).as_str())
|
||||
);
|
||||
assert_ne!(expected_period_end_display(winter), expected_period_end_display(summer));
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_falls_back_to_billing_period_end() {
|
||||
let end = "2026-07-01T20:00:00Z";
|
||||
let c = BillingConfig {
|
||||
billing_period_end: Some(end.into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(
|
||||
credit_balance_from_config(c).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(end).as_str())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_period_end_falls_back_when_current_period_has_no_end() {
|
||||
let end = "2026-07-01T20:00:00Z";
|
||||
let c = BillingConfig {
|
||||
current_period: Some(UsagePeriod {
|
||||
period_type: None,
|
||||
start: Some("2026-06-01T00:00:00Z".into()),
|
||||
end: None,
|
||||
}),
|
||||
billing_period_end: Some(end.into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(
|
||||
credit_balance_from_config(c).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(end).as_str())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_period_end_none_when_unavailable() {
|
||||
fn parse_usage_response_empty_and_malformed() {
|
||||
assert!(
|
||||
credit_balance_from_config(empty_billing_config()).period_end_display.is_none()
|
||||
parse_usage_response(&serde_json::json!({ "rows": [] }))
|
||||
.expect("empty rows parse")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_clamps_new_percent_above_100() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(150.0),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).usage_pct, 100.0);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_clamps_legacy_used_above_limit() {
|
||||
let c = BillingConfig {
|
||||
monthly_limit: Some(Cent { val: 1_000 }),
|
||||
used: Some(Cent { val: 2_500 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).usage_pct, 100.0);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_effective_equals_usage_when_no_on_demand() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(40.0),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let bal = credit_balance_from_config(c);
|
||||
assert!(! bal.pay_as_you_go);
|
||||
assert_eq!(bal.on_demand_cap_cents, None);
|
||||
assert_eq!(bal.effective_usage_pct, 40.0);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_effective_uses_on_demand_ratio_when_included_exhausted() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(100.0),
|
||||
on_demand_cap: Some(Cent { val: 5_000 }),
|
||||
on_demand_used: Some(Cent { val: 1_000 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let bal = credit_balance_from_config(c);
|
||||
assert!(bal.pay_as_you_go);
|
||||
assert_eq!(bal.usage_pct, 100.0);
|
||||
assert_eq!(bal.effective_usage_pct, 20.0);
|
||||
assert_eq!(bal.on_demand_cap_cents, Some(5_000));
|
||||
assert_eq!(bal.on_demand_used_cents, Some(1_000));
|
||||
}
|
||||
#[test]
|
||||
fn parse_auto_topup_present_rule_resolves() {
|
||||
let v = serde_json::json!(
|
||||
{ "rule" : { "enabled" : true, "topupAmount" : { "val" : 2000 },
|
||||
"maxAmountPerMonth" : { "val" : 10000 } } }
|
||||
);
|
||||
match parse_auto_topup_response(&v) {
|
||||
crate::views::credit_bar::AutoTopupFetch::Resolved(at) => {
|
||||
assert!(at.enabled);
|
||||
assert_eq!(at.topup_amount_cents, Some(2000));
|
||||
assert_eq!(at.max_amount_cents, Some(10000));
|
||||
}
|
||||
other => panic!("expected Resolved, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_auto_topup_empty_body_resolves_to_disabled() {
|
||||
for v in [serde_json::json!({}), serde_json::json!({ "rule" : null })] {
|
||||
match parse_auto_topup_response(&v) {
|
||||
crate::views::credit_bar::AutoTopupFetch::Resolved(at) => {
|
||||
assert!(! at.enabled);
|
||||
}
|
||||
other => panic!("expected Resolved(disabled), got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_auto_topup_rule_without_enabled_is_disabled() {
|
||||
let v = serde_json::json!({ "rule" : { "topupAmount" : { "val" : 500 } } });
|
||||
match parse_auto_topup_response(&v) {
|
||||
crate::views::credit_bar::AutoTopupFetch::Resolved(at) => {
|
||||
assert!(! at.enabled);
|
||||
assert_eq!(at.topup_amount_cents, Some(500));
|
||||
}
|
||||
other => panic!("expected Resolved(disabled), got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_auto_topup_malformed_body_is_unchanged() {
|
||||
for v in [serde_json::json!(null), serde_json::json!(42)] {
|
||||
match parse_auto_topup_response(&v) {
|
||||
crate::views::credit_bar::AutoTopupFetch::Unchanged => {}
|
||||
other => panic!("expected Unchanged, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_effective_tracks_included_for_new_shape_under_100() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(95.0),
|
||||
on_demand_cap: Some(Cent { val: 5_000 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let bal = credit_balance_from_config(c);
|
||||
assert!(bal.pay_as_you_go);
|
||||
assert_eq!(bal.effective_usage_pct, 95.0);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_effective_blends_budget_for_legacy_shape_under_100() {
|
||||
let c = BillingConfig {
|
||||
monthly_limit: Some(Cent { val: 10_000 }),
|
||||
used: Some(Cent { val: 5_000 }),
|
||||
on_demand_cap: Some(Cent { val: 10_000 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let bal = credit_balance_from_config(c);
|
||||
assert!(bal.pay_as_you_go);
|
||||
assert_eq!(bal.usage_pct, 50.0);
|
||||
assert_eq!(bal.effective_usage_pct, 25.0);
|
||||
assert!(parse_usage_response(&serde_json::json!({ "bogus": 1 })).is_err());
|
||||
assert!(parse_usage_response(&serde_json::json!(null)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn parse_worktree_restore_payload_full() {
|
||||
|
||||
@@ -595,10 +595,6 @@ pub(crate) async fn run(
|
||||
.as_ref()
|
||||
.and_then(|s| s.show_resolved_model)
|
||||
.unwrap_or(true);
|
||||
app.sharing_enabled = remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.sharing_enabled)
|
||||
.unwrap_or(false);
|
||||
app.session_picker_grouped = std::env::var("KIGI_SESSION_PICKER_GROUPED")
|
||||
.ok()
|
||||
.and_then(|v| match v.as_str() {
|
||||
@@ -681,7 +677,7 @@ pub(crate) async fn run(
|
||||
// else: auth_state defaults to Done (already authenticated eagerly)
|
||||
// Effects stashed until after the initial render, so the user sees the
|
||||
// welcome/auth UI right away.
|
||||
let mut post_render_effects = if needs_interactive_login {
|
||||
let post_render_effects = if needs_interactive_login {
|
||||
if connection.auth_methods.is_empty() {
|
||||
app.auth_state = super::app_view::AuthState::Pending {
|
||||
error: Some("No login method available".to_string()),
|
||||
@@ -711,21 +707,6 @@ pub(crate) async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: prefetch may have gate info the shell's AuthMeta missed.
|
||||
// Errs on the side of blocking if stale.
|
||||
if app.gate.is_none()
|
||||
&& let Some(rs) = remote_settings.as_ref()
|
||||
{
|
||||
app.gate = AppView::gate_from_settings(rs);
|
||||
}
|
||||
|
||||
// Re-impose the startup gate through the chokepoint: cached auth meta
|
||||
// and the settings prefetch are both possibly stale, so a consumer
|
||||
// session's gate is deferred for live verification before first paint.
|
||||
if let Some(gate) = app.gate.take() {
|
||||
post_render_effects.extend(app.impose_gate(gate));
|
||||
}
|
||||
|
||||
// Load config layers once, resolve tips and feature flags.
|
||||
let requirements = kigi_shell::config::load_merged_requirements();
|
||||
let user_config = kigi_shell::config::load_from_disk().ok();
|
||||
@@ -756,17 +737,6 @@ pub(crate) async fn run(
|
||||
);
|
||||
}
|
||||
|
||||
app.zdr_access_enabled = kigi_shell::util::config::resolve_zdr_access_enabled(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
remote_settings.as_ref(),
|
||||
);
|
||||
|
||||
app.subscription_watch_interval_secs = remote_settings
|
||||
.as_ref()
|
||||
.and_then(|rs| rs.subscription_watch_interval_secs);
|
||||
|
||||
// Full layered resolve (env/requirements/remote may beat plain `[ui]`).
|
||||
crate::appearance::cache::set_show_thinking_blocks(
|
||||
kigi_shell::util::config::resolve_show_thinking_blocks(
|
||||
@@ -796,14 +766,6 @@ pub(crate) async fn run(
|
||||
.value,
|
||||
);
|
||||
|
||||
app.usage_billing_redirect_url = remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.usage_billing_redirect_url.clone());
|
||||
|
||||
if app.is_access_blocked() {
|
||||
app.welcome_prompt_focused = false;
|
||||
}
|
||||
|
||||
{
|
||||
use kigi_shell::util::config::resolve_tips;
|
||||
|
||||
@@ -1124,20 +1086,6 @@ pub(crate) async fn run(
|
||||
// iteration so it is popped on every close path.
|
||||
let mut gboom_keyboard_pushed = false;
|
||||
|
||||
const BILLING_POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||||
let mut billing_poll_at: Option<Instant> = None;
|
||||
|
||||
const GATE_POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||||
let mut gate_poll_at: Option<Instant> = None;
|
||||
|
||||
// Free→paid subscription watch (see `app::subscription`).
|
||||
let mut subscription_watch_at: Option<Instant> = if app.subscription_watch_wanted() {
|
||||
app.subscription_watch_interval()
|
||||
.map(|iv| Instant::now() + iv)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Leader-mode roster poll (FleetView dashboard). Only fires while the
|
||||
// dashboard is open AND we're connected via a leader. Armed to fire
|
||||
// immediately at loop start so an already-open dashboard refreshes
|
||||
@@ -1168,22 +1116,12 @@ pub(crate) async fn run(
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
return Ok(make_run_result(&app));
|
||||
}
|
||||
// Fetch billing early so the welcome screen can show a credit warning.
|
||||
if app.usage_visible {
|
||||
let effs = vec![super::actions::Effect::FetchAppBilling];
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
return Ok(make_run_result(&app));
|
||||
}
|
||||
}
|
||||
// Fetch changelog off the render path so the welcome screen
|
||||
// can display bullets and /release-notes uses the cached result.
|
||||
let effs = vec![super::actions::Effect::FetchChangelog];
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
return Ok(make_run_result(&app));
|
||||
}
|
||||
if !app.has_access() {
|
||||
gate_poll_at = Some(Instant::now() + GATE_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
if !post_render_effects.is_empty()
|
||||
@@ -1276,16 +1214,14 @@ pub(crate) async fn run(
|
||||
app.draw(terminal);
|
||||
}
|
||||
|
||||
// Initial prompt from the CLI positional (`grok "fix the bug"`). When
|
||||
// Initial prompt from the CLI positional (`kigi "fix the bug"`). When
|
||||
// already authenticated, hand it to the shared dispatcher helper (same
|
||||
// `NewSession`/`SendPrompt` path the welcome screen uses). ZDR-blocked
|
||||
// accounts cannot start a session, so drop the prompt — this mirrors the
|
||||
// deferred post-login path, which clears the startup prompt for ZDR-blocked
|
||||
// accounts. When not yet authenticated, stash it for `AuthComplete`.
|
||||
// `NewSession`/`SendPrompt` path the welcome screen uses). When not yet
|
||||
// authenticated, stash it for `AuthComplete`.
|
||||
if let Some(initial_prompt) = args.initial_prompt() {
|
||||
if !app.session_startup_allowed() {
|
||||
app.deferred_startup.prompt = Some(initial_prompt.to_string());
|
||||
} else if !app.is_zdr_blocked() {
|
||||
} else {
|
||||
let effs = dispatch::dispatch_initial_prompt(&mut app, initial_prompt.to_string());
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
return Ok(make_run_result(&app));
|
||||
@@ -1322,10 +1258,7 @@ pub(crate) async fn run(
|
||||
// empty one so the user lands directly at the prompt. Unauthenticated /
|
||||
// ZDR-blocked startup stays on Welcome, where `crate::minimal::live` shows
|
||||
// a sign-in hint instead of a blank region.
|
||||
if term_state.screen_mode.is_minimal()
|
||||
&& matches!(app.active_view, ActiveView::Welcome)
|
||||
&& !app.is_zdr_blocked()
|
||||
{
|
||||
if term_state.screen_mode.is_minimal() && matches!(app.active_view, ActiveView::Welcome) {
|
||||
if app.session_startup_allowed() {
|
||||
// Already authenticated + trusted: open the empty session now so the
|
||||
// user lands directly at the prompt.
|
||||
@@ -1456,15 +1389,6 @@ pub(crate) async fn run(
|
||||
roster_poll_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
// (Re-)arm the subscription watch on the dormant→wanted transition
|
||||
// and after each fired tick.
|
||||
if subscription_watch_at.is_none()
|
||||
&& app.subscription_watch_wanted()
|
||||
&& let Some(iv) = app.subscription_watch_interval()
|
||||
{
|
||||
subscription_watch_at = Some(Instant::now() + iv);
|
||||
}
|
||||
|
||||
// Future that sleeps until the next animation tick, or waits forever if none.
|
||||
let animation_tick = async {
|
||||
match animation_tick_at {
|
||||
@@ -1511,27 +1435,6 @@ pub(crate) async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
let billing_poll = async {
|
||||
match billing_poll_at {
|
||||
Some(at) => sleep_until(at).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
|
||||
let gate_poll = async {
|
||||
match gate_poll_at {
|
||||
Some(at) => sleep_until(at).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
|
||||
let subscription_watch = async {
|
||||
match subscription_watch_at {
|
||||
Some(at) => sleep_until(at).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
|
||||
let roster_poll = async {
|
||||
match roster_poll_at {
|
||||
Some(at) => sleep_until(at).await,
|
||||
@@ -1629,18 +1532,6 @@ pub(crate) async fn run(
|
||||
schedule_tick(&mut animation_tick_at, &app, tick_interval);
|
||||
resize_debounce_at = None;
|
||||
|
||||
// Schedule/clear poll timers.
|
||||
if app.billing_poll_wanted && billing_poll_at.is_none() {
|
||||
billing_poll_at = Some(Instant::now() + BILLING_POLL_INTERVAL);
|
||||
} else if !app.billing_poll_wanted {
|
||||
billing_poll_at = None;
|
||||
}
|
||||
if !app.has_access() && gate_poll_at.is_none() {
|
||||
gate_poll_at = Some(Instant::now() + GATE_POLL_INTERVAL);
|
||||
} else if app.has_access() {
|
||||
gate_poll_at = None;
|
||||
}
|
||||
|
||||
app.draw(terminal);
|
||||
last_draw_at = Instant::now();
|
||||
draw_scheduled_at = None;
|
||||
@@ -1810,41 +1701,6 @@ pub(crate) async fn run(
|
||||
schedule_tick(&mut animation_tick_at, &app, tick_interval);
|
||||
}
|
||||
|
||||
_ = billing_poll => {
|
||||
billing_poll_at = None;
|
||||
if let ActiveView::Agent(id) = app.active_view {
|
||||
let effs = vec![Effect::FetchBilling {
|
||||
agent_id: id,
|
||||
silent: true,
|
||||
}];
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if app.billing_poll_wanted {
|
||||
billing_poll_at = Some(Instant::now() + BILLING_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
_ = gate_poll => {
|
||||
gate_poll_at = None;
|
||||
let effs = vec![Effect::RefreshGate];
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
break;
|
||||
}
|
||||
if !app.has_access() {
|
||||
gate_poll_at = Some(Instant::now() + GATE_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
_ = subscription_watch => {
|
||||
subscription_watch_at = None;
|
||||
let effs = app.fire_subscription_check("watch");
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_ = roster_poll => {
|
||||
roster_poll_at = None;
|
||||
// Only poll while the dashboard is open. When it is not active
|
||||
@@ -2519,12 +2375,6 @@ async fn drain_and_process(
|
||||
{
|
||||
crate::clipboard::prewarm_image_probe();
|
||||
}
|
||||
// The user may have just subscribed in the browser and
|
||||
// tabbed back.
|
||||
let effs = app.fire_subscription_check("focus");
|
||||
if process_effects(effs, tasks, app, progress_tx) {
|
||||
return true;
|
||||
}
|
||||
// Restore Prompt on refocus: needs-input overlay always, else idle non-vim.
|
||||
match app.active_view {
|
||||
ActiveView::Agent(id) => {
|
||||
|
||||
@@ -34,7 +34,6 @@ impl AppView {
|
||||
&& self.agents.is_empty()
|
||||
&& self.next_agent_id == 0
|
||||
&& !self.chat_mode
|
||||
&& !self.is_zdr_blocked()
|
||||
&& self.pending_update_version.is_none()
|
||||
}
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ impl PagerLeaderCluster {
|
||||
|
||||
let env = vec![
|
||||
crate::test_util::EnvVarGuard::set("KIGI_SHARE_DIR", kigi_home.path()),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_CLI_CHAT_PROXY_BASE_URL", server.url()),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_CODE_BASE_URL", server.url()),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_XAI_API_BASE_URL", server.url()),
|
||||
crate::test_util::EnvVarGuard::set("XAI_API_KEY", "test-key-for-ci"),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_TELEMETRY_ENABLED", "false"),
|
||||
|
||||
@@ -41,7 +41,6 @@ pub mod session_startup;
|
||||
mod signal_handler;
|
||||
pub mod status_blocks;
|
||||
pub mod subagent;
|
||||
pub mod subscription;
|
||||
mod turn_completion;
|
||||
mod xt_filter;
|
||||
pub(crate) use crate::terminal::kitty_flags_pushed;
|
||||
@@ -56,7 +55,6 @@ use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
|
||||
};
|
||||
pub(crate) use dispatch::{FREE_USAGE_USER_MESSAGE, acp_error_is_free_usage_exhausted};
|
||||
pub use foreign_sessions::ForeignScanCoordinator;
|
||||
pub(crate) use foreign_sessions::{
|
||||
badge_for_picker_source, foreign_tool_display_label, is_foreign_picker_source,
|
||||
@@ -272,7 +270,6 @@ pub fn resolve_use_leader(
|
||||
leader_flag: bool,
|
||||
no_leader_flag: bool,
|
||||
raw_config: &toml::Value,
|
||||
_remote_settings: Option<&kigi_shell::util::config::RemoteSettings>,
|
||||
eligible: bool,
|
||||
) -> (bool, Option<&'static str>) {
|
||||
if no_leader_flag {
|
||||
@@ -287,35 +284,8 @@ pub fn resolve_use_leader(
|
||||
if let Some(v) = config::use_leader_from_toml_opt(raw_config) {
|
||||
return (v, (!v).then_some("config"));
|
||||
}
|
||||
#[cfg(feature = "release-dist")]
|
||||
if let Some(remote_val) = _remote_settings.and_then(|s| s.leader_mode) {
|
||||
return (remote_val, (!remote_val).then_some("remote"));
|
||||
}
|
||||
(false, None)
|
||||
}
|
||||
/// Join early prefetch to get remote settings (with timeout).
|
||||
///
|
||||
/// Remote settings come from the product settings API and contain `leader_mode`,
|
||||
/// feature gates, etc. Waits up to 2 s for the background thread.
|
||||
pub fn join_early_prefetch(
|
||||
handle: Option<kigi_shell::agent::models::EarlyPrefetchHandle>,
|
||||
) -> Option<kigi_shell::util::config::RemoteSettings> {
|
||||
let handle = handle?;
|
||||
if handle.is_finished() {
|
||||
return match handle.join() {
|
||||
Ok(r) => r.settings,
|
||||
Err(_) => None,
|
||||
};
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(handle.join());
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
Ok(Ok(r)) => r.settings,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// First non-blank of CLI > env > config (precedence + blank-skip). `None` →
|
||||
/// nothing set; `acp::initialize` canonicalizes and applies the default.
|
||||
fn resolve_hunk_tracker_mode(
|
||||
@@ -360,27 +330,21 @@ pub async fn run(
|
||||
}
|
||||
};
|
||||
let refreshed_auth = kigi_shell::auth::try_ensure_fresh_auth(&kimi_code_config).await;
|
||||
let early_prefetch = kigi_shell::agent::models::start_early_prefetch_with_auth(refreshed_auth);
|
||||
// Fire-and-forget model-catalog warmup; nothing joins the handle now that
|
||||
// the xAI settings fetch it used to carry is gone.
|
||||
drop(kigi_shell::agent::models::start_early_prefetch_with_auth(
|
||||
refreshed_auth,
|
||||
));
|
||||
kigi_shell::agent::mvp_agent::warm_async_http_client();
|
||||
tokio::task::spawn_blocking(|| {});
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
crate::git_info::populate_from_cwd_async(cwd);
|
||||
}
|
||||
let remote_settings = join_early_prefetch(early_prefetch);
|
||||
kigi_shell::util::config::cache_remote_auto_mode(
|
||||
remote_settings.as_ref().and_then(|s| s.auto_mode.clone()),
|
||||
);
|
||||
kigi_shell::util::config::set_remote_campaigns_from_settings(remote_settings.as_ref());
|
||||
let raw_config = kigi_shell::config::load_effective_config()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
|
||||
let prefetch_elapsed = startup_start.elapsed();
|
||||
let (use_leader, policy_disable_reason) = resolve_use_leader(
|
||||
args.leader,
|
||||
args.no_leader,
|
||||
&raw_config,
|
||||
remote_settings.as_ref(),
|
||||
true,
|
||||
);
|
||||
let (use_leader, policy_disable_reason) =
|
||||
resolve_use_leader(args.leader, args.no_leader, &raw_config, true);
|
||||
tracing::info!(
|
||||
use_leader,
|
||||
?policy_disable_reason,
|
||||
@@ -473,9 +437,7 @@ pub async fn run(
|
||||
env_hunk_tracker_mode.as_deref(),
|
||||
config_hunk_tracker_mode,
|
||||
);
|
||||
let remote_permission_mode = remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.permission_mode.as_deref());
|
||||
let remote_permission_mode = None;
|
||||
let launch_yolo = kigi_shell::util::config::effective_yolo_for_launch(
|
||||
args.yolo,
|
||||
args.permission_mode_flag.as_deref(),
|
||||
@@ -500,7 +462,7 @@ pub async fn run(
|
||||
fs_read: args.fs_read,
|
||||
fs_write: args.fs_write,
|
||||
installer: args.installer.clone(),
|
||||
remote_settings: remote_settings.clone(),
|
||||
remote_settings: None,
|
||||
system_prompt_override: args.system_prompt_override.clone(),
|
||||
rules: args.rules.clone(),
|
||||
reasoning_effort_override: args
|
||||
@@ -613,7 +575,7 @@ pub async fn run(
|
||||
&mut config_watcher,
|
||||
&effective_args,
|
||||
session_cwd,
|
||||
remote_settings,
|
||||
None,
|
||||
term_state,
|
||||
materialized,
|
||||
bg_update_rx,
|
||||
@@ -1305,47 +1267,47 @@ mod tests {
|
||||
#[test]
|
||||
fn no_leader_flag_wins_over_leader_flag_and_config() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) = resolve_use_leader(true, true, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(true, true, &cfg, true);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn leader_flag_enables() {
|
||||
let (use_leader, reason) = resolve_use_leader(true, false, &empty_config(), None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(true, false, &empty_config(), true);
|
||||
assert!(use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn not_eligible_returns_false() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, false);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, false);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn config_toml_enables() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, true);
|
||||
assert!(use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn config_toml_disables() {
|
||||
let cfg = config_with_leader(false);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, true);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, Some("config"));
|
||||
}
|
||||
#[test]
|
||||
fn default_is_false() {
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), true);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn cli_flag_overrides_config() {
|
||||
let cfg = config_with_leader(false);
|
||||
let (use_leader, reason) = resolve_use_leader(true, false, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(true, false, &cfg, true);
|
||||
assert!(use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
@@ -1379,7 +1341,7 @@ mod tests {
|
||||
#[test]
|
||||
fn no_leader_flag_overrides_config_for_tui_fallback() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, true, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, true, &cfg, true);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
@@ -1405,59 +1367,11 @@ mod tests {
|
||||
assert!(try_parse_pager(&["grok-pager", "agent"]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn remote_settings_none_falls_through_to_default() {
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), None, true);
|
||||
fn leader_defaults_off_without_config() {
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), true);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[cfg(feature = "release-dist")]
|
||||
#[test]
|
||||
fn remote_settings_leader_mode_true_enables_leader() {
|
||||
let rs = kigi_shell::util::config::RemoteSettings {
|
||||
leader_mode: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
|
||||
assert!(use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[cfg(feature = "release-dist")]
|
||||
#[test]
|
||||
fn remote_settings_leader_mode_false_disables_leader() {
|
||||
let rs = kigi_shell::util::config::RemoteSettings {
|
||||
leader_mode: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, Some("remote"));
|
||||
}
|
||||
#[cfg(feature = "release-dist")]
|
||||
#[test]
|
||||
fn remote_settings_unknown_leader_mode_is_not_policy_disable() {
|
||||
let rs = kigi_shell::util::config::RemoteSettings {
|
||||
leader_mode: None,
|
||||
..Default::default()
|
||||
};
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[cfg(feature = "release-dist")]
|
||||
#[test]
|
||||
fn config_toml_overrides_remote_settings() {
|
||||
let rs = kigi_shell::util::config::RemoteSettings {
|
||||
leader_mode: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let cfg = config_with_leader(false);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, Some(&rs), true);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, Some("config"));
|
||||
}
|
||||
#[test]
|
||||
fn cli_resume_parses_session_id() {
|
||||
let args = try_parse_pager(&["grok-pager", "--resume", "abc-123"]).unwrap();
|
||||
|
||||
@@ -635,8 +635,7 @@ impl AgentView {
|
||||
entries: _, state, ..
|
||||
} => {
|
||||
// Build filtered entries for count and non-selectable indices.
|
||||
let filtered =
|
||||
crate::views::modal::filter_palette_entries(&state.query, self.sharing_enabled);
|
||||
let filtered = crate::views::modal::filter_palette_entries(&state.query);
|
||||
let non_sel: Vec<bool> = filtered
|
||||
.iter()
|
||||
.map(|e| matches!(e.command, PaletteCommand::SectionHeader(_)))
|
||||
@@ -840,14 +839,10 @@ impl AgentView {
|
||||
}
|
||||
PickerOutcome::Changed => {
|
||||
// Re-filter entries based on updated query.
|
||||
let sharing_enabled = self.sharing_enabled;
|
||||
if let Some(ActiveModal::CommandPalette { entries, state, .. }) =
|
||||
self.active_modal.as_mut()
|
||||
{
|
||||
*entries = crate::views::modal::filter_palette_entries(
|
||||
&state.query,
|
||||
sharing_enabled,
|
||||
);
|
||||
*entries = crate::views::modal::filter_palette_entries(&state.query);
|
||||
state.selected = state.selected.min(entries.len().saturating_sub(1));
|
||||
}
|
||||
InputOutcome::Changed
|
||||
@@ -1623,7 +1618,7 @@ impl AgentView {
|
||||
} = active_modal
|
||||
{
|
||||
// Command palette: ModalWindow chrome + picker content.
|
||||
let filtered = modal::filter_palette_entries(&state.query, self.sharing_enabled);
|
||||
let filtered = modal::filter_palette_entries(&state.query);
|
||||
let non_sel: Vec<bool> = filtered
|
||||
.iter()
|
||||
.map(|e| matches!(e.command, modal::PaletteCommand::SectionHeader(_)))
|
||||
@@ -2614,7 +2609,7 @@ mod command_palette_vim_input_tests {
|
||||
// INPUT mode (`input_active`) over the full palette entries.
|
||||
fn open_command_palette(agent: &mut AgentView) {
|
||||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
|
||||
entries: crate::views::modal::default_palette_entries(),
|
||||
state: PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
|
||||
@@ -698,31 +698,6 @@ impl AgentView {
|
||||
.scrollback
|
||||
.entry_index_at_screen_row(click_row, self.pane_areas.scrollback);
|
||||
if let Some(idx) = hit_idx {
|
||||
let credit_click = self.scrollback.entry(idx).and_then(|entry| {
|
||||
if let crate::scrollback::block::RenderBlock::CreditLimit(ref blk) =
|
||||
entry.block
|
||||
{
|
||||
Some(blk.url.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(url) = credit_click
|
||||
&& let Some((area, _, _)) = self
|
||||
.scrollback
|
||||
.entry_screen_area(idx, self.pane_areas.scrollback)
|
||||
{
|
||||
let url_row = area.y + area.height.saturating_sub(2);
|
||||
if click_row >= url_row {
|
||||
self.scrollback.set_selected(Some(idx));
|
||||
crate::app::link_opener::open_url_if_safe(
|
||||
&url,
|
||||
crate::terminal::hyperlinks::SchemeFilter::Standard,
|
||||
);
|
||||
self.last_click = None;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
let selectable = self
|
||||
.scrollback
|
||||
.get(idx)
|
||||
@@ -878,7 +853,6 @@ impl AgentView {
|
||||
.set_hovered_follow_up_chip(self.follow_up_chip_at(mouse.column, mouse.row));
|
||||
changed |= self.hit_badge.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_context.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_credits.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_todo_close.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_queue_close.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_queue_badge.update_hover(mouse.column, mouse.row);
|
||||
|
||||
@@ -1150,7 +1150,7 @@ mod tests {
|
||||
// early-return) is what leaves the palette alone.
|
||||
agent.prompt_mode = editing_lone_local();
|
||||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
|
||||
entries: crate::views::modal::default_palette_entries(),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
|
||||
@@ -561,8 +561,6 @@ mod tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
//! Free→paid subscription detection and gate imposition/lift.
|
||||
//!
|
||||
//! All gate transitions go through [`AppView::impose_gate`] /
|
||||
//! [`AppView::lift_gate`] so the defer-vs-show decision and the lift
|
||||
//! bookkeeping (focus, telemetry, JWT-refresh check) live in one place.
|
||||
//!
|
||||
//! Design constraints that are not obvious from the code:
|
||||
//! - Gates arriving from cached auth meta, prefetched settings, or settings
|
||||
//! pushes can be stale: the user may have subscribed since the snapshot
|
||||
//! was computed. Painting such a gate directly flashes a paywall at a
|
||||
//! paying user, so it is held in `pending_gate_verification` while a live
|
||||
//! check runs. On check failure or timeout we err on blocking.
|
||||
//! - Timer effects have no cancellation, so verifications are stamped with
|
||||
//! `gate_verify_gen`; results and timeouts from superseded deferrals are
|
||||
//! ignored by generation mismatch.
|
||||
|
||||
use super::actions::Effect;
|
||||
use super::app_view::{AppView, AuthState};
|
||||
|
||||
/// Default watch cadence. Overridable via the remote settings
|
||||
/// `grok_build_settings.subscription_watch_interval_secs` field.
|
||||
pub(crate) const SUBSCRIPTION_WATCH_INTERVAL: std::time::Duration =
|
||||
std::time::Duration::from_secs(60);
|
||||
|
||||
/// Floor for the server-supplied cadence: a fat-fingered remote settings value
|
||||
/// must not turn the fleet into a hot-poller. `0` means "disabled" and is
|
||||
/// special-cased before this clamp.
|
||||
pub(crate) const SUBSCRIPTION_WATCH_MIN_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
/// Floor for the `KIGI_SUBSCRIPTION_WATCH_INTERVAL_SECS` env override
|
||||
/// (test seam / power user — deliberately below the server floor).
|
||||
const SUBSCRIPTION_WATCH_ENV_MIN_SECS: u64 = 1;
|
||||
|
||||
/// Cap on the spacing between watch/focus-triggered checks.
|
||||
pub(crate) const SUBSCRIPTION_CHECK_DEBOUNCE: std::time::Duration =
|
||||
std::time::Duration::from_secs(30);
|
||||
|
||||
/// How long a deferred gate is held before being shown anyway. This is a
|
||||
/// safety net for a hung ACP round-trip only — a completed check (even a
|
||||
/// failed one) resolves the deferral immediately. Generous on purpose: the
|
||||
/// check can chain a `/user` fetch, a JWT refresh, and a settings re-fetch;
|
||||
/// 5s was observed timing out in CI under full-suite contention.
|
||||
pub(crate) const GATE_VERIFY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
impl AppView {
|
||||
/// Consumer xAI session auth: not an API key, not an enterprise team.
|
||||
/// Subscription gates and the watch only apply to these sessions.
|
||||
fn is_consumer_session(&self) -> bool {
|
||||
matches!(self.auth_state, AuthState::Done)
|
||||
&& !self.is_api_key_auth
|
||||
&& self.team_name.is_none()
|
||||
}
|
||||
|
||||
/// `None` tier counts as potentially-free so detection works before the
|
||||
/// first auth meta lands. Not a confirmed-free signal.
|
||||
pub fn may_be_free_tier(&self) -> bool {
|
||||
match self.subscription_tier.as_deref() {
|
||||
Some(t) => t.trim().eq_ignore_ascii_case("free"),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective watch cadence; `None` = disabled. Precedence: env override
|
||||
/// (`0` disables), server override (`0` disables, floor-clamped),
|
||||
/// default.
|
||||
pub fn subscription_watch_interval(&self) -> Option<std::time::Duration> {
|
||||
if let Ok(v) = std::env::var("KIGI_SUBSCRIPTION_WATCH_INTERVAL_SECS")
|
||||
&& let Ok(secs) = v.trim().parse::<u64>()
|
||||
{
|
||||
return match secs {
|
||||
0 => None,
|
||||
s => Some(std::time::Duration::from_secs(
|
||||
s.max(SUBSCRIPTION_WATCH_ENV_MIN_SECS),
|
||||
)),
|
||||
};
|
||||
}
|
||||
match self.subscription_watch_interval_secs {
|
||||
Some(0) => None,
|
||||
Some(secs) => Some(std::time::Duration::from_secs(
|
||||
secs.max(SUBSCRIPTION_WATCH_MIN_INTERVAL_SECS),
|
||||
)),
|
||||
None => Some(SUBSCRIPTION_WATCH_INTERVAL),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the watch (and the refocus check) should run: enabled,
|
||||
/// consumer session, and gated or possibly-free.
|
||||
pub fn subscription_watch_wanted(&self) -> bool {
|
||||
self.subscription_watch_interval().is_some()
|
||||
&& self.is_consumer_session()
|
||||
&& (self.gate.is_some() || self.may_be_free_tier())
|
||||
}
|
||||
|
||||
/// Half the effective interval, capped at [`SUBSCRIPTION_CHECK_DEBOUNCE`]
|
||||
/// — scaling keeps the debounce from swallowing watch ticks when the
|
||||
/// cadence is tightened.
|
||||
fn subscription_check_allowed(&self) -> bool {
|
||||
let debounce = self
|
||||
.subscription_watch_interval()
|
||||
.map(|iv| (iv / 2).min(SUBSCRIPTION_CHECK_DEBOUNCE))
|
||||
.unwrap_or(SUBSCRIPTION_CHECK_DEBOUNCE);
|
||||
self.last_subscription_check_at
|
||||
.is_none_or(|t| t.elapsed() >= debounce)
|
||||
}
|
||||
|
||||
fn note_subscription_check(&mut self) {
|
||||
self.last_subscription_check_at = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// Single guard-and-fire for the watch tick and the terminal-refocus
|
||||
/// trigger. Empty when unwanted or debounced. The 5s paywall chain
|
||||
/// deliberately bypasses this. `trigger` tags the unified-log entry
|
||||
/// (`"watch"` / `"focus"`) so the check cadence is reconstructable
|
||||
/// from logs.
|
||||
#[must_use]
|
||||
pub fn fire_subscription_check(&mut self, trigger: &'static str) -> Vec<Effect> {
|
||||
if self.subscription_watch_wanted() && self.subscription_check_allowed() {
|
||||
self.note_subscription_check();
|
||||
crate::unified_log::info(
|
||||
"subscription.check.fired",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"trigger": trigger,
|
||||
"interval_secs": self
|
||||
.subscription_watch_interval()
|
||||
.map(|iv| iv.as_secs()),
|
||||
"gated": self.gate.is_some(),
|
||||
"tier": self.subscription_tier,
|
||||
})),
|
||||
);
|
||||
vec![Effect::CheckSubscription { verify: None }]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Chokepoint for showing a gate. Already gated → update the copy.
|
||||
/// Consumer session with access → defer for live verification (the gate
|
||||
/// source may be stale). Otherwise → show directly.
|
||||
#[must_use]
|
||||
pub fn impose_gate(&mut self, gate: kigi_shell::auth::GateInfo) -> Vec<Effect> {
|
||||
if self.gate.is_some() {
|
||||
self.gate = Some(gate);
|
||||
return vec![];
|
||||
}
|
||||
if self.is_consumer_session() {
|
||||
return self.defer_gate_for_verification(gate);
|
||||
}
|
||||
crate::unified_log::info(
|
||||
"subscription.gate.imposed",
|
||||
None,
|
||||
Some(serde_json::json!({ "deferred": false })),
|
||||
);
|
||||
self.gate = Some(gate);
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Chokepoint for a settings-confirmed gate lift. Clears the visible
|
||||
/// gate and any pending deferral; when either existed, runs the lift
|
||||
/// bookkeeping and returns the JWT-refresh check (the tier claim is
|
||||
/// baked into the JWT, so the shell must re-mint it).
|
||||
#[must_use]
|
||||
pub fn lift_gate(&mut self) -> Vec<Effect> {
|
||||
let was_blocked = self.gate.is_some() || self.pending_gate_verification.is_some();
|
||||
self.gate = None;
|
||||
self.pending_gate_verification = None;
|
||||
if !was_blocked {
|
||||
return vec![];
|
||||
}
|
||||
self.welcome_prompt_focused = true;
|
||||
self.paywall_check_started = None;
|
||||
crate::unified_log::info(
|
||||
"subscription.gate.lifted",
|
||||
None,
|
||||
Some(serde_json::json!({ "tier": self.subscription_tier })),
|
||||
);
|
||||
vec![Effect::CheckSubscription { verify: None }]
|
||||
}
|
||||
|
||||
/// Hold `gate` out of `self.gate` while a generation-stamped live check
|
||||
/// verifies it. Resolution: authoritative meta via `apply_auth_meta`
|
||||
/// (drops the deferral), or promotion on same-generation check failure /
|
||||
/// timeout via [`Self::promote_deferred_gate`].
|
||||
#[must_use]
|
||||
fn defer_gate_for_verification(&mut self, gate: kigi_shell::auth::GateInfo) -> Vec<Effect> {
|
||||
self.pending_gate_verification = Some(gate);
|
||||
self.gate_verify_gen = self.gate_verify_gen.wrapping_add(1);
|
||||
self.note_subscription_check();
|
||||
crate::unified_log::info(
|
||||
"subscription.gate.deferred",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"generation": self.gate_verify_gen,
|
||||
"tier": self.subscription_tier,
|
||||
})),
|
||||
);
|
||||
vec![
|
||||
Effect::CheckSubscription {
|
||||
verify: Some(self.gate_verify_gen),
|
||||
},
|
||||
Effect::ScheduleGateVerifyTimeout {
|
||||
generation: self.gate_verify_gen,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Show a deferred gate (err on blocking) — no-op unless `generation`
|
||||
/// is the current verification and nothing resolved it meanwhile.
|
||||
/// `reason` tags the unified-log entry (`"check_failed"` /
|
||||
/// `"verify_timeout"`).
|
||||
pub(crate) fn promote_deferred_gate(&mut self, generation: u64, reason: &'static str) {
|
||||
if generation == self.gate_verify_gen
|
||||
&& let Some(gate) = self.pending_gate_verification.take()
|
||||
&& self.gate.is_none()
|
||||
{
|
||||
// Warn: the verification did not confirm access, so the user is
|
||||
// now blocked. If this is wrong (paying user paywalled), this
|
||||
// entry plus the preceding check.fired/check.complete lines
|
||||
// show which path failed.
|
||||
crate::unified_log::warn(
|
||||
"subscription.gate.promoted",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"generation": generation,
|
||||
"reason": reason,
|
||||
"tier": self.subscription_tier,
|
||||
})),
|
||||
);
|
||||
self.gate = Some(gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::app_view::tests::test_app;
|
||||
|
||||
fn watch_gate() -> kigi_shell::auth::GateInfo {
|
||||
kigi_shell::auth::GateInfo {
|
||||
message: "Subscribe".into(),
|
||||
url: None,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn may_be_free_tier_matrix() {
|
||||
let mut app = test_app();
|
||||
app.subscription_tier = None;
|
||||
assert!(app.may_be_free_tier(), "unknown tier is potentially free");
|
||||
app.subscription_tier = Some("Free".into());
|
||||
assert!(app.may_be_free_tier());
|
||||
app.subscription_tier = Some(" FREE ".into());
|
||||
assert!(app.may_be_free_tier(), "case/whitespace-insensitive");
|
||||
app.subscription_tier = Some("SuperGrok Heavy".into());
|
||||
assert!(!app.may_be_free_tier());
|
||||
app.subscription_tier = Some("X Premium".into());
|
||||
assert!(!app.may_be_free_tier());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_watch_wanted_matrix() {
|
||||
let mut app = test_app(); // AuthState::Done, consumer, tier unknown
|
||||
assert!(
|
||||
app.subscription_watch_wanted(),
|
||||
"unknown-tier consumer session watches"
|
||||
);
|
||||
|
||||
app.subscription_tier = Some("Free".into());
|
||||
assert!(app.subscription_watch_wanted(), "free tier watches");
|
||||
|
||||
app.subscription_tier = Some("SuperGrok".into());
|
||||
assert!(!app.subscription_watch_wanted(), "paid tier is dormant");
|
||||
|
||||
// Gated — watches regardless of the (stale) tier string.
|
||||
app.gate = Some(watch_gate());
|
||||
assert!(app.subscription_watch_wanted(), "gated session watches");
|
||||
app.gate = None;
|
||||
|
||||
app.subscription_tier = Some("Free".into());
|
||||
app.is_api_key_auth = true;
|
||||
assert!(
|
||||
!app.subscription_watch_wanted(),
|
||||
"API-key auth never watches"
|
||||
);
|
||||
app.is_api_key_auth = false;
|
||||
app.team_name = Some("Acme Corp".into());
|
||||
assert!(
|
||||
!app.subscription_watch_wanted(),
|
||||
"team session never watches"
|
||||
);
|
||||
app.team_name = None;
|
||||
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
assert!(!app.subscription_watch_wanted(), "pre-auth never watches");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_watch_interval_override_clamp_and_disable() {
|
||||
let mut app = test_app();
|
||||
assert_eq!(
|
||||
app.subscription_watch_interval(),
|
||||
Some(SUBSCRIPTION_WATCH_INTERVAL)
|
||||
);
|
||||
app.subscription_watch_interval_secs = Some(120);
|
||||
assert_eq!(
|
||||
app.subscription_watch_interval(),
|
||||
Some(std::time::Duration::from_secs(120))
|
||||
);
|
||||
app.subscription_watch_interval_secs = Some(1);
|
||||
assert_eq!(
|
||||
app.subscription_watch_interval(),
|
||||
Some(std::time::Duration::from_secs(
|
||||
SUBSCRIPTION_WATCH_MIN_INTERVAL_SECS
|
||||
)),
|
||||
"sub-floor values are clamped"
|
||||
);
|
||||
app.subscription_watch_interval_secs = Some(0);
|
||||
assert_eq!(app.subscription_watch_interval(), None);
|
||||
app.subscription_tier = Some("Free".into());
|
||||
assert!(
|
||||
!app.subscription_watch_wanted(),
|
||||
"interval 0 must disable the watch even on the free tier"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_check_debounce() {
|
||||
let mut app = test_app();
|
||||
assert!(app.subscription_check_allowed(), "no prior check — allowed");
|
||||
|
||||
app.note_subscription_check();
|
||||
assert!(
|
||||
!app.subscription_check_allowed(),
|
||||
"right after a check — debounced"
|
||||
);
|
||||
|
||||
app.last_subscription_check_at =
|
||||
Some(std::time::Instant::now() - SUBSCRIPTION_CHECK_DEBOUNCE);
|
||||
assert!(app.subscription_check_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_subscription_check_guards_and_debounces() {
|
||||
let mut app = test_app();
|
||||
let effs = app.fire_subscription_check("watch");
|
||||
assert!(matches!(
|
||||
effs.as_slice(),
|
||||
[Effect::CheckSubscription { verify: None }]
|
||||
));
|
||||
assert!(
|
||||
app.fire_subscription_check("watch").is_empty(),
|
||||
"second fire inside the debounce window must be empty"
|
||||
);
|
||||
|
||||
let mut paid = test_app();
|
||||
paid.subscription_tier = Some("SuperGrok".into());
|
||||
assert!(
|
||||
paid.fire_subscription_check("watch").is_empty(),
|
||||
"paid tier never fires"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impose_gate_defers_for_consumer_session() {
|
||||
let mut app = test_app();
|
||||
let effs = app.impose_gate(watch_gate());
|
||||
|
||||
assert!(
|
||||
app.has_access(),
|
||||
"deferred gate must not render as a paywall"
|
||||
);
|
||||
assert!(app.pending_gate_verification.is_some());
|
||||
assert!(
|
||||
!app.subscription_check_allowed(),
|
||||
"the deferral's own check counts for the debounce"
|
||||
);
|
||||
assert!(matches!(
|
||||
effs.as_slice(),
|
||||
[
|
||||
Effect::CheckSubscription {
|
||||
verify: Some(check_gen)
|
||||
},
|
||||
Effect::ScheduleGateVerifyTimeout {
|
||||
generation: timeout_gen
|
||||
}
|
||||
] if *check_gen == app.gate_verify_gen && *timeout_gen == app.gate_verify_gen
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impose_gate_direct_for_non_consumer_and_already_gated() {
|
||||
// Team session: no live verification possible — show directly.
|
||||
let mut app = test_app();
|
||||
app.team_name = Some("Acme Corp".into());
|
||||
assert!(app.impose_gate(watch_gate()).is_empty());
|
||||
assert!(!app.has_access());
|
||||
assert!(app.pending_gate_verification.is_none());
|
||||
|
||||
// Already gated: update the copy only.
|
||||
let mut gated = test_app();
|
||||
gated.gate = Some(watch_gate());
|
||||
let new_copy = kigi_shell::auth::GateInfo {
|
||||
message: "New copy".into(),
|
||||
url: None,
|
||||
label: None,
|
||||
};
|
||||
assert!(gated.impose_gate(new_copy).is_empty());
|
||||
assert_eq!(gated.gate.as_ref().unwrap().message, "New copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impose_gate_bumps_generation_each_time() {
|
||||
let mut app = test_app();
|
||||
let _ = app.impose_gate(watch_gate());
|
||||
let first = app.gate_verify_gen;
|
||||
app.pending_gate_verification = None; // simulate resolution
|
||||
let _ = app.impose_gate(watch_gate());
|
||||
assert_eq!(app.gate_verify_gen, first + 1, "each deferral re-stamps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lift_gate_runs_bookkeeping_once() {
|
||||
let mut app = test_app();
|
||||
app.gate = Some(watch_gate());
|
||||
app.paywall_check_started = Some(std::time::Instant::now());
|
||||
|
||||
let effs = app.lift_gate();
|
||||
assert!(app.has_access());
|
||||
assert!(app.welcome_prompt_focused);
|
||||
assert!(app.paywall_check_started.is_none());
|
||||
assert!(matches!(
|
||||
effs.as_slice(),
|
||||
[Effect::CheckSubscription { verify: None }]
|
||||
));
|
||||
|
||||
assert!(
|
||||
app.lift_gate().is_empty(),
|
||||
"lift without a gate or deferral is a no-op"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lift_gate_counts_pending_deferral_as_blocked() {
|
||||
let mut app = test_app();
|
||||
let _ = app.impose_gate(watch_gate());
|
||||
|
||||
let effs = app.lift_gate();
|
||||
assert!(app.pending_gate_verification.is_none());
|
||||
assert!(
|
||||
matches!(
|
||||
effs.as_slice(),
|
||||
[Effect::CheckSubscription { verify: None }]
|
||||
),
|
||||
"a confirmed lift of a pending gate must still refresh the JWT"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_deferred_gate_is_generation_scoped() {
|
||||
let mut app = test_app();
|
||||
let _ = app.impose_gate(watch_gate());
|
||||
let stale_gen = app.gate_verify_gen;
|
||||
let _ = app.impose_gate(watch_gate());
|
||||
|
||||
app.promote_deferred_gate(stale_gen, "verify_timeout");
|
||||
assert!(
|
||||
app.has_access(),
|
||||
"stale generation must not promote the newer deferral"
|
||||
);
|
||||
|
||||
app.promote_deferred_gate(app.gate_verify_gen, "verify_timeout");
|
||||
assert!(!app.has_access(), "current generation promotes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_auth_meta_drops_pending_gate_verification() {
|
||||
let mut app = test_app();
|
||||
let _effs = app.impose_gate(watch_gate());
|
||||
|
||||
app.apply_auth_meta(&kigi_shell::auth::AuthMeta::default());
|
||||
|
||||
assert!(app.pending_gate_verification.is_none());
|
||||
assert!(app.has_access());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
pub const PAGER_CLIENT_TYPE: &str = "grok-pager";
|
||||
pub const HEADLESS_CLIENT_TYPE: &str = "grok-shell";
|
||||
pub const HEADLESS_CLIENT_TYPE: &str = "kigi";
|
||||
|
||||
pub const PAGER_CLIENT_VERSION: &str = kigi_version::VERSION;
|
||||
|
||||
@@ -838,7 +838,6 @@ pub async fn run_single_turn(
|
||||
) -> Result<()> {
|
||||
// Stamp proxy requests as headless before the agent spawns and issues
|
||||
// its first request (auth enrichment, model list, etc.).
|
||||
kigi_shell::http::set_process_client_mode_headless();
|
||||
|
||||
let cwd = match options.cwd {
|
||||
None => std::env::current_dir()?,
|
||||
@@ -1318,13 +1317,7 @@ pub async fn run_single_turn(
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
let msg = if i32::from(err.code) == RATE_LIMITED_ERROR_CODE {
|
||||
// The -32003 data is the flattened server message; a
|
||||
// free-usage 429 carries the well-known code inline there.
|
||||
if crate::app::acp_error_is_free_usage_exhausted(&err) {
|
||||
crate::app::FREE_USAGE_USER_MESSAGE.to_string()
|
||||
} else {
|
||||
rate_limited_user_message(is_api_key_auth).to_string()
|
||||
}
|
||||
rate_limited_user_message(is_api_key_auth).to_string()
|
||||
} else {
|
||||
err.to_string()
|
||||
};
|
||||
|
||||
@@ -47,7 +47,6 @@ pub mod scrollback;
|
||||
pub mod search;
|
||||
pub mod sessions_cmd;
|
||||
pub mod settings;
|
||||
pub mod share_cmd;
|
||||
pub mod slash;
|
||||
pub mod startup;
|
||||
pub mod tips;
|
||||
|
||||
@@ -13,11 +13,10 @@ use crate::prompt_images::{InlineMediaInfo, ScrollbackImageRef, ScrollbackVideoR
|
||||
|
||||
use super::blocks::mermaid_content::DiagramAffordance;
|
||||
use super::blocks::{
|
||||
AgentMessageBlock, BgTaskBlock, BtwBlock, ContextInfoBlock, CreditLimitBlock,
|
||||
EditToolCallBlock, ExecuteToolCallBlock, LineRange, ListDirToolCallBlock, OtherToolCallBlock,
|
||||
ReadToolCallBlock, SearchFileMatch, SearchToolCallBlock, SessionEvent, SessionEventBlock,
|
||||
SubagentBlock, SubagentBlockKind, SystemMessageBlock, ThinkingBlock, ToolCallBlock,
|
||||
UserPromptBlock,
|
||||
AgentMessageBlock, BgTaskBlock, BtwBlock, ContextInfoBlock, EditToolCallBlock,
|
||||
ExecuteToolCallBlock, LineRange, ListDirToolCallBlock, OtherToolCallBlock, ReadToolCallBlock,
|
||||
SearchFileMatch, SearchToolCallBlock, SessionEvent, SessionEventBlock, SubagentBlock,
|
||||
SubagentBlockKind, SystemMessageBlock, ThinkingBlock, ToolCallBlock, UserPromptBlock,
|
||||
};
|
||||
use super::types::{
|
||||
AccentStyle, BlockBackground, BlockContext, BlockOutput, DisplayMode, RenderedBlockOutput,
|
||||
@@ -383,8 +382,6 @@ pub enum RenderBlock {
|
||||
Btw(BtwBlock),
|
||||
/// `/context` snapshot with categorical bar + breakdown.
|
||||
ContextInfo(ContextInfoBlock),
|
||||
/// Credit-limit card for max-tier users (red accent, single action).
|
||||
CreditLimit(CreditLimitBlock),
|
||||
}
|
||||
|
||||
/// Delegate a method call to the inner block variant.
|
||||
@@ -402,7 +399,6 @@ macro_rules! delegate_block {
|
||||
RenderBlock::Subagent(b) => b.$method($($arg),*),
|
||||
RenderBlock::Btw(b) => b.$method($($arg),*),
|
||||
RenderBlock::ContextInfo(b) => b.$method($($arg),*),
|
||||
RenderBlock::CreditLimit(b) => b.$method($($arg),*),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -778,15 +774,6 @@ impl RenderBlock {
|
||||
RenderBlock::SessionEvent(SessionEventBlock::new(event))
|
||||
}
|
||||
|
||||
/// Create a credit-limit card (inline scrollback block for max-tier users).
|
||||
pub fn credit_limit_card(
|
||||
heading: impl Into<String>,
|
||||
action: crate::scrollback::blocks::CreditLimitCardAction,
|
||||
url: impl Into<String>,
|
||||
) -> Self {
|
||||
RenderBlock::CreditLimit(CreditLimitBlock::new(heading, action, url))
|
||||
}
|
||||
|
||||
/// Create a "Task started" background task block.
|
||||
pub fn bg_task(command: impl Into<String>, task_id: impl Into<String>) -> Self {
|
||||
RenderBlock::BgTask(BgTaskBlock::started(command, task_id))
|
||||
@@ -912,11 +899,6 @@ impl RenderBlock {
|
||||
matches!(self, RenderBlock::AgentMessage(_))
|
||||
}
|
||||
|
||||
/// Check if this block is a CreditLimit card.
|
||||
pub fn is_credit_limit(&self) -> bool {
|
||||
matches!(self, RenderBlock::CreditLimit(_))
|
||||
}
|
||||
|
||||
/// Check if this block is a plan mode tool call (enter or exit).
|
||||
///
|
||||
/// Exact-matches the canonical tool-name set rather than substring-matching
|
||||
@@ -1014,10 +996,9 @@ impl RenderBlock {
|
||||
None
|
||||
}
|
||||
}
|
||||
RenderBlock::System(_)
|
||||
| RenderBlock::SessionEvent(_)
|
||||
| RenderBlock::ContextInfo(_)
|
||||
| RenderBlock::CreditLimit(_) => None,
|
||||
RenderBlock::System(_) | RenderBlock::SessionEvent(_) | RenderBlock::ContextInfo(_) => {
|
||||
None
|
||||
}
|
||||
RenderBlock::Btw(_) => Some(theme.accent_plan),
|
||||
RenderBlock::Stub(block) => Some(block.accent_color),
|
||||
}
|
||||
@@ -1161,9 +1142,6 @@ impl RenderBlock {
|
||||
Some(b.content().rendered_plain_text()),
|
||||
]),
|
||||
RenderBlock::ContextInfo(b) => join_searchable([Some(b.model.clone())]),
|
||||
RenderBlock::CreditLimit(b) => {
|
||||
join_searchable([Some(b.heading.clone()), Some(b.url.clone())])
|
||||
}
|
||||
RenderBlock::ToolCall(tc) => tc.searchable_text(),
|
||||
}
|
||||
}
|
||||
@@ -1566,18 +1544,6 @@ mod searchable_text_tests {
|
||||
assert_eq!(block.searchable_text().as_deref(), Some("grok-4.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credit_limit_indexes_heading_and_url() {
|
||||
let block = RenderBlock::credit_limit_card(
|
||||
"credit limit reached",
|
||||
crate::scrollback::blocks::CreditLimitCardAction::EnablePayg,
|
||||
"https://grok.com?_s=usage",
|
||||
);
|
||||
let text = block.searchable_text().expect("credit limit text");
|
||||
assert!(text.contains("credit limit reached"), "got: {text:?}");
|
||||
assert!(text.contains("https://grok.com?_s=usage"), "got: {text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_indexes_pattern_and_match_line() {
|
||||
let block = RenderBlock::search(
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
//! CreditLimitBlock — scrollback card shown when a max-tier user exhausts credits.
|
||||
//!
|
||||
//! Replaces the Q&A question modal for users already at the highest tier
|
||||
//! (SuperGrok Heavy). Instead of offering "Upgrade tier" + PAYG / buy-credits
|
||||
//! options in the question overlay, this block renders an inline card with a
|
||||
//! descriptive message and a link to the usage/billing page.
|
||||
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
use crate::scrollback::block::BlockContent;
|
||||
use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput, DisplayMode};
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Which continue-path the max-tier credit-limit card recommends.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CreditLimitCardAction {
|
||||
/// Legacy on-demand: PAYG not enabled yet.
|
||||
EnablePayg,
|
||||
/// Legacy on-demand: PAYG on but at spending cap.
|
||||
IncreasePaygLimit,
|
||||
/// Unified usage billing: purchase prepaid credits.
|
||||
PurchaseCredits,
|
||||
}
|
||||
|
||||
/// Inline scrollback card for credit-limit exhaustion on max-tier accounts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreditLimitBlock {
|
||||
/// Card heading (e.g. "You've hit your free credits limit.").
|
||||
pub heading: String,
|
||||
/// Continue-path body copy selector.
|
||||
pub action: CreditLimitCardAction,
|
||||
/// URL to the usage/billing page.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl CreditLimitBlock {
|
||||
/// Create a new credit-limit card.
|
||||
pub fn new(
|
||||
heading: impl Into<String>,
|
||||
action: CreditLimitCardAction,
|
||||
url: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
heading: heading.into(),
|
||||
action,
|
||||
url: url.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockContent for CreditLimitBlock {
|
||||
fn output(&self, _ctx: &BlockContext) -> BlockOutput {
|
||||
let theme = Theme::current();
|
||||
|
||||
// Heading in bold warning color (amber/yellow).
|
||||
let heading_style = Style::default()
|
||||
.fg(theme.warning)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let heading = Line::from(Span::styled(self.heading.clone(), heading_style));
|
||||
|
||||
// Body copy — contextual message based on billing mode.
|
||||
let muted = theme.muted();
|
||||
let body = match self.action {
|
||||
CreditLimitCardAction::IncreasePaygLimit => {
|
||||
"You can continue by increasing your spending limit."
|
||||
}
|
||||
CreditLimitCardAction::EnablePayg => {
|
||||
"You can continue by enabling pay-as-you-go usage."
|
||||
}
|
||||
CreditLimitCardAction::PurchaseCredits => {
|
||||
"You can continue by purchasing more credits."
|
||||
}
|
||||
};
|
||||
let body_line = Line::from(Span::styled(body.to_string(), muted));
|
||||
|
||||
// Clickable link styled as a button.
|
||||
let link_style = theme.link_style();
|
||||
let link_line = Line::from(vec![Span::styled(self.url.clone(), link_style)]);
|
||||
|
||||
BlockOutput {
|
||||
lines: vec![
|
||||
BlockLine::styled(heading).with_selection_range(Some(0)),
|
||||
BlockLine::separator(Line::from("")),
|
||||
BlockLine::styled(body_line).with_selection_range(Some(0)),
|
||||
BlockLine::styled(link_line).with_selection_range(Some(0)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
let theme = Theme::current();
|
||||
Some(AccentStyle::static_color(theme.warning))
|
||||
}
|
||||
|
||||
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn has_raw_mode(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_foldable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_display_mode(&self) -> DisplayMode {
|
||||
DisplayMode::Expanded
|
||||
}
|
||||
|
||||
fn is_selectable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_groupable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::appearance::AppearanceConfig;
|
||||
|
||||
fn ctx() -> BlockContext {
|
||||
BlockContext {
|
||||
mode: DisplayMode::Expanded,
|
||||
is_running: false,
|
||||
width: 80,
|
||||
raw: false,
|
||||
max_lines: None,
|
||||
appearance: AppearanceConfig::default(),
|
||||
is_selected: false,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_payg_off_mentions_enabling() {
|
||||
let block = CreditLimitBlock::new(
|
||||
"You\u{2019}ve hit your credit limit.",
|
||||
CreditLimitCardAction::EnablePayg,
|
||||
"https://grok.com?_s=usage",
|
||||
);
|
||||
let output = block.output(&ctx());
|
||||
let all_text: String = output
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|l| l.content.spans.iter().map(|s| s.content.as_ref()))
|
||||
.collect();
|
||||
assert!(all_text.contains("credit limit"));
|
||||
assert!(all_text.contains("enabling pay-as-you-go"));
|
||||
assert!(all_text.contains("grok.com?_s=usage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_payg_on_mentions_increasing() {
|
||||
let block = CreditLimitBlock::new(
|
||||
"You\u{2019}ve hit your spending cap.",
|
||||
CreditLimitCardAction::IncreasePaygLimit,
|
||||
"https://grok.com?_s=usage",
|
||||
);
|
||||
let output = block.output(&ctx());
|
||||
let all_text: String = output
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|l| l.content.spans.iter().map(|s| s.content.as_ref()))
|
||||
.collect();
|
||||
assert!(all_text.contains("spending cap"));
|
||||
assert!(all_text.contains("increasing your spending limit"));
|
||||
assert!(all_text.contains("grok.com?_s=usage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_unified_mentions_purchasing_credits() {
|
||||
let block = CreditLimitBlock::new(
|
||||
"You hit your weekly limit.",
|
||||
CreditLimitCardAction::PurchaseCredits,
|
||||
"https://grok.com?_s=usage",
|
||||
);
|
||||
let output = block.output(&ctx());
|
||||
let all_text: String = output
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|l| l.content.spans.iter().map(|s| s.content.as_ref()))
|
||||
.collect();
|
||||
assert!(all_text.contains("purchasing more credits"));
|
||||
assert!(all_text.contains("grok.com?_s=usage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_warning_accent() {
|
||||
let block = CreditLimitBlock::new("heading", CreditLimitCardAction::EnablePayg, "url");
|
||||
let accent = block.accent(&ctx());
|
||||
let theme = Theme::current();
|
||||
assert!(accent.is_some());
|
||||
assert_eq!(accent.unwrap().color, theme.warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_content_contract() {
|
||||
let block = CreditLimitBlock::new("heading", CreditLimitCardAction::EnablePayg, "url");
|
||||
let c = ctx();
|
||||
assert!(!block.is_foldable());
|
||||
assert!(block.is_selectable());
|
||||
assert!(!block.is_groupable());
|
||||
assert!(matches!(
|
||||
block.default_display_mode(),
|
||||
DisplayMode::Expanded
|
||||
));
|
||||
assert!(block.has_vpad(&c));
|
||||
assert!(!block.has_raw_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_structure_and_content() {
|
||||
let url = "https://grok.com?_s=usage";
|
||||
let block = CreditLimitBlock::new("Test heading", CreditLimitCardAction::EnablePayg, url);
|
||||
let output = block.output(&ctx());
|
||||
|
||||
// heading, separator, body, link = 4 lines
|
||||
assert_eq!(output.lines.len(), 4);
|
||||
|
||||
let all_text: String = output
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|l| l.content.spans.iter().map(|s| s.content.as_ref()))
|
||||
.collect();
|
||||
assert!(all_text.contains(url));
|
||||
|
||||
// Heading uses bold modifier.
|
||||
assert!(
|
||||
output.lines[0]
|
||||
.content
|
||||
.spans
|
||||
.iter()
|
||||
.any(|s| s.style.add_modifier.contains(Modifier::BOLD))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_stores_fields_correctly() {
|
||||
let block = CreditLimitBlock::new(
|
||||
"my heading",
|
||||
CreditLimitCardAction::IncreasePaygLimit,
|
||||
"https://example.com",
|
||||
);
|
||||
assert_eq!(block.heading, "my heading");
|
||||
assert_eq!(block.action, CreditLimitCardAction::IncreasePaygLimit);
|
||||
assert_eq!(block.url, "https://example.com");
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ mod agent;
|
||||
mod bg_task;
|
||||
mod btw;
|
||||
mod context_info;
|
||||
mod credit_limit;
|
||||
pub mod markdown_content;
|
||||
pub mod mermaid_content;
|
||||
mod quote_bar;
|
||||
@@ -21,7 +20,6 @@ pub use agent::AgentMessageBlock;
|
||||
pub use bg_task::{BgTaskBlock, BgTaskKind};
|
||||
pub use btw::BtwBlock;
|
||||
pub use context_info::ContextInfoBlock;
|
||||
pub use credit_limit::{CreditLimitBlock, CreditLimitCardAction};
|
||||
pub use session_event::{EndWork, SessionEvent, SessionEventBlock};
|
||||
pub use subagent::{SubagentBlock, SubagentBlockKind};
|
||||
pub use system::SystemMessageBlock;
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn render_blocks_to_markdown<'a>(blocks: impl IntoIterator<Item = &'a Render
|
||||
last_was_agent = false;
|
||||
}
|
||||
// Skip all non-conversation chrome: Thinking, System, SessionEvent, BgTask,
|
||||
// Subagent, Btw, CreditLimit, Stub, etc. Thinking blocks are
|
||||
// Subagent, Btw, Stub, etc. Thinking blocks are
|
||||
// treated as intra-Assistant glue (no new header).
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ impl<'a> EntryRenderer<'a> {
|
||||
///
|
||||
/// EXACT for blocks whose searchable text mirrors their selectable rendered
|
||||
/// lines (plain source blocks, markdown/thinking bodies); a best-effort
|
||||
/// estimate for field-joined source (Subagent/BgTask/CreditLimit), kept on
|
||||
/// estimate for field-joined source (Subagent/BgTask/etc.), kept on
|
||||
/// screen by the caller's entry-height clamp. Past the last logical line,
|
||||
/// clamps to the final content row.
|
||||
pub fn rendered_row_of_logical_line(&self, width: u16, logical_line: usize) -> u16 {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use kigi_shell::agent::config::Config as AgentConfig;
|
||||
use kigi_shell::auth::{AuthManager, try_ensure_fresh_auth};
|
||||
use kigi_shell::session::merge::MergedSession;
|
||||
use kigi_shell::util::kigi_home::kigi_home;
|
||||
#[derive(Debug, clap::Args, Clone)]
|
||||
@@ -33,41 +31,17 @@ enum SessionsCommand {
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run(args: SessionsArgs, agent_config: &AgentConfig) -> Result<()> {
|
||||
// Best-effort only. Do not force an interactive public login for enterprise
|
||||
// deployments that only configure a deployment_key + custom xai_api_base_url.
|
||||
// If the user has previously run the interactive `grok` TUI (which succeeds
|
||||
// for these setups), any cached credential will be used. Otherwise we still
|
||||
// proceed so the SessionRegistryClient can use the deployment_key when
|
||||
// talking to the custom proxy.
|
||||
let auth = try_ensure_fresh_auth(&agent_config.kimi_code_config).await;
|
||||
|
||||
let auth_manager = std::sync::Arc::new(AuthManager::new(
|
||||
&kigi_home(),
|
||||
agent_config.kimi_code_config.clone(),
|
||||
));
|
||||
|
||||
let client = kigi_shell::agent::session_registry_client::SessionRegistryClient::new(
|
||||
agent_config.endpoints.proxy_url(),
|
||||
String::new(),
|
||||
)
|
||||
.with_deployment_key(agent_config.endpoints.deployment_key.clone())
|
||||
.with_alpha_test_key(agent_config.endpoints.alpha_test_key.clone())
|
||||
.with_auth(auth_manager.clone());
|
||||
|
||||
pub async fn run(args: SessionsArgs) -> Result<()> {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| ".".into());
|
||||
|
||||
match args.command {
|
||||
SessionsCommand::List { limit } => {
|
||||
let sessions =
|
||||
kigi_shell::session::merge::fetch_merged(Some(&client), cwd.to_str(), None, limit)
|
||||
.await;
|
||||
kigi_shell::session::merge::fetch_merged(None, cwd.to_str(), None, limit).await;
|
||||
print_sessions_grouped(&sessions);
|
||||
}
|
||||
SessionsCommand::Search { query, limit } => {
|
||||
use kigi_shell::session::merge::REMOTE_TIMEOUT;
|
||||
use kigi_shell::session::storage::search::{SessionSearchRequest, execute_search};
|
||||
use std::collections::HashSet;
|
||||
|
||||
let req = SessionSearchRequest {
|
||||
query,
|
||||
@@ -78,28 +52,7 @@ pub async fn run(args: SessionsArgs, agent_config: &AgentConfig) -> Result<()> {
|
||||
};
|
||||
let root = kigi_home();
|
||||
|
||||
let remote_limit = (limit * 3).max(100) as i64;
|
||||
let (local_resp, remote_results) = tokio::join!(execute_search(&root, &req), async {
|
||||
tokio::time::timeout(
|
||||
REMOTE_TIMEOUT,
|
||||
client.search(Some(&req.query), remote_limit),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
eprintln!(
|
||||
"warning: remote session search timed out, showing local results only"
|
||||
);
|
||||
Ok(Vec::new())
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("warning: remote session search failed: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
});
|
||||
|
||||
let resp = local_resp?;
|
||||
let local_ids: HashSet<&str> =
|
||||
resp.results.iter().map(|r| r.session_id.as_str()).collect();
|
||||
let resp = execute_search(&root, &req).await?;
|
||||
|
||||
for hit in &resp.results {
|
||||
let title = if hit.title.is_empty() {
|
||||
@@ -124,63 +77,14 @@ pub async fn run(args: SessionsArgs, agent_config: &AgentConfig) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let remaining = limit.saturating_sub(resp.results.len());
|
||||
let mut remote_shown = 0usize;
|
||||
for r in &remote_results {
|
||||
if remote_shown >= remaining {
|
||||
break;
|
||||
}
|
||||
if local_ids.contains(r.session_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let title = if r.summary.is_empty() {
|
||||
"(untitled)"
|
||||
} else {
|
||||
&r.summary
|
||||
};
|
||||
let time = chrono::DateTime::parse_from_rfc3339(&r.updated_at)
|
||||
.map(|dt| {
|
||||
dt.with_timezone(&chrono::Local)
|
||||
.format("%b %d, %l:%M%P")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let snippet: String = r
|
||||
.first_prompt
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(80)
|
||||
.collect();
|
||||
println!(
|
||||
"{} (remote) {}\n {}\n {}",
|
||||
r.session_id, time, title, snippet
|
||||
);
|
||||
remote_shown += 1;
|
||||
}
|
||||
|
||||
println!("\nTotal: {}", resp.results.len() + remote_shown);
|
||||
println!("\nTotal: {}", resp.results.len());
|
||||
}
|
||||
SessionsCommand::Delete { id } => {
|
||||
// Always attempt the remote delete when authenticated and not
|
||||
// ZDR — `list` / `search` likewise query remote unconditionally
|
||||
// rather than gating on storage mode (which the CLI cannot
|
||||
// resolve here: it builds config without remote settings). The
|
||||
// backend delete is idempotent (a `404` is treated as success),
|
||||
// so this is safe for local-only sessions with no remote copy.
|
||||
// ZDR teams never upload, so there is nothing remote to delete.
|
||||
let needs_remote = auth.is_some();
|
||||
|
||||
// Pass `cwd = None` so the session is found by id regardless of
|
||||
// which workspace it was created in; the local delete still uses
|
||||
// the resolved per-session cwd.
|
||||
let deletion = kigi_shell::session::persistence::delete_session_history(
|
||||
&id,
|
||||
None,
|
||||
needs_remote,
|
||||
auth_manager.clone(),
|
||||
)
|
||||
.await?;
|
||||
let deletion =
|
||||
kigi_shell::session::persistence::delete_session_history(&id, None).await?;
|
||||
|
||||
if deletion.any_removed() {
|
||||
println!("Deleted session {id}");
|
||||
|
||||
@@ -119,30 +119,6 @@ const PERMISSION_MODE_CHOICES: &[EnumChoice] = &[
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coding-data-sharing catalog.
|
||||
//
|
||||
// Persisted in auth metadata (`AuthEntry::coding_data_retention_opt_out`),
|
||||
// NOT config.toml. Two choices only — the pager has no `Option`/`Unset`
|
||||
// representation for this field.
|
||||
//
|
||||
// `supports_preview: false` — toggling fires an async ACP call that
|
||||
// can fail. Commit on Enter only.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CODING_DATA_SHARING_CHOICES: &[EnumChoice] = &[
|
||||
EnumChoice {
|
||||
canonical: "opt-in",
|
||||
display: "Opt in",
|
||||
description: "Allow SpaceXAI to retain and use coding session data for training and product improvement.",
|
||||
},
|
||||
EnumChoice {
|
||||
canonical: "opt-out",
|
||||
display: "Opt out",
|
||||
description: "Do not retain coding session data. Code requests will not be used for training.",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan-mode catalog.
|
||||
//
|
||||
@@ -947,35 +923,6 @@ pub fn default_settings() -> Vec<SettingMeta> {
|
||||
restart_required: false,
|
||||
hidden_in_minimal: false,
|
||||
},
|
||||
// SHELL-owned. Persisted in auth metadata (not config.toml).
|
||||
// Reads from `PagerLocalSnapshot.coding_data_sharing_opt_out`.
|
||||
// Default "opt-in" matches `AuthEntry::coding_data_retention_opt_out = false`.
|
||||
// ZDR / non-admin guards are enforced at dispatch time.
|
||||
SettingMeta {
|
||||
key: "coding_data_sharing",
|
||||
category: SettingCategory::Privacy,
|
||||
owner: SettingOwner::Shell,
|
||||
label: "Coding data sharing",
|
||||
description: "Controls whether SpaceXAI may retain and train on coding session data.",
|
||||
keywords: &[
|
||||
"privacy",
|
||||
"data",
|
||||
"sharing",
|
||||
"coding",
|
||||
"retention",
|
||||
"telemetry",
|
||||
"training",
|
||||
"opt-in",
|
||||
"opt-out",
|
||||
],
|
||||
kind: SettingKind::Enum {
|
||||
default: "opt-in",
|
||||
choices: CODING_DATA_SHARING_CHOICES,
|
||||
supports_preview: false,
|
||||
},
|
||||
restart_required: false,
|
||||
hidden_in_minimal: false,
|
||||
},
|
||||
// SHELL-owned, persisted to `[ui].default_selected_permission` in
|
||||
// config.toml. Read by the pager via `appearance::permission_cursor`.
|
||||
// Canonical `always_allow_all_sessions` (the effective default) lands
|
||||
|
||||
@@ -38,7 +38,6 @@ pub enum SettingCategory {
|
||||
Mouse,
|
||||
Editor,
|
||||
Agent,
|
||||
Privacy,
|
||||
Models,
|
||||
Session,
|
||||
Advanced,
|
||||
@@ -51,7 +50,6 @@ impl SettingCategory {
|
||||
Self::Mouse,
|
||||
Self::Editor,
|
||||
Self::Agent,
|
||||
Self::Privacy,
|
||||
Self::Models,
|
||||
Self::Session,
|
||||
Self::Advanced,
|
||||
@@ -64,7 +62,6 @@ impl SettingCategory {
|
||||
Self::Mouse => "Mouse",
|
||||
Self::Editor => "Editor & Input",
|
||||
Self::Agent => "Agent & Approval",
|
||||
Self::Privacy => "Privacy",
|
||||
Self::Models => "Models",
|
||||
Self::Session => "Session",
|
||||
Self::Advanced => "Advanced",
|
||||
@@ -247,10 +244,6 @@ pub struct PagerLocalSnapshot {
|
||||
/// Cloned into the snapshot so the modal's validator/resolver is
|
||||
/// self-contained (the modal outlives the borrow on `app.agents`).
|
||||
pub available_models: Vec<(String, acp::ModelId)>,
|
||||
/// Whether the user has opted OUT of coding data sharing.
|
||||
/// Lives in auth metadata (no `UiConfig` field). Inverted mapping:
|
||||
/// `opt_out == false` → canonical "opt-in".
|
||||
pub coding_data_sharing_opt_out: bool,
|
||||
/// Whether plan mode is active. Uses effective state
|
||||
/// (`pending.unwrap_or(active)`) so rapid toggles don't double-send.
|
||||
/// Refreshed on all mutation paths including ACP `CurrentModeUpdate`.
|
||||
@@ -285,7 +278,6 @@ impl Default for PagerLocalSnapshot {
|
||||
auto_mode: false,
|
||||
current_model_name: None,
|
||||
available_models: Vec::new(),
|
||||
coding_data_sharing_opt_out: false,
|
||||
plan_mode_active: false,
|
||||
show_tips: None,
|
||||
auto_update: None,
|
||||
@@ -602,12 +594,6 @@ pub fn current_value_for(
|
||||
)),
|
||||
// max_thoughts_width: `u16` widened to `i64`.
|
||||
"max_thoughts_width" => Some(SettingValue::Int(ui.max_thoughts_width as i64)),
|
||||
// coding_data_sharing: inverts the `_opt_out` bool.
|
||||
"coding_data_sharing" => Some(SettingValue::Enum(if pager.coding_data_sharing_opt_out {
|
||||
"opt-out"
|
||||
} else {
|
||||
"opt-in"
|
||||
})),
|
||||
// plan_mode: canonical via `PlanModeKind::from_bool().as_canonical()`.
|
||||
"plan_mode" => Some(SettingValue::Enum(
|
||||
crate::app::actions::PlanModeKind::from_bool(pager.plan_mode_active).as_canonical(),
|
||||
@@ -816,17 +802,6 @@ mod tests {
|
||||
"max_thoughts_width default drifts from UiConfig::default()",
|
||||
);
|
||||
}
|
||||
// coding_data_sharing: no UiConfig field; default pinned
|
||||
// against auth metadata (opt_out=false → "opt-in").
|
||||
("coding_data_sharing", SettingKind::Enum { default, .. }) => {
|
||||
let expected = "opt-in";
|
||||
assert_eq!(
|
||||
*default, expected,
|
||||
"coding_data_sharing registry default must be 'opt-in' — \
|
||||
the on-disk source of truth is `AuthEntry::coding_data_retention_opt_out: \
|
||||
bool` (defaults to `false`, i.e. user has NOT opted out)",
|
||||
);
|
||||
}
|
||||
// CLI batch: fields live on CliConfig, not UiConfig.
|
||||
// Defaults pinned literally.
|
||||
("show_tips", SettingKind::Bool { default }) => {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use kigi_shell::agent::config::Config as AgentConfig;
|
||||
use kigi_shell::session::share::{ShareSessionRequest, ShareSessionResponse};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_acp_lib::acp_send;
|
||||
|
||||
#[derive(Debug, clap::Args, Clone)]
|
||||
pub struct ShareArgs {
|
||||
/// Session ID to share
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
pub async fn run(args: &ShareArgs, agent_config: &AgentConfig) -> Result<()> {
|
||||
let cancel = CancellationToken::new();
|
||||
let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?;
|
||||
|
||||
let _init: acp::InitializeResponse = acp_send(
|
||||
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
|
||||
.client_capabilities(
|
||||
acp::ClientCapabilities::new()
|
||||
.fs(acp::FileSystemCapabilities::new())
|
||||
.terminal(false),
|
||||
)
|
||||
.meta(
|
||||
serde_json::json!({
|
||||
"clientType": crate::client_identity::HEADLESS_CLIENT_TYPE,
|
||||
"clientVersion": crate::client_identity::PAGER_CLIENT_VERSION
|
||||
})
|
||||
.as_object()
|
||||
.cloned(),
|
||||
),
|
||||
&spawned.channel.tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let params = serde_json::value::to_raw_value(&ShareSessionRequest {
|
||||
session_id: args.session_id.clone(),
|
||||
})?;
|
||||
let ext_req = acp::ExtRequest::new("x.ai/share_session", params.into());
|
||||
|
||||
let ext_resp: acp::ExtResponse = acp_send(ext_req, &spawned.channel.tx).await?;
|
||||
let response: ShareSessionResponse = serde_json::from_str(ext_resp.0.get())?;
|
||||
|
||||
println!("{}", response.share_url);
|
||||
cancel.cancel();
|
||||
Ok(())
|
||||
}
|
||||
@@ -41,7 +41,6 @@ pub mod new;
|
||||
pub mod personas;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod privacy;
|
||||
pub mod queue;
|
||||
pub mod recap;
|
||||
pub mod release_notes;
|
||||
@@ -53,7 +52,6 @@ pub mod screen_mode_switch;
|
||||
pub mod scroll_debug;
|
||||
pub mod session_info;
|
||||
pub mod settings_cmd;
|
||||
pub mod share;
|
||||
pub mod tasks;
|
||||
pub mod terminal_setup;
|
||||
pub mod theme;
|
||||
@@ -98,7 +96,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
||||
Arc::new(plugin::HooksCommand),
|
||||
Arc::new(plugin::PluginsCommand),
|
||||
Arc::new(plugin::SkillsCommand),
|
||||
Arc::new(share::ShareCommand),
|
||||
Arc::new(session_info::SessionInfoCommand),
|
||||
Arc::new(rename::RenameCommand),
|
||||
Arc::new(dashboard::DashboardCommand),
|
||||
@@ -120,7 +117,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
||||
Arc::new(timeline::TimelineCommand),
|
||||
Arc::new(toggle_mouse_reporting::ToggleMouseReportingCommand),
|
||||
Arc::new(settings_cmd::SettingsCommand),
|
||||
Arc::new(privacy::PrivacyCommand),
|
||||
Arc::new(rewind::RewindCommand),
|
||||
Arc::new(jump::JumpCommand),
|
||||
Arc::new(login::LoginCommand),
|
||||
@@ -455,83 +451,24 @@ mod tests {
|
||||
usage::UsageCommand.run(&mut ctx, args)
|
||||
}
|
||||
#[test]
|
||||
fn usage_no_args_returns_show_usage() {
|
||||
fn usage_returns_show_usage() {
|
||||
assert!(matches!(
|
||||
run_usage(""),
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn usage_show_returns_show_usage() {
|
||||
fn usage_ignores_stray_args() {
|
||||
assert!(matches!(
|
||||
run_usage("show"),
|
||||
run_usage(" anything "),
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn usage_manage_returns_open_url() {
|
||||
match run_usage("manage") {
|
||||
CommandResult::Action(Action::OpenUrl(url)) => {
|
||||
assert_eq!(url, "https://grok.com/?_s=usage");
|
||||
}
|
||||
other => panic!("expected Action(OpenUrl), got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn usage_invalid_arg_returns_error() {
|
||||
match run_usage("delete") {
|
||||
CommandResult::Error(msg) => {
|
||||
assert!(msg.contains("delete"), "got: {msg}");
|
||||
}
|
||||
other => panic!("expected Error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn usage_whitespace_only_treated_as_no_args() {
|
||||
assert!(matches!(
|
||||
run_usage(" "),
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn usage_show_with_leading_whitespace() {
|
||||
assert!(matches!(
|
||||
run_usage(" show "),
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn usage_manage_with_leading_whitespace() {
|
||||
match run_usage(" manage ") {
|
||||
CommandResult::Action(Action::OpenUrl(url)) => {
|
||||
assert_eq!(url, "https://grok.com/?_s=usage");
|
||||
}
|
||||
other => panic!("expected Action(OpenUrl), got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn usage_suggest_args_returns_show_and_manage() {
|
||||
let models = ModelState::default();
|
||||
let ctx = crate::slash::command::AppCtx {
|
||||
models: &models,
|
||||
cwd: std::path::Path::new("."),
|
||||
screen_mode: crate::app::ScreenMode::Fullscreen,
|
||||
};
|
||||
let items = usage::UsageCommand
|
||||
.suggest_args(&ctx, "")
|
||||
.expect("should have suggestions");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].display, "show");
|
||||
assert_eq!(items[0].insert_text, "show");
|
||||
assert_eq!(items[1].display, "manage");
|
||||
assert_eq!(items[1].insert_text, "manage");
|
||||
}
|
||||
#[test]
|
||||
fn usage_metadata() {
|
||||
let cmd = usage::UsageCommand;
|
||||
assert_eq!(cmd.name(), "usage");
|
||||
assert!(cmd.takes_args());
|
||||
assert_eq!(cmd.arg_placeholder(), Some("show | manage"));
|
||||
assert!(!cmd.takes_args());
|
||||
assert!(!cmd.description().is_empty());
|
||||
assert!(!cmd.usage().is_empty());
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
//! `/privacy` -- show or toggle privacy and data retention status.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Show or toggle privacy and data retention status.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `/privacy` show current status
|
||||
/// - `/privacy opt-in` opt in to coding data sharing
|
||||
/// - `/privacy opt-out` opt out of coding data sharing
|
||||
///
|
||||
/// Case-insensitive. Only unambiguous aliases are accepted (e.g. `in`,
|
||||
/// `share`, `out`, `private`) — generic toggles like `on`/`off` are
|
||||
/// rejected because they're ambiguous in privacy context.
|
||||
pub struct PrivacyCommand;
|
||||
|
||||
impl SlashCommand for PrivacyCommand {
|
||||
fn name(&self) -> &str {
|
||||
"privacy"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Show or toggle privacy & data retention status"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/privacy [opt-in|opt-out]"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let arg = args.trim();
|
||||
if arg.is_empty() {
|
||||
return CommandResult::Action(Action::ShowPrivacyInfo);
|
||||
}
|
||||
match parse_privacy_arg(arg) {
|
||||
Some(opted_in) => CommandResult::Action(Action::SetCodingDataSharing { opted_in }),
|
||||
None => CommandResult::Error(format!(
|
||||
"Unknown argument `{arg}`. Valid options: opt-in (aliases: in, share) | \
|
||||
opt-out (aliases: out, private)."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `/privacy <arg>` into `Some(true)` (opt-in), `Some(false)`
|
||||
/// (opt-out), or `None` (unknown). Case-insensitive ASCII matching.
|
||||
#[doc(hidden)]
|
||||
pub fn parse_privacy_arg(arg: &str) -> Option<bool> {
|
||||
const OPT_IN_ALIASES: &[&str] = &["opt-in", "in", "share"];
|
||||
const OPT_OUT_ALIASES: &[&str] = &["opt-out", "out", "private"];
|
||||
|
||||
if OPT_IN_ALIASES.iter().any(|a| arg.eq_ignore_ascii_case(a)) {
|
||||
return Some(true);
|
||||
}
|
||||
if OPT_OUT_ALIASES.iter().any(|a| arg.eq_ignore_ascii_case(a)) {
|
||||
return Some(false);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_opt_in_canonical() {
|
||||
assert_eq!(parse_privacy_arg("opt-in"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_opt_out_canonical() {
|
||||
assert_eq!(parse_privacy_arg("opt-out"), Some(false));
|
||||
}
|
||||
|
||||
/// Case-insensitive matching.
|
||||
#[test]
|
||||
fn parse_case_insensitive() {
|
||||
for variant in &["OPT-IN", "Opt-In", "opt-IN", "OpT-iN"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(variant),
|
||||
Some(true),
|
||||
"case-insensitive parse must accept `{variant}` as opt-in",
|
||||
);
|
||||
}
|
||||
for variant in &["OPT-OUT", "Opt-Out", "opt-OUT", "OpT-oUt"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(variant),
|
||||
Some(false),
|
||||
"case-insensitive parse must accept `{variant}` as opt-out",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins the accepted alias catalog.
|
||||
#[test]
|
||||
fn parse_opt_in_aliases() {
|
||||
for alias in &["in", "share"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(alias),
|
||||
Some(true),
|
||||
"alias `{alias}` must map to opt-in",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_opt_out_aliases() {
|
||||
for alias in &["out", "private"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(alias),
|
||||
Some(false),
|
||||
"alias `{alias}` must map to opt-out",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ambiguous generic-toggle aliases must be rejected — `/privacy on`
|
||||
/// is ambiguous (could mean opt-in or opt-out).
|
||||
#[test]
|
||||
fn parse_rejects_ambiguous_generic_aliases() {
|
||||
for ambiguous in &[
|
||||
"on", "off", "true", "false", "enable", "enabled", "disable", "disabled",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(ambiguous),
|
||||
None,
|
||||
"ambiguous alias `{ambiguous}` MUST be rejected — it would let a user typing \
|
||||
`/privacy {ambiguous}` get the OPPOSITE of their intent in privacy context. \
|
||||
See Security Issue 10 in PR 9 R1.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown arguments return None → the command surfaces an error
|
||||
/// listing valid options. Pins the "no silent fallback" contract.
|
||||
#[test]
|
||||
fn parse_unknown_returns_none() {
|
||||
for unknown in &["yes", "no", "maybe", "opt-maybe", "", " ", "1", "0"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(unknown),
|
||||
None,
|
||||
"unknown arg `{unknown}` must NOT parse",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias families must not overlap.
|
||||
#[test]
|
||||
fn alias_families_disjoint() {
|
||||
let opt_in_results: Vec<bool> = ["opt-in", "in", "share"]
|
||||
.iter()
|
||||
.map(|a| parse_privacy_arg(a).unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
opt_in_results.iter().all(|b| *b),
|
||||
"every opt-in alias must parse to true",
|
||||
);
|
||||
let opt_out_results: Vec<bool> = ["opt-out", "out", "private"]
|
||||
.iter()
|
||||
.map(|a| parse_privacy_arg(a).unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
opt_out_results.iter().all(|b| !*b),
|
||||
"every opt-out alias must parse to false",
|
||||
);
|
||||
}
|
||||
|
||||
/// Error message must list every accepted alias.
|
||||
#[test]
|
||||
fn error_message_lists_all_accepted_aliases() {
|
||||
use crate::acp::model_state::ModelState;
|
||||
use crate::app::bundle::BundleState;
|
||||
|
||||
let cmd = PrivacyCommand;
|
||||
let models = ModelState::default();
|
||||
let bundle = BundleState::default();
|
||||
let mut ctx = CommandExecCtx {
|
||||
models: &models,
|
||||
session_id: None,
|
||||
bundle_state: &bundle,
|
||||
screen_mode: crate::app::ScreenMode::Inline,
|
||||
pager_state: crate::settings::PagerLocalSnapshot::default(),
|
||||
};
|
||||
let result = cmd.run(&mut ctx, "garbage-input");
|
||||
match result {
|
||||
CommandResult::Error(msg) => {
|
||||
// Every accepted alias appears in the error message.
|
||||
for alias in &["opt-in", "in", "share", "opt-out", "out", "private"] {
|
||||
assert!(
|
||||
msg.contains(alias),
|
||||
"error message must mention alias `{alias}` so the user knows \
|
||||
what to type; msg = {msg:?}",
|
||||
);
|
||||
}
|
||||
// Dropped ambiguous aliases must not appear.
|
||||
for dropped in &["off", "true", "false", "enable", "disable"] {
|
||||
assert!(
|
||||
!msg.contains(dropped),
|
||||
"dropped alias `{dropped}` must NOT appear in error message \
|
||||
(would suggest it's still accepted); msg = {msg:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
other => panic!("expected Error result for unknown arg, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! `/share` -- share current session via URL.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Share the current session via a public URL.
|
||||
pub struct ShareCommand;
|
||||
|
||||
impl SlashCommand for ShareCommand {
|
||||
fn name(&self) -> &str {
|
||||
"share"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Share this session via URL"
|
||||
}
|
||||
|
||||
fn session_scoped(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/share"
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||
// Check if we have an active session
|
||||
if ctx.session_id.is_none() {
|
||||
return CommandResult::Error("No active session to share".to_string());
|
||||
}
|
||||
|
||||
CommandResult::Action(Action::ShareSession)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
//! `/usage` -- show credit usage or open billing management page.
|
||||
//! `/usage` -- display Kimi API usage and quota information.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Show coding credit usage or manage billing.
|
||||
///
|
||||
/// `/usage` -- show current credit usage
|
||||
/// `/usage show` -- same as above
|
||||
/// `/usage manage` -- open billing management page in browser
|
||||
/// Display API usage and quota information.
|
||||
pub struct UsageCommand;
|
||||
|
||||
impl SlashCommand for UsageCommand {
|
||||
@@ -15,56 +11,22 @@ impl SlashCommand for UsageCommand {
|
||||
"usage"
|
||||
}
|
||||
|
||||
/// `/cost` is the minimal-mode name for the same credit-usage summary:
|
||||
/// it commits a usage/cost system block rather than opening a
|
||||
/// pane, so it's an alias rather than a separate command.
|
||||
/// `/cost` is the minimal-mode name for the same usage summary: it
|
||||
/// commits a usage system block rather than opening a pane, so it's
|
||||
/// an alias rather than a separate command.
|
||||
fn aliases(&self) -> &[&str] {
|
||||
&["cost"]
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"View credit usage or manage billing"
|
||||
"Display API usage and quota information"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/usage [show|manage]"
|
||||
"/usage"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn arg_placeholder(&self) -> Option<&str> {
|
||||
Some("show | manage")
|
||||
}
|
||||
|
||||
fn suggest_args(&self, _ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
|
||||
Some(vec![
|
||||
ArgItem {
|
||||
display: "show".to_string(),
|
||||
match_text: "show".to_string(),
|
||||
insert_text: "show".to_string(),
|
||||
description: "View credit usage".to_string(),
|
||||
},
|
||||
ArgItem {
|
||||
display: "manage".to_string(),
|
||||
match_text: "manage".to_string(),
|
||||
insert_text: "manage".to_string(),
|
||||
description: "Open billing management page".to_string(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let arg = args.trim();
|
||||
match arg {
|
||||
"" | "show" => CommandResult::Action(Action::ShowUsage),
|
||||
"manage" => {
|
||||
CommandResult::Action(Action::OpenUrl("https://grok.com/?_s=usage".to_string()))
|
||||
}
|
||||
_ => CommandResult::Error(format!(
|
||||
"Unknown argument: {arg}. Use /usage show or /usage manage"
|
||||
)),
|
||||
}
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1828,7 +1828,6 @@ mod tests {
|
||||
"/compact",
|
||||
"/fork",
|
||||
"/rewind",
|
||||
"/share",
|
||||
"/context",
|
||||
"/copy",
|
||||
"/export",
|
||||
|
||||
@@ -351,12 +351,6 @@ impl CommandRegistry {
|
||||
self.available_tools = Some(tools);
|
||||
}
|
||||
|
||||
/// Show or hide the /share command.
|
||||
/// When hidden, it won't appear in the dropdown or be executable.
|
||||
pub fn set_share_visible(&mut self, visible: bool) {
|
||||
self.set_command_visible("share", visible);
|
||||
}
|
||||
|
||||
/// Show or hide the /usage command.
|
||||
/// When hidden, it won't appear in the dropdown or be executable.
|
||||
pub fn set_usage_visible(&mut self, visible: bool) {
|
||||
@@ -699,35 +693,6 @@ mod tests {
|
||||
assert!(registry.get("flush").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_share_visible_hides_and_restores_share_command() {
|
||||
let share: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
name: "share",
|
||||
aliases: &[],
|
||||
});
|
||||
let other: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
name: "exit",
|
||||
aliases: &[],
|
||||
});
|
||||
let mut registry = CommandRegistry::new(vec![share, other]);
|
||||
|
||||
// Default: /share is visible.
|
||||
assert!(registry.get("share").is_some());
|
||||
assert!(registry.triggers().iter().any(|t| t.canonical == "share"));
|
||||
|
||||
// Hiding /share removes it from lookup and triggers.
|
||||
registry.set_share_visible(false);
|
||||
assert!(registry.get("share").is_none());
|
||||
assert!(!registry.triggers().iter().any(|t| t.canonical == "share"));
|
||||
// Other commands are unaffected.
|
||||
assert!(registry.get("exit").is_some());
|
||||
|
||||
// Re-enabling restores it.
|
||||
registry.set_share_visible(true);
|
||||
assert!(registry.get("share").is_some());
|
||||
assert!(registry.triggers().iter().any(|t| t.canonical == "share"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_usage_visible_hides_and_restores_usage_command() {
|
||||
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
@@ -1110,9 +1075,9 @@ mod tests {
|
||||
/// unresolvable for dispatch, exactly like `get()`.
|
||||
#[test]
|
||||
fn get_for_dispatch_respects_hard_gates() {
|
||||
// Hard-hidden by name (e.g. /dashboard default, /share toggle).
|
||||
let share: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
name: "share",
|
||||
// Hard-hidden by name (e.g. /dashboard default).
|
||||
let dashboard: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
name: "dashboard",
|
||||
aliases: &[],
|
||||
});
|
||||
// Tier-restricted.
|
||||
@@ -1125,11 +1090,14 @@ mod tests {
|
||||
name: "loop",
|
||||
required: &["scheduler_create"],
|
||||
});
|
||||
let mut reg = CommandRegistry::new(vec![share, usage, gated]);
|
||||
reg.set_share_visible(false);
|
||||
let mut reg = CommandRegistry::new(vec![dashboard, usage, gated]);
|
||||
reg.set_dashboard_visible(false);
|
||||
reg.set_restricted_commands(&["usage".to_string()]);
|
||||
|
||||
assert!(reg.get_for_dispatch("share").is_none(), "hidden stays hard");
|
||||
assert!(
|
||||
reg.get_for_dispatch("dashboard").is_none(),
|
||||
"hidden stays hard"
|
||||
);
|
||||
assert!(
|
||||
reg.get_for_dispatch("usage").is_none(),
|
||||
"restricted stays blocked (upsell path owns it)"
|
||||
|
||||
@@ -26,8 +26,6 @@ pub fn make_agent_view(session_id: Option<&str>, cwd: &str) -> crate::app::agent
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -889,7 +889,6 @@ pub fn build_hints(
|
||||
has_queued_follow_up: bool,
|
||||
selected_is_user_prompt: bool,
|
||||
selected_is_agent_message: bool,
|
||||
selected_is_credit_limit: bool,
|
||||
shift_enter_unavailable: bool,
|
||||
scrollback_search: Option<&ScrollbackSearchState>,
|
||||
) -> Vec<HintItem> {
|
||||
@@ -1029,19 +1028,12 @@ pub fn build_hints(
|
||||
let mut hints = Vec::new();
|
||||
let nothing_special = !selected_is_agent_message
|
||||
&& !selected_is_user_prompt
|
||||
&& !selected_is_credit_limit
|
||||
&& fold_label.is_none()
|
||||
&& group_header_label.is_none()
|
||||
&& !selected_supports_fullscreen;
|
||||
if nothing_special {
|
||||
hints.push(space_prompt_hint());
|
||||
}
|
||||
if selected_is_credit_limit {
|
||||
if let Some(key) = registry.key_for(ActionId::OpenBlockViewer) {
|
||||
hints.push(HintItem::new(key, "open"));
|
||||
}
|
||||
hints.push(space_prompt_hint());
|
||||
}
|
||||
if selected_is_agent_message {
|
||||
if vim_mode
|
||||
&& selected_supports_copy
|
||||
@@ -1217,7 +1209,6 @@ mod tests {
|
||||
selected_is_user_prompt,
|
||||
selected_is_agent_message,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -1249,7 +1240,6 @@ mod tests {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
||||
@@ -1413,7 +1403,6 @@ mod tests {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some(&search),
|
||||
)
|
||||
}
|
||||
@@ -1516,7 +1505,6 @@ mod tests {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
@@ -1559,7 +1547,6 @@ mod tests {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
shift_enter_unavailable,
|
||||
None,
|
||||
)
|
||||
@@ -1619,7 +1606,6 @@ mod tests {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
||||
|
||||
@@ -1,817 +0,0 @@
|
||||
//! Credit balance indicator for the agent status bar.
|
||||
//!
|
||||
//! Shows the user's coding credit usage as a compact status bar item.
|
||||
//! Fetches real data from the `x.ai/billing` agent extension.
|
||||
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Credit balance state from the billing API.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreditBalance {
|
||||
/// Usage as a percentage of the allowance (0.0–100.0).
|
||||
pub usage_pct: f64,
|
||||
/// Usage as a percentage of total budget (free + on-demand when enabled).
|
||||
pub effective_usage_pct: f64,
|
||||
/// Billing period end as a formatted local wall-clock string (no zone
|
||||
/// label), e.g. "Mar 31, 12:00".
|
||||
pub period_end_display: Option<String>,
|
||||
/// Whether pay-as-you-go (on-demand) billing is enabled.
|
||||
pub pay_as_you_go: bool,
|
||||
/// On-demand spending cap in USD cents (e.g. 500 = $5.00).
|
||||
pub on_demand_cap_cents: Option<i64>,
|
||||
/// On-demand usage this period in USD cents.
|
||||
pub on_demand_used_cents: Option<i64>,
|
||||
/// Remaining prepaid ("bought") credit balance in USD cents.
|
||||
pub prepaid_balance_cents: Option<i64>,
|
||||
/// Usage period type from the billing response (the proto enum name, e.g.
|
||||
/// `USAGE_PERIOD_TYPE_WEEKLY`). Drives the "Weekly/Monthly limit" label.
|
||||
pub period_type: Option<String>,
|
||||
/// From credits config `is_unified_billing_user` (`None` if absent).
|
||||
/// `Some(true)` = unified pool / buy-credits UX; `Some(false)` = legacy
|
||||
/// on-demand / PAYG UX.
|
||||
pub is_unified_billing_user: Option<bool>,
|
||||
}
|
||||
|
||||
impl CreditBalance {
|
||||
/// Label for the percentage allowance, chosen from the period type:
|
||||
/// "Weekly limit" / "Monthly limit", falling back to "Usage" when unknown.
|
||||
pub fn usage_label(&self) -> &'static str {
|
||||
match self.period_type.as_deref() {
|
||||
Some(t) if t.contains("WEEKLY") => "Weekly limit",
|
||||
Some(t) if t.contains("MONTHLY") => "Monthly limit",
|
||||
_ => "Usage",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto top-up rule data used by the `/usage` summary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoTopupInfo {
|
||||
/// Whether auto top-up is enabled.
|
||||
pub enabled: bool,
|
||||
/// Per-trigger top-up amount in USD cents.
|
||||
pub topup_amount_cents: Option<i64>,
|
||||
/// Optional maximum monthly top-up amount in USD cents.
|
||||
pub max_amount_cents: Option<i64>,
|
||||
}
|
||||
|
||||
impl AutoTopupInfo {
|
||||
/// A known "no / disabled auto top-up" state — distinct from an unresolved
|
||||
/// `None`, which means the rule hasn't been fetched yet.
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
topup_amount_cents: None,
|
||||
max_amount_cents: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of an auto top-up rule fetch, so a transient failure doesn't clear a
|
||||
/// previously cached rule.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AutoTopupFetch {
|
||||
/// A definitive rule state (a real rule, or [`AutoTopupInfo::disabled`] when
|
||||
/// the backend reports none). Stored as the *known* auto top-up state.
|
||||
Resolved(AutoTopupInfo),
|
||||
/// Fetch failed — keep the cached value (last-known-good). A stored `None`
|
||||
/// therefore means "not yet known", not "no auto top-up".
|
||||
Unchanged,
|
||||
/// The rule is not applicable (no prepaid credits) — reset the cache to
|
||||
/// "unknown" so a later credits period doesn't read a stale rule.
|
||||
Cleared,
|
||||
}
|
||||
|
||||
/// Format `cents` as a dollar string: whole dollars as `$N`, otherwise `$N.NN`.
|
||||
fn fmt_dollars(cents: i64) -> String {
|
||||
let dollars = cents as f64 / 100.0;
|
||||
if dollars.fract() == 0.0 {
|
||||
format!("${dollars:.0}")
|
||||
} else {
|
||||
format!("${dollars:.2}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `/usage` summary block shown in scrollback.
|
||||
///
|
||||
/// Always shows usage % and (when known) the next reset time. The credits
|
||||
/// block is rendered only when the user has a positive prepaid balance:
|
||||
/// - no prepaid balance → credits block omitted entirely
|
||||
/// - auto top-up off/unknown → `Auto topup: disabled` (no max line)
|
||||
/// - auto top-up on, no max → `Auto topup: $N`
|
||||
/// - auto top-up on, max set → `Auto topup: $N` + `Max monthly topup: $M`
|
||||
pub fn format_usage_summary(balance: &CreditBalance, autotopup: Option<&AutoTopupInfo>) -> String {
|
||||
// Floor to match the backend SpendingLimiter's `as u8` truncation
|
||||
// (99.994% → 99%, never 100% until truly exhausted).
|
||||
let mut lines = vec![format!(
|
||||
"{}: {}%",
|
||||
balance.usage_label(),
|
||||
balance.usage_pct.floor() as i64
|
||||
)];
|
||||
if let Some(reset) = &balance.period_end_display {
|
||||
lines.push(format!("Next reset: {reset}"));
|
||||
}
|
||||
|
||||
// Billing stores credit / top-up amounts as negative cents (accounting
|
||||
// convention); display the absolute USD value, matching the web clients.
|
||||
if let Some(prepaid) = balance
|
||||
.prepaid_balance_cents
|
||||
.map(i64::abs)
|
||||
.filter(|c| *c > 0)
|
||||
{
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Credits: {}", fmt_dollars(prepaid)));
|
||||
match autotopup {
|
||||
Some(at) if at.enabled && at.topup_amount_cents.is_some() => {
|
||||
lines.push(format!(
|
||||
"Auto topup: {}",
|
||||
fmt_dollars(at.topup_amount_cents.unwrap().abs())
|
||||
));
|
||||
if let Some(max) = at.max_amount_cents {
|
||||
lines.push(format!("Max monthly topup: {}", fmt_dollars(max.abs())));
|
||||
}
|
||||
}
|
||||
_ => lines.push("Auto topup: disabled".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy on-demand (pay-as-you-go) billing — shown only when enabled, for
|
||||
// users on the older monthly + on-demand model. Amounts always carry cents
|
||||
// (e.g. `$50.00`), matching the web client.
|
||||
if balance.pay_as_you_go {
|
||||
let used = balance.on_demand_used_cents.unwrap_or(0).abs() as f64 / 100.0;
|
||||
let cap = balance.on_demand_cap_cents.unwrap_or(0).abs() as f64 / 100.0;
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Pay-as-you-go: ${used:.2} used of ${cap:.2} limit"));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Low-balance ($10) and pay-as-you-go critical ($5) warning thresholds, in cents.
|
||||
const LOW_BALANCE_CENTS: i64 = 1000;
|
||||
const PAY_AS_YOU_GO_CRITICAL_CENTS: i64 = 500;
|
||||
|
||||
/// The prompt's usage/credits warning as `(text, critical)`, or `None`
|
||||
/// (`critical` = yellow, else grey; team users with `usage_visible = false`
|
||||
/// never warn). Behaviour splits by billing model — prepaid credits,
|
||||
/// pay-as-you-go on-demand, or the included-allowance percentage — with exact
|
||||
/// thresholds and copy pinned by the unit tests.
|
||||
///
|
||||
/// Gateway light-frontend (`kind: "chat"`) sessions must not surface Build
|
||||
/// coding-credit warnings — use [`usage_warning_for_session`] with
|
||||
/// `gateway_chat = true` so the prompt shows no fake local sampler telemetry.
|
||||
pub fn usage_warning(
|
||||
balance: &CreditBalance,
|
||||
autotopup: Option<&AutoTopupInfo>,
|
||||
usage_visible: bool,
|
||||
) -> Option<(String, bool)> {
|
||||
usage_warning_for_session(balance, autotopup, usage_visible, false)
|
||||
}
|
||||
|
||||
/// Like [`usage_warning`], but suppresses output for gateway/chat-kind sessions.
|
||||
pub fn usage_warning_for_session(
|
||||
balance: &CreditBalance,
|
||||
autotopup: Option<&AutoTopupInfo>,
|
||||
usage_visible: bool,
|
||||
gateway_chat: bool,
|
||||
) -> Option<(String, bool)> {
|
||||
if gateway_chat || !usage_visible {
|
||||
return None;
|
||||
}
|
||||
|
||||
// A non-zero prepaid balance (stored as signed cents) means the credits model.
|
||||
let credits = balance
|
||||
.prepaid_balance_cents
|
||||
.map(i64::abs)
|
||||
.filter(|c| *c > 0);
|
||||
|
||||
let Some(credits_cents) = credits else {
|
||||
// Pay-as-you-go (legacy on-demand): warn on dollars left in the cap once
|
||||
// the included allowance is spent.
|
||||
if balance.pay_as_you_go {
|
||||
if balance.usage_pct >= 100.0 {
|
||||
let cap = balance.on_demand_cap_cents.unwrap_or(0).abs();
|
||||
let used = balance.on_demand_used_cents.unwrap_or(0).abs();
|
||||
let remaining = (cap - used).max(0);
|
||||
if remaining <= LOW_BALANCE_CENTS {
|
||||
let text = format!("Pay-as-you-go limit left: {}", fmt_dollars(remaining));
|
||||
return Some((text, remaining <= PAY_AS_YOU_GO_CRITICAL_CENTS));
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let pct = balance.effective_usage_pct;
|
||||
if pct > 90.0 {
|
||||
// "Left" = complement of floored usage, so it agrees with the
|
||||
// floored summary (99.994% → "1% left", not "0%").
|
||||
let remaining = (100 - pct.floor() as i64).max(0);
|
||||
let label = balance.usage_label();
|
||||
return Some((format!("{label} left: {remaining}%"), pct > 95.0));
|
||||
}
|
||||
return None;
|
||||
};
|
||||
|
||||
// Credits are only drawn down at 100% usage; don't warn before then.
|
||||
if balance.usage_pct < 100.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let credits_warning = || {
|
||||
(
|
||||
format!("Credits left: {}", fmt_dollars(credits_cents)),
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
// Auto top-up gates the warning: unknown → silent; disabled → warn when low;
|
||||
// enabled w/o max → never; enabled w/ max → warn below one top-up amount.
|
||||
match autotopup {
|
||||
None => None,
|
||||
Some(at) if !at.enabled => (credits_cents <= LOW_BALANCE_CENTS).then(credits_warning),
|
||||
Some(at) if at.max_amount_cents.is_none() => None,
|
||||
Some(at) => at
|
||||
.topup_amount_cents
|
||||
.map(i64::abs)
|
||||
.and_then(|amt| (credits_cents < amt).then(credits_warning)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the credit balance indicator as a `Line<'static>`.
|
||||
///
|
||||
/// Shows `Credits used: XX%` in the status bar.
|
||||
///
|
||||
/// Gateway light-frontend (`kind: "chat"`) sessions must not show Build coding
|
||||
/// credits — use [`credit_bar_line_for_session`] with `gateway_chat = true`
|
||||
/// (returns `None`). remote settings / managed opt-in for chat entry can share the
|
||||
/// same gate later; for now it only zeros/suppresses misleading local telemetry.
|
||||
pub fn credit_bar_line(balance: &CreditBalance, hovered: bool, theme: &Theme) -> Line<'static> {
|
||||
credit_bar_line_for_session(balance, hovered, theme, false)
|
||||
.expect("non-chat credit_bar_line always renders")
|
||||
}
|
||||
|
||||
/// Like [`credit_bar_line`], but returns `None` for gateway/chat-kind sessions
|
||||
/// so the status bar never implies Build sampler / coding-credit usage.
|
||||
pub fn credit_bar_line_for_session(
|
||||
balance: &CreditBalance,
|
||||
_hovered: bool,
|
||||
theme: &Theme,
|
||||
gateway_chat: bool,
|
||||
) -> Option<Line<'static>> {
|
||||
if gateway_chat {
|
||||
return None;
|
||||
}
|
||||
let pct = balance.usage_pct;
|
||||
let color = if pct >= 100.0 {
|
||||
theme.accent_error
|
||||
} else if pct >= 80.0 {
|
||||
theme.warning
|
||||
} else {
|
||||
theme.accent_success
|
||||
};
|
||||
|
||||
let text = format!("Credits used: {pct:.0}%");
|
||||
|
||||
let style = Style::default().fg(color).bg(theme.bg_base);
|
||||
Some(Line::from(Span::styled(text, style)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bal(pct: f64) -> CreditBalance {
|
||||
CreditBalance {
|
||||
usage_pct: pct,
|
||||
effective_usage_pct: pct,
|
||||
period_end_display: None,
|
||||
pay_as_you_go: false,
|
||||
on_demand_cap_cents: None,
|
||||
on_demand_used_cents: None,
|
||||
prepaid_balance_cents: None,
|
||||
period_type: None,
|
||||
is_unified_billing_user: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn topup(enabled: bool, amount: Option<i64>, max: Option<i64>) -> AutoTopupInfo {
|
||||
AutoTopupInfo {
|
||||
enabled,
|
||||
topup_amount_cents: amount,
|
||||
max_amount_cents: max,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_no_credits_omits_credits_block() {
|
||||
let b = CreditBalance {
|
||||
period_end_display: Some("June 14, 16:00".into()),
|
||||
prepaid_balance_cents: Some(0),
|
||||
..bal(25.0)
|
||||
};
|
||||
// Even with an auto-topup rule present, zero prepaid → no credits block.
|
||||
let out = format_usage_summary(&b, Some(&topup(true, Some(2000), Some(10000))));
|
||||
assert_eq!(out, "Usage: 25%\nNext reset: June 14, 16:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_credits_without_autotopup_shows_disabled() {
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(10000),
|
||||
..bal(25.0)
|
||||
};
|
||||
assert_eq!(
|
||||
format_usage_summary(&b, None),
|
||||
"Usage: 25%\n\nCredits: $100\nAuto topup: disabled"
|
||||
);
|
||||
// A disabled rule renders the same.
|
||||
assert_eq!(
|
||||
format_usage_summary(&b, Some(&topup(false, Some(2000), Some(10000)))),
|
||||
"Usage: 25%\n\nCredits: $100\nAuto topup: disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_autotopup_enabled_without_max_omits_max() {
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(10000),
|
||||
..bal(25.0)
|
||||
};
|
||||
assert_eq!(
|
||||
format_usage_summary(&b, Some(&topup(true, Some(2000), None))),
|
||||
"Usage: 25%\n\nCredits: $100\nAuto topup: $20"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_autotopup_enabled_with_max_renders_all() {
|
||||
let b = CreditBalance {
|
||||
period_end_display: Some("June 14, 16:00".into()),
|
||||
prepaid_balance_cents: Some(10000),
|
||||
..bal(25.0)
|
||||
};
|
||||
assert_eq!(
|
||||
format_usage_summary(&b, Some(&topup(true, Some(2000), Some(10000)))),
|
||||
"Usage: 25%\nNext reset: June 14, 16:00\n\nCredits: $100\nAuto topup: $20\nMax monthly topup: $100"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_formats_fractional_dollars() {
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(1250),
|
||||
..bal(25.0)
|
||||
};
|
||||
assert_eq!(
|
||||
format_usage_summary(&b, Some(&topup(true, Some(550), None))),
|
||||
"Usage: 25%\n\nCredits: $12.50\nAuto topup: $5.50"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_abs_negative_billing_amounts() {
|
||||
// Billing returns credit / top-up amounts as negative cents; the
|
||||
// summary must render them as positive USD (matching the web).
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(-500),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(
|
||||
format_usage_summary(&b, Some(&topup(true, Some(-500), Some(-1000)))),
|
||||
"Usage: 100%\n\nCredits: $5\nAuto topup: $5\nMax monthly topup: $10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_pay_as_you_go_enabled_renders_used_of_limit() {
|
||||
let b = CreditBalance {
|
||||
pay_as_you_go: true,
|
||||
on_demand_used_cents: Some(355),
|
||||
on_demand_cap_cents: Some(5000),
|
||||
period_type: Some("USAGE_PERIOD_TYPE_MONTHLY".into()),
|
||||
period_end_display: Some("June 30, 16:00".into()),
|
||||
..bal(91.0)
|
||||
};
|
||||
assert_eq!(
|
||||
format_usage_summary(&b, None),
|
||||
"Monthly limit: 91%\nNext reset: June 30, 16:00\n\nPay-as-you-go: $3.55 used of $50.00 limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_pay_as_you_go_disabled_omits_line() {
|
||||
let b = CreditBalance {
|
||||
pay_as_you_go: false,
|
||||
period_type: Some("USAGE_PERIOD_TYPE_MONTHLY".into()),
|
||||
period_end_display: Some("June 30, 16:00".into()),
|
||||
..bal(91.0)
|
||||
};
|
||||
assert_eq!(
|
||||
format_usage_summary(&b, None),
|
||||
"Monthly limit: 91%\nNext reset: June 30, 16:00"
|
||||
);
|
||||
}
|
||||
|
||||
// ── usage_label / period type ────────────────────────────────────
|
||||
|
||||
fn bal_period(pct: f64, period_type: &str) -> CreditBalance {
|
||||
CreditBalance {
|
||||
period_type: Some(period_type.to_string()),
|
||||
..bal(pct)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_label_from_period_type() {
|
||||
assert_eq!(
|
||||
bal_period(0.0, "USAGE_PERIOD_TYPE_WEEKLY").usage_label(),
|
||||
"Weekly limit"
|
||||
);
|
||||
assert_eq!(
|
||||
bal_period(0.0, "USAGE_PERIOD_TYPE_MONTHLY").usage_label(),
|
||||
"Monthly limit"
|
||||
);
|
||||
// Unknown / unspecified / absent → falls back to "Usage".
|
||||
assert_eq!(
|
||||
bal_period(0.0, "USAGE_PERIOD_TYPE_UNSPECIFIED").usage_label(),
|
||||
"Usage"
|
||||
);
|
||||
assert_eq!(bal(0.0).usage_label(), "Usage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_uses_period_label() {
|
||||
let weekly = bal_period(25.0, "USAGE_PERIOD_TYPE_WEEKLY");
|
||||
assert_eq!(format_usage_summary(&weekly, None), "Weekly limit: 25%");
|
||||
let monthly = bal_period(25.0, "USAGE_PERIOD_TYPE_MONTHLY");
|
||||
assert_eq!(format_usage_summary(&monthly, None), "Monthly limit: 25%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_uses_period_label() {
|
||||
let weekly = bal_period(92.0, "USAGE_PERIOD_TYPE_WEEKLY");
|
||||
assert_eq!(
|
||||
usage_warning(&weekly, None, true),
|
||||
Some(("Weekly limit left: 8%".to_string(), false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_floors_usage_percent() {
|
||||
// Match the backend SpendingLimiter (`as u8` truncation): 99.994% must
|
||||
// render as 99%, not round up to 100%.
|
||||
let almost = bal_period(99.994, "USAGE_PERIOD_TYPE_WEEKLY");
|
||||
assert_eq!(format_usage_summary(&almost, None), "Weekly limit: 99%");
|
||||
// A true 100% still shows 100%.
|
||||
let full = bal_period(100.0, "USAGE_PERIOD_TYPE_WEEKLY");
|
||||
assert_eq!(format_usage_summary(&full, None), "Weekly limit: 100%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_percent_left_is_floor_complement() {
|
||||
// 99.994% used → floored to 99% → "1% left" (not "0% left"), so the
|
||||
// warning and the floored summary always sum to 100.
|
||||
let almost = bal_period(99.994, "USAGE_PERIOD_TYPE_WEEKLY");
|
||||
assert_eq!(
|
||||
usage_warning(&almost, None, true),
|
||||
Some(("Weekly limit left: 1%".to_string(), true))
|
||||
);
|
||||
// A true 100% (no credits) → "0% left".
|
||||
let full = bal_period(100.0, "USAGE_PERIOD_TYPE_WEEKLY");
|
||||
assert_eq!(
|
||||
usage_warning(&full, None, true),
|
||||
Some(("Weekly limit left: 0%".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
// ── usage_warning (prompt info row) ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn warning_usage_model_thresholds() {
|
||||
assert_eq!(usage_warning(&bal(50.0), None, true), None);
|
||||
assert_eq!(
|
||||
usage_warning(&bal(92.0), None, true),
|
||||
Some(("Usage left: 8%".to_string(), false))
|
||||
);
|
||||
assert_eq!(
|
||||
usage_warning(&bal(97.0), None, true),
|
||||
Some(("Usage left: 3%".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_hidden_for_team_users() {
|
||||
assert_eq!(usage_warning(&bal(99.0), None, false), None);
|
||||
let credits = CreditBalance {
|
||||
prepaid_balance_cents: Some(100),
|
||||
..bal(0.0)
|
||||
};
|
||||
assert_eq!(usage_warning(&credits, None, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_credits_unknown_topup_is_suppressed() {
|
||||
// At 100% usage with prepaid credits, but the rule isn't known yet
|
||||
// (None) — never warn; it resolves on the next billing fetch.
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(100),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(usage_warning(&b, None, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_credits_suppressed_below_full_usage() {
|
||||
// Low credits + no auto top-up, but the included allowance still has
|
||||
// room (usage < 100%) → no warning (credits aren't being spent yet).
|
||||
let disabled = topup(false, None, None);
|
||||
let low = CreditBalance {
|
||||
prepaid_balance_cents: Some(453),
|
||||
..bal(0.0)
|
||||
};
|
||||
assert_eq!(usage_warning(&low, Some(&disabled), true), None);
|
||||
// Same balance once the allowance is exhausted → warn.
|
||||
let exhausted = CreditBalance {
|
||||
prepaid_balance_cents: Some(453),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(
|
||||
usage_warning(&exhausted, Some(&disabled), true),
|
||||
Some(("Credits left: $4.53".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_credits_no_topup_low_shows_dollars() {
|
||||
// "No auto top-up" is a known, disabled rule (not an unresolved None).
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(453),
|
||||
..bal(100.0)
|
||||
};
|
||||
let disabled = topup(false, None, None);
|
||||
assert_eq!(
|
||||
usage_warning(&b, Some(&disabled), true),
|
||||
Some(("Credits left: $4.53".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_credits_no_topup_above_threshold_silent() {
|
||||
let disabled = topup(false, None, None);
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(1500),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(usage_warning(&b, Some(&disabled), true), None);
|
||||
// Exactly $10 is still "low".
|
||||
let at_ten = CreditBalance {
|
||||
prepaid_balance_cents: Some(1000),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(
|
||||
usage_warning(&at_ten, Some(&disabled), true),
|
||||
Some(("Credits left: $10".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_credits_topup_no_max_never_warns() {
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(1),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(
|
||||
usage_warning(&b, Some(&topup(true, Some(2000), None)), true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_credits_topup_with_max_below_topup_amount() {
|
||||
// $15 balance, $20 top-up amount, $100 max → below one top-up → warn.
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(1500),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(
|
||||
usage_warning(&b, Some(&topup(true, Some(2000), Some(10000))), true),
|
||||
Some(("Credits left: $15".to_string(), true))
|
||||
);
|
||||
let plenty = CreditBalance {
|
||||
prepaid_balance_cents: Some(2500),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(
|
||||
usage_warning(&plenty, Some(&topup(true, Some(2000), Some(10000))), true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_credits_handles_negative_cents() {
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(-453),
|
||||
..bal(100.0)
|
||||
};
|
||||
assert_eq!(
|
||||
usage_warning(&b, Some(&topup(true, Some(-2000), Some(-10000))), true),
|
||||
Some(("Credits left: $4.53".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_credits_take_precedence_over_usage() {
|
||||
// A credits user below 100% usage gets no warning at all (no usage-%
|
||||
// warning, and credits aren't being spent yet) — unlike a non-credits
|
||||
// user, who would see "Usage left: 1%" at 99%.
|
||||
let b = CreditBalance {
|
||||
prepaid_balance_cents: Some(5000),
|
||||
..bal(99.0)
|
||||
};
|
||||
assert_eq!(
|
||||
usage_warning(&b, Some(&topup(false, None, None)), true),
|
||||
None
|
||||
);
|
||||
// Zero prepaid falls back to the usage model.
|
||||
let zero = CreditBalance {
|
||||
prepaid_balance_cents: Some(0),
|
||||
..bal(99.0)
|
||||
};
|
||||
assert_eq!(
|
||||
usage_warning(&zero, None, true),
|
||||
Some(("Usage left: 1%".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
// ── usage_warning: pay-as-you-go (monthly on-demand) ─────────────
|
||||
|
||||
fn pay_as_you_go(usage_pct: f64, cap_cents: i64, used_cents: i64) -> CreditBalance {
|
||||
CreditBalance {
|
||||
pay_as_you_go: true,
|
||||
on_demand_cap_cents: Some(cap_cents),
|
||||
on_demand_used_cents: Some(used_cents),
|
||||
period_type: Some("USAGE_PERIOD_TYPE_MONTHLY".into()),
|
||||
..bal(usage_pct)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_pay_as_you_go_low_dollars_shows_remaining() {
|
||||
// $50 cap, $42 used → $8 left → grey (above $5).
|
||||
let grey = pay_as_you_go(100.0, 5000, 4200);
|
||||
assert_eq!(
|
||||
usage_warning(&grey, None, true),
|
||||
Some(("Pay-as-you-go limit left: $8".to_string(), false))
|
||||
);
|
||||
// $50 cap, $46 used → $4 left → critical (yellow).
|
||||
let yellow = pay_as_you_go(100.0, 5000, 4600);
|
||||
assert_eq!(
|
||||
usage_warning(&yellow, None, true),
|
||||
Some(("Pay-as-you-go limit left: $4".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_pay_as_you_go_boundaries() {
|
||||
// Exactly $10 left → show, grey.
|
||||
let at_ten = pay_as_you_go(100.0, 5000, 4000);
|
||||
assert_eq!(
|
||||
usage_warning(&at_ten, None, true),
|
||||
Some(("Pay-as-you-go limit left: $10".to_string(), false))
|
||||
);
|
||||
// Exactly $5 left → critical (yellow).
|
||||
let at_five = pay_as_you_go(100.0, 5000, 4500);
|
||||
assert_eq!(
|
||||
usage_warning(&at_five, None, true),
|
||||
Some(("Pay-as-you-go limit left: $5".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_pay_as_you_go_above_threshold_silent() {
|
||||
// $20 left (> $10) → no warning.
|
||||
let b = pay_as_you_go(100.0, 5000, 3000);
|
||||
assert_eq!(usage_warning(&b, None, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_pay_as_you_go_suppressed_below_full_usage() {
|
||||
// Pay-as-you-go users get NO percentage warning before the included
|
||||
// allowance is exhausted, even with low on-demand room remaining.
|
||||
let b = pay_as_you_go(95.0, 5000, 4800);
|
||||
assert_eq!(usage_warning(&b, None, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_pay_as_you_go_fractional_dollars() {
|
||||
// $50 cap, $46.50 used → $3.50 left → critical, fractional formatting.
|
||||
let b = pay_as_you_go(100.0, 5000, 4650);
|
||||
assert_eq!(
|
||||
usage_warning(&b, None, true),
|
||||
Some(("Pay-as-you-go limit left: $3.50".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credit_bar_line_shows_percentage() {
|
||||
let theme = Theme::default();
|
||||
let line = credit_bar_line(&bal(24.0), false, &theme);
|
||||
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert_eq!(text, "Credits used: 24%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_thresholds() {
|
||||
let theme = Theme::default();
|
||||
|
||||
let low = credit_bar_line(&bal(50.0), false, &theme);
|
||||
assert_eq!(low.spans[0].style.fg, Some(theme.accent_success));
|
||||
|
||||
let high = credit_bar_line(&bal(85.0), false, &theme);
|
||||
assert_eq!(high.spans[0].style.fg, Some(theme.warning));
|
||||
|
||||
let over = credit_bar_line(&bal(100.0), false, &theme);
|
||||
assert_eq!(over.spans[0].style.fg, Some(theme.accent_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_percent() {
|
||||
let theme = Theme::default();
|
||||
let line = credit_bar_line(&bal(0.0), false, &theme);
|
||||
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert_eq!(text, "Credits used: 0%");
|
||||
assert_eq!(line.spans[0].style.fg, Some(theme.accent_success));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_boundary_at_80_percent() {
|
||||
let theme = Theme::default();
|
||||
// Exactly 80% should be warning (yellow).
|
||||
let at_80 = credit_bar_line(&bal(80.0), false, &theme);
|
||||
assert_eq!(at_80.spans[0].style.fg, Some(theme.warning));
|
||||
|
||||
// Just below 80% should be success (green).
|
||||
let below_80 = credit_bar_line(&bal(79.9), false, &theme);
|
||||
assert_eq!(below_80.spans[0].style.fg, Some(theme.accent_success));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_boundary_at_100_percent() {
|
||||
let theme = Theme::default();
|
||||
// Exactly 100% should be error (red).
|
||||
let at_100 = credit_bar_line(&bal(100.0), false, &theme);
|
||||
assert_eq!(at_100.spans[0].style.fg, Some(theme.accent_error));
|
||||
|
||||
// Just below 100% should be warning (yellow).
|
||||
let below_100 = credit_bar_line(&bal(99.9), false, &theme);
|
||||
assert_eq!(below_100.spans[0].style.fg, Some(theme.warning));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_over_100_percent() {
|
||||
let theme = Theme::default();
|
||||
let line = credit_bar_line(&bal(150.0), false, &theme);
|
||||
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert_eq!(text, "Credits used: 150%");
|
||||
assert_eq!(line.spans[0].style.fg, Some(theme.accent_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fractional_percentage_rounds_display() {
|
||||
let theme = Theme::default();
|
||||
let line = credit_bar_line(&bal(33.7), false, &theme);
|
||||
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert_eq!(text, "Credits used: 34%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credit_balance_with_on_demand_fields() {
|
||||
let balance = CreditBalance {
|
||||
effective_usage_pct: 25.0,
|
||||
period_end_display: Some("Jun 1, 00:00".into()),
|
||||
pay_as_you_go: true,
|
||||
on_demand_cap_cents: Some(2000),
|
||||
on_demand_used_cents: Some(500),
|
||||
..bal(50.0)
|
||||
};
|
||||
let theme = Theme::default();
|
||||
// The credit bar uses usage_pct (not effective_usage_pct).
|
||||
let line = credit_bar_line(&balance, false, &theme);
|
||||
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert_eq!(text, "Credits used: 50%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_chat_suppresses_credit_bar_and_usage_warning() {
|
||||
let theme = Theme::default();
|
||||
let b = bal(90.0);
|
||||
assert!(credit_bar_line_for_session(&b, false, &theme, true).is_none());
|
||||
assert!(usage_warning_for_session(&b, None, true, true).is_none());
|
||||
// Build path still renders.
|
||||
assert!(credit_bar_line_for_session(&b, false, &theme, false).is_some());
|
||||
}
|
||||
}
|
||||
@@ -520,8 +520,6 @@ fn paint_peek_config_badge(
|
||||
model_name: &model_label,
|
||||
flags: &flags,
|
||||
multiline,
|
||||
usage_warning: None,
|
||||
usage_warning_critical: false,
|
||||
};
|
||||
// Bottom border row, inside the corners — the same content rect the
|
||||
// chat prompt and dispatch box use for their info line.
|
||||
@@ -1111,7 +1109,6 @@ pub fn extract_last_response_type(agent: &AgentView) -> String {
|
||||
RenderBlock::BgTask(_) => return "Task".to_string(),
|
||||
RenderBlock::Btw(_) => return "Btw".to_string(),
|
||||
RenderBlock::ContextInfo(_) => return "Context".to_string(),
|
||||
RenderBlock::CreditLimit(_) => return "Credit limit".to_string(),
|
||||
// The user's latest input marks the turn boundary — there's
|
||||
// no agent response after it yet.
|
||||
RenderBlock::UserPrompt(_) => break,
|
||||
@@ -1310,7 +1307,6 @@ fn block_short_text(block: &crate::scrollback::block::RenderBlock) -> Option<Str
|
||||
RenderBlock::Subagent(_) => Some("(subagent)".to_string()),
|
||||
RenderBlock::Btw(_) => Some("(btw)".to_string()),
|
||||
RenderBlock::ContextInfo(_) => Some("(context info)".to_string()),
|
||||
RenderBlock::CreditLimit(_) => Some("(credit limit)".to_string()),
|
||||
RenderBlock::Stub(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2596,8 +2596,6 @@ fn paint_dispatch_config_badge(
|
||||
model_name: &model_label,
|
||||
flags: &flags,
|
||||
multiline: state.multiline_mode,
|
||||
usage_warning: None,
|
||||
usage_warning_critical: false,
|
||||
};
|
||||
// Bottom border row, inside the corners — the same content rect the chat
|
||||
// prompt uses for its info line.
|
||||
|
||||
@@ -1721,8 +1721,6 @@ mod tests {
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: Vec::new(),
|
||||
available_commands_generation: 0,
|
||||
available_tools: None,
|
||||
|
||||
@@ -6,7 +6,6 @@ pub mod block_viewer;
|
||||
pub mod btw_overlay;
|
||||
pub mod completion_dropdown;
|
||||
pub mod context_bar;
|
||||
pub mod credit_bar;
|
||||
pub mod dashboard;
|
||||
pub mod debug_style;
|
||||
pub mod extensions_modal;
|
||||
|
||||
@@ -363,11 +363,8 @@ pub enum PaletteCommand {
|
||||
OpenAgentsModal,
|
||||
}
|
||||
/// Build the default set of palette entries with section grouping.
|
||||
///
|
||||
/// `sharing_enabled` controls whether the `/share` entry is included.
|
||||
/// Pass `true` to preserve the default behavior (show `/share`).
|
||||
pub fn default_palette_entries(sharing_enabled: bool) -> Vec<PaletteEntry> {
|
||||
let mut entries = vec![
|
||||
pub fn default_palette_entries() -> Vec<PaletteEntry> {
|
||||
let entries = vec![
|
||||
PaletteEntry {
|
||||
label: "Session".into(),
|
||||
shortcut: String::new(),
|
||||
@@ -398,11 +395,6 @@ pub fn default_palette_entries(sharing_enabled: bool) -> Vec<PaletteEntry> {
|
||||
shortcut: "/resume".into(),
|
||||
command: PaletteCommand::SlashCommand("/resume".into()),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Share Session".into(),
|
||||
shortcut: "/share".into(),
|
||||
command: PaletteCommand::SlashCommand("/share".into()),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Rename Session".into(),
|
||||
shortcut: "/rename ".into(),
|
||||
@@ -536,19 +528,12 @@ pub fn default_palette_entries(sharing_enabled: bool) -> Vec<PaletteEntry> {
|
||||
command: PaletteCommand::Quit,
|
||||
},
|
||||
];
|
||||
if !sharing_enabled {
|
||||
entries.retain(|e| {
|
||||
!matches!(
|
||||
& e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/share"
|
||||
)
|
||||
});
|
||||
}
|
||||
entries
|
||||
}
|
||||
#[allow(clippy::collapsible_if)]
|
||||
/// Filter palette entries for search, preserving section headers when any item in the section matches.
|
||||
pub fn filter_palette_entries(query: &str, sharing_enabled: bool) -> Vec<PaletteEntry> {
|
||||
let all = default_palette_entries(sharing_enabled);
|
||||
pub fn filter_palette_entries(query: &str) -> Vec<PaletteEntry> {
|
||||
let all = default_palette_entries();
|
||||
let query_lower = query.to_lowercase();
|
||||
if query_lower.is_empty() {
|
||||
return all;
|
||||
@@ -1233,26 +1218,11 @@ mod doc_viewer_scroll_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod palette_sharing_tests {
|
||||
mod palette_tests {
|
||||
use super::*;
|
||||
fn has_share(entries: &[PaletteEntry]) -> bool {
|
||||
entries.iter().any(|e| {
|
||||
matches!(
|
||||
& e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/share"
|
||||
)
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn default_palette_includes_share_when_enabled() {
|
||||
let entries = default_palette_entries(true);
|
||||
assert!(
|
||||
has_share(&entries),
|
||||
"/share should be present when sharing_enabled=true"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn default_palette_includes_dashboard() {
|
||||
let entries = default_palette_entries(true);
|
||||
let entries = default_palette_entries();
|
||||
let has_dashboard = entries.iter().any(|e| {
|
||||
matches!(
|
||||
& e.command, PaletteCommand::SlashCommand(s) if s.trim() ==
|
||||
@@ -1270,38 +1240,9 @@ mod palette_sharing_tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn default_palette_omits_share_when_disabled() {
|
||||
let entries = default_palette_entries(false);
|
||||
assert!(
|
||||
!has_share(&entries),
|
||||
"/share must not appear in palette when sharing_enabled=false"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn filter_palette_omits_share_when_disabled() {
|
||||
let entries = filter_palette_entries("", false);
|
||||
assert!(
|
||||
!has_share(&entries),
|
||||
"/share must not appear in unfiltered palette when sharing_enabled=false"
|
||||
);
|
||||
let entries = filter_palette_entries("share", false);
|
||||
assert!(
|
||||
!has_share(&entries),
|
||||
"/share must not appear when filtering for 'share' with sharing_enabled=false"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn filter_palette_includes_share_when_enabled_and_matched() {
|
||||
let entries = filter_palette_entries("share", true);
|
||||
assert!(
|
||||
has_share(&entries),
|
||||
"/share should match a 'share' query when sharing_enabled=true"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn palette_tools_section_routes_each_tab_to_itself() {
|
||||
use crate::views::extensions_modal::ExtensionsTab;
|
||||
let entries = default_palette_entries(true);
|
||||
let entries = default_palette_entries();
|
||||
for (label, expected) in [
|
||||
("Hooks", ExtensionsTab::Hooks),
|
||||
("Plugins", ExtensionsTab::Plugins),
|
||||
|
||||
@@ -286,11 +286,6 @@ pub struct PromptInfo<'a> {
|
||||
pub flags: &'a [PromptFlag<'a>],
|
||||
/// Whether multiline mode is active (shown right-aligned).
|
||||
pub multiline: bool,
|
||||
/// Optional usage warning displayed right-aligned (e.g. "5% usage left").
|
||||
pub usage_warning: Option<&'a str>,
|
||||
/// When true the warning uses the yellow warning color (<=5% left);
|
||||
/// when false it uses dim grey text (5-10% left).
|
||||
pub usage_warning_critical: bool,
|
||||
}
|
||||
|
||||
/// Result of rendering the prompt.
|
||||
@@ -3198,16 +3193,6 @@ impl PromptWidget {
|
||||
// bottom-border fill — giving 1 cell of visual padding on each side.
|
||||
let pad_style = Style::default().bg(bg);
|
||||
let mut left_spans = vec![Span::styled(" ", pad_style)];
|
||||
if let Some(warning) = info.usage_warning {
|
||||
let fg = if info.usage_warning_critical {
|
||||
theme.warning
|
||||
} else {
|
||||
sep_fg
|
||||
};
|
||||
let warning_style = Style::default().fg(fg).bg(bg);
|
||||
left_spans.push(Span::styled(warning.to_owned(), warning_style));
|
||||
left_spans.push(Span::styled(" · ", sep_style));
|
||||
}
|
||||
left_spans.push(Span::styled(info.model_name, model_style));
|
||||
for flag in info.flags {
|
||||
left_spans.push(Span::styled(" · ", sep_style));
|
||||
|
||||
@@ -105,14 +105,6 @@ pub enum LocalQuestionKind {
|
||||
/// On submit, the selected option index is translated into an
|
||||
/// [`crate::app::actions::Action::NewSessionAnswered`].
|
||||
NewSession,
|
||||
/// Modal shown when the user hits the credit/rate limit (403).
|
||||
/// Options map to upsell URLs: upgrade tier or enable on-demand.
|
||||
CreditLimitUpsell,
|
||||
/// SuperGrok upsell modal: the free-usage paywall (429 +
|
||||
/// `subscription:free-usage-exhausted`) or a tier-restricted slash
|
||||
/// command invocation. Upgrade options carry their URL in the option
|
||||
/// `id`.
|
||||
FreeUsageUpsell,
|
||||
/// Modal shown when the shell rejects a model switch due to agent
|
||||
/// type incompatibility. Carries the target model + effort so the
|
||||
/// answer handler can create a new session with it.
|
||||
|
||||
@@ -755,11 +755,6 @@ fn action_for_enum_commit(key: SettingKey, choice: &'static str) -> Option<Actio
|
||||
)),
|
||||
_ => None,
|
||||
},
|
||||
"coding_data_sharing" => match choice {
|
||||
"opt-in" => Some(Action::SetCodingDataSharing { opted_in: true }),
|
||||
"opt-out" => Some(Action::SetCodingDataSharing { opted_in: false }),
|
||||
_ => None,
|
||||
},
|
||||
"plan_mode" => match choice {
|
||||
"on" => Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::On)),
|
||||
"off" => Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::Off)),
|
||||
@@ -5566,9 +5561,9 @@ mod tests {
|
||||
/// The default registry contains Appearance settings
|
||||
/// (3 bools + 3 enums + 1 int = 7 entries), the Editor entry
|
||||
/// `multiline_mode`, the Agent entries `permission_mode` and
|
||||
/// `plan_mode`, the Privacy entry `coding_data_sharing`, the
|
||||
/// Models entry `default_model`, and the Advanced entries
|
||||
/// `show_tips` and `auto_update`. `default_reasoning_effort` and
|
||||
/// `plan_mode`, the Models entry `default_model`, and the
|
||||
/// Advanced entries `show_tips` and `auto_update`.
|
||||
/// `default_reasoning_effort` and
|
||||
/// `auto_compact_threshold_percent` are not exposed in the modal.
|
||||
#[test]
|
||||
fn rows_contain_categories_and_settings_through_pr_14() {
|
||||
@@ -5591,7 +5586,6 @@ mod tests {
|
||||
&SettingCategory::Mouse,
|
||||
&SettingCategory::Editor,
|
||||
&SettingCategory::Agent,
|
||||
&SettingCategory::Privacy,
|
||||
&SettingCategory::Models,
|
||||
// The Session category has no registered settings, so its
|
||||
// header is not emitted.
|
||||
@@ -5672,8 +5666,6 @@ mod tests {
|
||||
"toolset.ask_user_question.timeout_enabled",
|
||||
// PAGER-owned plan_mode (Agent category).
|
||||
"plan_mode",
|
||||
// SHELL-owned coding_data_sharing (Privacy category).
|
||||
"coding_data_sharing",
|
||||
// SHELL-owned default_model (Models category).
|
||||
"default_model",
|
||||
// Models category. `default_reasoning_effort`,
|
||||
@@ -8095,7 +8087,7 @@ mod tests {
|
||||
fn picker_visual_smoke_debug() {
|
||||
let entries = vec![SettingMeta {
|
||||
key: "wrap_enum",
|
||||
category: SettingCategory::Privacy,
|
||||
category: SettingCategory::Advanced,
|
||||
owner: SettingOwner::Shared,
|
||||
label: "Coding data sharing",
|
||||
description: "Controls whether SpaceXAI may retain and train on coding data.",
|
||||
@@ -8157,7 +8149,7 @@ mod tests {
|
||||
fn picker_long_description_wraps_to_multiple_lines() {
|
||||
let entries = vec![SettingMeta {
|
||||
key: "wrap_enum",
|
||||
category: SettingCategory::Privacy,
|
||||
category: SettingCategory::Advanced,
|
||||
owner: SettingOwner::Shared,
|
||||
label: "Coding data sharing",
|
||||
description: "Controls whether SpaceXAI may retain and train on coding data.",
|
||||
@@ -8442,7 +8434,7 @@ mod tests {
|
||||
// Reuse the wrap fixture: long descriptions on both choices.
|
||||
let entries = vec![SettingMeta {
|
||||
key: "wrap_enum",
|
||||
category: SettingCategory::Privacy,
|
||||
category: SettingCategory::Advanced,
|
||||
owner: SettingOwner::Shared,
|
||||
label: "Coding data sharing",
|
||||
description: "Controls whether SpaceXAI may retain coding data.",
|
||||
@@ -9501,7 +9493,7 @@ mod tests {
|
||||
fn synthetic_enum_chevron_meta() -> SettingMeta {
|
||||
SettingMeta {
|
||||
key: "test-enum-with-chevron",
|
||||
category: SettingCategory::Privacy,
|
||||
category: SettingCategory::Advanced,
|
||||
owner: SettingOwner::Shared,
|
||||
label: "Coding data sharing",
|
||||
description: "Enum row that opens a picker — chevron suffix applies.",
|
||||
@@ -9665,9 +9657,9 @@ mod tests {
|
||||
/// Two-line rows expand `state.row_rects` to span BOTH lines so
|
||||
/// mouse clicks on either line trigger the same default action.
|
||||
///
|
||||
/// `coding_data_sharing`: label 19 + value "Opt out" 7 + chevron
|
||||
/// 2 + chrome 4 = 32 cells one-line. We render at width=28 so
|
||||
/// the row drops to two lines.
|
||||
/// `default_selected_permission`: label 27 + value "Always allow
|
||||
/// on all sessions" 28 + chevron 2 + chrome 4 = 61 cells
|
||||
/// one-line. We render at width=40 so the row drops to two lines.
|
||||
#[test]
|
||||
fn two_line_row_hit_rect_spans_both_lines() {
|
||||
let mut s = make_state();
|
||||
@@ -9675,15 +9667,15 @@ mod tests {
|
||||
.rows
|
||||
.iter()
|
||||
.position(
|
||||
|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"),
|
||||
|r| matches!(r, RowEntry::Setting { key, .. } if *key == "default_selected_permission"),
|
||||
)
|
||||
.expect("coding_data_sharing must be registered");
|
||||
// Render at a narrow width so coding_data_sharing forces a
|
||||
// two-line layout.
|
||||
.expect("default_selected_permission must be registered");
|
||||
// Render at a narrow width so default_selected_permission
|
||||
// forces a two-line layout.
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 28,
|
||||
width: 40,
|
||||
height: 60,
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -9700,7 +9692,7 @@ mod tests {
|
||||
|
||||
// Synthesize a click on line 2 of the row. The mouse handler
|
||||
// should fire the default action (open the enum picker for
|
||||
// coding_data_sharing).
|
||||
// default_selected_permission).
|
||||
s.list_area = area;
|
||||
let click_y = rect.y + 1;
|
||||
// Click somewhere in the middle of line 2.
|
||||
@@ -9736,22 +9728,22 @@ mod tests {
|
||||
#[test]
|
||||
fn two_line_row_with_expansion_renders_three_segments() {
|
||||
let mut s = make_state();
|
||||
// Coding data sharing's label + value (with chevron) won't
|
||||
// fit on a 28-col line, forcing two-line layout.
|
||||
// Default selected permission's label + value (with chevron)
|
||||
// won't fit on a 40-col line, forcing two-line layout.
|
||||
let row_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(
|
||||
|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"),
|
||||
|r| matches!(r, RowEntry::Setting { key, .. } if *key == "default_selected_permission"),
|
||||
)
|
||||
.expect("coding_data_sharing must be registered");
|
||||
.expect("default_selected_permission must be registered");
|
||||
s.selected = row_idx;
|
||||
s.expanded_keys.insert("coding_data_sharing");
|
||||
s.expanded_keys.insert("default_selected_permission");
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 28,
|
||||
width: 40,
|
||||
height: 60,
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -9767,18 +9759,16 @@ mod tests {
|
||||
// The row label is on line 1.
|
||||
let label_line = buf_row_text(&buf, rect.y, area.x, area.width);
|
||||
assert!(
|
||||
label_line.contains("Coding data sharing"),
|
||||
label_line.contains("Default selected permission"),
|
||||
"line 1 must contain the row label: {label_line:?}"
|
||||
);
|
||||
// The value (display: "Opt out" or similar) is on line 2.
|
||||
// The value is on line 2. It comes from the canonical →
|
||||
// display mapping: `UiConfig::default()` resolves to the
|
||||
// `always_allow_all_sessions` canonical, whose registered
|
||||
// display is "Always allow on all sessions".
|
||||
let value_line = buf_row_text(&buf, rect.y + 1, area.x, area.width);
|
||||
// Value comes from displaying the canonical → display mapping,
|
||||
// which uses the synthetic enum's "Third Option" canonical of
|
||||
// "opt-out". The display fallback returns the canonical when
|
||||
// the lookup misses — registry has the real `CodingDataSharing`
|
||||
// choices, so display should be "Opt out".
|
||||
assert!(
|
||||
value_line.contains("Opt") || value_line.contains("opt") || value_line.contains("out"),
|
||||
value_line.contains("Always allow"),
|
||||
"line 2 must contain the value text: {value_line:?}"
|
||||
);
|
||||
// The expanded description renders on line 3 and below.
|
||||
|
||||
@@ -100,9 +100,6 @@ pub struct WelcomeRenderResult {
|
||||
/// Hit-test rect for the "show full URL" fallback link.
|
||||
pub auth_fallback_rect: Option<Rect>,
|
||||
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
|
||||
pub refresh_rect: Option<Rect>,
|
||||
/// Hit-test rect for the gate URL link (click to open in browser).
|
||||
pub gate_url_rect: Option<Rect>,
|
||||
/// Whether a "Changelog" menu action was rendered (above Quit), so the
|
||||
/// input handler can map the extra menu row to the release-notes action
|
||||
/// once markdown is available.
|
||||
@@ -334,12 +331,12 @@ impl WelcomeLayout {
|
||||
}
|
||||
|
||||
/// Controls what the version badge renders.
|
||||
pub(super) enum VersionBadgeMode<'a> {
|
||||
/// Full badge: team | tier | api_key | **Grok Build** VERSION+channel **Beta** (right-aligned).
|
||||
Full { subscription_tier: Option<&'a str> },
|
||||
/// Hero footer: team | api_key | Grok Build Beta [channel] (right-aligned, gray).
|
||||
pub(super) enum VersionBadgeMode {
|
||||
/// Full badge: team | tier | api_key | **Kigi** VERSION+channel (right-aligned).
|
||||
Full,
|
||||
/// Hero footer: team | api_key | Kigi [channel] (right-aligned, gray).
|
||||
HeroFooter,
|
||||
/// Hero inline: **Grok Build Beta** VERSION (left-aligned).
|
||||
/// Hero inline: **Kigi** VERSION (left-aligned).
|
||||
HeroInline,
|
||||
}
|
||||
|
||||
@@ -350,7 +347,7 @@ pub(super) fn render_version_badge(
|
||||
team_name: Option<&str>,
|
||||
h_margin: u16,
|
||||
is_api_key_auth: bool,
|
||||
mode: VersionBadgeMode<'_>,
|
||||
mode: VersionBadgeMode,
|
||||
) {
|
||||
let version_area = Rect {
|
||||
width: version_rect.width.saturating_sub(h_margin),
|
||||
@@ -362,27 +359,16 @@ pub(super) fn render_version_badge(
|
||||
);
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let (show_team, show_tier, show_api_key, align) = match &mode {
|
||||
VersionBadgeMode::Full { .. } => (true, true, true, Alignment::Right),
|
||||
VersionBadgeMode::HeroFooter => (true, false, true, Alignment::Right),
|
||||
VersionBadgeMode::HeroInline => (false, false, false, Alignment::Left),
|
||||
let (show_team, show_api_key, align) = match &mode {
|
||||
VersionBadgeMode::Full => (true, true, Alignment::Right),
|
||||
VersionBadgeMode::HeroFooter => (true, true, Alignment::Right),
|
||||
VersionBadgeMode::HeroInline => (false, false, Alignment::Left),
|
||||
};
|
||||
|
||||
if show_team && let Some(team) = team_name {
|
||||
spans.push(Span::styled(team, Style::default().fg(theme.gray)));
|
||||
spans.push(sep.clone());
|
||||
}
|
||||
if show_tier
|
||||
&& let VersionBadgeMode::Full {
|
||||
subscription_tier: Some(tier),
|
||||
} = &mode
|
||||
{
|
||||
spans.push(Span::styled(
|
||||
format!("Tier: {tier}"),
|
||||
Style::default().fg(theme.gray),
|
||||
));
|
||||
spans.push(sep.clone());
|
||||
}
|
||||
if show_api_key && is_api_key_auth {
|
||||
spans.push(Span::styled(
|
||||
"Logged in with API key",
|
||||
@@ -393,9 +379,9 @@ pub(super) fn render_version_badge(
|
||||
|
||||
let channel = kigi_update::channel_label();
|
||||
match &mode {
|
||||
VersionBadgeMode::Full { .. } => {
|
||||
VersionBadgeMode::Full => {
|
||||
spans.push(Span::styled(
|
||||
"Grok Build ",
|
||||
"Kigi ",
|
||||
Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -404,16 +390,10 @@ pub(super) fn render_version_badge(
|
||||
format!("{}{}", kigi_version::VERSION, channel),
|
||||
Style::default().fg(theme.gray),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
" Beta",
|
||||
Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
VersionBadgeMode::HeroFooter => {
|
||||
let channel_display = if channel.is_empty() {
|
||||
"Beta"
|
||||
"Kigi"
|
||||
} else {
|
||||
channel.trim()
|
||||
};
|
||||
@@ -424,7 +404,7 @@ pub(super) fn render_version_badge(
|
||||
}
|
||||
VersionBadgeMode::HeroInline => {
|
||||
spans.push(Span::styled(
|
||||
"Grok Build Beta ",
|
||||
"Kigi ",
|
||||
Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -520,9 +500,7 @@ fn render_prompt_and_version(
|
||||
team_name,
|
||||
h_margin,
|
||||
is_api_key_auth,
|
||||
VersionBadgeMode::Full {
|
||||
subscription_tier: None,
|
||||
},
|
||||
VersionBadgeMode::Full,
|
||||
);
|
||||
} else {
|
||||
render_version_badge(
|
||||
@@ -554,11 +532,8 @@ pub struct WelcomeRenderParams<'a> {
|
||||
pub model_name: &'a str,
|
||||
pub flags: &'a [PromptFlag<'a>],
|
||||
pub selected: Option<usize>,
|
||||
pub team_name: Option<&'a str>,
|
||||
pub has_access: bool,
|
||||
pub has_claude_import: bool,
|
||||
pub mouse_pos: Option<(u16, u16)>,
|
||||
pub is_zdr_blocked: bool,
|
||||
pub session_picker: Option<&'a [SessionPickerEntry]>,
|
||||
pub session_picker_loading: bool,
|
||||
pub compact: bool,
|
||||
@@ -575,8 +550,6 @@ pub struct WelcomeRenderParams<'a> {
|
||||
/// [`crate::views::session_picker::effective_filter_query`]).
|
||||
pub session_picker_entries_query: Option<&'a str>,
|
||||
pub welcome_tick: u64,
|
||||
pub gate: Option<&'a kigi_shell::auth::GateInfo>,
|
||||
pub subscription_tier: Option<&'a str>,
|
||||
pub session_picker_grouped: bool,
|
||||
/// Source filter (local/remote/all) for the session picker.
|
||||
pub session_picker_source_filter: crate::views::session_picker::SourceFilter,
|
||||
@@ -586,12 +559,6 @@ pub struct WelcomeRenderParams<'a> {
|
||||
/// Live working directory (tracks `Effect::SetWorkingDir`), used to pin
|
||||
/// the current repo's session group to the top of the picker.
|
||||
pub cwd: &'a std::path::Path,
|
||||
/// App-level credit balance for showing the usage warning on the welcome screen.
|
||||
pub credit_balance: Option<&'a crate::views::credit_bar::CreditBalance>,
|
||||
/// Auto top-up rule paired with `credit_balance` for the welcome warning.
|
||||
pub auto_topup: Option<&'a crate::views::credit_bar::AutoTopupInfo>,
|
||||
/// Whether /usage is visible (false for team users — suppresses the warning).
|
||||
pub usage_visible: bool,
|
||||
/// Cached changelog bullets for the welcome screen (up to 3).
|
||||
pub changelog_bullets: &'a [String],
|
||||
/// Whether full release notes markdown is available (controls the CTA hint).
|
||||
@@ -635,7 +602,7 @@ pub fn render_welcome(
|
||||
|
||||
let mut result = match params.auth_state {
|
||||
AuthState::Pending { error } => {
|
||||
let label = params.login_label.unwrap_or("grok.com");
|
||||
let label = params.login_label.unwrap_or("kimi.com");
|
||||
let login_text = format!("Login with {}", label);
|
||||
let menu = [("l", login_text.as_str()), ("q", "Quit")];
|
||||
let msg = error.as_deref().map(|e| (e, theme.accent_error));
|
||||
@@ -643,8 +610,6 @@ pub fn render_welcome(
|
||||
model_name: params.model_name,
|
||||
flags: params.flags,
|
||||
multiline: false,
|
||||
usage_warning: None,
|
||||
usage_warning_critical: false,
|
||||
};
|
||||
let (menu_rects, post_flush_escapes) = render_welcome_blocked(
|
||||
content_area,
|
||||
@@ -665,8 +630,6 @@ pub fn render_welcome(
|
||||
import_banner_rect: None,
|
||||
auth_url_rect: None,
|
||||
auth_fallback_rect: None,
|
||||
refresh_rect: None,
|
||||
gate_url_rect: None,
|
||||
changelog_action_present: false,
|
||||
changelog_cta_rect: None,
|
||||
}
|
||||
@@ -693,49 +656,15 @@ pub fn render_welcome(
|
||||
import_banner_rect: None,
|
||||
auth_url_rect: url_rect,
|
||||
auth_fallback_rect: fallback_rect,
|
||||
refresh_rect: None,
|
||||
gate_url_rect: None,
|
||||
changelog_action_present: false,
|
||||
changelog_cta_rect: None,
|
||||
}
|
||||
}
|
||||
AuthState::Done if params.is_zdr_blocked => {
|
||||
let menu = [("l", "Switch account"), ("q", "Quit")];
|
||||
let (menu_rects, post_flush_escapes) = render_welcome_blocked(
|
||||
content_area,
|
||||
buf,
|
||||
Some((
|
||||
"Grok Build is not yet available for this account.",
|
||||
theme.gray_bright,
|
||||
)),
|
||||
&menu,
|
||||
params.selected,
|
||||
None,
|
||||
h_margin,
|
||||
params.compact,
|
||||
);
|
||||
WelcomeRenderResult {
|
||||
cursor_pos: None,
|
||||
post_flush_escapes,
|
||||
menu_rects,
|
||||
prompt_rect: None,
|
||||
session_picker_hit_areas: None,
|
||||
import_banner_rect: None,
|
||||
auth_url_rect: None,
|
||||
auth_fallback_rect: None,
|
||||
refresh_rect: None,
|
||||
gate_url_rect: None,
|
||||
changelog_action_present: false,
|
||||
changelog_cta_rect: None,
|
||||
}
|
||||
}
|
||||
// Folder-trust question: shown after auth, before any session is
|
||||
// created, when the cwd has untrusted repo-local config. Mirrors the
|
||||
// Pending login screen. Skipped under ZDR/access gates (the ZDR arm
|
||||
// above and the !has_access arm below) since those already block
|
||||
// sessions. The `if let` destructure makes the `Pending`-only render
|
||||
// structurally exhaustive (no `unreachable!`).
|
||||
AuthState::Done if params.has_access => {
|
||||
// Pending login screen. The `if let` destructure makes the
|
||||
// `Pending`-only render structurally exhaustive (no `unreachable!`).
|
||||
AuthState::Done => {
|
||||
if let TrustState::Pending { workspace } = params.trust_state {
|
||||
render_welcome_trust(
|
||||
content_area,
|
||||
@@ -758,15 +687,6 @@ pub fn render_welcome(
|
||||
)
|
||||
}
|
||||
}
|
||||
AuthState::Done => render_welcome_done(
|
||||
content_area,
|
||||
buf,
|
||||
&theme,
|
||||
params,
|
||||
prompt,
|
||||
session_picker_state,
|
||||
h_margin,
|
||||
),
|
||||
};
|
||||
if result.post_flush_escapes.is_none() {
|
||||
result.post_flush_escapes = crate::terminal::overlay::clear().map(Into::into);
|
||||
@@ -851,16 +771,14 @@ fn render_welcome_blocked(
|
||||
None,
|
||||
h_margin,
|
||||
false,
|
||||
VersionBadgeMode::Full {
|
||||
subscription_tier: None,
|
||||
},
|
||||
VersionBadgeMode::Full,
|
||||
);
|
||||
(menu_rects, post_flush_escapes)
|
||||
}
|
||||
|
||||
/// Render the folder-trust question. Mirrors [`render_welcome_blocked`]'s
|
||||
/// stacked layout (logo + message + menu + version badge), but the message is a
|
||||
/// multi-line block showing the workspace path and the warning that Grok Build
|
||||
/// multi-line block showing the workspace path and the warning that Kigi
|
||||
/// may run or modify contents in this directory (a security risk). The y/N
|
||||
/// answer is handled by the welcome input interceptor, so this only paints;
|
||||
/// `menu_rects` are returned for parity with the other welcome arms.
|
||||
@@ -889,7 +807,7 @@ fn render_welcome_trust(
|
||||
// Two lines so the warning never clips at narrow / compact widths
|
||||
// (a single ~78-char line would truncate "...posing security risks").
|
||||
Line::from(Span::styled(
|
||||
"Grok Build may run or modify contents in this directory,",
|
||||
"Kigi may run or modify contents in this directory,",
|
||||
Style::default().fg(theme.gray),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
@@ -926,9 +844,7 @@ fn render_welcome_trust(
|
||||
None,
|
||||
h_margin,
|
||||
false,
|
||||
VersionBadgeMode::Full {
|
||||
subscription_tier: None,
|
||||
},
|
||||
VersionBadgeMode::Full,
|
||||
);
|
||||
|
||||
// Only `menu_rects` are meaningful here; the rest are absent (no prompt,
|
||||
@@ -1527,16 +1443,7 @@ fn render_welcome_done(
|
||||
// normal welcome layout.
|
||||
let welcome_compact = show_picker;
|
||||
|
||||
let cta = p
|
||||
.gate
|
||||
.and_then(|g| g.label.as_deref())
|
||||
.unwrap_or("Upgrade Subscription");
|
||||
let in_vscode_family = welcome_in_vscode_family();
|
||||
let (key_g, key_l, key_q) = (
|
||||
"ctrl+g",
|
||||
"ctrl+l",
|
||||
if in_vscode_family { "ctrl+d" } else { "ctrl+q" },
|
||||
);
|
||||
|
||||
// Heights that don't depend on the menu — computed first so the menu
|
||||
// builder can probe the layout to decide whether to add a Changelog row.
|
||||
@@ -1561,21 +1468,17 @@ fn render_welcome_done(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let changelog_height = if p.has_access && !show_picker && !p.changelog_bullets.is_empty() {
|
||||
let changelog_height = if !show_picker && !p.changelog_bullets.is_empty() {
|
||||
2 + p.changelog_bullets.len() as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Changelog is reachable via this menu row (ctrl+l). Show from the first
|
||||
// frame so the menu doesn't shift while the CDN fetch completes.
|
||||
let show_changelog_action = p.has_access && !show_picker;
|
||||
let show_changelog_action = !show_picker;
|
||||
|
||||
let gate_menu;
|
||||
let owned_menu;
|
||||
let menu_items: &[(&str, &str)] = if !p.has_access {
|
||||
gate_menu = [(key_g, cta), (key_l, "Logout"), (key_q, "Quit")];
|
||||
&gate_menu
|
||||
} else {
|
||||
let menu_items: &[(&str, &str)] = {
|
||||
let (key_w, key_s, key_q, key_i_with_x) = (
|
||||
"ctrl+w",
|
||||
"ctrl+s",
|
||||
@@ -1723,116 +1626,8 @@ fn render_welcome_done(
|
||||
|
||||
// Skip the prompt input when picker is visible to save space;
|
||||
// shortcuts are rendered inside the picker content area.
|
||||
let mut refresh_hit_rect: Option<Rect> = None;
|
||||
let mut gate_url_hit_rect: Option<Rect> = None;
|
||||
let (cursor_pos, post_flush_escapes) = if show_picker {
|
||||
(None, None)
|
||||
} else if !p.has_access {
|
||||
// Show CTA message and version instead of the prompt.
|
||||
let [_, centered, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(content_area.width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(layout.prompt);
|
||||
// Show the user's current tier + clickable refresh button above the gate message.
|
||||
let tier_label = p.subscription_tier.unwrap_or("Free");
|
||||
let tier_prefix = format!("Tier: {tier_label} ");
|
||||
let refresh_text = "[Refresh]";
|
||||
let total_width = tier_prefix.len() + refresh_text.len();
|
||||
let tier_line = Line::from(vec![
|
||||
Span::styled("Tier: ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
tier_label,
|
||||
Style::default()
|
||||
.fg(theme.gray_bright)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(
|
||||
refresh_text,
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
),
|
||||
])
|
||||
.alignment(Alignment::Center);
|
||||
let tier_area = Rect {
|
||||
height: 1,
|
||||
..centered
|
||||
};
|
||||
Paragraph::new(tier_line).render(tier_area, buf);
|
||||
|
||||
// Compute the click rect for "[Refresh]" within the centered line.
|
||||
let line_start_x = tier_area.x + tier_area.width.saturating_sub(total_width as u16) / 2;
|
||||
refresh_hit_rect = Some(Rect {
|
||||
x: line_start_x + tier_prefix.len() as u16,
|
||||
y: tier_area.y,
|
||||
width: refresh_text.len() as u16,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
let gate_text = p
|
||||
.gate
|
||||
.map(|g| g.message.as_str())
|
||||
.unwrap_or("SuperGrok subscription required");
|
||||
let msg = Line::from(Span::styled(
|
||||
gate_text,
|
||||
Style::default().fg(theme.gray_bright),
|
||||
))
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(msg).render(
|
||||
Rect {
|
||||
y: centered.y + 1,
|
||||
height: 1,
|
||||
..centered
|
||||
},
|
||||
buf,
|
||||
);
|
||||
|
||||
if centered.height > 2 {
|
||||
let url_area = Rect {
|
||||
y: centered.y + 2,
|
||||
height: 1,
|
||||
..centered
|
||||
};
|
||||
let gate_link = p
|
||||
.gate
|
||||
.and_then(|g| g.url.as_deref())
|
||||
.unwrap_or("https://grok.com/supergrok?referrer=grok-build");
|
||||
let url = Line::from(Span::styled(
|
||||
gate_link,
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
))
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(url).render(url_area, buf);
|
||||
|
||||
// Compute click rect for the gate URL text (centered within url_area).
|
||||
let link_width = gate_link.len() as u16;
|
||||
let link_x = url_area.x + url_area.width.saturating_sub(link_width) / 2;
|
||||
gate_url_hit_rect = Some(Rect {
|
||||
x: link_x,
|
||||
y: url_area.y,
|
||||
width: link_width.min(url_area.width),
|
||||
height: 1,
|
||||
});
|
||||
}
|
||||
|
||||
render_version_badge(
|
||||
layout.version,
|
||||
buf,
|
||||
theme,
|
||||
p.team_name,
|
||||
h_margin,
|
||||
p.is_api_key_auth,
|
||||
VersionBadgeMode::Full {
|
||||
subscription_tier: p.subscription_tier,
|
||||
},
|
||||
);
|
||||
(None, None)
|
||||
} else {
|
||||
// When a background update is available, show the update
|
||||
// notification in the tip area instead of the random tip.
|
||||
@@ -1913,19 +1708,10 @@ fn render_welcome_done(
|
||||
.render(tip_inset, buf);
|
||||
}
|
||||
|
||||
let warning = p.credit_balance.and_then(|bal| {
|
||||
crate::views::credit_bar::usage_warning(bal, p.auto_topup, p.usage_visible)
|
||||
});
|
||||
let (usage_warning_text, usage_warning_critical) = match warning {
|
||||
Some((text, critical)) => (Some(text), critical),
|
||||
None => (None, false),
|
||||
};
|
||||
let usage_info = PromptInfo {
|
||||
model_name: p.model_name,
|
||||
flags: p.flags,
|
||||
multiline: false,
|
||||
usage_warning: usage_warning_text.as_deref(),
|
||||
usage_warning_critical,
|
||||
};
|
||||
|
||||
render_prompt_and_version(
|
||||
@@ -1942,7 +1728,7 @@ fn render_welcome_done(
|
||||
} else {
|
||||
p.tip
|
||||
},
|
||||
p.team_name,
|
||||
None,
|
||||
h_margin,
|
||||
p.compact,
|
||||
p.pending_hint,
|
||||
@@ -1955,7 +1741,7 @@ fn render_welcome_done(
|
||||
cursor_pos,
|
||||
post_flush_escapes,
|
||||
menu_rects,
|
||||
prompt_rect: if show_picker || !p.has_access {
|
||||
prompt_rect: if show_picker {
|
||||
None
|
||||
} else {
|
||||
Some(layout.prompt)
|
||||
@@ -1964,8 +1750,6 @@ fn render_welcome_done(
|
||||
import_banner_rect,
|
||||
auth_url_rect: None,
|
||||
auth_fallback_rect: None,
|
||||
refresh_rect: refresh_hit_rect,
|
||||
gate_url_rect: gate_url_hit_rect,
|
||||
changelog_action_present: show_changelog_action,
|
||||
changelog_cta_rect,
|
||||
}
|
||||
@@ -2377,11 +2161,8 @@ mod tests {
|
||||
model_name: "test",
|
||||
flags: &[],
|
||||
selected: None,
|
||||
team_name: None,
|
||||
has_access: true,
|
||||
has_claude_import: false,
|
||||
mouse_pos: None,
|
||||
is_zdr_blocked: false,
|
||||
session_picker,
|
||||
session_picker_loading: false,
|
||||
compact: false,
|
||||
@@ -2394,15 +2175,10 @@ mod tests {
|
||||
session_picker_content_loading: false,
|
||||
session_picker_entries_query: None,
|
||||
welcome_tick: 0,
|
||||
gate: None,
|
||||
subscription_tier: None,
|
||||
session_picker_grouped: false,
|
||||
session_picker_source_filter: crate::views::session_picker::SourceFilter::All,
|
||||
chat_mode: false,
|
||||
cwd: std::path::Path::new("/repo"),
|
||||
credit_balance: None,
|
||||
auto_topup: None,
|
||||
usage_visible: true,
|
||||
changelog_bullets: &[],
|
||||
changelog_has_full_notes: false,
|
||||
}
|
||||
@@ -3156,24 +2932,33 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_user_code_parses_verification_url() {
|
||||
// The live Kimi device flow returns this exact URL shape.
|
||||
assert_eq!(
|
||||
extract_user_code("https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH"),
|
||||
extract_user_code("https://www.kimi.com/code/authorize_device?user_code=ABCD-EFGH"),
|
||||
Some("ABCD-EFGH"),
|
||||
);
|
||||
// Trailing params after the code are ignored.
|
||||
assert_eq!(
|
||||
extract_user_code("https://x.ai/oauth2/device?user_code=WXYZ-1234&foo=bar"),
|
||||
extract_user_code(
|
||||
"https://www.kimi.com/code/authorize_device?user_code=WXYZ-1234&foo=bar"
|
||||
),
|
||||
Some("WXYZ-1234"),
|
||||
);
|
||||
// A param whose name merely ends in `user_code` must not be matched.
|
||||
assert_eq!(
|
||||
extract_user_code("https://x.ai/d?foo_user_code=BAD&user_code=GOOD"),
|
||||
extract_user_code("https://example.com/d?foo_user_code=BAD&user_code=GOOD"),
|
||||
Some("GOOD"),
|
||||
);
|
||||
// No code param, empty code, and unexpected characters all yield None.
|
||||
assert_eq!(extract_user_code("https://x.ai/oauth2/device"), None);
|
||||
assert_eq!(extract_user_code("https://x.ai/d?user_code="), None);
|
||||
assert_eq!(extract_user_code("https://x.ai/d?user_code=AB%20CD"), None);
|
||||
assert_eq!(
|
||||
extract_user_code("https://www.kimi.com/code/authorize_device"),
|
||||
None
|
||||
);
|
||||
assert_eq!(extract_user_code("https://example.com/d?user_code="), None);
|
||||
assert_eq!(
|
||||
extract_user_code("https://example.com/d?user_code=AB%20CD"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3181,7 +2966,7 @@ mod tests {
|
||||
let area = Rect::new(0, 0, 80, 40);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let theme = Theme::current();
|
||||
let url = "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH";
|
||||
let url = "https://www.kimi.com/code/authorize_device?user_code=ABCD-EFGH";
|
||||
|
||||
let (copy_rect, fallback_rect) = render_welcome_authenticating(
|
||||
area,
|
||||
@@ -3235,7 +3020,7 @@ mod tests {
|
||||
let area = Rect::new(0, 0, 80, 40);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let theme = Theme::current();
|
||||
let url = "https://accounts.x.ai/oauth2/device?user_code=WXYZ-1234";
|
||||
let url = "https://www.kimi.com/code/authorize_device?user_code=WXYZ-1234";
|
||||
|
||||
render_welcome_authenticating(
|
||||
area,
|
||||
@@ -3261,7 +3046,7 @@ mod tests {
|
||||
let area = Rect::new(0, 0, 80, 40);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let theme = Theme::current();
|
||||
let url = "https://accounts.x.ai/oauth2/device?user_code=WXYZ-1234";
|
||||
let url = "https://www.kimi.com/code/authorize_device?user_code=WXYZ-1234";
|
||||
|
||||
render_welcome_authenticating(
|
||||
area,
|
||||
@@ -3298,7 +3083,7 @@ mod tests {
|
||||
let theme = Theme::current();
|
||||
// 40-col terminal; URL longer than one row must wrap at the exact
|
||||
// screen edge with no leading spaces so copy-paste stays intact.
|
||||
let url = "https://accounts.x.ai/oauth2/device?user_code=WXYZ-1234&extra=0123456789";
|
||||
let url = "https://www.kimi.com/code/authorize_device?user_code=WXYZ-1234&extra=0123456789";
|
||||
|
||||
render_welcome_authenticating(
|
||||
area,
|
||||
@@ -3337,7 +3122,7 @@ mod tests {
|
||||
let area = Rect::new(0, 0, 80, 40);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let theme = Theme::current();
|
||||
let url = "https://accounts.x.ai/oauth2/authorize?client_id=grok";
|
||||
let url = "https://example.com/oauth2/authorize?client_id=kigi";
|
||||
|
||||
let (copy_rect, fallback_rect) = render_welcome_authenticating(
|
||||
area,
|
||||
|
||||
@@ -79,8 +79,6 @@ mod tests {
|
||||
model_name: "test",
|
||||
flags: &[],
|
||||
multiline: false,
|
||||
usage_warning: None,
|
||||
usage_warning_critical: false,
|
||||
};
|
||||
|
||||
let (_, post_flush) = render_prompt(
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ async fn managed_policy_gate_refusal_reaches_real_terminal() {
|
||||
"[endpoints]\n\
|
||||
deployment_key = \"KEY-AAA\"\n\
|
||||
managed_config_url = \"http://127.0.0.1:1/deployment/config\"\n\
|
||||
cli_chat_proxy_base_url = \"http://127.0.0.1:1\"\n",
|
||||
coding_api_base_url = \"http://127.0.0.1:1\"\n",
|
||||
)
|
||||
.expect("write config.toml");
|
||||
std::fs::write(
|
||||
|
||||
@@ -50,7 +50,6 @@ const ALL_SETTINGS_EXERCISED: &[&str] = &[
|
||||
"scroll_lines",
|
||||
"invert_scroll",
|
||||
"display_refresh_auto_cadence",
|
||||
"coding_data_sharing",
|
||||
"default_selected_permission",
|
||||
"plan_mode",
|
||||
"show_tips",
|
||||
@@ -1608,7 +1607,6 @@ fn registry_kind_membership_through_pr_14() {
|
||||
vec![
|
||||
"auto_dark_theme",
|
||||
"auto_light_theme",
|
||||
"coding_data_sharing",
|
||||
"default_selected_permission",
|
||||
"hunk_tracker_mode",
|
||||
"keep_text_selection",
|
||||
@@ -1675,7 +1673,6 @@ fn enum_settings_membership_through_pr_14() {
|
||||
vec![
|
||||
"auto_dark_theme",
|
||||
"auto_light_theme",
|
||||
"coding_data_sharing",
|
||||
"default_selected_permission",
|
||||
"hunk_tracker_mode",
|
||||
"keep_text_selection",
|
||||
@@ -1736,7 +1733,6 @@ fn defaults_round_trip_through_registry() {
|
||||
"scroll_lines" => SettingValue::Int(3),
|
||||
"invert_scroll" => SettingValue::Bool(false),
|
||||
"display_refresh_auto_cadence" => SettingValue::Bool(false),
|
||||
"coding_data_sharing" => SettingValue::Enum("opt-in"),
|
||||
"default_selected_permission" => SettingValue::Enum("always_allow_all_sessions"),
|
||||
"hunk_tracker_mode" => SettingValue::Enum("agent_only"),
|
||||
"plan_mode" => SettingValue::Enum("off"),
|
||||
@@ -4380,382 +4376,6 @@ fn pr8_default_model_and_max_thoughts_width_defaults_roundtrip() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// coding_data_sharing (Privacy Enum, no preview — async ACP)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `coding_data_sharing` lives under `Privacy`.
|
||||
#[test]
|
||||
fn pr9_coding_data_sharing_renders_under_privacy_category() {
|
||||
let reg = SettingsRegistry::defaults();
|
||||
let meta = reg
|
||||
.find("coding_data_sharing")
|
||||
.expect("coding_data_sharing must be registered");
|
||||
assert_eq!(
|
||||
meta.category,
|
||||
SettingCategory::Privacy,
|
||||
"coding_data_sharing must live under Privacy"
|
||||
);
|
||||
assert_eq!(
|
||||
meta.owner,
|
||||
SettingOwner::Shell,
|
||||
"coding_data_sharing is SHELL-owned (auth-metadata-backed, persists via ACP)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `coding_data_sharing` must be `supports_preview: false` (async ACP).
|
||||
#[test]
|
||||
fn pr9_coding_data_sharing_does_not_support_preview() {
|
||||
let reg = SettingsRegistry::defaults();
|
||||
let meta = reg
|
||||
.find("coding_data_sharing")
|
||||
.expect("coding_data_sharing must be registered");
|
||||
match &meta.kind {
|
||||
SettingKind::Enum {
|
||||
supports_preview, ..
|
||||
} => {
|
||||
assert!(
|
||||
!supports_preview,
|
||||
"coding_data_sharing MUST be supports_preview: false — every preview \
|
||||
would fire an async ACP round-trip OR commit-on-every-nav, both \
|
||||
unacceptable",
|
||||
);
|
||||
}
|
||||
other => panic!("expected Enum kind for coding_data_sharing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads from pager snapshot; inverts `_opt_out` bool.
|
||||
#[test]
|
||||
fn pr9_current_value_for_reads_pager_snapshot_inverts_opt_out() {
|
||||
use kigi_tui::settings::current_value_for;
|
||||
|
||||
let ui = UiConfig::default();
|
||||
|
||||
let opted_in_snap = PagerLocalSnapshot {
|
||||
coding_data_sharing_opt_out: false,
|
||||
..PagerLocalSnapshot::default()
|
||||
};
|
||||
let opted_out_snap = PagerLocalSnapshot {
|
||||
coding_data_sharing_opt_out: true,
|
||||
..PagerLocalSnapshot::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
current_value_for("coding_data_sharing", &ui, &opted_in_snap),
|
||||
Some(SettingValue::Enum("opt-in")),
|
||||
"opt_out=false → canonical 'opt-in' (user IS sharing data)",
|
||||
);
|
||||
assert_eq!(
|
||||
current_value_for("coding_data_sharing", &ui, &opted_out_snap),
|
||||
Some(SettingValue::Enum("opt-out")),
|
||||
"opt_out=true → canonical 'opt-out' (user opted OUT of sharing)",
|
||||
);
|
||||
}
|
||||
|
||||
/// Enter opens picker seeded to current state.
|
||||
#[test]
|
||||
fn pr9_enter_on_coding_data_sharing_row_enters_picking_enum() {
|
||||
let mut s = make_state();
|
||||
navigate_to(&mut s, "coding_data_sharing");
|
||||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||||
"Enter on coding_data_sharing row must transition to PickingEnum, got {outcome:?}"
|
||||
);
|
||||
match &s.mode {
|
||||
SettingsModalMode::PickingEnum {
|
||||
key,
|
||||
original_value,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*key, "coding_data_sharing");
|
||||
assert_eq!(
|
||||
original_value,
|
||||
&SettingValue::Enum("opt-in"),
|
||||
"default snapshot opt_out=false → original 'opt-in'"
|
||||
);
|
||||
}
|
||||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nav in picker must NOT dispatch preview (async ACP).
|
||||
#[test]
|
||||
fn pr9_coding_data_sharing_picker_nav_does_not_dispatch_preview() {
|
||||
for nav_key in &[
|
||||
KeyCode::Down,
|
||||
KeyCode::Char('j'),
|
||||
KeyCode::Up,
|
||||
KeyCode::Char('k'),
|
||||
] {
|
||||
let mut s = make_state();
|
||||
navigate_to(&mut s, "coding_data_sharing");
|
||||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||||
assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. }));
|
||||
|
||||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||||
}
|
||||
|
||||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||||
"Nav key {nav_key:?} in coding_data_sharing picker MUST NOT dispatch a preview \
|
||||
Action — that would fire a network round-trip per keystroke. Got {outcome:?}",
|
||||
);
|
||||
assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter commits `SetCodingDataSharing { opted_in }` (opt-in→true).
|
||||
#[test]
|
||||
fn pr9_coding_data_sharing_picker_enter_dispatches_set_commit() {
|
||||
let reg = SettingsRegistry::defaults();
|
||||
let meta = reg.find("coding_data_sharing").unwrap();
|
||||
let (default_canonical, choices) = match &meta.kind {
|
||||
SettingKind::Enum {
|
||||
default, choices, ..
|
||||
} => (*default, *choices),
|
||||
_ => panic!("coding_data_sharing must be Enum"),
|
||||
};
|
||||
// Resolve "the other" canonical from the registry rather than
|
||||
// hardcoding — robust against future catalog additions.
|
||||
let other_canonical = choices
|
||||
.iter()
|
||||
.map(|c| c.canonical)
|
||||
.find(|c| *c != default_canonical)
|
||||
.expect("coding_data_sharing must have ≥2 choices");
|
||||
let expected_opted_in = match other_canonical {
|
||||
"opt-in" => true,
|
||||
"opt-out" => false,
|
||||
_ => panic!("unexpected canonical: {other_canonical:?}"),
|
||||
};
|
||||
|
||||
let mut s = make_state();
|
||||
navigate_to(&mut s, "coding_data_sharing");
|
||||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||||
// Nav to the OTHER choice.
|
||||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||||
// Enter → commit.
|
||||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||||
match outcome {
|
||||
SettingsKeyOutcome::Action(Action::SetCodingDataSharing { opted_in }) => {
|
||||
assert_eq!(
|
||||
opted_in, expected_opted_in,
|
||||
"Enter must commit `{other_canonical}` → SetCodingDataSharing(opted_in={expected_opted_in})"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Action::SetCodingDataSharing commit, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
matches!(s.mode, SettingsModalMode::Browse),
|
||||
"Enter commit must return to Browse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Esc in non-preview picker returns to Browse without Action.
|
||||
#[test]
|
||||
fn pr9_coding_data_sharing_picker_esc_does_not_dispatch_action() {
|
||||
let mut s = make_state();
|
||||
navigate_to(&mut s, "coding_data_sharing");
|
||||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||||
|
||||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||||
"Esc on non-preview Enum picker must NOT emit an Action — \
|
||||
doing so would fire an ACP round-trip on every Esc. Got {outcome:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(s.mode, SettingsModalMode::Browse),
|
||||
"Esc must return to Browse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Picker seeds at "opt-out" when `coding_data_sharing_opt_out: true`.
|
||||
#[test]
|
||||
fn pr9_picker_seeds_choices_idx_from_pager_snapshot_opt_out_true() {
|
||||
let snapshot = PagerLocalSnapshot {
|
||||
coding_data_sharing_opt_out: true,
|
||||
..PagerLocalSnapshot::default()
|
||||
};
|
||||
let mut s = SettingsModalState::new(
|
||||
Arc::new(SettingsRegistry::defaults()),
|
||||
UiConfig::default(),
|
||||
snapshot,
|
||||
);
|
||||
navigate_to(&mut s, "coding_data_sharing");
|
||||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||||
let reg = SettingsRegistry::defaults();
|
||||
let opt_out_idx = match ®.find("coding_data_sharing").unwrap().kind {
|
||||
SettingKind::Enum { choices, .. } => choices
|
||||
.iter()
|
||||
.position(|c| c.canonical == "opt-out")
|
||||
.expect("coding_data_sharing must have 'opt-out' choice"),
|
||||
_ => panic!("coding_data_sharing must be Enum"),
|
||||
};
|
||||
match s.mode {
|
||||
SettingsModalMode::PickingEnum {
|
||||
choices_idx,
|
||||
ref original_value,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
choices_idx, opt_out_idx,
|
||||
"picker must seed at the 'opt-out' index when snapshot says opt_out=true"
|
||||
);
|
||||
assert_eq!(
|
||||
original_value,
|
||||
&SettingValue::Enum("opt-out"),
|
||||
"original_value must match the live snapshot"
|
||||
);
|
||||
}
|
||||
ref other => panic!("expected PickingEnum mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exactly 2 canonical choices: {opt-in, opt-out}.
|
||||
#[test]
|
||||
fn pr9_coding_data_sharing_choices_use_canonical_strings() {
|
||||
let reg = SettingsRegistry::defaults();
|
||||
let meta = reg.find("coding_data_sharing").unwrap();
|
||||
let canonicals: Vec<&str> = match &meta.kind {
|
||||
SettingKind::Enum { choices, .. } => choices.iter().map(|c| c.canonical).collect(),
|
||||
_ => panic!("coding_data_sharing must be Enum"),
|
||||
};
|
||||
assert_eq!(
|
||||
canonicals.len(),
|
||||
2,
|
||||
"coding_data_sharing catalog must be exactly {{opt-in, opt-out}} — adding a \
|
||||
choice requires updating the action_for_enum_commit arm in \
|
||||
views/settings_modal.rs AND the action_for_reset arm in dispatch.rs",
|
||||
);
|
||||
assert!(
|
||||
canonicals.contains(&"opt-in"),
|
||||
"coding_data_sharing must include 'opt-in' canonical"
|
||||
);
|
||||
assert!(
|
||||
canonicals.contains(&"opt-out"),
|
||||
"coding_data_sharing must include 'opt-out' canonical"
|
||||
);
|
||||
}
|
||||
|
||||
/// Search "privacy" finds exactly `coding_data_sharing`.
|
||||
#[test]
|
||||
fn pr9_search_privacy_matches_coding_data_sharing() {
|
||||
let reg = SettingsRegistry::defaults();
|
||||
let hits = reg.search("privacy");
|
||||
// The category label "Privacy" appears as a header but is not
|
||||
// part of `search()`'s haystack (search ignores categories);
|
||||
// matches come from the meta's keywords + label + description.
|
||||
let hit_keys: Vec<&str> = hits.iter().map(|m| m.key).collect();
|
||||
assert_eq!(
|
||||
hits.len(),
|
||||
1,
|
||||
"search('privacy') must return EXACTLY one result (coding_data_sharing). \
|
||||
Found {} results: {hit_keys:?}. \
|
||||
If this fails because another setting added 'privacy' to its keywords/label/\
|
||||
description, decide: (a) is 'privacy' a real keyword for that setting? If yes, \
|
||||
loosen this assertion to a presence-only check `hit_keys.contains(&\"coding_data_sharing\")`. \
|
||||
(b) If no, remove 'privacy' from the other setting's haystack — search relevance \
|
||||
is more important than tag promiscuity.",
|
||||
hits.len(),
|
||||
);
|
||||
assert_eq!(
|
||||
hits[0].key, "coding_data_sharing",
|
||||
"search('privacy') unique result must be coding_data_sharing"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mouse path tests for coding_data_sharing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// First click on unselected row only selects.
|
||||
#[test]
|
||||
fn pr9_mouse_click_on_unselected_coding_data_sharing_row_only_selects() {
|
||||
let mut s = make_state();
|
||||
synth_rects(&mut s);
|
||||
let row_y = row_idx_for(&s, "coding_data_sharing") as u16;
|
||||
|
||||
let outcome = handle_settings_mouse(
|
||||
&mut s,
|
||||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||||
10,
|
||||
row_y,
|
||||
);
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||||
"first body-click on unselected coding_data_sharing row should only select, got: {outcome:?}",
|
||||
);
|
||||
assert_eq!(s.selected, row_y as usize);
|
||||
assert!(matches!(s.mode, SettingsModalMode::Browse));
|
||||
}
|
||||
|
||||
/// Second click on selected row opens picker.
|
||||
#[test]
|
||||
fn pr9_mouse_click_on_selected_coding_data_sharing_row_opens_picker() {
|
||||
let mut s = make_state();
|
||||
synth_rects(&mut s);
|
||||
let row_y = row_idx_for(&s, "coding_data_sharing") as u16;
|
||||
|
||||
// First click: select.
|
||||
let _ = handle_settings_mouse(
|
||||
&mut s,
|
||||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||||
10,
|
||||
row_y,
|
||||
);
|
||||
assert_eq!(s.selected, row_y as usize);
|
||||
|
||||
// Second click on the focused row: open the picker.
|
||||
let outcome = handle_settings_mouse(
|
||||
&mut s,
|
||||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||||
10,
|
||||
row_y,
|
||||
);
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||||
"second click on focused Enum row must open picker, got: {outcome:?}",
|
||||
);
|
||||
match &s.mode {
|
||||
SettingsModalMode::PickingEnum { key, .. } => {
|
||||
assert_eq!(*key, "coding_data_sharing");
|
||||
}
|
||||
_ => panic!("second click on focused coding_data_sharing row must enter PickingEnum"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Value-column click opens picker in one click.
|
||||
#[test]
|
||||
fn pr9_mouse_click_on_coding_data_sharing_indicator_opens_picker_in_one_click() {
|
||||
let mut s = make_state();
|
||||
synth_rects(&mut s);
|
||||
let row_y = row_idx_for(&s, "coding_data_sharing") as u16;
|
||||
|
||||
let outcome = handle_settings_mouse(
|
||||
&mut s,
|
||||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||||
72,
|
||||
row_y,
|
||||
);
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||||
"value click must open picker in one click, got: {outcome:?}",
|
||||
);
|
||||
match &s.mode {
|
||||
SettingsModalMode::PickingEnum { key, .. } => {
|
||||
assert_eq!(*key, "coding_data_sharing");
|
||||
}
|
||||
_ => {
|
||||
panic!("value click on coding_data_sharing must enter PickingEnum")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// default_selected_permission (Agent Enum, no preview — SHELL-owned, persists)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5024,53 +4644,6 @@ fn default_selected_permission_mouse_click_on_indicator_opens_picker_in_one_clic
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/privacy` slash command's argument parser
|
||||
/// is case-insensitive and supports a deliberately-pared-down list of
|
||||
/// unambiguous-semantic aliases. The unit-level coverage lives in the
|
||||
/// slash command module; this e2e test pins the integration contract
|
||||
/// (the parser is reachable from the slash command and produces the
|
||||
/// expected `Action`).
|
||||
///
|
||||
/// Ambiguous aliases
|
||||
/// (`on/off/true/false/enable/disable`) were DROPPED because they
|
||||
/// could be read either as "turn on privacy" (=opt-out) or "turn on
|
||||
/// sharing" (=opt-in). For a privacy-critical setting we err on the
|
||||
/// side of explicit, unambiguous arguments. The test below verifies
|
||||
/// both the accept list AND the reject list.
|
||||
#[test]
|
||||
fn pr9_privacy_slash_command_parses_aliases() {
|
||||
use kigi_tui::slash::commands::privacy::parse_privacy_arg;
|
||||
|
||||
// Canonical names.
|
||||
assert_eq!(parse_privacy_arg("opt-in"), Some(true));
|
||||
assert_eq!(parse_privacy_arg("opt-out"), Some(false));
|
||||
|
||||
// Case-insensitive (sample).
|
||||
assert_eq!(parse_privacy_arg("Opt-In"), Some(true));
|
||||
assert_eq!(parse_privacy_arg("OPT-OUT"), Some(false));
|
||||
|
||||
// Unambiguous-semantic aliases (pruned list).
|
||||
assert_eq!(parse_privacy_arg("in"), Some(true));
|
||||
assert_eq!(parse_privacy_arg("out"), Some(false));
|
||||
assert_eq!(parse_privacy_arg("share"), Some(true));
|
||||
assert_eq!(parse_privacy_arg("private"), Some(false));
|
||||
|
||||
// Ambiguous aliases MUST be rejected. `/privacy on`
|
||||
// could be read as "turn on privacy" (=opt-out, the OPPOSITE of
|
||||
// what an earlier mapping returned). For a privacy
|
||||
// setting, ambiguity = silent data-exfiltration risk.
|
||||
for ambiguous in &["on", "off", "true", "false", "enable", "disable"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(ambiguous),
|
||||
None,
|
||||
"ambiguous alias `{ambiguous}` MUST be rejected (PR 9 R1, Security Issue 10)",
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown.
|
||||
assert_eq!(parse_privacy_arg("maybe"), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `plan_mode` (Agent-category Enum, PAGER-owned + ACP-mediated,
|
||||
// supports_preview: false)
|
||||
|
||||
Reference in New Issue
Block a user