diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1c0fa57d..a908ac31 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -2,7 +2,7 @@ "name": "@start/desktop", "private": true, "productName": "Start", - "version": "0.1.0-alpha.34", + "version": "0.1.0-alpha.35", "license": "MIT", "description": "your coding assistant", "type": "module", diff --git a/packages/desktop/src/main/chat.ts b/packages/desktop/src/main/chat.ts index 6d5f0c5c..01b3fb09 100644 --- a/packages/desktop/src/main/chat.ts +++ b/packages/desktop/src/main/chat.ts @@ -28,7 +28,7 @@ import { sessionWorkspacePath, tabFromSession, tabFromSessionStatus } from '@mai import { closeStartDb, openStartDb } from '@main/db'; import { historyDetail, imageAttachments, textContent } from '@main/details'; import { chatEvent } from '@main/events'; -import { addWorktree, getGitBranch, gitTopLevel, listWorktrees } from '@main/git'; +import { addWorktree, discardWorktree, getGitBranch, gitMainWorktree, listWorktrees } from '@main/git'; import { agentEndError, clampThinkingLevel, @@ -40,17 +40,19 @@ import { providerAuthKind, providerAuthLabel, providerAuthSlots, + subscriptionProviderId, textDelta, thinkingDelta } from '@main/helpers'; import { historyTurns } from '@main/history'; import { disposeMcpClients } from '@main/mcp/clients'; import { mcpToolsForSession, warmMcpServers } from '@main/mcp/tools'; +import { modelScore } from '@main/models'; import { createStartResourceLoader } from '@main/prompt/loader'; import { resolveAuthBackend } from '@main/providers/auth'; import { InMemorySettingsBackend } from '@main/providers/settings'; -import { warmWebSearchTools } from '@main/providers/tools/search/index'; import { createStartCustomTools } from '@main/providers/tools/index'; +import { warmWebSearchTools } from '@main/providers/tools/search/index'; import type { SessionController, SessionEnvironment, SessionSummary } from '@main/providers/tools/sessions'; import type { WorktreeOwner } from '@main/providers/tools/worktree'; import { disposeWorkspaceFinders, refreshWorkspaceFinder, warmWorkspaceFinder } from '@main/search/client'; @@ -67,7 +69,6 @@ import { updateSessionTitle, upsertSessionOnStart } from '@main/sessions'; -import { modelScore } from '@main/models'; import { readStartState, type StartState, updateStartState } from '@main/storage'; import { SubagentNameAllocator } from '@main/subagents/allocator'; import type { WorkflowModelOption } from '@main/subagents/types'; @@ -101,11 +102,19 @@ import { type SwitchWorkspaceResult, type WorkspaceFolder } from '@main/types'; +import type { ProviderUsageCredential } from '@main/usage/types'; import { directoryStatus, workspaceDisplayName } from '@main/utils/workspace'; import { sendToMainWindowIfOpen, sendToRendererWindows } from '@main/window'; import { activateWorkspaceAccess } from '@main/workspace/access'; import { workspaceHistoryWith } from '@main/workspace/history'; -import { isManagedWorktree, worktreeBranch, worktreePathFor, worktreeSlug } from '@main/workspace/worktree'; +import { + isManagedWorktree, + managedWorktreeRepoFolder, + repoFolderName, + worktreeBranch, + worktreePathFor, + worktreeSlug +} from '@main/workspace/worktree'; import type { WebContents } from 'electron'; import electron from 'electron'; @@ -204,6 +213,12 @@ interface MobileSessionChange { workspacePath: string; } +interface ManagedWorktree { + path: string; + branch: string; + repoRoot: string; +} + type MobileSessionChangeHandler = (change: MobileSessionChange) => void; const visibleQueuedMessage = (message: PendingQueuedMessage): QueuedMessage => ({ @@ -687,20 +702,40 @@ export class ChatService { }; } - private async addManagedWorktree(branchName?: string, base?: string): Promise { - const repoRoot = await gitTopLevel(this.workspaceCwd); - if (!repoRoot) return ''; + private async addManagedWorktree(cwd: string, branchName?: string, base?: string): Promise { + const repoRoot = await gitMainWorktree(cwd); + if (!repoRoot) throw new Error(`${cwd} is not a Git repository.`); + if (isManagedWorktree(baseDir, repoRoot)) + throw new Error(`Could not resolve the primary Git repository for ${cwd}.`); const slug = `${worktreeSlug(branchName ?? '')}-${randomBytes(4).toString('hex')}`; - const worktree = await addWorktree(repoRoot, worktreePathFor(baseDir, repoRoot, slug), { - branch: worktreeBranch(slug), - ...(base ? { base } : {}) - }); - return worktree ? worktree.path : ''; + const branch = worktreeBranch(slug); + const worktreePath = worktreePathFor(baseDir, repoRoot, slug); + const worktree = await addWorktree(repoRoot, worktreePath, { branch, ...(base ? { base } : {}) }); + return { branch, repoRoot, path: worktree.path }; + } + + private async withManagedWorktree( + cwd: string, + run: (workspacePath: string) => Promise, + branchName?: string, + base?: string + ): Promise { + const worktree = await this.addManagedWorktree(cwd, branchName, base); + try { + return await run(worktree.path); + } catch (error) { + await discardWorktree(worktree.repoRoot, worktree.path, worktree.branch); + throw error; + } } async createWorktreeTab(branchName?: string, base?: string): Promise { - const worktreePath = await this.addManagedWorktree(branchName, base); - return worktreePath ? this.createTab(worktreePath) : this.createTab(); + return this.withManagedWorktree( + this.workspaceCwd, + (workspacePath) => this.createTab(workspacePath), + branchName, + base + ); } private async createBackgroundSession(workspacePath: string): Promise { @@ -781,22 +816,42 @@ export class ChatService { return this.sessionResult(session); } - private async workspaceRootForCwd(cwd: string): Promise { + private knownWorkspaceRootFor(cwd: string, candidates: readonly string[] = []): string { + const folder = managedWorktreeRepoFolder(baseDir, cwd); + if (!folder) return ''; + + const history = Object.keys(this.appState.workspaceHistory ?? {}); + return ( + [...new Set([this.workspaceCwd, ...history, ...candidates])].find( + (candidate) => !isManagedWorktree(baseDir, candidate) && repoFolderName(candidate) === folder + ) ?? '' + ); + } + + private async workspaceRootForCwd(cwd: string, candidates: readonly string[] = []): Promise { if (!cwd || !isManagedWorktree(baseDir, cwd)) return cwd; const cached = this.worktreeRepoCache.get(cwd); if (cached) return cached; - const repoRoot = (await listWorktrees(cwd)).find((tree) => tree.isMain)?.path ?? cwd; - this.worktreeRepoCache.set(cwd, repoRoot); + const known = this.knownWorkspaceRootFor(cwd, candidates); + if (known) { + this.worktreeRepoCache.set(cwd, known); + return known; + } + + const resolved = await gitMainWorktree(cwd); + const repoRoot = !isManagedWorktree(baseDir, resolved) ? resolved : ''; + if (repoRoot) this.worktreeRepoCache.set(cwd, repoRoot); return repoRoot; } private async warmWorkspaceRoots(cwds: string[]): Promise { - await Promise.all([...new Set(cwds)].map((cwd) => this.workspaceRootForCwd(cwd))); + await Promise.all([...new Set(cwds)].map((cwd) => this.workspaceRootForCwd(cwd, cwds))); } private async managedWorktreesFor(repoRoot: string): Promise { + if (!repoRoot) return []; return (await listWorktrees(repoRoot)) .filter((tree) => isManagedWorktree(baseDir, tree.path)) .map((tree) => ({ path: tree.path, branch: tree.branch })); @@ -826,38 +881,36 @@ export class ChatService { const sessions = await SessionManager.listAll(); const folders = new Map(); const rawAttention = this.workspaceAttentionStatuses(); - await this.warmWorkspaceRoots([ - this.workspaceCwd, - ...rawAttention.keys(), - ...sessions.flatMap((session) => (session.cwd ? [session.cwd] : [])) - ]); - const attentionStatuses = await this.resolvedAttentionStatuses(rawAttention); - const activeRoot = await this.workspaceRootForCwd(this.workspaceCwd); - folders.set(activeRoot, { - sessionCount: 0, - path: activeRoot, - modified: Date.now(), - name: workspaceDisplayName(activeRoot), - ...this.workspaceAttention(activeRoot, attentionStatuses) + const workspaceHistory = this.appState.workspaceHistory ?? {}; + const sessionCwds = sessions.flatMap((session) => (session.cwd ? [session.cwd] : [])); + const workspaceCwds = [this.workspaceCwd, ...Object.keys(workspaceHistory), ...rawAttention.keys(), ...sessionCwds]; + await this.warmWorkspaceRoots(workspaceCwds); + const activeRoot = await this.workspaceRootForCwd(this.workspaceCwd, workspaceCwds); + const attentionStatuses = await this.resolvedAttentionStatuses(rawAttention, workspaceCwds); + const workspaceFolder = (path: string, modified: number, sessionCount: number): WorkspaceFolder => ({ + path, + modified, + sessionCount, + active: path === activeRoot, + name: workspaceDisplayName(path), + ...this.workspaceAttention(path, attentionStatuses) }); + if (activeRoot) { + folders.set(activeRoot, workspaceFolder(activeRoot, Date.now(), 0)); + } - const workspaceHistory = this.appState.workspaceHistory ?? {}; for (const [workspacePath, lastOpenedAt] of Object.entries(workspaceHistory)) { - if (folders.has(workspacePath)) continue; + const root = await this.workspaceRootForCwd(workspacePath, workspaceCwds); + if (!root || folders.has(root)) continue; - folders.set(workspacePath, { - sessionCount: 0, - path: workspacePath, - modified: lastOpenedAt, - name: workspaceDisplayName(workspacePath), - ...this.workspaceAttention(workspacePath, attentionStatuses) - }); + folders.set(root, workspaceFolder(root, lastOpenedAt, 0)); } for (const session of sessions) { if (!session.cwd || session.messageCount === 0) continue; - const root = await this.workspaceRootForCwd(session.cwd); + const root = await this.workspaceRootForCwd(session.cwd, workspaceCwds); + if (!root) continue; const current = folders.get(root); const modified = session.modified.getTime(); if (current) { @@ -865,26 +918,14 @@ export class ChatService { current.sessionCount += 1; Object.assign(current, this.workspaceAttention(root, attentionStatuses)); } else { - folders.set(root, { - modified, - sessionCount: 1, - path: root, - name: workspaceDisplayName(root), - ...this.workspaceAttention(root, attentionStatuses) - }); + folders.set(root, workspaceFolder(root, modified, 1)); } } for (const workspacePath of attentionStatuses.keys()) { if (folders.has(workspacePath)) continue; - folders.set(workspacePath, { - sessionCount: 0, - path: workspacePath, - modified: Date.now(), - name: workspaceDisplayName(workspacePath), - ...this.workspaceAttention(workspacePath, attentionStatuses) - }); + folders.set(workspacePath, workspaceFolder(workspacePath, Date.now(), 0)); } const list = [...folders.values()].sort((a, b) => b.modified - a.modified); @@ -898,7 +939,7 @@ export class ChatService { const missingCandidates = [...missingPaths]; const currentStatuses = await Promise.all(missingCandidates.map((workspacePath) => directoryStatus(workspacePath))); - const currentActiveRoot = await this.workspaceRootForCwd(this.workspaceCwd); + const currentActiveRoot = await this.workspaceRootForCwd(this.workspaceCwd, workspaceCwds); const confirmedMissingPaths = new Set( missingCandidates.filter( (workspacePath, index) => workspacePath !== currentActiveRoot && currentStatuses[index] === 'missing' @@ -1015,6 +1056,24 @@ export class ChatService { ]; } + async getProviderUsageCredential(provider: ProviderKey): Promise { + const providerId = subscriptionProviderId(provider); + const credential = this.authStorage.get(providerId); + if (credential?.type !== 'oauth') return null; + const token = await this.authStorage.getApiKey(providerId, { includeFallback: false }); + if (!token) return null; + + const accountId = credential.accountId; + return { + token, + ...(typeof accountId === 'string' && accountId ? { accountId } : {}) + }; + } + + prepareProviderUsageCredentials(): void { + this.authStorage.reload(); + } + async setApiKey(provider: string, apiKey: string): Promise { const cleanProvider = provider.trim().toLowerCase(); const cleanApiKey = apiKey.trim(); @@ -1037,7 +1096,7 @@ export class ChatService { } async loginSubscription(provider: string, webContents: WebContents): Promise { - const providerId = provider === 'openai' ? 'openai-codex' : provider; + const providerId = subscriptionProviderId(provider); this.authInputReject?.(new Error('Login restarted.')); this.clearSubscriptionAuthInput(); @@ -1979,15 +2038,18 @@ export class ChatService { kind: SessionNotice['kind'], webContents?: WebContents ): void { - this.notices.set(sessionId, { + const payload: SessionNotice = { kind, sessionId, workspacePath, createdAt: Date.now() - }); + }; + this.notices.set(sessionId, payload); this.persistNotices(); - webContents?.send('chat:recent-sessions-changed', { workspacePath }); - webContents?.send('chat:notice', { tabId: sessionId, payload: this.notices.get(sessionId), workspacePath }); + const notice = { tabId: sessionId, payload, workspacePath }; + sendToRendererWindows('chat:recent-sessions-changed', { workspacePath }); + if (webContents) webContents.send('chat:notice', notice); + else sendToMainWindowIfOpen('chat:notice', notice); } private persistNotices(): void { @@ -2035,10 +2097,14 @@ export class ChatService { return this.collapseTopStatuses(statuses); } - private async resolvedAttentionStatuses(raw: Map): Promise> { + private async resolvedAttentionStatuses( + raw: Map, + candidates: readonly string[] = [] + ): Promise> { const grouped = new Map(); for (const [workspacePath, status] of raw) { - const root = await this.workspaceRootForCwd(workspacePath); + const root = await this.workspaceRootForCwd(workspacePath, candidates); + if (!root) continue; grouped.set(root, [...(grouped.get(root) ?? []), status]); } @@ -2098,13 +2164,13 @@ export class ChatService { return { ...base, - sessions: this.sessionController(), + sessions: this.sessionController(cwd), customTools: subagentSessionTools, worktreeOwners: (path) => this.worktreeOwners(path) }; } - private sessionController(): SessionController { + private sessionController(cwd: string): SessionController { const toSummary = (tab: AgentTab) => ({ id: tab.id, status: tab.status, @@ -2124,7 +2190,7 @@ export class ChatService { if (!session) return; this.generate(session, sessionWorkspacePath(session, this.workspaceCwd), prompt, []).catch(() => {}); }, - create: (input) => this.startSession(input) + create: (input) => this.startSession({ ...input, cwd }) }; } @@ -2137,17 +2203,32 @@ export class ChatService { } async startSession({ + cwd = this.workspaceCwd, prompt, environment, attachments = [] }: { + cwd?: string; prompt: string; environment: SessionEnvironment; attachments?: ImageAttachment[]; }): Promise { - const worktreePath = - environment.type === 'worktree' ? await this.addManagedWorktree(environment.branch, environment.base) : ''; - const workspacePath = worktreePath || this.workspaceCwd; + if (environment.type === 'worktree') { + return this.withManagedWorktree( + cwd, + (workspacePath) => this.startBackgroundSession(workspacePath, prompt, attachments), + environment.branch, + environment.base + ); + } + return this.startBackgroundSession(cwd, prompt, attachments); + } + + private async startBackgroundSession( + workspacePath: string, + prompt: string, + attachments: ImageAttachment[] + ): Promise { const session = await this.createBackgroundSession(workspacePath); this.generate(session, workspacePath, prompt, attachments).catch(() => {}); return { @@ -2256,7 +2337,7 @@ export class ChatService { const apiKeyStatus = this.authStorage.getAuthStatus(key); const hasApiKey = apiKeyStatus.configured; const supportsSubscription = key === 'anthropic' || key === 'openai'; - const subscriptionProvider = key === 'openai' ? 'openai-codex' : key; + const subscriptionProvider = subscriptionProviderId(key); const hasSubscription = supportsSubscription && (this.authStorage.get(subscriptionProvider)?.type === 'oauth' || diff --git a/packages/desktop/src/main/git.ts b/packages/desktop/src/main/git.ts index e2541554..f8e943dc 100644 --- a/packages/desktop/src/main/git.ts +++ b/packages/desktop/src/main/git.ts @@ -1,5 +1,5 @@ import { execFile, spawn } from 'node:child_process'; -import { readFile, stat } from 'node:fs/promises'; +import { readFile, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import { resolveInside } from '@main/utils/workspace'; @@ -47,6 +47,7 @@ type UntrackedFileData = { const maxPatchFiles = 2000; const maxUntrackedFiles = 64; const gitShowTimeoutMs = 4000; +const gitWorktreeAddTimeoutMs = 60_000; const gitWorktreeTimeoutMs = 10_000; const maxPatchLines = 200_000; const gitMaxBuffer = 32 * 1024 * 1024; @@ -483,22 +484,35 @@ export const listWorktrees = async (cwd: string): Promise => { } }; +export const gitMainWorktree = async (cwd: string): Promise => { + const main = (await listWorktrees(cwd)).find((tree) => tree.isMain); + return main?.path ?? gitTopLevel(cwd); +}; + export const addWorktree = async ( cwd: string, worktreePath: string, options: { branch?: string; base?: string } = {} -): Promise => { +): Promise => { + const branchExisted = options.branch + ? Boolean( + (await git(cwd, ['for-each-ref', '--count=1', '--format=%(refname)', `refs/heads/${options.branch}`])).trim() + ) + : false; try { const args = ['worktree', 'add', '--quiet']; if (options.branch) args.push('-b', options.branch); args.push(worktreePath); if (options.base) args.push(options.base); - await git(cwd, args, gitWorktreeTimeoutMs); + await git(cwd, args, gitWorktreeAddTimeoutMs); const target = path.resolve(worktreePath); - return (await listWorktrees(cwd)).find((tree) => path.resolve(tree.path) === target); - } catch { - return; + const worktree = (await listWorktrees(cwd)).find((tree) => path.resolve(tree.path) === target); + if (!worktree) throw new Error(`Git created ${worktreePath}, but it could not be verified.`); + return worktree; + } catch (error) { + await discardWorktree(cwd, worktreePath, branchExisted ? '' : (options.branch ?? '')); + throw error; } }; @@ -519,3 +533,12 @@ export const removeWorktree = async ( return false; } }; + +export const discardWorktree = async (cwd: string, worktreePath: string, branch: string): Promise => { + const removed = await removeWorktree(cwd, worktreePath, { force: true }); + if (!removed) { + await rm(worktreePath, { recursive: true, force: true }).catch(() => {}); + await git(cwd, ['worktree', 'prune'], gitWorktreeTimeoutMs).catch(() => {}); + } + if (branch) await git(cwd, ['branch', '-D', branch], gitWorktreeTimeoutMs).catch(() => {}); +}; diff --git a/packages/desktop/src/main/helpers.ts b/packages/desktop/src/main/helpers.ts index 26f89a3b..bbb454e6 100644 --- a/packages/desktop/src/main/helpers.ts +++ b/packages/desktop/src/main/helpers.ts @@ -61,6 +61,8 @@ export const providerAuthSlots = (provider: string): string[] => { return [provider]; }; +export const subscriptionProviderId = (provider: string) => (provider === 'openai' ? 'openai-codex' : provider); + export const isProviderModel = (model: { provider: string; id: string; name?: string }, provider: ProviderKey) => { const haystack = `${model.provider} ${model.id} ${model.name ?? ''}`.toLowerCase(); if (provider === 'openai') { diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 7c7b7982..977eb479 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -55,6 +55,7 @@ import { } from '@main/settings'; import type { ChatStatus } from '@main/types'; import { checkForUpdatesNow, registerUpdateIpc, startAutoUpdateChecks, stopAutoUpdateChecks } from '@main/updates'; +import { ProviderUsageService } from '@main/usage/index'; import { logger } from '@main/utils/logger'; import { setStayAwake, shouldStayAwake } from '@main/utils/power'; import { resolveInside } from '@main/utils/workspace'; @@ -102,6 +103,7 @@ let quitCleanupStarted = false; let appQuitConfirmationOpen = false; let appSettings: AppSettings | null = null; let stopResourceRefresh: (() => void) | null = null; +let providerUsage: ProviderUsageService | null = null; let stopMainWindowChanged: (() => void) | null = null; const mobileResult = ( @@ -328,9 +330,11 @@ const registerComposerShortcut = (accelerator: string) => { const menuActions = () => ({ onShowSettings: showSettings, onShowShortcuts: showShortcuts, - onQuickAccess: () => toggleQuickAccess('menu'), onNewSession: () => startNewSession('menu'), onCheckForUpdates: () => checkForUpdatesNow(), + onQuickAccess: () => toggleQuickAccess('menu'), + onShowProviders: () => showSettings('providers'), + providerUsage: providerUsage?.getUsage() ?? null, recentSessions: chat.getStatusItemRecentSessions(), onOpenRecentSession: (sessionId: string) => openRecentSession(sessionId), composerShortcut: appSettings?.composerShortcut ?? defaultAppSettings.composerShortcut @@ -430,6 +434,15 @@ if (!singleInstanceLock) { registerComposerShortcut(appSettings.composerShortcut); activateWorkspaceAccess(chat.getWorkspaceCwd()); + if (isMac) { + providerUsage = new ProviderUsageService({ + onChange: () => installStatusItem(menuActions()), + prepareCredentials: () => chat.prepareProviderUsageCredentials(), + getCredential: (provider) => chat.getProviderUsageCredential(provider) + }); + providerUsage.start(); + } + installApplicationMenu(menuActions()); installStatusItem(menuActions()); stopWorkspaceChanged = onWorkspaceChanged((workspace) => { @@ -439,6 +452,7 @@ if (!singleInstanceLock) { if (!focused) return; gitChanges.flushPendingRefreshes(); + providerUsage?.refreshIfStale().catch(() => {}); chat .refreshActiveSessionResources() .then((refreshed) => { @@ -460,6 +474,7 @@ if (!singleInstanceLock) { getWorkspace(workspacePath ?? chat.getWorkspaceCwd()) ); ipcMain.handle('app:settings', () => appSettings); + ipcMain.handle('app:provider-usage', () => providerUsage?.getUsage() ?? []); ipcMain.handle('app:mobile-relay-code', () => desktopRelay.currentCode); ipcMain.handle('app:probe-mobile-relay', (_event, settings: { relayUrl: string; relayToken: string }) => probeRelay(settings.relayUrl, settings.relayToken) @@ -614,6 +629,8 @@ const runQuitCleanup = async () => { stopMainWindowChanged?.(); stopMainWindowChanged = null; stopAutoUpdateChecks(); + providerUsage?.stop(); + providerUsage = null; desktopRelay.stop(); setStayAwake(false); await destroyBrowserSilently(); diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 4fa6ecd3..ff03f8bf 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -1,5 +1,6 @@ import { appIconPath, appMenuName, isMac, isProd, trayIconPath } from '@main/application'; import type { StatusItemRecentSession } from '@main/types'; +import type { ProviderUsage } from '@main/usage/types'; import type { Tray as ElectronTray, MenuItemConstructorOptions } from 'electron'; import electron from 'electron'; @@ -11,8 +12,10 @@ type MenuActions = { onNewSession: () => void; onQuickAccess: () => void; onShowSettings: () => void; + onShowProviders: () => void; onShowShortcuts: () => void; onCheckForUpdates: () => void; + providerUsage: ProviderUsage[] | null; recentSessions: StatusItemRecentSession[]; onOpenRecentSession: (id: string) => void; }; @@ -60,11 +63,36 @@ const recentSessionItems = ( ]; }; +const usageProviders = [ + { id: 'openai', name: 'OpenAI' }, + { id: 'anthropic', name: 'Anthropic' } +] as const; + +const usageSublabel = (usage: ProviderUsage | undefined) => { + if (!usage) return 'Unavailable'; + return `${usage.remainingPercent}% remaining`; +}; + +const providerUsageItems = (usage: ProviderUsage[] | null, onShowProviders: () => void): MenuItemOptions[] => { + if (!usage) return []; + return [ + { type: 'separator' }, + { label: 'Usage', enabled: false }, + ...usageProviders.map((provider) => ({ + label: provider.name, + click: onShowProviders, + sublabel: usageSublabel(usage.find((item) => item.id === provider.id)) + })) + ]; +}; + export const installStatusItem = ({ onNewSession, onQuickAccess, + providerUsage, onShowSettings, recentSessions, + onShowProviders, composerShortcut, onOpenRecentSession }: MenuActions) => { @@ -86,6 +114,7 @@ export const installStatusItem = ({ click: onQuickAccess }, ...recentSessionItems(recentSessions, onOpenRecentSession), + ...providerUsageItems(providerUsage, onShowProviders), { type: 'separator' }, { label: 'Settings', diff --git a/packages/desktop/src/main/providers/tools/sessions.ts b/packages/desktop/src/main/providers/tools/sessions.ts index f9775633..01fd9743 100644 --- a/packages/desktop/src/main/providers/tools/sessions.ts +++ b/packages/desktop/src/main/providers/tools/sessions.ts @@ -57,12 +57,16 @@ interface CreateArgs { export const runCreateSession = async (controller: SessionController, { prompt, environment }: CreateArgs) => { if (!prompt.trim()) return toolResult('Provide a non-empty prompt.', null); const env: SessionEnvironment = environment ?? { type: 'local' }; - const session = await controller.create({ prompt, environment: env }); - const message = - env.type === 'worktree' && !session.isolated - ? `Could not isolate a worktree (not a git repository or git failed). Started a local session ${session.id} at ${session.workspacePath} instead.` - : `Created ${session.isolated ? 'worktree' : 'local'} session ${session.id} at ${session.workspacePath}`; - return toolResult(message, null); + try { + const session = await controller.create({ prompt, environment: env }); + return toolResult( + `Created ${session.isolated ? 'worktree' : 'local'} session ${session.id} at ${session.workspacePath}`, + null + ); + } catch (error) { + const reason = error instanceof Error ? error.message : 'The session could not be created.'; + return toolResult(`Could not create ${env.type} session: ${reason}`, null); + } }; export const runListSessions = ( diff --git a/packages/desktop/src/main/types.ts b/packages/desktop/src/main/types.ts index 254a208b..5850f3ad 100644 --- a/packages/desktop/src/main/types.ts +++ b/packages/desktop/src/main/types.ts @@ -234,9 +234,10 @@ export type ScopedChatEvent = { export type WorkspaceFolder = { name: string; path: string; + active: boolean; modified: number; - status?: AgentTabStatus; sessionCount: number; + status?: AgentTabStatus; noticeKind?: SessionNoticeKind; }; diff --git a/packages/desktop/src/main/usage/index.ts b/packages/desktop/src/main/usage/index.ts new file mode 100644 index 00000000..48d47bc1 --- /dev/null +++ b/packages/desktop/src/main/usage/index.ts @@ -0,0 +1,84 @@ +import type { ProviderKey } from '@main/types'; +import { fetchProviderUsage } from '@main/usage/providers'; +import type { ProviderUsage, ProviderUsageCredential } from '@main/usage/types'; +import { logger } from '@main/utils/logger'; + +const refreshIntervalMs = 5 * 60 * 1000; +const providers: ProviderKey[] = ['openai', 'anthropic']; + +interface ProviderUsageServiceOptions { + onChange: () => void; + prepareCredentials: () => void; + getCredential: (provider: ProviderKey) => Promise; +} + +export class ProviderUsageService { + private lastUsage = '[]'; + private lastRefreshAt = 0; + private refreshPromise: Promise | null = null; + private usage = new Map(); + private timer: ReturnType | null = null; + + constructor(private readonly options: ProviderUsageServiceOptions) {} + + start(): void { + if (this.timer) return; + this.refresh().catch(() => {}); + this.timer = setInterval(() => this.refresh().catch(() => {}), refreshIntervalMs); + } + + stop(): void { + if (!this.timer) return; + clearInterval(this.timer); + this.timer = null; + } + + refresh(): Promise { + this.refreshPromise ??= this.refreshProviders().finally(() => { + this.refreshPromise = null; + }); + return this.refreshPromise; + } + + async refreshIfStale(): Promise { + if (Date.now() - this.lastRefreshAt < refreshIntervalMs) return; + await this.refresh(); + } + + getUsage(): ProviderUsage[] { + return providers.flatMap((provider) => { + const usage = this.usage.get(provider); + return usage ? [usage] : []; + }); + } + + private async refreshProviders(): Promise { + this.lastRefreshAt = Date.now(); + this.options.prepareCredentials(); + await Promise.all( + providers.map(async (provider) => { + try { + const credential = await this.options.getCredential(provider); + if (!credential) { + this.usage.delete(provider); + return; + } + + this.usage.set(provider, await fetchProviderUsage(provider, credential)); + } catch (error) { + this.usage.delete(provider); + logger.error(`${provider} usage`, error); + } + }) + ); + + this.emitChange(); + } + + private emitChange(): void { + const serialized = JSON.stringify(this.getUsage()); + if (serialized === this.lastUsage) return; + this.lastUsage = serialized; + this.options.onChange(); + } +} diff --git a/packages/desktop/src/main/usage/providers.ts b/packages/desktop/src/main/usage/providers.ts new file mode 100644 index 00000000..134e5514 --- /dev/null +++ b/packages/desktop/src/main/usage/providers.ts @@ -0,0 +1,121 @@ +import { timestampValue } from '@main/details'; +import type { ProviderKey } from '@main/types'; +import type { ProviderUsage, ProviderUsageCredential } from '@main/usage/types'; +import * as v from 'valibot'; + +const requestTimeoutMs = 10_000; + +const codexWindowSchema = v.looseObject({ + used_percent: v.number(), + reset_at: v.optional(v.nullable(v.number())) +}); + +const codexUsageSchema = v.looseObject({ + rate_limit: v.looseObject({ + primary_window: v.optional(v.nullable(codexWindowSchema)), + secondary_window: v.optional(v.nullable(codexWindowSchema)) + }) +}); + +const claudeWindowSchema = v.looseObject({ + utilization: v.number(), + resets_at: v.optional(v.nullable(v.string())) +}); + +const claudeUsageSchema = v.looseObject({ + five_hour: v.optional(v.nullable(claudeWindowSchema)), + seven_day: v.optional(v.nullable(claudeWindowSchema)) +}); + +interface UsageWindow { + resetAt?: number; + usedPercent: number; +} + +const clampPercent = (value: number) => Math.min(100, Math.max(0, value)); + +const timestampFromSeconds = (value: number | null) => { + if (!value || value <= 0) return 0; + return value * 1000; +}; + +const selectUsage = (id: ProviderKey, name: string, windows: UsageWindow[]) => { + const selected = [...windows].sort( + (left, right) => right.usedPercent - left.usedPercent || (left.resetAt ?? Infinity) - (right.resetAt ?? Infinity) + )[0]; + if (!selected) throw new Error(`${name} did not return a supported usage window.`); + + return { + id, + ...(selected.resetAt ? { resetAt: selected.resetAt } : {}), + remainingPercent: Math.round(100 - clampPercent(selected.usedPercent)) + } satisfies ProviderUsage; +}; + +const parseCodexUsage = (value: unknown) => { + const result = v.safeParse(codexUsageSchema, value); + if (!result.success) throw new Error('OpenAI returned an invalid usage response.'); + + const windows = [result.output.rate_limit.primary_window, result.output.rate_limit.secondary_window].flatMap( + (window): UsageWindow[] => { + if (!window) return []; + const resetAt = timestampFromSeconds(window.reset_at ?? null); + return [ + { + ...(resetAt ? { resetAt } : {}), + usedPercent: window.used_percent + } + ]; + } + ); + + return selectUsage('openai', 'OpenAI', windows); +}; + +const parseClaudeUsage = (value: unknown) => { + const result = v.safeParse(claudeUsageSchema, value); + if (!result.success) throw new Error('Anthropic returned an invalid usage response.'); + + const windows = [result.output.five_hour, result.output.seven_day].flatMap((window): UsageWindow[] => { + if (!window) return []; + const resetAt = timestampValue(window.resets_at); + return [ + { + ...(resetAt ? { resetAt } : {}), + usedPercent: window.utilization + } + ]; + }); + + return selectUsage('anthropic', 'Anthropic', windows); +}; + +const fetchJson = async (url: string, headers: Record) => { + const response = await fetch(url, { + headers, + signal: AbortSignal.timeout(requestTimeoutMs) + }); + if (!response.ok) throw new Error(`Usage request failed with status ${response.status}.`); + return response.json() as Promise; +}; + +export const fetchProviderUsage = async (provider: ProviderKey, credential: ProviderUsageCredential) => { + if (provider === 'openai') { + if (!credential.accountId) throw new Error('OpenAI account ID is unavailable.'); + const value = await fetchJson('https://chatgpt.com/backend-api/wham/usage', { + 'ChatGPT-Account-Id': credential.accountId, + Authorization: `Bearer ${credential.token}` + }); + return parseCodexUsage(value); + } + + const value = await fetchJson('https://api.anthropic.com/api/oauth/usage', { + 'User-Agent': 'claude-code/2.1.80', + 'anthropic-beta': 'oauth-2025-04-20', + Authorization: `Bearer ${credential.token}` + }); + return parseClaudeUsage(value); +}; + +export const parseProviderUsage = (provider: ProviderKey, value: unknown) => + provider === 'openai' ? parseCodexUsage(value) : parseClaudeUsage(value); diff --git a/packages/desktop/src/main/usage/types.ts b/packages/desktop/src/main/usage/types.ts new file mode 100644 index 00000000..bafad7f3 --- /dev/null +++ b/packages/desktop/src/main/usage/types.ts @@ -0,0 +1,8 @@ +import type { ProviderUsage as PublicProviderUsage } from '@preload/index'; + +export type ProviderUsage = PublicProviderUsage; + +export interface ProviderUsageCredential { + token: string; + accountId?: string; +} diff --git a/packages/desktop/src/main/workspace/worktree.ts b/packages/desktop/src/main/workspace/worktree.ts index 4b1932d3..b85e1cee 100644 --- a/packages/desktop/src/main/workspace/worktree.ts +++ b/packages/desktop/src/main/workspace/worktree.ts @@ -24,6 +24,12 @@ export const managedWorktreeRoot = (baseDir: string) => path.join(baseDir, 'work export const worktreePathFor = (baseDir: string, repoRoot: string, slug: string) => path.join(managedWorktreeRoot(baseDir), repoFolderName(repoRoot), slug); +export const managedWorktreeRepoFolder = (baseDir: string, candidate: string) => { + const relative = relativeInside(managedWorktreeRoot(baseDir), candidate); + if (!relative) return ''; + return relative.split(path.sep)[0] ?? ''; +}; + export const isManagedWorktree = (baseDir: string, candidate: string) => { const relative = relativeInside(managedWorktreeRoot(baseDir), candidate); return relative !== null && relative !== ''; diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 6e4d81db..d268cefc 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -78,6 +78,12 @@ export interface ProviderAuthStatus { hasCredentials: boolean; } +export interface ProviderUsage { + resetAt?: number; + remainingPercent: number; + id: 'anthropic' | 'openai'; +} + export interface ProviderLoginResult { ok: boolean; error?: string; @@ -247,6 +253,7 @@ export interface GitFileBlob { export interface WorkspaceFolder { name: string; path: string; + active: boolean; modified: number; sessionCount: number; status?: AgentTabStatus; @@ -399,6 +406,7 @@ const api = { onWorkspaceChanged: (listener: (workspace: WorkspaceInfo) => void): IpcDisposer => onIpc<[WorkspaceInfo]>('app:workspace-changed', listener), settings: (): Promise => ipcRenderer.invoke('app:settings'), + providerUsage: (): Promise => ipcRenderer.invoke('app:provider-usage'), mobileRelayCode: (): Promise => ipcRenderer.invoke('app:mobile-relay-code'), onMobileRelayCode: (listener: (code: string) => void): IpcDisposer => onIpc<[string]>('app:mobile-relay-code', listener), diff --git a/packages/desktop/src/renderer/src/shared/attention-status.ts b/packages/desktop/src/renderer/src/shared/attention.ts similarity index 87% rename from packages/desktop/src/renderer/src/shared/attention-status.ts rename to packages/desktop/src/renderer/src/shared/attention.ts index 1e24f281..5c771956 100644 --- a/packages/desktop/src/renderer/src/shared/attention-status.ts +++ b/packages/desktop/src/renderer/src/shared/attention.ts @@ -34,12 +34,9 @@ export const topAttentionStatus = (statuses: AttentionState[]): AttentionState = return ''; }; -export const workspaceFoldersAttention = ( - folders: WorkspaceFolder[], - activeWorkspacePath: string -): { kind: AttentionState; countLabel: string } => { +export const workspaceFoldersAttention = (folders: WorkspaceFolder[]): { kind: AttentionState; countLabel: string } => { const statuses = folders - .filter((folder) => folder.path !== activeWorkspacePath) + .filter((folder) => !folder.active) .map((folder) => attentionStatus(folder.status, folder.noticeKind)); return { kind: topAttentionStatus(statuses), countLabel: attentionCountLabel(attentionStatusCount(statuses)) }; }; diff --git a/packages/desktop/src/renderer/src/shared/badge.tsx b/packages/desktop/src/renderer/src/shared/badge.tsx index 9b4f93ea..f12c4b44 100644 --- a/packages/desktop/src/renderer/src/shared/badge.tsx +++ b/packages/desktop/src/renderer/src/shared/badge.tsx @@ -1,4 +1,4 @@ -import type { AttentionState } from '@renderer/shared/attention-status'; +import type { AttentionState } from '@renderer/shared/attention'; import { tw } from '@renderer/utils/tw'; interface AttentionBadgeProps { diff --git a/packages/desktop/src/renderer/src/shared/chat/events.ts b/packages/desktop/src/renderer/src/shared/chat/events.ts index 7d9a9981..5aec26e3 100644 --- a/packages/desktop/src/renderer/src/shared/chat/events.ts +++ b/packages/desktop/src/renderer/src/shared/chat/events.ts @@ -338,8 +338,8 @@ export const useChatEvents = (options: UseChatEventsOptions) => { setTurns((current) => [...current, createTurn('system', payload)]); }); - const offNotice = window.pi.chat.onNotice(({ payload }) => { - if (payload) playAttentionSound(); + const offNotice = window.pi.chat.onNotice(({ tabId, payload }) => { + if (payload && !activeScopedSession(tabId)) playAttentionSound(); }); const offStatusChanged = window.pi.chat.onStatusChanged(refreshChatState); diff --git a/packages/desktop/src/renderer/src/shared/chat/use-chat.ts b/packages/desktop/src/renderer/src/shared/chat/use-chat.ts index 8597bdfa..3c4b408a 100644 --- a/packages/desktop/src/renderer/src/shared/chat/use-chat.ts +++ b/packages/desktop/src/renderer/src/shared/chat/use-chat.ts @@ -14,6 +14,7 @@ import { buildRecallList } from '@renderer/shared/chat/recall'; import { useChatSend } from '@renderer/shared/chat/send'; import { useTurnSummary } from '@renderer/shared/chat/turn-summary'; import { clearFinderItemsCache } from '@renderer/shared/finder/use-items'; +import { refreshProviderUsage } from '@renderer/shared/settings/state'; import type { SettingsTab } from '@renderer/shared/settings/tab'; import { clearSlashCommandsCache } from '@renderer/shared/slash-commands'; import { scrollSessionToBottom } from '@renderer/shared/turn/scroll'; @@ -102,7 +103,7 @@ export const useChat = ({ onShowChat, onShowSettings, textareaRef }: UseChatOpti const applyStatus = useCallback( (nextStatus: ChatStatus) => { - primeWorkspaceFolders(nextStatus.workspacePath); + primeWorkspaceFolders(); setWorkspacePath(nextStatus.workspacePath); selectedModelKeyState.value = nextStatus.selectedModelKey ?? ''; setSelectedModelKey(nextStatus.selectedModelKey ?? ''); @@ -331,7 +332,7 @@ export const useChat = ({ onShowChat, onShowSettings, textareaRef }: UseChatOpti selectedModelKeyState.value = result.status.selectedModelKey ?? ''; setSelectedModelKey(result.status.selectedModelKey ?? ''); if (result.status.thinkingLevel) setThinkingLevel(result.status.thinkingLevel); - primeWorkspaceFolders(result.status.workspacePath); + primeWorkspaceFolders(); textareaRef.current?.focus(); return true; }, @@ -447,6 +448,7 @@ export const useChat = ({ onShowChat, onShowSettings, textareaRef }: UseChatOpti const refreshSettings = useCallback(() => { loadModels().catch(() => {}); loadAuthProviders().catch(() => {}); + refreshProviderUsage(); }, [loadAuthProviders, loadModels]); return { diff --git a/packages/desktop/src/renderer/src/shared/composer/workspace.tsx b/packages/desktop/src/renderer/src/shared/composer/workspace.tsx index 12d5f358..517ce7cb 100644 --- a/packages/desktop/src/renderer/src/shared/composer/workspace.tsx +++ b/packages/desktop/src/renderer/src/shared/composer/workspace.tsx @@ -1,4 +1,4 @@ -import { workspaceFoldersAttention } from '@renderer/shared/attention-status'; +import { workspaceFoldersAttention } from '@renderer/shared/attention'; import { AttentionBadge } from '@renderer/shared/badge'; import { useWorkspaceFolders } from '@renderer/shared/workspace/folders'; import { useWorkspace } from '@renderer/shared/workspace/info'; @@ -16,8 +16,8 @@ interface WorkspaceProps { export const Workspace = ({ workspacePath, onChooseDirectory, onSelectWorkspace }: WorkspaceProps) => { const [open, setOpen] = useState(false); const workspace = useWorkspace(workspacePath); - const { folders } = useWorkspaceFolders({ workspacePath }); - const { kind: attention, countLabel } = workspaceFoldersAttention(folders, workspacePath); + const { folders } = useWorkspaceFolders(); + const { kind: attention, countLabel } = workspaceFoldersAttention(folders); if (!workspace) return null; @@ -42,12 +42,7 @@ export const Workspace = ({ workspacePath, onChooseDirectory, onSelectWorkspace - + diff --git a/packages/desktop/src/renderer/src/shared/sessions.tsx b/packages/desktop/src/renderer/src/shared/sessions.tsx index 7f276786..d7cb61e1 100644 --- a/packages/desktop/src/renderer/src/shared/sessions.tsx +++ b/packages/desktop/src/renderer/src/shared/sessions.tsx @@ -5,7 +5,7 @@ import { attentionStatusCount, sessionAttentionStatus, topAttentionStatus -} from '@renderer/shared/attention-status'; +} from '@renderer/shared/attention'; import { AttentionBadge } from '@renderer/shared/badge'; import { Indicator } from '@renderer/shared/indicator'; import { appHotkeys, useAppHotkey } from '@renderer/ui/hotkeys'; diff --git a/packages/desktop/src/renderer/src/shared/settings/providers.tsx b/packages/desktop/src/renderer/src/shared/settings/providers.tsx index d1e3efbc..b190b004 100644 --- a/packages/desktop/src/renderer/src/shared/settings/providers.tsx +++ b/packages/desktop/src/renderer/src/shared/settings/providers.tsx @@ -1,5 +1,8 @@ -import type { ProviderAuthStatus } from '@preload/index'; -import { AnthropicIcon, ChevronDownIcon, OpenAIIcon } from '@renderer/ui/icons'; +import type { ProviderAuthStatus, ProviderUsage } from '@preload/index'; +import { providerUsage } from '@renderer/shared/settings/state'; +import { usageLabel } from '@renderer/shared/settings/usage'; +import { AnthropicIcon, ChevronDownIcon, OpenAIIcon, UsageIcon } from '@renderer/ui/icons'; +import { Tooltip } from '@renderer/ui/tooltip'; import { closeMotionTransition, openMotionTransition } from '@renderer/ui/motion'; import { tw } from '@renderer/utils/tw'; import { AnimatePresence, motion } from 'motion/react'; @@ -37,6 +40,7 @@ interface ProviderHeadingProps { authLabel: string; connected: boolean; authDetail: string; + usage?: ProviderUsage; } const providerStatus = (providers: ProviderAuthStatus[], provider: ProviderKey) => @@ -53,9 +57,22 @@ const ProviderAvatar = ({ provider }: { provider: ProviderKey }) => ( ); -const ProviderHeading = ({ name, authLabel, connected, authDetail }: ProviderHeadingProps) => ( +const ProviderHeading = ({ name, authLabel, connected, authDetail, usage }: ProviderHeadingProps) => (
-

{name}

+

+ {name} + {usage && ( + + + + )} +

{connected ? ( <> @@ -111,6 +128,7 @@ export const Providers = ({ const authDetail = connected ? connectionDetail(auth?.label) : ''; const expandable = !hasCredentials; const open = expandable && openProvider === provider.key; + const usage = connected ? providerUsage.value.find((item) => item.id === provider.key) : undefined; return (

0 ? 'pt-4 pb-4' : 'pb-4')} key={provider.key}> @@ -127,6 +145,7 @@ export const Providers = ({ authLabel={authLabel} connected={connected} authDetail={authDetail} + {...(usage ? { usage } : {})} />