Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
35e707a
Merge branch 'feat/codex-multi-account' into main
Rag30 Mar 20, 2026
d4c3992
fix: skip status item rebuild when only config revision changes
Rag30 Mar 20, 2026
069128b
feat: Codex credits follow active account (OAuth per tab)
Rag30 Mar 20, 2026
ec09606
Fix Codex token-account env for costs; align default selection when p…
Rag30 Mar 20, 2026
e1df48e
Refactor CodexBar account handling to support explicit account manage…
Rag30 Mar 21, 2026
91ba221
Multi Account clarificatoin
Rag30 Mar 22, 2026
7e392c6
feat: Multiple Accounts toggle, drag-reorder, scroll-stable layout
Rag30 Mar 22, 2026
fe936d3
feat: per-account dashboard login with combined OAuth flow
Rag30 Mar 22, 2026
2e9ba4c
feat: per-account OpenAI dashboard with workspace isolation
Rag30 Mar 23, 2026
67b8b86
fix: suppress browser cookie import in multi-account dashboard mode
Rag30 Mar 23, 2026
d04b7cb
Merge upstream/main into main (steipete/codexbar Cursor changes)
Rag30 Mar 23, 2026
d64f353
Merge upstream/main: resolve conflicts in UsageStore, add plan utiliz…
Rag30 Mar 23, 2026
3747c2c
fix: clear stale Codex data, reset account on multi-account off, stab…
Rag30 Mar 23, 2026
0aaf96c
fix: use account identifier for dashboard refresh, hide API-key dashb…
Rag30 Mar 23, 2026
3d1f8a7
fix: multi-account UX polish
Rag30 Mar 23, 2026
dcdf56b
Merge Feat-Multi-Codex-Bar: multi-account UX polish
Rag30 Mar 23, 2026
7c2735c
fix: suppress redundant 'not signed in' hint from Credits block
Rag30 Mar 23, 2026
6676a9f
fix: API-key cost isolation and active-account preserve on delete
Rag30 Mar 23, 2026
94ec6e6
fix: three multi-account review items
Rag30 Mar 23, 2026
b92358c
fix: dashboard login-required drops logged-in flag, API-key cost TTL,…
Rag30 Mar 23, 2026
598c6da
Fix 3 dashboard bugs: logout flicker, stale key in markDashboardLogin…
Rag30 Mar 23, 2026
7ab804b
Fix account-switch destroying new account login flag; guard removeTok…
Rag30 Mar 23, 2026
6e10f4e
Clean up dashboardLoggedInEmails when token account is deleted
Rag30 Mar 23, 2026
e053b0b
Auto-install zsh shell integration on first OAuth account add; keep a…
Rag30 Mar 23, 2026
162c828
Auto-symlink ~/.codex/sessions into new OAuth account dir on creation
Rag30 Mar 23, 2026
14721e9
Add OpenAI REST API cost fetching for API-key Codex accounts; isolate…
Rag30 Mar 24, 2026
712f1dd
Fix unquoted CODEX_HOME in zsh hook and missing shell repair on expli…
Rag30 Mar 24, 2026
1921133
Merge remote-tracking branch 'upstream/main'
Rag30 Mar 24, 2026
71dbe18
Fix six Codex multi-account bugs
Rag30 Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(swift build:*)"
]
}
}
198 changes: 198 additions & 0 deletions Sources/CodexBar/AccountCostsMenuCardView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import CodexBarCore
import SwiftUI

/// Menu card showing plan/tier info for every connected Codex account.
struct AccountCostsMenuCardView: View {
let entries: [AccountCostEntry]
let isLoading: Bool
let width: CGFloat

@Environment(\.menuItemHighlighted) private var isHighlighted

static let nameWidth: CGFloat = 70
static let badgeWidth: CGFloat = 42
static let colWidth: CGFloat = 72

var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
// Mirror the row layout: icon(small) + name + badge, then columns
Spacer()
.frame(width: 14) // icon space
Spacer()
.frame(width: Self.nameWidth)
Spacer()
.frame(width: Self.badgeWidth)
Text("Session")
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.frame(width: Self.colWidth, alignment: .leading)
Text("Weekly")
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.frame(width: Self.colWidth, alignment: .leading)
Text("Credits")
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.frame(width: Self.colWidth, alignment: .trailing)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 6)

Divider()
.padding(.horizontal, 16)

if self.isLoading, self.entries.isEmpty {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Loading…")
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
} else if self.entries.isEmpty {
Text("No accounts connected.")
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.padding(.horizontal, 16)
.padding(.vertical, 10)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.entries) { entry in
AccountCostRow(entry: entry, isHighlighted: self.isHighlighted)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 10)
}
}
.frame(width: self.width, alignment: .leading)
}
}

private struct AccountCostRow: View {
let entry: AccountCostEntry
let isHighlighted: Bool

private static let colWidth: CGFloat = AccountCostsMenuCardView.colWidth

private static let nameWidth: CGFloat = AccountCostsMenuCardView.nameWidth
private static let badgeWidth: CGFloat = AccountCostsMenuCardView.badgeWidth

var body: some View {
HStack(alignment: .center, spacing: 4) {
Image(systemName: self.entry.isDefault ? "person.circle.fill" : "person.circle")
.imageScale(.small)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))

Text(self.entry.label)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.tail)
.frame(width: Self.nameWidth, alignment: .leading)

if self.entry.error == nil {
if self.entry.isUnlimited {
self.planBadge("Unlimited")
.frame(width: Self.badgeWidth, alignment: .leading)
} else if let plan = self.entry.planType {
self.planBadge(plan)
.frame(width: Self.badgeWidth, alignment: .leading)
} else {
Spacer()
.frame(width: Self.badgeWidth)
}
} else {
Spacer()
.frame(width: Self.badgeWidth)
}

// Right columns: Session | Weekly
if let error = self.entry.error {
Text(self.shortError(error))
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
.frame(width: Self.colWidth * 3 + 16, alignment: .trailing)
} else {
self.percentCell(
usedPercent: self.entry.primaryUsedPercent,
resetDescription: self.entry.primaryResetDescription)
self.percentCell(
usedPercent: self.entry.secondaryUsedPercent,
resetDescription: self.entry.secondaryResetDescription)
self.creditsCell()
}
}
}

private static let pctWidth: CGFloat = 30

@ViewBuilder
private func creditsCell() -> some View {
if self.entry.isUnlimited {
Text("∞")
.font(.caption2.monospacedDigit())
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.frame(width: Self.colWidth, alignment: .trailing)
} else if let balance = self.entry.creditsRemaining, balance > 0 {
let isLow = balance < 5
Text(UsageFormatter.creditsBalanceString(from: balance))
.font(.caption2.monospacedDigit())
.foregroundStyle(isLow ? Color.orange : MenuHighlightStyle.secondary(self.isHighlighted))
.frame(width: Self.colWidth, alignment: .trailing)
} else {
Text("—")
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.5))
.frame(width: Self.colWidth, alignment: .trailing)
}
}

@ViewBuilder
private func percentCell(usedPercent: Double?, resetDescription: String?) -> some View {
if let used = usedPercent {
let remaining = max(0, 100 - used)
let isLow = remaining < 20
let pctColor: Color = isLow ? .orange : MenuHighlightStyle.secondary(self.isHighlighted)
HStack(alignment: .firstTextBaseline, spacing: 1) {
Text(String(format: "%.0f%%", remaining))
.font(.caption2.monospacedDigit())
.foregroundStyle(pctColor)
.frame(width: Self.pctWidth, alignment: .leading)
if let reset = resetDescription {
Text(reset)
.font(.system(size: 9).monospacedDigit())
.foregroundStyle(pctColor.opacity(0.65))
}
}
.frame(width: Self.colWidth, alignment: .leading)
} else {
Text("—")
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.5))
.frame(width: Self.colWidth, alignment: .leading)
}
}

private func planBadge(_ text: String) -> some View {
Text(text)
.font(.caption2.weight(.medium))
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.12)))
}

private func shortError(_ error: String) -> String {
if error.contains("not found") || error.contains("notFound") { return "Not signed in" }
if error.contains("unauthorized") || error.contains("401") { return "Token expired" }
return "Error"
}
}
108 changes: 108 additions & 0 deletions Sources/CodexBar/CodexBarShellIntegration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Foundation

/// Manages the `~/.codexbar/active-codex-home` file and optional one-time `.zshrc` hook injection.
///
/// The file contains the absolute path of the currently selected Codex account's CODEX_HOME directory.
/// A shell `precmd` hook installed in `.zshrc` re-exports `CODEX_HOME` on every prompt:
///
/// precmd_codexbar() { export CODEX_HOME="$(cat ~/.codexbar/active-codex-home 2>/dev/null)"; }
/// autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar
///
/// This means switching accounts in CodexBar immediately takes effect at the next shell prompt,
/// so `codex` CLI sessions are written to the correct per-account `sessions/` directory.
enum CodexBarShellIntegration {
// MARK: - Paths

private static var codexbarDir: URL {
URL(fileURLWithPath: ("~/.codexbar" as NSString).expandingTildeInPath)
}

private static var zshrcFile: URL {
URL(fileURLWithPath: ("~/.zshrc" as NSString).expandingTildeInPath)
}

// MARK: - Shell hook snippet

/// A unique sentinel so we never double-insert the hook.
private static let hookMarker = "# CodexBar shell integration"

private static let hookSnippet = """

# CodexBar shell integration — auto-switches CODEX_HOME when you change accounts in CodexBar
precmd_codexbar() { export CODEX_HOME="$(cat ~/.codexbar/active-codex-home 2>/dev/null)"; }
autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar
"""

// MARK: - Public API

/// Write the given CODEX_HOME path as the active account.
/// Pass `nil` to clear (e.g. when reverting to the default ~/.codex account).
static func setActiveCodexHome(
_ path: String?,
fileManager fm: FileManager = .default,
codexbarDirectory: URL? = nil)
{
let directory = codexbarDirectory ?? self.codexbarDir
let activeFile = directory.appendingPathComponent("active-codex-home")
let dir = directory.path
if !fm.fileExists(atPath: dir) {
try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true)
}
if let path, !path.isEmpty {
try? path.write(to: activeFile, atomically: true, encoding: .utf8)
} else {
try? fm.removeItem(at: activeFile)
}
}

/// Append the precmd hook to ~/.zshrc if it isn't already there.
/// Called once on first OAuth account creation — silently does nothing if already set up.
static func installZshHookIfNeeded(fileManager fm: FileManager = .default, zshrcURL: URL? = nil) {
let zshrc = (zshrcURL ?? self.zshrcFile).path
// If .zshrc doesn't exist yet, create it.
if !fm.fileExists(atPath: zshrc) {
fm.createFile(atPath: zshrc, contents: nil)
}
guard let existing = try? String(contentsOfFile: zshrc, encoding: .utf8) else { return }
guard !existing.contains(self.hookMarker) else { return }
try? (existing + self.hookSnippet).write(toFile: zshrc, atomically: true, encoding: .utf8)
}

/// Returns true if the zsh hook is already installed.
static var isZshHookInstalled: Bool {
guard let content = try? String(contentsOf: zshrcFile, encoding: .utf8) else { return false }
return content.contains(hookMarker)
}

/// Ensure each Codex account has its own dedicated `sessions/` directory.
/// If a legacy symlink points back to the shared `~/.codex/sessions`, replace it with a real
/// per-account directory so future cost data stays isolated by account.
static func ensureDedicatedSessionsDirectoryIfNeeded(
into codexHomePath: String,
fileManager fm: FileManager = .default,
defaultSessionsRoot: URL? = nil)
{
let defaultSessions = (defaultSessionsRoot ?? fm.homeDirectoryForCurrentUser
.appendingPathComponent(".codex/sessions", isDirectory: true)
.resolvingSymlinksInPath()
.standardizedFileURL)
let accountSessions = URL(fileURLWithPath: (codexHomePath as NSString).expandingTildeInPath)
.appendingPathComponent("sessions", isDirectory: true)

if let destination = try? fm.destinationOfSymbolicLink(atPath: accountSessions.path) {
let destinationURL = URL(
fileURLWithPath: destination,
relativeTo: accountSessions.deletingLastPathComponent())
.resolvingSymlinksInPath()
.standardizedFileURL
if destinationURL.path == defaultSessions.path {
try? fm.removeItem(at: accountSessions)
} else {
return
}
}

guard !fm.fileExists(atPath: accountSessions.path) else { return }
try? fm.createDirectory(at: accountSessions, withIntermediateDirectories: true)
}
}
6 changes: 5 additions & 1 deletion Sources/CodexBar/CodexLoginRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ struct CodexLoginRunner {
let output: String
}

static func run(timeout: TimeInterval = 120) async -> Result {
static func run(codexHome: String? = nil, timeout: TimeInterval = 120) async -> Result {
await Task(priority: .userInitiated) {
var env = ProcessInfo.processInfo.environment
if let codexHome {
let expanded = (codexHome as NSString).expandingTildeInPath
env["CODEX_HOME"] = expanded
}
env["PATH"] = PathBuilder.effectivePATH(
purposes: [.rpc, .tty, .nodeTooling],
env: env,
Expand Down
58 changes: 58 additions & 0 deletions Sources/CodexBar/FlowLayout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import SwiftUI

/// A layout that arranges children left-to-right, wrapping to a new row when they overflow.
struct FlowLayout: Layout {
var spacing: CGFloat = 6

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var currentX: CGFloat = 0
var currentRowHeight: CGFloat = 0
var totalHeight: CGFloat = 0
var isFirstInRow = true

for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let neededWidth = isFirstInRow ? size.width : self.spacing + size.width
if !isFirstInRow, currentX + neededWidth > maxWidth {
totalHeight += currentRowHeight + self.spacing
currentX = size.width
currentRowHeight = size.height
isFirstInRow = false
} else {
currentX += neededWidth
currentRowHeight = max(currentRowHeight, size.height)
isFirstInRow = false
}
}
totalHeight += currentRowHeight
return CGSize(width: maxWidth, height: max(totalHeight, 0))
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let maxWidth = bounds.width
var currentX = bounds.minX
var currentY = bounds.minY
var currentRowHeight: CGFloat = 0
var isFirstInRow = true

for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let neededWidth = isFirstInRow ? size.width : self.spacing + size.width
if !isFirstInRow, currentX - bounds.minX + neededWidth > maxWidth {
currentY += currentRowHeight + self.spacing
currentX = bounds.minX
currentRowHeight = size.height
subview.place(at: CGPoint(x: currentX, y: currentY), proposal: .unspecified)
currentX += size.width
isFirstInRow = false
} else {
if !isFirstInRow { currentX += self.spacing }
subview.place(at: CGPoint(x: currentX, y: currentY), proposal: .unspecified)
currentX += size.width
currentRowHeight = max(currentRowHeight, size.height)
isFirstInRow = false
}
}
}
}
Loading