-
Notifications
You must be signed in to change notification settings - Fork 776
Multiple OpenAI/Codex Accounts #569
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 26 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 d4c3992
fix: skip status item rebuild when only config revision changes
Rag30 069128b
feat: Codex credits follow active account (OAuth per tab)
Rag30 ec09606
Fix Codex token-account env for costs; align default selection when p…
Rag30 e1df48e
Refactor CodexBar account handling to support explicit account manage…
Rag30 91ba221
Multi Account clarificatoin
Rag30 7e392c6
feat: Multiple Accounts toggle, drag-reorder, scroll-stable layout
Rag30 fe936d3
feat: per-account dashboard login with combined OAuth flow
Rag30 2e9ba4c
feat: per-account OpenAI dashboard with workspace isolation
Rag30 67b8b86
fix: suppress browser cookie import in multi-account dashboard mode
Rag30 d04b7cb
Merge upstream/main into main (steipete/codexbar Cursor changes)
Rag30 d64f353
Merge upstream/main: resolve conflicts in UsageStore, add plan utiliz…
Rag30 3747c2c
fix: clear stale Codex data, reset account on multi-account off, stab…
Rag30 0aaf96c
fix: use account identifier for dashboard refresh, hide API-key dashb…
Rag30 3d1f8a7
fix: multi-account UX polish
Rag30 dcdf56b
Merge Feat-Multi-Codex-Bar: multi-account UX polish
Rag30 7c2735c
fix: suppress redundant 'not signed in' hint from Credits block
Rag30 6676a9f
fix: API-key cost isolation and active-account preserve on delete
Rag30 94ec6e6
fix: three multi-account review items
Rag30 b92358c
fix: dashboard login-required drops logged-in flag, API-key cost TTL,…
Rag30 598c6da
Fix 3 dashboard bugs: logout flicker, stale key in markDashboardLogin…
Rag30 7ab804b
Fix account-switch destroying new account login flag; guard removeTok…
Rag30 6e10f4e
Clean up dashboardLoggedInEmails when token account is deleted
Rag30 e053b0b
Auto-install zsh shell integration on first OAuth account add; keep a…
Rag30 162c828
Auto-symlink ~/.codex/sessions into new OAuth account dir on creation
Rag30 14721e9
Add OpenAI REST API cost fetching for API-key Codex accounts; isolate…
Rag30 712f1dd
Fix unquoted CODEX_HOME in zsh hook and missing shell repair on expli…
Rag30 1921133
Merge remote-tracking branch 'upstream/main'
Rag30 71dbe18
Fix six Codex multi-account bugs
Rag30 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "permissions": { | ||
| "allow": [ | ||
| "Bash(swift build:*)" | ||
| ] | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| 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(hookMarker) else { return } | ||
| try? (existing + 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) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.