diff --git a/src/main/agents.test.ts b/src/main/agents.test.ts index fbf4202..5aa70fb 100644 --- a/src/main/agents.test.ts +++ b/src/main/agents.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { getProvider, availableAgents, resolveProjectOpenCommand } from './agents'; +import { join } from 'node:path'; +import { getProvider, availableAgents, resolveProjectOpenCommand, agentAvailableAtHome } from './agents'; describe('agent providers', () => { it('claude buildCommand maps kinds correctly', () => { @@ -31,6 +32,13 @@ describe('agent providers', () => { expect(availableAgents(() => true).sort()).toEqual(['antigravity', 'claude', 'codex']); }); + it('recognizes a fresh login root before the first session creates a projects/sessions folder', () => { + const home = join(process.cwd(), 'fresh-home'); + expect(agentAvailableAtHome('codex', home, (path) => path === join(home, '.codex'))).toBe(true); + expect(agentAvailableAtHome('claude', home, (path) => path === join(home, '.claude'))).toBe(true); + expect(agentAvailableAtHome('codex', home, () => false)).toBe(false); + }); + it('resolves an external launch only inside the explicitly requested provider', () => { const codex = getProvider('codex'); expect(resolveProjectOpenCommand(codex, { mode: 'auto', sessionId: null, hasHistory: true })) diff --git a/src/main/agents.ts b/src/main/agents.ts index ca8b2c8..fdffabb 100644 --- a/src/main/agents.ts +++ b/src/main/agents.ts @@ -11,6 +11,13 @@ const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects'); const ANTIGRAVITY_DIR = join(homedir(), '.gemini', 'antigravity'); const CODEX_SESSIONS = join(homedir(), '.codex', 'sessions'); +/** A login creates the provider root before the first conversation creates its session subfolder. */ +export function agentAvailableAtHome(id: AgentId, home: string, exists: (path: string) => boolean = existsSync): boolean { + if (id === 'claude') return exists(join(home, '.claude')); + if (id === 'codex') return exists(join(home, '.codex')); + return exists(join(home, '.gemini', 'antigravity')); +} + export type LaunchKind = 'new' | 'continue' | 'resume'; export interface AgentProvider { @@ -30,7 +37,7 @@ const claudeProvider: AgentProvider = { id: 'claude', label: 'Claude', supportsSessionId: true, - isAvailable: () => existsSync(CLAUDE_PROJECTS), + isAvailable: () => agentAvailableAtHome('claude', homedir()), listSessions: (p, limit) => listSessions(p, CLAUDE_PROJECTS, limit), listSessionIds: (p) => listSessionIds(p, CLAUDE_PROJECTS), lastUserMessage: (p, id) => lastUserMessageForSession(p, id, CLAUDE_PROJECTS), @@ -46,7 +53,7 @@ const antigravityProvider: AgentProvider = { id: 'antigravity', label: 'Antigravity', supportsSessionId: false, // agy has no --session-id pin; --conversation resumes by id only - isAvailable: () => antigravityAvailable(ANTIGRAVITY_DIR), + isAvailable: () => agentAvailableAtHome('antigravity', homedir()) || antigravityAvailable(ANTIGRAVITY_DIR), // Async to match the interface; antigravity's own reads stay sync (rare provider, small .db files). listSessions: async (p, limit) => listAntigravitySessions(p, ANTIGRAVITY_DIR, limit), listSessionIds: (p) => listAntigravitySessionIds(p, ANTIGRAVITY_DIR), @@ -61,7 +68,7 @@ const codexProvider: AgentProvider = { id: 'codex', label: 'Codex', supportsSessionId: false, - isAvailable: () => codexAvailable(CODEX_SESSIONS), + isAvailable: () => agentAvailableAtHome('codex', homedir()) || codexAvailable(CODEX_SESSIONS), listSessions: async (p, limit) => listCodexSessions(p, CODEX_SESSIONS, limit), listSessionIds: (p) => listCodexSessionIds(p, CODEX_SESSIONS), lastUserMessage: async (p, id) => lastUserMessageForCodexSession(p, id, CODEX_SESSIONS), @@ -91,7 +98,7 @@ export function resolveProjectOpenCommand( return provider.buildCommand(intent.hasHistory ? 'continue' : 'new'); } -/** Installed agents (claude, antigravity, and Codex when their session directories exist). `probe` overridable for tests. */ +/** Known agents (a login/config root is enough; the first session may not exist yet). `probe` overridable for tests. */ export function availableAgents(probe?: (id: AgentId) => boolean): AgentId[] { const ids: AgentId[] = ['claude', 'antigravity', 'codex']; const isAvail = probe ?? ((id) => PROVIDERS[id].isAvailable()); diff --git a/src/main/cliExecutable.ts b/src/main/cliExecutable.ts new file mode 100644 index 0000000..798553e --- /dev/null +++ b/src/main/cliExecutable.ts @@ -0,0 +1,49 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { win32 as pathWin32 } from 'node:path'; +import type { AgentId } from '../shared/types'; + +type LoginProvider = Extract; + +function knownWindowsCandidates(providerId: LoginProvider, env: NodeJS.ProcessEnv): string[] { + const appData = env.APPDATA; + const localAppData = env.LOCALAPPDATA; + const profile = env.USERPROFILE; + if (providerId === 'codex') return [ + ...(appData ? [pathWin32.join(appData, 'npm', 'codex.cmd')] : []), + ...(localAppData ? [pathWin32.join(localAppData, 'Programs', 'OpenAI', 'Codex', 'bin', 'codex.exe')] : []), + ]; + return [ + ...(appData ? [pathWin32.join(appData, 'npm', 'claude.cmd')] : []), + ...(profile ? [pathWin32.join(profile, '.local', 'bin', 'claude.exe')] : []), + ...(localAppData ? [pathWin32.join(localAppData, 'Programs', 'Claude', 'claude.exe')] : []), + ]; +} + +/** + * Resolve the concrete CLI executable used by background adapters and setup terminals. + * Known per-user install locations are checked even when DevDeck's long-lived tray process has an + * older PATH than a terminal opened after installation. + */ +export function resolveAgentCliPath( + providerId: LoginProvider, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + exists: (path: string) => boolean = existsSync, +): string { + if (platform !== 'win32') return providerId; + try { + const found = execFileSync('where', [providerId], { env, windowsHide: true }) + .toString().split(/\r?\n/).map((line) => line.trim()).find((line) => /\.(?:cmd|exe)$/i.test(line) && exists(line)); + if (found) return found; + } catch { /* stale/missing PATH — check stable per-user locations below */ } + return knownWindowsCandidates(providerId, env).find(exists) ?? providerId; +} + +/** Fixed, provider-owned login command for the visible PowerShell setup terminal. */ +export function loginPowerShellCommand(providerId: LoginProvider): string { + const executable = resolveAgentCliPath(providerId).replace(/'/g, "''"); + const args = providerId === 'codex' ? 'login' : 'auth login'; + return `& '${executable}' ${args}`; +} + diff --git a/src/main/codexUsage.test.ts b/src/main/codexUsage.test.ts index defd1ae..f11d8a9 100644 --- a/src/main/codexUsage.test.ts +++ b/src/main/codexUsage.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, vi } from 'vitest'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; -import { getCodexUsage, parseCodexRateLimits, type CodexUsageDeps } from './codexUsage'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getCodexUsage, parseCodexRateLimits, spawnCodexAppServer, type CodexUsageDeps } from './codexUsage'; const NOW = 1_700_000_000_000; @@ -260,3 +263,56 @@ describe('getCodexUsage protocol', () => { expect(h.child.kill).toHaveBeenCalledTimes(1); }); }); + +describe.runIf(process.platform === 'win32')('spawnCodexAppServer on Windows', () => { + it('runs an npm codex.cmd shim instead of reporting the CLI missing', async () => { + const home = mkdtempSync(join(tmpdir(), 'devdeck-codex-shim-')); + const dir = join(home, 'npm'); + const previousPath = process.env.PATH; + const previousAppData = process.env.APPDATA; + const previousLocalAppData = process.env.LOCALAPPDATA; + try { + mkdirSync(dir); + writeFileSync(join(dir, 'codex.cmd'), '@echo off\r\nexit /b 0\r\n', 'utf8'); + process.env.APPDATA = home; + process.env.LOCALAPPDATA = home; + process.env.PATH = dir; + const child = spawnCodexAppServer(); + const result = await new Promise<{ code: number | null; error: NodeJS.ErrnoException | null }>((resolve) => { + let error: NodeJS.ErrnoException | null = null; + child.on('error', (value) => { error = value; }); + child.on('close', (code) => resolve({ code, error })); + }); + expect(result).toEqual({ code: 0, error: null }); + } finally { + process.env.PATH = previousPath; + process.env.APPDATA = previousAppData; + process.env.LOCALAPPDATA = previousLocalAppData; + rmSync(home, { recursive: true, force: true }); + } + }); + + it('finds the npm shim from APPDATA when the tray process has a stale PATH', async () => { + const home = mkdtempSync(join(tmpdir(), 'devdeck-stale-path-')); + const npmDir = join(home, 'npm'); + const previousPath = process.env.PATH; + const previousAppData = process.env.APPDATA; + try { + mkdirSync(npmDir); + writeFileSync(join(npmDir, 'codex.cmd'), '@echo off\r\nexit /b 0\r\n', 'utf8'); + process.env.APPDATA = home; + process.env.PATH = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32'); + const child = spawnCodexAppServer(); + const result = await new Promise<{ code: number | null; error: NodeJS.ErrnoException | null }>((resolve) => { + let error: NodeJS.ErrnoException | null = null; + child.on('error', (value) => { error = value; }); + child.on('close', (code) => resolve({ code, error })); + }); + expect(result).toEqual({ code: 0, error: null }); + } finally { + process.env.PATH = previousPath; + process.env.APPDATA = previousAppData; + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/src/main/codexUsage.ts b/src/main/codexUsage.ts index 40d31ad..1f0a1bf 100644 --- a/src/main/codexUsage.ts +++ b/src/main/codexUsage.ts @@ -4,6 +4,7 @@ // first-party request; we only speak its JSON-RPC and normalize the answer. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { clampPercent, parseResetTime, safeUsageLabel, type ProviderUsage, type UsageCredits, type UsageLimit } from '../shared/usageWindows'; +import { resolveAgentCliPath } from './cliExecutable'; const STARTUP_TIMEOUT_MS = 8_000; const REQUEST_TIMEOUT_MS = 12_000; @@ -203,5 +204,10 @@ function loginOrOffline(error: unknown, now: number): ProviderUsage { /** Production spawn: the official CLI, stdio pipes only, no shell. */ export function spawnCodexAppServer(): ChildProcessWithoutNullStreams { - return spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) as ChildProcessWithoutNullStreams; + const command = resolveAgentCliPath('codex'); + // npm installs a `codex.cmd` shim on Windows. CreateProcess cannot execute that shim directly; + // route the fixed command through cmd.exe there (there is no user-controlled argv in this call). + return spawn(command, ['app-server'], { + stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: process.platform === 'win32', + }) as ChildProcessWithoutNullStreams; } diff --git a/src/main/ipc.cockpit.test.ts b/src/main/ipc.cockpit.test.ts index 9df7a58..a62d799 100644 --- a/src/main/ipc.cockpit.test.ts +++ b/src/main/ipc.cockpit.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import { join } from 'node:path'; +import { homedir } from 'node:os'; const { handlers, claudeStats, codexStats, codexIndex, codexSessions, claudeIds, probe } = vi.hoisted(() => ({ handlers: new Map unknown>(), @@ -241,6 +242,34 @@ describe('cockpit:sessionMeta summary per provider', () => { }); }); +describe('usage:login embedded terminal', () => { + it('opens the requested provider login in a visible PTY using fixed commands', async () => { + const open = handlers.get('usage:login')!; + ptyCreate.mockClear(); + + const codex = await open(null, 'codex', 92, 28) as { id: string; providerId: string }; + expect(codex).toMatchObject({ providerId: 'codex' }); + expect(codex.id).toMatch(/^usage-login:codex:/); + expect(ptyCreate).toHaveBeenLastCalledWith( + codex.id, expect.any(String), ['-NoExit', '-Command', expect.stringMatching(/codex(?:\.cmd|\.exe)?['"]?\s+login/i)], + homedir(), 92, 28, expect.any(Function), expect.any(Function), + ); + + const claude = await open(null, 'claude', 80, 24) as { id: string; providerId: string }; + expect(claude).toMatchObject({ providerId: 'claude' }); + expect(ptyCreate).toHaveBeenLastCalledWith( + claude.id, expect.any(String), ['-NoExit', '-Command', expect.stringMatching(/claude(?:\.cmd|\.exe)?['"]?\s+auth\s+login/i)], + homedir(), 80, 24, expect.any(Function), expect.any(Function), + ); + }); + + it('refuses unsupported providers instead of exposing a general command terminal', async () => { + ptyCreate.mockClear(); + expect(await handlers.get('usage:login')!(null, 'antigravity', 80, 24)).toBeNull(); + expect(ptyCreate).not.toHaveBeenCalled(); + }); +}); + // A saved entry names ONE conversation. When that conversation is gone the tile comes back as a fresh // session under the same name (resolveRestoreTarget) — so the "Previous" list has to be able to say so // BEFORE the click. This answers the whole list at once: per-entry would re-index the flat Codex diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9499313..9f19baa 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -32,6 +32,7 @@ import { getCodexUsage, spawnCodexAppServer } from './codexUsage'; import { UsageCoordinator, antigravityUsage } from './usageProviders'; import { pickAdoptedSessionId, pickDriftedSessionId, type PersistedSession } from '../shared/cockpitPersist'; import { makeAgentProbe } from './agentProcess'; +import { loginPowerShellCommand } from './cliExecutable'; import { listSessionStats, listSessionIds } from './sessions'; import { listCodexSessionStats, indexCodexSessionsByCwd, readCodexSessionMeta } from './codexSessions'; import { indexAntigravitySessionsByCwd } from './antigravitySessions'; @@ -391,6 +392,25 @@ export function registerIpc(cfg: IpcConfig): void { // Coalesce pty output (~one frame) before it crosses IPC so many streaming sessions don't flood the // renderer's single UI thread; input is never batched, and a big burst flushes immediately via the cap. const ptyBatch = new PtyBatcher((id, chunk) => sendToWin('cockpit:data', { id, chunk }), (flush) => { setTimeout(flush, 16); }); + // Visible, interactive OAuth setup inside DevDeck. This is intentionally NOT a general command + // runner: the renderer chooses only a provider id and main owns the two fixed login commands. + ipcMain.handle('usage:login', (_e, rawProviderId: unknown, cols: number, rows: number) => { + const providerId = rawProviderId === 'claude' || rawProviderId === 'codex' ? rawProviderId : null; + if (!providerId || !cfg.ptyAvailable) return null; + const id = `usage-login:${providerId}:${++cockpitSeq}`; + try { + cfg.ptyHost.create( + id, resolveShellPath(), ['-NoExit', '-Command', loginPowerShellCommand(providerId)], homedir(), + Math.max(20, Number(cols) | 0), Math.max(5, Number(rows) | 0), + (chunk) => ptyBatch.push(id, chunk), + (exit) => { ptyBatch.flush(); sendToWin('cockpit:exit', { id, exitCode: exit.exitCode }); }, + ); + return { id, providerId }; + } catch (err) { + cfg.sendError(`Could not open ${providerId} login: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + }); ipcMain.handle('cockpit:open', async (_e, req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode?: OpenMode; agentId?: AgentId }) => { const folders = effFolders(); if (!isAllowedPath(folders, req.projectPath)) { diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 939365a..39c6793 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -34,6 +34,7 @@ contextBridge.exposeInMainWorld('devdeck', { setAiSessionSummary: (on: boolean) => ipcRenderer.invoke('settings:setAiSessionSummary', on), usageSnapshot: () => ipcRenderer.invoke('usage:snapshot'), refreshUsageProviders: (opts?: { force?: boolean }) => ipcRenderer.invoke('usage:refresh', { force: opts?.force === true }), + openUsageLogin: (providerId: 'claude' | 'codex', cols: number, rows: number) => ipcRenderer.invoke('usage:login', providerId, cols, rows), onUpdate: (cb: (p: import('../shared/update').UpdatePayload) => void) => ipcRenderer.on('devdeck:update', (_e, p) => cb(p as import('../shared/update').UpdatePayload)), downloadUpdate: () => ipcRenderer.invoke('update:download'), diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 1b43a55..e3f85bf 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -40,6 +40,7 @@ declare global { setAiSessionSummary(on: boolean): Promise; usageSnapshot(): Promise; refreshUsageProviders(opts?: { force?: boolean }): Promise; + openUsageLogin(providerId: 'claude' | 'codex', cols: number, rows: number): Promise<{ id: string; providerId: 'claude' | 'codex' } | null>; onUpdate(cb: (p: import('../shared/update').UpdatePayload) => void): void; downloadUpdate(): Promise; installUpdate(): Promise; diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 0e805de..59d8553 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -264,6 +264,12 @@ "usage.credits_off": "None", "usage.guidance_cli": "Check this provider from its CLI:", "usage.copy_command": "Copy command", + "usage.login_open": "Open login terminal", + "usage.install_hint": "Install the CLI, then refresh:", + "usage.login_title": "{provider} CLI login", + "usage.login_hint": "Complete the browser sign-in, then close this terminal. Usage status refreshes automatically.", + "usage.login_exited": "Login terminal exited with code", + "usage.login_unavailable": "The embedded terminal is unavailable on this device.", "usage.state_ready": "Current", "usage.state_stale": "Last known", "usage.state_login_required": "Login required", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index c9bac90..ea3a309 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -264,6 +264,12 @@ "usage.credits_off": "なし", "usage.guidance_cli": "このプロバイダーはCLIで確認してください:", "usage.copy_command": "コマンドをコピー", + "usage.login_open": "ログイン端末を開く", + "usage.install_hint": "CLIをインストールして更新:", + "usage.login_title": "{provider} CLI ログイン", + "usage.login_hint": "ブラウザーでログインを完了してから、この端末を閉じてください。使用状況は自動的に更新されます。", + "usage.login_exited": "ログイン端末の終了コード", + "usage.login_unavailable": "このデバイスでは内蔵端末を使用できません。", "usage.state_ready": "最新", "usage.state_stale": "以前のデータ", "usage.state_login_required": "ログインが必要", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 85f16fa..29ac144 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -264,6 +264,12 @@ "usage.credits_off": "없음", "usage.guidance_cli": "이 프로바이더는 CLI에서 직접 확인하세요:", "usage.copy_command": "명령 복사", + "usage.login_open": "로그인 터미널 열기", + "usage.install_hint": "CLI 설치 후 새로고침:", + "usage.login_title": "{provider} CLI 로그인", + "usage.login_hint": "브라우저 로그인을 완료한 뒤 이 터미널을 닫으세요. 사용량 상태가 자동으로 새로고침됩니다.", + "usage.login_exited": "로그인 터미널 종료 코드", + "usage.login_unavailable": "이 기기에서는 내장 터미널을 사용할 수 없습니다.", "usage.state_ready": "최신", "usage.state_stale": "이전 데이터", "usage.state_login_required": "로그인 필요", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 6b89426..3b3a760 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -264,6 +264,12 @@ "usage.credits_off": "无", "usage.guidance_cli": "该提供方请在其 CLI 中查看:", "usage.copy_command": "复制命令", + "usage.login_open": "打开登录终端", + "usage.install_hint": "安装 CLI 后刷新:", + "usage.login_title": "{provider} CLI 登录", + "usage.login_hint": "在浏览器中完成登录,然后关闭此终端。用量状态将自动刷新。", + "usage.login_exited": "登录终端退出代码", + "usage.login_unavailable": "此设备无法使用内置终端。", "usage.state_ready": "最新", "usage.state_stale": "上次数据", "usage.state_login_required": "需要登录", diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 7116680..7aa3d71 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -752,6 +752,16 @@ button.primary:hover { background: var(--accent-hover); } .um-cmd { font-family: var(--mono); font-size: 10.5px; background: var(--bg); border: 1px solid var(--border-strong); color: var(--text); border-radius: 6px; padding: 1px 7px; cursor: pointer; } .um-cmd:hover { border-color: var(--accent); } .um-cmd.copied { border-color: #3fb950; color: #3fb950; } +.um-action, .usage-login-close { background: var(--accent); border: 1px solid var(--accent); color: #fff; border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 11px; } +.um-action:hover, .usage-login-close:hover { filter: brightness(1.12); } +.usage-login-overlay { position: fixed; inset: 0; z-index: 110; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(0,0,0,.72); } +.usage-login-dialog { display: flex; flex-direction: column; width: min(860px, calc(100vw - 40px)); height: min(560px, calc(100vh - 40px)); overflow: hidden; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--surface); box-shadow: 0 18px 60px rgba(0,0,0,.72); } +.usage-login-head { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); } +.usage-login-head h2 { flex: 1; margin: 0; color: var(--text); font-size: 14px; } +.usage-login-close { display: grid; place-items: center; padding: 4px 7px; background: transparent; border-color: var(--border-strong); color: var(--text-dim); } +.usage-login-note { margin: 0; padding: 8px 14px; color: var(--text-dim); font-size: 11.5px; border-bottom: 1px solid var(--border); } +.usage-login-terminal { flex: 1; min-height: 0; padding: 8px; background: #0a0b0e; } +.usage-login-terminal .xterm { height: 100%; } /* 🌙 idle shutdown */ #shutdown-banner { position: fixed; left: 50%; transform: translateX(-50%); bottom: 44px; z-index: 60; diff --git a/src/renderer/usageLoginTerminal.ts b/src/renderer/usageLoginTerminal.ts new file mode 100644 index 0000000..93e72e2 --- /dev/null +++ b/src/renderer/usageLoginTerminal.ts @@ -0,0 +1,69 @@ +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import type { AgentId } from '../shared/types'; +import { tr } from './i18n-runtime'; +import { providerName } from './providerLogo'; +import { createIcon } from './icons'; + +type LoginProvider = Extract; + +let active: { id: string | null; overlay: HTMLElement; term: Terminal; fit: FitAddon; resize: ResizeObserver } | null = null; +let listenersMounted = false; + +function ensureBridgeListeners(): void { + if (listenersMounted) return; + listenersMounted = true; + window.devdeck.cockpit.onData(({ id, chunk }) => { if (active?.id === id) active.term.write(chunk); }); + window.devdeck.cockpit.onExit(({ id, exitCode }) => { + if (active?.id === id) active.term.writeln(`\r\n[${tr('usage.login_exited')} ${exitCode}]`); + }); +} + +function closeLoginTerminal(): void { + const current = active; + if (!current) return; + active = null; + current.resize.disconnect(); + if (current.id) window.devdeck.cockpit.close(current.id); + current.term.dispose(); + current.overlay.remove(); + void import('./usageBar').then((module) => module.refreshUsageBar(true)); +} + +/** Open one provider-owned OAuth command in a visible, interactive in-app terminal. */ +export async function openUsageLoginTerminal(providerId: LoginProvider): Promise { + if (active) closeLoginTerminal(); + ensureBridgeListeners(); + + const overlay = document.createElement('div'); overlay.className = 'usage-login-overlay'; + const dialog = document.createElement('div'); dialog.className = 'usage-login-dialog'; + dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); + const head = document.createElement('div'); head.className = 'usage-login-head'; + const title = document.createElement('h2'); title.textContent = tr('usage.login_title').replace('{provider}', providerName(providerId)); + const close = document.createElement('button'); close.type = 'button'; close.className = 'usage-login-close'; + close.appendChild(createIcon('close')); close.title = tr('usage.modal_close'); close.setAttribute('aria-label', tr('usage.modal_close')); + head.append(title, close); + const note = document.createElement('p'); note.className = 'usage-login-note'; note.textContent = tr('usage.login_hint'); + const terminalHost = document.createElement('div'); terminalHost.className = 'usage-login-terminal'; + dialog.append(head, note, terminalHost); overlay.appendChild(dialog); document.body.appendChild(overlay); + + const term = new Terminal({ fontFamily: 'Cascadia Mono, Consolas, monospace', fontSize: 12, theme: { background: '#0a0b0e' }, cursorBlink: true }); + const fit = new FitAddon(); term.loadAddon(fit); term.open(terminalHost); fit.fit(); + const resize = new ResizeObserver(() => { + if (!active || active.overlay !== overlay) return; + fit.fit(); + if (active.id) window.devdeck.cockpit.resize(active.id, term.cols, term.rows); + }); + resize.observe(terminalHost); + active = { id: null, overlay, term, fit, resize }; + term.onData((data) => { if (active?.overlay === overlay && active.id) window.devdeck.cockpit.input(active.id, data); }); + close.addEventListener('click', closeLoginTerminal); + overlay.addEventListener('mousedown', (event) => { if (event.target === overlay) closeLoginTerminal(); }); + + const opened = await window.devdeck.openUsageLogin(providerId, term.cols, term.rows); + if (!active || active.overlay !== overlay) { if (opened?.id) window.devdeck.cockpit.close(opened.id); return; } + if (!opened) { term.writeln(tr('usage.login_unavailable')); return; } + active.id = opened.id; + terminalHost.focus(); +} + diff --git a/src/renderer/usageModal.ts b/src/renderer/usageModal.ts index f9bbe83..f9900ef 100644 --- a/src/renderer/usageModal.ts +++ b/src/renderer/usageModal.ts @@ -1,8 +1,9 @@ import { tr } from './i18n-runtime'; import { createProviderLogo, providerName } from './providerLogo'; import { staleAgeMinutes } from '../shared/usagePresentation'; -import { formatReset, usageSeverity, usageStateKey, type ProviderUsage, type UsageSnapshot } from '../shared/usageWindows'; +import { formatReset, usageActionFor, usageSeverity, usageStateKey, type ProviderUsage, type UsageSnapshot } from '../shared/usageWindows'; import { createIcon } from './icons'; +import { openUsageLoginTerminal } from './usageLoginTerminal'; // The all-provider limits dialog. It is an OVERLAY (fixed, outside the flex column) precisely so that // opening it cannot change the shell, cockpit, or xterm geometry — a terminal resize storm was a real @@ -134,6 +135,22 @@ function providerSection(p: ProviderUsage): HTMLElement { sec.appendChild(c); } + const action = usageActionFor(p.providerId, p.state); + if (action === 'login') { + const row = document.createElement('div'); row.className = 'um-guidance'; + const button = document.createElement('button'); button.type = 'button'; button.className = 'um-action'; + button.textContent = tr('usage.login_open'); + button.addEventListener('click', () => { void openUsageLoginTerminal(p.providerId as 'claude' | 'codex'); }); + row.appendChild(button); sec.appendChild(row); + } else if (action === 'install') { + const command = p.providerId === 'codex' ? 'npm install -g @openai/codex' : 'npm install -g @anthropic-ai/claude-code'; + const row = document.createElement('div'); row.className = 'um-guidance'; + const text = document.createElement('span'); text.textContent = tr('usage.install_hint'); + const button = document.createElement('button'); button.type = 'button'; button.className = 'um-cmd'; button.textContent = command; + button.addEventListener('click', () => { window.devdeck.clipboard.writeText(command); button.classList.add('copied'); setTimeout(() => button.classList.remove('copied'), 1200); }); + row.append(text, button); sec.appendChild(row); + } + if (p.guidance) { const g = document.createElement('div'); g.className = 'um-guidance'; const text = document.createElement('span'); text.textContent = tr('usage.guidance_cli'); diff --git a/src/shared/usageWindows.test.ts b/src/shared/usageWindows.test.ts index 363f2f0..810c20a 100644 --- a/src/shared/usageWindows.test.ts +++ b/src/shared/usageWindows.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { usageSeverity, clampPercent, parseResetTime, safeUsageLabel, formatReset, - parseClaudeUsageResponse, usageStateKey, + parseClaudeUsageResponse, usageStateKey, usageActionFor, } from './usageWindows'; describe('usageSeverity', () => { @@ -71,6 +71,16 @@ describe('usageStateKey', () => { }); }); +describe('usageActionFor', () => { + it('offers login for missing/expired credentials and install help only for a missing CLI', () => { + expect(usageActionFor('codex', 'login-required')).toBe('login'); + expect(usageActionFor('claude', 'expired')).toBe('login'); + expect(usageActionFor('codex', 'cli-missing')).toBe('install'); + expect(usageActionFor('claude', 'ready')).toBeNull(); + expect(usageActionFor('antigravity', 'unsupported')).toBeNull(); + }); +}); + describe('parseClaudeUsageResponse', () => { const ISO_5H = '2026-06-15T14:32:00Z'; const ISO_WEEK = '2026-06-20T00:00:00Z'; diff --git a/src/shared/usageWindows.ts b/src/shared/usageWindows.ts index b6ee900..ab64542 100644 --- a/src/shared/usageWindows.ts +++ b/src/shared/usageWindows.ts @@ -50,6 +50,13 @@ export function usageStateKey(state: UsageProviderState): string { return `usage.state_${state.replace(/-/g, '_')}`; } +/** Actionable recovery shown beside a provider state; Antigravity has its own CLI guidance. */ +export function usageActionFor(providerId: AgentId, state: UsageProviderState): 'login' | 'install' | null { + if (providerId !== 'claude' && providerId !== 'codex') return null; + if (state === 'login-required' || state === 'expired') return 'login'; + return state === 'cli-missing' ? 'install' : null; +} + export function clampPercent(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? Math.round(Math.max(0, Math.min(100, value))) : null;