Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
navigateSettings,
selectSettingsItem,
goBackInSettings,
getSelectedProviderUrl,
type SettingsState,
} from "./components/SettingsPanel";

Expand Down Expand Up @@ -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,
Expand Down
34 changes: 32 additions & 2 deletions src/components/SettingsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import {
OPENAI_MODELS,
} from "../config";

const PROVIDER_API_KEY_URLS: Record<Provider, { display: string; full: string }> = {
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 }
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
60 changes: 60 additions & 0 deletions src/test/settings.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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");
}
});
});