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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user