diff --git a/src/app.ts b/src/app.ts index 330c05b..3cf7858 100644 --- a/src/app.ts +++ b/src/app.ts @@ -68,6 +68,7 @@ import { navigateSettings, selectSettingsItem, goBackInSettings, + getSelectedProviderUrl, type SettingsState, } from "./components/SettingsPanel"; @@ -363,6 +364,15 @@ export class HackerNewsApp { } else if (key.name === "k" || key.name === "up") { navigateSettings(this.settingsState, -1, getConfiguredProvider() || "anthropic"); this.rerenderSettings(); + } else if (key.name === "tab") { + // Open API key URL if a provider without a key is selected + const url = getSelectedProviderUrl( + this.settingsState, + getConfiguredProvider() || "anthropic", + ); + if (url) { + this.callbacks.onOpenUrl?.(url); + } } else if (key.name === "return" || key.name === "enter") { const action = selectSettingsItem( this.settingsState, diff --git a/src/components/SettingsPanel.ts b/src/components/SettingsPanel.ts index 57155ca..e5cb12e 100644 --- a/src/components/SettingsPanel.ts +++ b/src/components/SettingsPanel.ts @@ -8,6 +8,17 @@ import { OPENAI_MODELS, } from "../config"; +const PROVIDER_API_KEY_URLS: Record = { + anthropic: { + display: "platform.claude.com", + full: "https://platform.claude.com/settings/keys", + }, + openai: { + display: "platform.openai.com", + full: "https://platform.openai.com/api-keys", + }, +}; + type SettingsItemType = | { type: "provider"; provider: Provider; hasKey: boolean } | { type: "model"; modelId: string; modelName: string } @@ -139,8 +150,8 @@ export function renderSettings( if (!hasKey) { const hint = new TextRenderable(ctx, { - content: "Add API Key", - fg: COLORS.textSecondary, + content: `${PROVIDER_API_KEY_URLS[listItem.item.provider].display} (tab)`, + fg: COLORS.textTertiary, }); itemBox.add(hint); } @@ -305,3 +316,22 @@ export function selectSettingsItem( export function goBackInSettings(_state: SettingsState): boolean { return false; // Always exit settings on Esc } + +/** + * Returns the API key URL for the currently selected provider if it doesn't have a key. + * Returns null if the selection is not a provider or if the provider already has a key. + */ +export function getSelectedProviderUrl( + state: SettingsState, + chatProvider: Provider, +): string | null { + const items = getSettingsList(chatProvider); + const selected = items[state.selectedIndex]; + if (!selected) return null; + + if (selected.item.type === "provider" && !selected.item.hasKey) { + return PROVIDER_API_KEY_URLS[selected.item.provider].full; + } + + return null; +} diff --git a/src/test/settings.test.ts b/src/test/settings.test.ts index 0196c41..7b7e116 100644 --- a/src/test/settings.test.ts +++ b/src/test/settings.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "bun:test"; +import { getSelectedProviderUrl, initSettingsState, type SettingsState } from "../components/SettingsPanel"; +import { getApiKey } from "../config"; // NOTE: Settings Mode rendering tests are skipped due to a pre-existing Yoga layout engine crash // in the OpenTUI test framework. The settings action logic is tested by directly calling @@ -289,3 +291,61 @@ describe("Auth Setup State Management", () => { expect(mockState.keyInput).toBeNull(); }); }); + +describe("Provider API Key URLs", () => { + it("should return correct Anthropic URL when Anthropic has no key and is selected", () => { + // Only test if Anthropic doesn't have a key configured + if (getApiKey("anthropic")) return; + + const state = initSettingsState(); + state.selectedIndex = 0; // Anthropic is first in the list + + const url = getSelectedProviderUrl(state, "anthropic"); + expect(url).toBe("https://platform.claude.com/settings/keys"); + }); + + it("should return correct OpenAI URL when OpenAI has no key and is selected", () => { + // Only test if OpenAI doesn't have a key configured + if (getApiKey("openai")) return; + + const state = initSettingsState(); + state.selectedIndex = 1; // OpenAI is second in the list + + const url = getSelectedProviderUrl(state, "anthropic"); + expect(url).toBe("https://platform.openai.com/api-keys"); + }); + + it("should return null when selection is not a provider (action item)", () => { + const state = initSettingsState(); + // Done action is at index 2 when no keys are configured + state.selectedIndex = 2; + + const url = getSelectedProviderUrl(state, "anthropic"); + expect(url).toBeNull(); + }); + + it("should return null when selected index is out of bounds", () => { + const state = initSettingsState(); + state.selectedIndex = 999; + + const url = getSelectedProviderUrl(state, "anthropic"); + expect(url).toBeNull(); + }); + + it("should return null when provider has a key configured", () => { + // This test verifies the behavior when a provider already has a key + // We can't easily mock this, but we can verify the function exists + // and handles the case based on real config state + const state = initSettingsState(); + state.selectedIndex = 0; + + const url = getSelectedProviderUrl(state, "anthropic"); + + // If Anthropic has a key, URL should be null; otherwise it should be the Anthropic URL + if (getApiKey("anthropic")) { + expect(url).toBeNull(); + } else { + expect(url).toBe("https://platform.claude.com/settings/keys"); + } + }); +});