mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-06 08:13:49 +00:00
fix(bar): lead carousel with default account, label it as the base command
Address UX review: the bare login (e.g. ~/.codex) is the default way of running a
surface, not a profile. Rename it from 'personal' to 'default' and present it as
the base command ('ccsx') with a 'default' badge, while named profiles show their
name ('ck') plus the owning surface tag ('ccsx'). Order each provider carousel so
the default account leads, then by tightest quota window.
This commit is contained in:
1 parent
50893319e5
commit
b9f6837913
5 files changed
+80
-17
No files matched your search
@@ -340,11 +340,14 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Order subscription cards by tightest binding window ascending (closest to
|
||||
/// empty on top). Rows with no binding window (error/reauth) sink to the bottom
|
||||
/// so the actionable quota always leads.
|
||||
/// Order subscription cards so the default/base account leads its provider
|
||||
/// carousel (it is the account the user runs by default), then by tightest
|
||||
/// binding window ascending (closest to empty next). Rows with no binding
|
||||
/// window (error/reauth) sink to the bottom so actionable quota leads.
|
||||
private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] {
|
||||
subs.sorted { a, b in
|
||||
// Default account first within its provider group.
|
||||
if a.isDefault != b.isDefault { return a.isDefault }
|
||||
let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent
|
||||
let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent
|
||||
switch (ra, rb) {
|
||||
|
||||
@@ -61,12 +61,14 @@ struct BarSubscriptionCard: View {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
// Multi-profile: show profile name when available; fall back to provider label.
|
||||
Text(row.profile ?? BarFormatting.providerLabel(row.provider))
|
||||
// Default account shows the bare command ("ccsx"); a named profile shows its
|
||||
// name ("ck"). Falls back to the provider label for legacy rows.
|
||||
Text(BarFormatting.accountTitle(row))
|
||||
.font(.system(.body, design: .default).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
// Surface tag chip ("ccs · work", "ccsx · personal") for multi-profile rows.
|
||||
if let tag = BarFormatting.surfaceProfileLabel(row) {
|
||||
// Tag: "default" for the base account, else the owning surface ("ccsx") so a
|
||||
// named profile reads as e.g. "ck · ccsx".
|
||||
if let tag = BarFormatting.accountTag(row) {
|
||||
Chip(tag, tint: theme.subscription.opacity(isParked ? 0.5 : 1))
|
||||
}
|
||||
if row.needsReauth {
|
||||
|
||||
@@ -1832,6 +1832,32 @@ do {
|
||||
check(
|
||||
BarFormatting.surfaceProfileLabel(noSurfaceRow) == "codex · personal",
|
||||
"mp: surfaceProfileLabel falls back to provider when surface is nil")
|
||||
|
||||
// (MP9) base/default account vs named profile presentation
|
||||
let defaultCodexRow = BarSummaryRow(
|
||||
accountId: "ccsx:default", provider: "codex",
|
||||
surface: "ccsx", profile: "default", isSubscription: true)
|
||||
check(
|
||||
BarFormatting.isBaseAccount(defaultCodexRow),
|
||||
"mp: 'default' profile is the base account")
|
||||
check(
|
||||
!BarFormatting.isBaseAccount(codexRow),
|
||||
"mp: named profile 'ck' is not the base account")
|
||||
check(
|
||||
BarFormatting.accountTitle(defaultCodexRow) == "ccsx",
|
||||
"mp: accountTitle for default account -> 'ccsx' (bare command)")
|
||||
check(
|
||||
BarFormatting.accountTitle(codexRow) == "ck",
|
||||
"mp: accountTitle for named profile -> 'ck'")
|
||||
check(
|
||||
BarFormatting.accountTag(defaultCodexRow) == "default",
|
||||
"mp: accountTag for default account -> 'default'")
|
||||
check(
|
||||
BarFormatting.accountTag(codexRow) == "ccsx",
|
||||
"mp: accountTag for named profile -> 'ccsx' (surface)")
|
||||
check(
|
||||
BarFormatting.accountTag(legacyRow) == nil,
|
||||
"mp: accountTag for CLIProxy pool row -> nil")
|
||||
} catch {
|
||||
check(false, "mp: multi-profile JSON decode failed: \(error)")
|
||||
}
|
||||
|
||||
@@ -177,13 +177,37 @@ public enum BarFormatting {
|
||||
}
|
||||
|
||||
/// Surface + profile chip label for multi-profile cards, e.g. "ccs · work" or
|
||||
/// "ccsx · personal". Returns nil for CLIProxy pool rows that have no profile.
|
||||
/// "ccsx · ck". Returns nil for CLIProxy pool rows that have no profile.
|
||||
public static func surfaceProfileLabel(_ row: BarSummaryRow) -> String? {
|
||||
guard let p = row.profile else { return nil }
|
||||
let s = row.surface ?? row.provider
|
||||
return "\(s) · \(p)"
|
||||
}
|
||||
|
||||
/// True when this row is the surface's default/base account — the bare login
|
||||
/// (e.g. `ccsx` => ~/.codex), as opposed to a named `ccsx <profile>` profile.
|
||||
/// The server names this account "default".
|
||||
public static func isBaseAccount(_ row: BarSummaryRow) -> Bool {
|
||||
row.profile == "default"
|
||||
}
|
||||
|
||||
/// Primary card title. For the default/base account it is the bare command the
|
||||
/// user actually runs ("ccsx" / "ccs"); for a named profile it is the profile
|
||||
/// name ("ck"). Falls back to the provider product label for legacy rows.
|
||||
public static func accountTitle(_ row: BarSummaryRow) -> String {
|
||||
if isBaseAccount(row) { return row.surface ?? providerLabel(row.provider) }
|
||||
return row.profile ?? providerLabel(row.provider)
|
||||
}
|
||||
|
||||
/// Secondary chip beside the title. The default/base account is tagged
|
||||
/// "default"; a named profile is tagged with the surface that owns it
|
||||
/// ("ccs"/"ccsx") so "ck" reads as a ccsx profile. Nil for pool rows.
|
||||
public static func accountTag(_ row: BarSummaryRow) -> String? {
|
||||
guard row.profile != nil else { return nil }
|
||||
if isBaseAccount(row) { return "default" }
|
||||
return row.surface ?? row.provider
|
||||
}
|
||||
|
||||
/// Friendly product label for a provider key. Native subscription keys read as
|
||||
/// products ("Claude Code", "Codex"); any other provider passes through verbatim
|
||||
/// (so "agy"/"ghcp"/"kiro" keep their established short chip text).
|
||||
|
||||
@@ -80,6 +80,11 @@ const CB_COOLDOWN_MS = 900_000; // 15 minutes
|
||||
const SURFACE_CLAUDE = 'ccs';
|
||||
const SURFACE_CODEX = 'ccsx';
|
||||
|
||||
// The "default way of running" a surface — the bare login (e.g. ~/.codex for
|
||||
// ccsx), as opposed to a named `ccsx <profile>` profile. Rendered in the Bar as
|
||||
// the base command ("ccsx") with a "default" badge, not as a named profile.
|
||||
const DEFAULT_PROFILE = 'default';
|
||||
|
||||
// Provider values on the wire (unchanged from before)
|
||||
const CLAUDE_NATIVE_PROVIDER = 'claude-code';
|
||||
const CODEX_NATIVE_PROVIDER = 'codex';
|
||||
@@ -117,13 +122,13 @@ export interface NativeQuotaDeps {
|
||||
getCodexQuota?: () => Promise<CodexLocalQuota | null>;
|
||||
/**
|
||||
* Read the native Codex auth for a profile (file-only, no keychain).
|
||||
* 'personal' reads ~/.codex/auth.json; other names read codex-instances/<name>/auth.json.
|
||||
* DEFAULT_PROFILE ('default') reads ~/.codex/auth.json; other names read codex-instances/<name>/auth.json.
|
||||
* Returns null when absent/unparseable.
|
||||
*/
|
||||
readCodexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null;
|
||||
/** Enumerate Claude profile names. Injected so tests never touch real fs. */
|
||||
listClaudeProfiles?: () => string[];
|
||||
/** Enumerate Codex profile names (including 'personal' for bare ~/.codex). */
|
||||
/** Enumerate Codex profile names (including DEFAULT_PROFILE for bare ~/.codex). */
|
||||
listCodexProfiles?: () => string[];
|
||||
/** Resolve the default Claude profile name. */
|
||||
defaultClaudeProfile?: () => string | null;
|
||||
@@ -513,7 +518,7 @@ function readCodexNativeAuthFromDisk(
|
||||
): { accessToken: string; accountId: string } | null {
|
||||
try {
|
||||
let authPath: string;
|
||||
if (profile === 'personal') {
|
||||
if (profile === DEFAULT_PROFILE) {
|
||||
authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
} else {
|
||||
// resolveCodexProfileDir would validate, but we do it inline to avoid the
|
||||
@@ -576,7 +581,7 @@ function getDefaultClaudeProfileFromDisk(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Codex profiles from the registry, plus 'personal' when the bare
|
||||
* List all Codex profiles from the registry, plus DEFAULT_PROFILE when the bare
|
||||
* ~/.codex/auth.json exists. Returns [] on any read error.
|
||||
*/
|
||||
function listCodexProfilesFromDisk(): string[] {
|
||||
@@ -588,9 +593,11 @@ function listCodexProfilesFromDisk(): string[] {
|
||||
};
|
||||
const registry = new CodexProfileRegistry();
|
||||
const profiles = registry.listProfiles();
|
||||
// Add 'personal' for the bare ~/.codex/auth.json if it exists
|
||||
// Add the bare ~/.codex/auth.json (the default `ccsx` invocation) as the
|
||||
// DEFAULT_PROFILE account when it exists. It is the "default way of running",
|
||||
// distinct from named `ccsx <profile>` profiles.
|
||||
if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) {
|
||||
if (!profiles.includes('personal')) profiles.push('personal');
|
||||
if (!profiles.includes(DEFAULT_PROFILE)) profiles.push(DEFAULT_PROFILE);
|
||||
}
|
||||
return profiles;
|
||||
} catch {
|
||||
@@ -599,7 +606,7 @@ function listCodexProfilesFromDisk(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default Codex profile. Falls back to 'personal' when the bare
|
||||
* Resolve the default Codex profile. Falls back to DEFAULT_PROFILE when the bare
|
||||
* ~/.codex/auth.json exists and no registry default is set.
|
||||
*/
|
||||
function getDefaultCodexProfileFromDisk(): string | null {
|
||||
@@ -612,8 +619,9 @@ function getDefaultCodexProfileFromDisk(): string | null {
|
||||
const registry = new CodexProfileRegistry();
|
||||
const def = registry.getDefault();
|
||||
if (def) return def;
|
||||
// Fall back to 'personal' if the bare auth.json exists
|
||||
if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) return 'personal';
|
||||
// Fall back to the bare ~/.codex account (DEFAULT_PROFILE) when no registry
|
||||
// default is set — it is the default `ccsx` invocation.
|
||||
if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) return DEFAULT_PROFILE;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user