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
361 changes: 360 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

29 changes: 27 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
renderChatMessages,
scrollChatToBottom,
addChatMessage,
startTypingIndicator,
stopTypingIndicator,
type ChatPanelState,
} from "./components/ChatPanel";
import { renderSuggestions, navigateSuggestion } from "./components/Suggestions";
Expand Down Expand Up @@ -687,22 +689,38 @@ export class HackerNewsApp {

if (!this.selectedPost) return;

// Start typing indicator animation
startTypingIndicator(this.ctx, this.chatPanelState, this.chatServiceState.provider);

let receivedFirstText = false;

await streamAIResponse(
this.chatServiceState,
this.chatPanelState.messages,
userMessage,
this.selectedPost,
{
onText: (text) => {
// Stop typing indicator on first text
if (!receivedFirstText && this.chatPanelState) {
receivedFirstText = true;
stopTypingIndicator(this.chatPanelState);
}
if (this.chatPanelState?.messages[assistantMsgIndex]) {
this.chatPanelState.messages[assistantMsgIndex].content = text;
renderChatMessages(this.ctx, this.chatPanelState, this.chatServiceState!.provider);
}
},
onComplete: () => {
if (this.chatPanelState) {
stopTypingIndicator(this.chatPanelState);
}
this.generateFollowUpQuestionsIfNeeded();
},
onError: (error) => {
if (this.chatPanelState) {
stopTypingIndicator(this.chatPanelState);
}
const providerName =
this.chatServiceState?.provider === "anthropic" ? "Anthropic" : "OpenAI";
if (this.chatPanelState?.messages[assistantMsgIndex]) {
Expand Down Expand Up @@ -892,6 +910,11 @@ export class HackerNewsApp {
this.settingsMode = true;
this.settingsState = initSettingsState();

// Blur the chat input to remove cursor
if (this.chatPanelState?.input) {
this.chatPanelState.input.blur();
}

// Save the chat session before switching to settings
if (this.selectedPost && this.chatPanelState) {
this.savedChatSessions.set(this.selectedPost.id, {
Expand Down Expand Up @@ -955,15 +978,17 @@ export class HackerNewsApp {
this.chatPanelState.suggestions.selectedIndex = savedSession.suggestions.length > 0
? savedSession.suggestions.length - 1
: -1;
renderChatMessages(this.ctx, this.chatPanelState, this.chatServiceState.provider);
}

// Re-focus chat input
// Render messages and focus input
renderChatMessages(this.ctx, this.chatPanelState, this.chatServiceState.provider);

if (this.chatPanelState.input) {
this.chatPanelState.input.focus();
this.chatPanelState.input.clear();
}

// Render suggestions (either restored from saved session or default)
renderSuggestions(this.ctx, this.chatPanelState.suggestions);
}

Expand Down
46 changes: 46 additions & 0 deletions src/components/ChatPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createSuggestionsContainer, type SuggestionsState, initSuggestionsState
import { createChatInput } from "./ChatInput";
import { createStoryHeader } from "./StoryHeader";
import type { Provider } from "../config";
import { LOADING_CHARS } from "../utils";

export interface ChatMessage {
role: "user" | "assistant";
Expand All @@ -31,6 +32,10 @@ export interface ChatPanelState {
suggestions: SuggestionsState;
messages: ChatMessage[];
isActive: boolean;
// Typing indicator state
isTyping: boolean;
typingFrame: number;
typingInterval: ReturnType<typeof setInterval> | null;
}

export function createChatPanel(
Expand Down Expand Up @@ -100,6 +105,9 @@ export function createChatPanel(
suggestions: suggestionsState,
messages: [],
isActive: true,
isTyping: false,
typingFrame: 0,
typingInterval: null,
};
}

Expand Down Expand Up @@ -174,3 +182,41 @@ export function addChatMessage(
state.messages.push({ role, content });
renderChatMessages(ctx, state, provider);
}

export function startTypingIndicator(
ctx: RenderContext,
state: ChatPanelState,
provider: Provider,
): void {
if (state.typingInterval) return; // Already running

state.isTyping = true;
state.typingFrame = 0;

state.typingInterval = setInterval(() => {
if (!state.isActive || !state.isTyping) {
stopTypingIndicator(state);
return;
}

state.typingFrame = (state.typingFrame + 1) % LOADING_CHARS.length;

// Update the last message (the placeholder) with the typing indicator
const lastMsg = state.messages[state.messages.length - 1];
if (lastMsg && lastMsg.role === "assistant" && lastMsg.content === "...") {
const char = LOADING_CHARS[state.typingFrame] ?? "\u280B";
lastMsg.content = char;
renderChatMessages(ctx, state, provider);
// Reset to "..." so the next frame updates correctly
lastMsg.content = "...";
}
}, 80);
}

export function stopTypingIndicator(state: ChatPanelState): void {
state.isTyping = false;
if (state.typingInterval) {
clearInterval(state.typingInterval);
state.typingInterval = null;
}
}
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export const OPENAI_MODELS: { id: OpenAIModel; name: string }[] = [
export const DEFAULT_ANTHROPIC_MODEL: AnthropicModel = "claude-haiku-4-5-20251001";
export const DEFAULT_OPENAI_MODEL: OpenAIModel = "gpt-5-nano-2025-08-07";

// Cheap models used for auxiliary tasks (suggestions, follow-ups) to save cost.
// Currently same as defaults; change these to use smaller models if cost becomes a concern.
export const CHEAP_ANTHROPIC_MODEL: AnthropicModel = "claude-haiku-4-5-20251001";
export const CHEAP_OPENAI_MODEL: OpenAIModel = "gpt-5-nano-2025-08-07";

export interface Config {
provider?: Provider;
anthropicApiKey?: string;
Expand Down
93 changes: 74 additions & 19 deletions src/services/ChatService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import type { HackerNewsPost, HackerNewsComment } from "../types";
import { type Provider, getApiKey, getModel } from "../config";
import { type Provider, getApiKey, getModel, CHEAP_ANTHROPIC_MODEL, CHEAP_OPENAI_MODEL } from "../config";
import { stripHtml } from "../utils";
import { log } from "../logger";
import type { ChatMessage } from "../components/ChatPanel";
Expand All @@ -27,7 +27,7 @@ export function initChatServiceState(provider: Provider): ChatServiceState {
export function buildStoryContext(post: HackerNewsPost): string {
const storyUrl = post.url || `https://news.ycombinator.com/item?id=${post.id}`;

let context = `# Hacker News Story\n\n`;
let context = `# Story Being Discussed\n\n`;
context += `**Title:** ${post.title}\n`;
context += `**URL:** ${storyUrl}\n`;
if (post.domain) context += `**Domain:** ${post.domain}\n`;
Expand All @@ -36,11 +36,12 @@ export function buildStoryContext(post: HackerNewsPost): string {
context += `**Comments:** ${post.comments_count}\n\n`;

if (post.content) {
context += `## Story Content\n\n${stripHtml(post.content)}\n\n`;
context += `## Story Text\n\n${stripHtml(post.content)}\n\n`;
}

if (post.comments && post.comments.length > 0) {
context += `## Comments\n\n`;
context += `# Hacker News Discussion\n\n`;
context += `The following are comments from the Hacker News community discussing this story:\n\n`;
context += formatCommentsForContext(post.comments);
}

Expand Down Expand Up @@ -94,21 +95,25 @@ export async function streamAIResponse(
}

const storyUrl = post.url || "";
const systemPrompt = `You are helping a user understand and discuss a Hacker News story and its comments. Here is the full context:

const systemPrompt = `You are helping a user understand and discuss a Hacker News story.

${state.storyContext}

---

The user is reading this in a terminal app and wants to discuss it with you. Be concise but insightful. If they ask about the article content and it would help to have more context, you can suggest they share more details or you can work with what's in the comments.
IMPORTANT CONTEXT DISTINCTION:
- The "Story Being Discussed" section above contains metadata about the linked article/content
- The "Hacker News Discussion" section contains community comments ABOUT that story
- If the user asks about the original article/video content, use web search to fetch and read the URL: ${storyUrl}

${storyUrl ? `The original article URL is: ${storyUrl}` : ""}`;
The user is reading this in a terminal app. Be concise but insightful. When you search the web for article content, clearly distinguish between what's in the article versus what's being discussed in the HN comments.`;

try {
if (state.provider === "anthropic") {
await streamAnthropicResponse(state, messages, userMessage, systemPrompt, callbacks);
await streamAnthropicResponse(state, messages, userMessage, systemPrompt, storyUrl, callbacks);
} else {
await streamOpenAIResponse(state, messages, userMessage, systemPrompt, callbacks);
await streamOpenAIResponse(state, messages, userMessage, systemPrompt, storyUrl, callbacks);
}
} catch (error) {
callbacks.onError(error instanceof Error ? error : new Error(String(error)));
Expand All @@ -122,6 +127,7 @@ async function streamAnthropicResponse(
messages: ChatMessage[],
userMessage: string,
systemPrompt: string,
storyUrl: string,
callbacks: StreamCallbacks,
): Promise<void> {
// Initialize Anthropic client if needed
Expand All @@ -130,10 +136,23 @@ async function streamAnthropicResponse(
state.anthropic = new Anthropic({ apiKey });
}

// Build the tools array with web search if we have a story URL
// Cast through unknown since the web search tool type isn't fully typed in the SDK yet
const tools: Anthropic.Messages.Tool[] = storyUrl
? [
{
type: "web_search_20250305",
name: "web_search",
max_uses: 3,
} as unknown as Anthropic.Messages.Tool,
]
: [];

const stream = state.anthropic.messages.stream({
model: getModel("anthropic") as string,
max_tokens: 4096,
system: systemPrompt,
tools: tools.length > 0 ? tools : undefined,
messages: messages
.slice(0, -1)
.map((m) => ({
Expand All @@ -159,6 +178,7 @@ async function streamOpenAIResponse(
messages: ChatMessage[],
userMessage: string,
systemPrompt: string,
storyUrl: string,
callbacks: StreamCallbacks,
): Promise<void> {
log("[openai-stream] Starting stream...");
Expand All @@ -174,6 +194,41 @@ async function streamOpenAIResponse(
log("[openai-stream] Model:", model);
log("[openai-stream] Message count:", messages.length);

// Use Responses API with streaming and web search for OpenAI
if (storyUrl) {
try {
log("[openai-stream] Using Responses API stream with web_search tool");
const stream = state.openai.responses.stream({
model,
tools: [{ type: "web_search" }],
input: [
{ role: "developer", content: systemPrompt },
...messages.slice(0, -1).map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
{ role: "user", content: userMessage },
],
});

// Listen for text delta events (snapshot contains accumulated text)
stream.on("response.output_text.delta", (event) => {
callbacks.onText(event.snapshot);
});

// Wait for stream to complete
await stream.finalResponse();
log("[openai-stream] Responses API stream complete");
callbacks.onComplete();
return;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log("[openai-stream] Responses API failed, falling back to chat completions without web search. Error:", errorMessage);
// Fall through to chat completions (web search will not be available)
}
}

// Fallback to Chat Completions (no web search capability)
const stream = await state.openai.chat.completions.create({
model,
max_completion_tokens: 4096,
Expand Down Expand Up @@ -226,19 +281,19 @@ ${commentsPreview}`;
try {
let questions: string[] = [];

// Use cheap models for suggestion generation to save cost
if (state.provider === "anthropic") {
log("[suggestions] Using Anthropic API");
log("[suggestions] Using Anthropic API with cheap model");
if (!state.anthropic) {
const apiKey = getApiKey("anthropic");
log("[suggestions] API key exists:", !!apiKey);
state.anthropic = new Anthropic({ apiKey });
}

const model = getModel("anthropic") as string;
log("[suggestions] Model:", model);
log("[suggestions] Model:", CHEAP_ANTHROPIC_MODEL);

const response = await state.anthropic.messages.create({
model,
model: CHEAP_ANTHROPIC_MODEL,
max_tokens: 1024,
messages: [{ role: "user", content: prompt }],
});
Expand All @@ -252,19 +307,18 @@ ${commentsPreview}`;
.filter((q: string) => q.trim())
.slice(0, 3);
} else {
log("[suggestions] Using OpenAI API");
log("[suggestions] Using OpenAI API with cheap model");
if (!state.openai) {
const apiKey = getApiKey("openai");
log("[suggestions] API key exists:", !!apiKey);
state.openai = new OpenAI({ apiKey });
}

const model = getModel("openai") as string;
log("[suggestions] Model:", model);
log("[suggestions] Model:", CHEAP_OPENAI_MODEL);

log("[suggestions] Making OpenAI request...");
const response = await state.openai.chat.completions.create({
model,
model: CHEAP_OPENAI_MODEL,
max_completion_tokens: 1024,
messages: [{ role: "user", content: prompt }],
});
Expand Down Expand Up @@ -318,14 +372,15 @@ ${recentMessages}
Return ONLY the 3 questions, one per line, no numbering or bullets.`;

try {
// Use cheap models for follow-up generation to save cost
if (state.provider === "anthropic") {
if (!state.anthropic) {
const apiKey = getApiKey("anthropic");
state.anthropic = new Anthropic({ apiKey });
}

const response = await state.anthropic.messages.create({
model: getModel("anthropic") as string,
model: CHEAP_ANTHROPIC_MODEL,
max_tokens: 256,
messages: [{ role: "user", content: prompt }],
});
Expand All @@ -344,7 +399,7 @@ Return ONLY the 3 questions, one per line, no numbering or bullets.`;
}

const response = await state.openai.chat.completions.create({
model: getModel("openai") as string,
model: CHEAP_OPENAI_MODEL,
max_completion_tokens: 256,
messages: [{ role: "user", content: prompt }],
});
Expand Down
Loading