diff --git a/src/app.ts b/src/app.ts index 3dd2c4b..464f53a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -87,6 +87,7 @@ import { streamAIResponse, generateSuggestions, generateFollowUpQuestions, + cancelChatStream, resetChatServiceClients, setProvider, type ChatServiceState, @@ -852,6 +853,7 @@ export class HackerNewsApp { this.chatPanelState = createChatPanel(this.ctx, this.selectedPost, { onOpenStoryUrl: () => this.openStoryUrl(), onSubmit: () => this.sendChatMessage(), + onClearChat: () => this.clearChat(), }); // Add chat panel children directly to detail panel @@ -953,6 +955,7 @@ export class HackerNewsApp { this.chatPanelState = createChatPanel(this.ctx, this.selectedPost, { onOpenStoryUrl: () => this.openStoryUrl(), onSubmit: () => this.sendChatMessage(), + onClearChat: () => this.clearChat(), }); // Add chat panel children directly to detail panel @@ -1164,6 +1167,51 @@ export class HackerNewsApp { } } + /** + * Clears the chat history for the current story and resets to initial state. + */ + private clearChat() { + if (!this.chatPanelState || !this.selectedPost || !this.chatServiceState) return; + + if (this.chatServiceState.isStreaming) { + cancelChatStream(this.chatServiceState); + stopTypingIndicator(this.chatPanelState); + this.stopAiIndicator(this.selectedIndex); + } + + telemetry.track("chat_clear"); + + // Clear the saved session for this story + this.savedChatSessions.delete(this.selectedPost.id); + + // Reset follow-up count + this.followUpCount = 0; + + // Clear messages and add fresh welcome message + this.chatPanelState.messages = []; + addChatMessage( + this.ctx, + this.chatPanelState, + "assistant", + `Ask me anything about "${this.selectedPost.title}" or the discussion about it on Hacker News...`, + this.chatServiceState.provider, + ); + + // Clear and regenerate suggestions + this.chatPanelState.suggestions.suggestions = []; + this.chatPanelState.suggestions.originalSuggestions = []; + this.chatPanelState.suggestions.selectedIndex = -1; + this.chatPanelState.suggestions.loading = true; + this.chatPanelState.suggestions.hidden = false; + renderSuggestions(this.ctx, this.chatPanelState.suggestions); + + // Generate fresh suggestions + this.generateInitialSuggestions(); + + // Save to cache (clears the session) + this.saveToCache(); + } + /** * Restores suggestions from a saved session or generates new ones. * Handles three cases: @@ -1484,6 +1532,7 @@ export class HackerNewsApp { this.chatPanelState = createChatPanel(this.ctx, this.selectedPost, { onOpenStoryUrl: () => this.openStoryUrl(), onSubmit: () => this.sendChatMessage(), + onClearChat: () => this.clearChat(), }); for (const child of this.chatPanelState.panel.getChildren()) { diff --git a/src/components/ChatInput.ts b/src/components/ChatInput.ts index 6d1e334..c6af17c 100644 --- a/src/components/ChatInput.ts +++ b/src/components/ChatInput.ts @@ -58,6 +58,11 @@ export function createChatInput( callbacks.onSubmit(); }); + // Click container to focus input + container.on("click", () => { + input.focus(); + }); + container.add(input); return { container, input }; diff --git a/src/components/ChatPanel.ts b/src/components/ChatPanel.ts index 5fb7b6d..ec41237 100644 --- a/src/components/ChatPanel.ts +++ b/src/components/ChatPanel.ts @@ -9,6 +9,12 @@ import type { HackerNewsPost } from "../types"; import { COLORS } from "../theme"; import { createShortcutsBar, CHAT_SHORTCUTS } from "./ShortcutsBar"; import { createSuggestionsContainer, type SuggestionsState, initSuggestionsState } from "./Suggestions"; +import { + createSlashCommandsContainer, + initSlashCommandsState, + type SlashCommandsState, + type SlashCommand, +} from "./SlashCommands"; import { createChatInput } from "./ChatInput"; import { createStoryHeader } from "./StoryHeader"; import type { Provider } from "../config"; @@ -22,6 +28,7 @@ export interface ChatMessage { export interface ChatPanelCallbacks { onOpenStoryUrl: () => void; onSubmit: () => void; + onClearChat: () => void; } export interface ChatPanelState { @@ -30,6 +37,7 @@ export interface ChatPanelState { content: BoxRenderable; input: TextareaRenderable; suggestions: SuggestionsState; + slashCommands: SlashCommandsState; messages: ChatMessage[]; isActive: boolean; // Typing indicator state @@ -70,6 +78,17 @@ export function createChatPanel( const suggestionsContainer = createSuggestionsContainer(ctx); const suggestionsState = initSuggestionsState(suggestionsContainer); + // Create slash commands container (shown in place of suggestions when "/" is typed) + const slashCommandsContainer = createSlashCommandsContainer(ctx); + const defaultSlashCommands: SlashCommand[] = [ + { + name: "clear", + description: "Clear chat history", + handler: () => callbacks.onClearChat(), + }, + ]; + const slashCommandsState = initSlashCommandsState(slashCommandsContainer, defaultSlashCommands); + // Create input area const chatInputState = createChatInput(ctx, { onSubmit: callbacks.onSubmit, @@ -94,6 +113,7 @@ export function createChatPanel( panel.add(chatHeader); panel.add(scroll); panel.add(suggestionsContainer); + panel.add(slashCommandsContainer); panel.add(chatInputState.container); panel.add(chatShortcutsBar); @@ -103,6 +123,7 @@ export function createChatPanel( content, input: chatInputState.input, suggestions: suggestionsState, + slashCommands: slashCommandsState, messages: [], isActive: true, isTyping: false, diff --git a/src/components/ShortcutsBar.ts b/src/components/ShortcutsBar.ts index 6d7653a..7e9df0c 100644 --- a/src/components/ShortcutsBar.ts +++ b/src/components/ShortcutsBar.ts @@ -101,7 +101,8 @@ export const STORY_LIST_SHORTCUTS: ShortcutItem[] = [ ]; export const CHAT_SHORTCUTS: ShortcutItem[] = [ - { key: "esc", desc: "close" }, + { key: "/", desc: "commands" }, + { key: "esc", desc: "close", rightAlign: true }, ]; export const SETTINGS_SHORTCUTS: ShortcutItem[] = [ diff --git a/src/components/SlashCommands.ts b/src/components/SlashCommands.ts new file mode 100644 index 0000000..8cec37b --- /dev/null +++ b/src/components/SlashCommands.ts @@ -0,0 +1,167 @@ +import { BoxRenderable, TextRenderable, type RenderContext } from "@opentui/core"; +import { COLORS } from "../theme"; + +export interface SlashCommand { + name: string; + description: string; + handler: () => void; +} + +export interface SlashCommandsState { + container: BoxRenderable; + commands: SlashCommand[]; + filteredCommands: SlashCommand[]; + selectedIndex: number; + isVisible: boolean; + query: string; // Current filter query (after "/") +} + +export function createSlashCommandsContainer(ctx: RenderContext): BoxRenderable { + return new BoxRenderable(ctx, { + id: "slash-commands-container", + width: "100%", + flexDirection: "column", + flexShrink: 0, + paddingLeft: 2, + paddingRight: 2, + paddingTop: 0, + paddingBottom: 0, + backgroundColor: COLORS.bg, + borderStyle: "single", + border: [], + borderColor: COLORS.border, + }); +} + +export function initSlashCommandsState( + container: BoxRenderable, + commands: SlashCommand[], +): SlashCommandsState { + return { + container, + commands, + filteredCommands: [...commands], + selectedIndex: commands.length - 1, // Start at bottom (nearest to input) + isVisible: false, + query: "", + }; +} + +export function filterCommands(state: SlashCommandsState, query: string): void { + state.query = query; + const lowerQuery = query.toLowerCase(); + + if (!lowerQuery) { + state.filteredCommands = [...state.commands]; + } else { + state.filteredCommands = state.commands.filter( + (cmd) => + cmd.name.toLowerCase().includes(lowerQuery) || + cmd.description.toLowerCase().includes(lowerQuery), + ); + } + + // Select the last item (nearest to input) or -1 if no matches + state.selectedIndex = + state.filteredCommands.length > 0 ? state.filteredCommands.length - 1 : -1; +} + +export function navigateSlashCommands(state: SlashCommandsState, delta: number): void { + if (state.filteredCommands.length === 0) return; + + const newIndex = state.selectedIndex + delta; + state.selectedIndex = Math.max( + 0, + Math.min(state.filteredCommands.length - 1, newIndex), + ); +} + +export function renderSlashCommands( + ctx: RenderContext, + state: SlashCommandsState, +): void { + if (!state.container) return; + + // Clear existing content + for (const child of state.container.getChildren()) { + state.container.remove(child.id); + } + + // Hide container styling when not visible + const container = state.container as any; + if (!state.isVisible || state.filteredCommands.length === 0) { + container.paddingTop = 0; + container.paddingBottom = 0; + container.border = []; + return; + } + + container.paddingTop = 0; + container.paddingBottom = 0; + container.border = ["top"]; + + // Render each command (in natural order - lowest index at top, highest at bottom) + for (let index = 0; index < state.filteredCommands.length; index++) { + const cmd = state.filteredCommands[index]; + if (!cmd) continue; + + const isSelected = index === state.selectedIndex; + + // Row container + const row = new BoxRenderable(ctx, { + id: `slash-command-row-${index}`, + width: "100%", + flexDirection: "row", + backgroundColor: COLORS.bg, + }); + + // Indicator: chevron when selected, space when not + const indicator = new TextRenderable(ctx, { + id: `slash-command-indicator-${index}`, + content: isSelected ? "› " : " ", + fg: isSelected ? COLORS.accent : COLORS.textSecondary, + width: 2, + flexShrink: 0, + }); + row.add(indicator); + + // Command name with "/" prefix + const nameText = new TextRenderable(ctx, { + id: `slash-command-name-${index}`, + content: `/${cmd.name}`, + fg: isSelected ? COLORS.accent : COLORS.textPrimary, + }); + row.add(nameText); + + // Description + const descText = new TextRenderable(ctx, { + id: `slash-command-desc-${index}`, + content: ` — ${cmd.description}`, + fg: COLORS.textSecondary, + }); + row.add(descText); + + state.container.add(row); + } +} + +export function getSelectedCommand(state: SlashCommandsState): SlashCommand | null { + if (state.selectedIndex < 0 || state.selectedIndex >= state.filteredCommands.length) { + return null; + } + return state.filteredCommands[state.selectedIndex] ?? null; +} + +export function showSlashCommands(state: SlashCommandsState): void { + state.isVisible = true; + state.query = ""; + state.filteredCommands = [...state.commands]; + state.selectedIndex = state.commands.length - 1; +} + +export function hideSlashCommands(state: SlashCommandsState): void { + state.isVisible = false; + state.query = ""; + state.filteredCommands = [...state.commands]; + state.selectedIndex = state.commands.length - 1; +} diff --git a/src/components/Suggestions.ts b/src/components/Suggestions.ts index ca654dc..fa9b79d 100644 --- a/src/components/Suggestions.ts +++ b/src/components/Suggestions.ts @@ -9,6 +9,7 @@ export interface SuggestionsState { selectedIndex: number; loading: boolean; loadingFrame: number; + hidden: boolean; // Hide panel while slash commands are open } export function createSuggestionsContainer(ctx: RenderContext): BoxRenderable { @@ -19,11 +20,11 @@ export function createSuggestionsContainer(ctx: RenderContext): BoxRenderable { flexShrink: 0, paddingLeft: 2, paddingRight: 2, - paddingTop: 1, - paddingBottom: 1, + paddingTop: 0, + paddingBottom: 0, backgroundColor: COLORS.bg, borderStyle: "single", - border: ["top"], + border: [], borderColor: COLORS.border, }); } @@ -39,14 +40,23 @@ export function renderSuggestions( state.container.remove(child.id); } + const container = state.container as any; + + // If hidden (e.g., slash commands open), hide container but keep data loading + if (state.hidden) { + container.paddingTop = 0; + container.paddingBottom = 0; + container.border = []; + return; + } + // Determine if we have content to show const hasContent = state.loading || state.suggestions.length > 0; // Hide container styling when empty (no border, no padding) - const container = state.container as any; if (hasContent) { - container.paddingTop = 1; - container.paddingBottom = 1; + container.paddingTop = 0; + container.paddingBottom = 0; container.border = ["top"]; } else { container.paddingTop = 0; @@ -161,5 +171,6 @@ export function initSuggestionsState(container: BoxRenderable): SuggestionsState selectedIndex: -1, loading: false, loadingFrame: 0, + hidden: false, }; } diff --git a/src/handlers/chat-keys.ts b/src/handlers/chat-keys.ts index 9c5a9c9..3c4fddb 100644 --- a/src/handlers/chat-keys.ts +++ b/src/handlers/chat-keys.ts @@ -5,6 +5,14 @@ import type { RenderContext } from "@opentui/core"; import type { KeyEvent } from "../types"; import type { ChatPanelState } from "../components/ChatPanel"; import { renderSuggestions } from "../components/Suggestions"; +import { + renderSlashCommands, + filterCommands, + navigateSlashCommands, + getSelectedCommand, + showSlashCommands, + hideSlashCommands, +} from "../components/SlashCommands"; export interface ChatKeyCallbacks { hideChatView: () => void; @@ -13,6 +21,50 @@ export interface ChatKeyCallbacks { selectSuggestion: () => void; } +/** + * Updates slash commands visibility and filtering based on input text. + * Returns true if slash commands are active (input starts with "/" and no space). + */ +function updateSlashCommandsState( + ctx: RenderContext, + chatPanelState: ChatPanelState, +): boolean { + const inputText = chatPanelState.input?.plainText ?? ""; + const slashState = chatPanelState.slashCommands; + const suggestionsState = chatPanelState.suggestions; + + // Extract query after "/" + const query = inputText.startsWith("/") ? inputText.slice(1) : ""; + + // Check if input starts with "/" AND query has no space (space breaks typeahead) + const isValidSlashMode = inputText.startsWith("/") && !query.includes(" "); + + if (isValidSlashMode) { + // Show slash commands if not already visible + if (!slashState.isVisible) { + showSlashCommands(slashState); + // Hide suggestions panel (but keep data loading in background) + suggestionsState.hidden = true; + renderSuggestions(ctx, suggestionsState); + } + + // Filter commands based on query + filterCommands(slashState, query); + renderSlashCommands(ctx, slashState); + return true; + } else { + // Hide slash commands if visible + if (slashState.isVisible) { + hideSlashCommands(slashState); + renderSlashCommands(ctx, slashState); + // Show suggestions panel again + suggestionsState.hidden = false; + renderSuggestions(ctx, suggestionsState); + } + return false; + } +} + export function handleChatKey( key: KeyEvent, ctx: RenderContext, @@ -20,6 +72,16 @@ export function handleChatKey( callbacks: ChatKeyCallbacks ): void { if (key.name === "escape") { + // If slash commands are visible, hide them and clear input + if (chatPanelState.slashCommands?.isVisible) { + hideSlashCommands(chatPanelState.slashCommands); + renderSlashCommands(ctx, chatPanelState.slashCommands); + chatPanelState.input?.clear(); + // Show suggestions panel again + chatPanelState.suggestions.hidden = false; + renderSuggestions(ctx, chatPanelState.suggestions); + return; + } callbacks.hideChatView(); return; } @@ -32,9 +94,35 @@ export function handleChatKey( } const suggestionsState = chatPanelState.suggestions; + const slashState = chatPanelState.slashCommands; + + // Check if we're in slash command mode (starts with "/" and no space in query) + const inputText = chatPanelState.input?.plainText ?? ""; + const slashQuery = inputText.startsWith("/") ? inputText.slice(1) : ""; + const isSlashMode = inputText.startsWith("/") && !slashQuery.includes(" "); + + // Slash command navigation with up/down keys + if (isSlashMode && slashState?.isVisible && slashState.filteredCommands.length > 0) { + if (key.name === "up" || key.name === "k") { + // Navigate up in slash commands + if (slashState.selectedIndex > 0) { + navigateSlashCommands(slashState, -1); + renderSlashCommands(ctx, slashState); + } + return; + } else if (key.name === "down" || key.name === "j") { + // Navigate down in slash commands + if (slashState.selectedIndex < slashState.filteredCommands.length - 1) { + navigateSlashCommands(slashState, 1); + renderSlashCommands(ctx, slashState); + } + return; + } + } - // Suggestion navigation when input is empty + // Suggestion navigation when input is empty (and not in slash mode) if ( + !isSlashMode && suggestionsState.suggestions.length > 0 && chatPanelState.input && !chatPanelState.input.plainText.trim() @@ -85,8 +173,26 @@ export function handleChatKey( } } - // Handle Enter key for chat submission + // Handle Enter key for chat submission or slash command execution if ((key.name === "return" || key.name === "enter") && !key.shift) { + // Execute slash command if in slash mode + if (isSlashMode && slashState?.isVisible) { + const selectedCommand = getSelectedCommand(slashState); + if (selectedCommand) { + // Clear input and hide slash commands first + chatPanelState.input?.clear(); + hideSlashCommands(slashState); + renderSlashCommands(ctx, slashState); + // Show suggestions panel again before executing command + chatPanelState.suggestions.hidden = false; + renderSuggestions(ctx, chatPanelState.suggestions); + // Execute the command + selectedCommand.handler(); + return; + } + return; + } + if (chatPanelState.input && chatPanelState.input.plainText.trim()) { callbacks.sendChatMessage(); return; @@ -98,9 +204,9 @@ export function handleChatKey( return; } - // Clear suggestions and focus input when user starts typing + // Focus input when user starts typing a printable character + // This handles both: 1) typing while browsing suggestions, 2) typing when no suggestions exist if ( - suggestionsState.suggestions.length > 0 && key.sequence && key.sequence.length === 1 && !key.ctrl && @@ -108,33 +214,56 @@ export function handleChatKey( ) { const charCode = key.sequence.charCodeAt(0); if (charCode >= 32 && charCode <= 126) { - // Focus input if it was blurred while browsing suggestions - if (chatPanelState.input) { + // Focus input if it's not already focused + if (chatPanelState.input && !chatPanelState.input.focused) { chatPanelState.input.focus(); } - suggestionsState.suggestions = []; - suggestionsState.selectedIndex = -1; - renderSuggestions(ctx, suggestionsState); + + // Check if typing "/" as first character (will show slash commands) + // We need to handle this after the character is inserted, so use setTimeout + if (key.sequence === "/" && !inputText) { + setTimeout(() => { + updateSlashCommandsState(ctx, chatPanelState); + }, 0); + return; + } + + // Update slash commands state for any typing while in slash mode + if (isSlashMode) { + setTimeout(() => { + updateSlashCommandsState(ctx, chatPanelState); + }, 0); + return; + } + + // Clear suggestions if they exist (only when not entering slash mode) + if (suggestionsState.suggestions.length > 0) { + suggestionsState.suggestions = []; + suggestionsState.selectedIndex = -1; + renderSuggestions(ctx, suggestionsState); + } } } - // Restore suggestions when backspace clears the input - if ( - key.name === "backspace" && - chatPanelState.input && - suggestionsState.originalSuggestions.length > 0 - ) { - // Capture original suggestions to avoid closure over potentially stale state - const originalSuggestions = [...suggestionsState.originalSuggestions]; + // Handle backspace - may need to update slash commands or restore suggestions + if (key.name === "backspace" && chatPanelState.input) { setTimeout(() => { // Re-check state validity since chat panel may have been closed + if (!chatPanelState?.input || !chatPanelState?.suggestions) return; + + const newInputText = chatPanelState.input.plainText; + + // Update slash commands state (may show or hide based on "/" prefix) + const stillInSlashMode = updateSlashCommandsState(ctx, chatPanelState); + + // Restore suggestions when backspace clears the input (and not in slash mode) if ( - chatPanelState?.input && - chatPanelState?.suggestions && - !chatPanelState.input.plainText.trim() && + !stillInSlashMode && + !newInputText.trim() && + chatPanelState.suggestions.originalSuggestions.length > 0 && chatPanelState.suggestions.suggestions.length === 0 ) { - chatPanelState.suggestions.suggestions = [...originalSuggestions]; + chatPanelState.suggestions.suggestions = [...chatPanelState.suggestions.originalSuggestions]; chatPanelState.suggestions.selectedIndex = chatPanelState.suggestions.suggestions.length - 1; renderSuggestions(ctx, chatPanelState.suggestions); } diff --git a/src/services/ChatService.ts b/src/services/ChatService.ts index d4c56ef..ee4d72d 100644 --- a/src/services/ChatService.ts +++ b/src/services/ChatService.ts @@ -12,6 +12,8 @@ export interface ChatServiceState { provider: Provider; storyContext: string; isStreaming: boolean; + activeStream: { abort: () => void } | null; + abortRequested: boolean; } export function initChatServiceState(provider: Provider): ChatServiceState { @@ -21,9 +23,24 @@ export function initChatServiceState(provider: Provider): ChatServiceState { provider, storyContext: "", isStreaming: false, + activeStream: null, + abortRequested: false, }; } +export function cancelChatStream(state: ChatServiceState): void { + state.abortRequested = true; + if (state.activeStream) { + try { + state.activeStream.abort(); + } catch (error) { + log("[chat-stream] Abort failed:", error instanceof Error ? error.message : String(error)); + } + state.activeStream = null; + } + state.isStreaming = false; +} + export function buildStoryContext(post: HackerNewsPost): string { const storyUrl = post.url || `https://news.ycombinator.com/item?id=${post.id}`; @@ -103,6 +120,8 @@ export async function streamAIResponse( post: HackerNewsPost, callbacks: StreamCallbacks, ): Promise { + state.abortRequested = false; + state.activeStream = null; state.isStreaming = true; // Build system context if not already built @@ -125,17 +144,39 @@ IMPORTANT CONTEXT DISTINCTION: 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.`; + const guardedCallbacks: StreamCallbacks = { + onText: (text) => { + if (!state.abortRequested) { + callbacks.onText(text); + } + }, + onComplete: () => { + if (!state.abortRequested) { + callbacks.onComplete(); + } + }, + onError: (error) => { + if (!state.abortRequested) { + callbacks.onError(error); + } + }, + }; + try { if (state.provider === "anthropic") { - await streamAnthropicResponse(state, messages, userMessage, systemPrompt, storyUrl, callbacks); + await streamAnthropicResponse(state, messages, userMessage, systemPrompt, storyUrl, guardedCallbacks); } else { - await streamOpenAIResponse(state, messages, userMessage, systemPrompt, storyUrl, callbacks); + await streamOpenAIResponse(state, messages, userMessage, systemPrompt, storyUrl, guardedCallbacks); } } catch (error) { - callbacks.onError(error instanceof Error ? error : new Error(String(error))); + if (!state.abortRequested) { + callbacks.onError(error instanceof Error ? error : new Error(String(error))); + } + } finally { + state.isStreaming = false; + state.activeStream = null; + state.abortRequested = false; } - - state.isStreaming = false; } async function streamAnthropicResponse( @@ -177,6 +218,11 @@ async function streamAnthropicResponse( })) .concat([{ role: "user", content: userMessage }]), }); + state.activeStream = { abort: () => stream.abort() }; + if (state.abortRequested) { + state.activeStream.abort(); + return; + } let fullResponse = ""; @@ -226,6 +272,11 @@ async function streamOpenAIResponse( { role: "user", content: userMessage }, ], }); + state.activeStream = { abort: () => stream.abort() }; + if (state.abortRequested) { + state.activeStream.abort(); + return; + } // Listen for text delta events (snapshot contains accumulated text) stream.on("response.output_text.delta", (event) => { @@ -238,6 +289,9 @@ async function streamOpenAIResponse( callbacks.onComplete(); return; } catch (error) { + if (state.abortRequested) { + throw 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) @@ -258,6 +312,11 @@ async function streamOpenAIResponse( { role: "user", content: userMessage }, ], }); + state.activeStream = { abort: () => stream.controller.abort() }; + if (state.abortRequested) { + state.activeStream.abort(); + return; + } log("[openai-stream] Stream created, reading chunks..."); let fullResponse = ""; diff --git a/src/telemetry.ts b/src/telemetry.ts index 8103ca4..b41325b 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -44,6 +44,7 @@ export type TelemetryEvent = | "tldr_completed" | "chat_opened" | "chat_message" + | "chat_clear" | "url_opened" | "refresh" | "settings_opened"; diff --git a/src/test/chat-service.test.ts b/src/test/chat-service.test.ts index 4a25e9d..8d48fed 100644 --- a/src/test/chat-service.test.ts +++ b/src/test/chat-service.test.ts @@ -1,49 +1,111 @@ -import { describe, it, expect } from "bun:test"; -import { buildStoryContext } from "../services/ChatService"; -import { createMockPost, createMockPostWithComments } from "./fixtures"; - -describe("ChatService", () => { - describe("buildStoryContext", () => { - it("should build context with story metadata", () => { - const post = createMockPost({ - title: "Test Story Title", - url: "https://example.com/story", - domain: "example.com", - points: 150, - user: "testuser", - comments_count: 42, - }); - - const context = buildStoryContext(post); - - expect(context).toContain("**Title:** Test Story Title"); - expect(context).toContain("**URL:** https://example.com/story"); - expect(context).toContain("**Domain:** example.com"); - expect(context).toContain("**Points:** 150"); - expect(context).toContain("**Posted by:** testuser"); - expect(context).toContain("**Comments:** 42"); - }); +import { describe, it, expect, mock } from "bun:test"; + +class FakeResponseStream { + private handlers = new Map void>>(); + private resolveFinal: (() => void) | null = null; + aborted = false; + + on(event: string, handler: (event: any) => void) { + const existing = this.handlers.get(event) ?? []; + existing.push(handler); + this.handlers.set(event, existing); + return this; + } - it("should use HN URL when story has no URL", () => { - const post = createMockPost({ - id: 12345, - url: "", - domain: null, - }); + emit(event: string, payload: any) { + const handlers = this.handlers.get(event) ?? []; + for (const handler of handlers) { + handler(payload); + } + } - const context = buildStoryContext(post); + abort() { + this.aborted = true; + if (this.resolveFinal) { + this.resolveFinal(); + this.resolveFinal = null; + } + } - expect(context).toContain("**URL:** https://news.ycombinator.com/item?id=12345"); + finalResponse() { + return new Promise((resolve) => { + if (this.aborted) { + resolve({}); + return; + } + this.resolveFinal = () => resolve({}); }); + } +} - it("should include comments in context", () => { - const post = createMockPostWithComments({}, 2); +let lastStream: FakeResponseStream | null = null; - const context = buildStoryContext(post); +mock.module("openai", () => ({ + default: class OpenAI { + responses = { + stream: () => { + lastStream = new FakeResponseStream(); + return lastStream; + }, + }; - expect(context).toContain("# Hacker News Discussion"); - expect(context).toContain("**user1:**"); - expect(context).toContain("Root comment 1"); + chat = { + completions: { + create: async () => { + throw new Error("Unexpected chat.completions.create call"); + }, + }, + }; + }, +})); + +import { + initChatServiceState, + streamAIResponse, + cancelChatStream, +} from "../services/ChatService"; +import type { HackerNewsPost } from "../types"; + +const createPost = (): HackerNewsPost => ({ + id: 1, + title: "Test story", + points: 0, + user: "tester", + time: Date.now(), + time_ago: "just now", + type: "story", + content: null, + url: "https://example.com", + domain: "example.com", + comments: [], + comments_count: 0, +}); + +describe("ChatService cancellation", () => { + it("cancels an active stream and suppresses callbacks", async () => { + const state = initChatServiceState("openai"); + const post = createPost(); + const onText = mock(() => {}); + const onComplete = mock(() => {}); + const onError = mock(() => {}); + + const streamPromise = streamAIResponse(state, [], "hello", post, { + onText, + onComplete, + onError, }); + + expect(lastStream).not.toBeNull(); + cancelChatStream(state); + + lastStream?.emit("response.output_text.delta", { snapshot: "hi" }); + await streamPromise; + + expect(lastStream?.aborted).toBe(true); + expect(state.isStreaming).toBe(false); + expect(state.activeStream).toBeNull(); + expect(onText).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); }); }); diff --git a/src/test/handlers.test.ts b/src/test/handlers.test.ts index 2dcfe62..4c89ede 100644 --- a/src/test/handlers.test.ts +++ b/src/test/handlers.test.ts @@ -1,14 +1,40 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; -import { handleMainKey, handleAuthSetupKey, handleSettingsKey } from "../handlers"; +import { handleMainKey, handleAuthSetupKey, handleSettingsKey, handleChatKey } from "../handlers"; import type { KeyEvent } from "../types"; import type { AuthSetupState } from "../components/AuthSetup"; import type { SettingsState } from "../components/SettingsPanel"; +import type { ChatPanelState } from "../components/ChatPanel"; +import type { SuggestionsState } from "../components/Suggestions"; +import type { SlashCommand, SlashCommandsState } from "../components/SlashCommands"; + +const renderSuggestionsMock = mock(() => {}); +const renderSlashCommandsMock = mock(() => {}); +const filterCommandsMock = mock(() => {}); +const navigateSlashCommandsMock = mock(() => {}); +const getSelectedCommandMock = mock<() => SlashCommand | null>(() => null); +const showSlashCommandsMock = mock(() => {}); +const hideSlashCommandsMock = mock(() => {}); // Mock telemetry module mock.module("../telemetry", () => ({ track: mock(() => {}), })); +// Mock the Suggestions component to avoid render context issues +mock.module("../components/Suggestions", () => ({ + renderSuggestions: renderSuggestionsMock, +})); + +// Mock the SlashCommands component +mock.module("../components/SlashCommands", () => ({ + renderSlashCommands: renderSlashCommandsMock, + filterCommands: filterCommandsMock, + navigateSlashCommands: navigateSlashCommandsMock, + getSelectedCommand: getSelectedCommandMock, + showSlashCommands: showSlashCommandsMock, + hideSlashCommands: hideSlashCommandsMock, +})); + describe("Main Key Handler", () => { let callbacks: { navigateStory: ReturnType; @@ -217,3 +243,187 @@ describe("KeyEvent type consistency", () => { expect(fullKey.meta).toBe(true); }); }); + +describe("Chat Key Handler", () => { + let callbacks: { + hideChatView: ReturnType; + navigateStory: ReturnType; + sendChatMessage: ReturnType; + selectSuggestion: ReturnType; + }; + + // Create a mock input that tracks focus state + const createMockInput = (options?: { focused?: boolean; plainText?: string }) => { + let focusedState = options?.focused ?? false; + return { + get focused() { return focusedState; }, + focus: mock(() => { focusedState = true; }), + blur: mock(() => { focusedState = false; }), + clear: mock(() => {}), + insertText: mock(() => {}), + get plainText() { return options?.plainText ?? ""; }, + }; + }; + + const createSuggestionsState = (suggestions: string[] = []): SuggestionsState => ({ + container: {} as any, + suggestions, + originalSuggestions: [...suggestions], + selectedIndex: suggestions.length > 0 ? suggestions.length - 1 : -1, + loading: false, + loadingFrame: 0, + hidden: false, + }); + + const createSlashCommandsState = (): SlashCommandsState => ({ + container: {} as any, + commands: [{ name: "clear", description: "Clear chat history", handler: mock(() => {}) }], + filteredCommands: [{ name: "clear", description: "Clear chat history", handler: mock(() => {}) }], + selectedIndex: 0, + isVisible: false, + query: "", + }); + + const createChatState = (options?: { + suggestions?: string[]; + inputFocused?: boolean; + inputText?: string; + }): ChatPanelState => ({ + panel: {} as any, + scroll: {} as any, + content: {} as any, + input: createMockInput({ focused: options?.inputFocused, plainText: options?.inputText }) as any, + suggestions: createSuggestionsState(options?.suggestions), + slashCommands: createSlashCommandsState(), + messages: [], + isActive: true, + isTyping: false, + typingFrame: 0, + typingInterval: null, + }); + + beforeEach(() => { + callbacks = { + hideChatView: mock(() => {}), + navigateStory: mock(() => {}), + sendChatMessage: mock(() => {}), + selectSuggestion: mock(() => {}), + }; + getSelectedCommandMock.mockReturnValue(null); + renderSuggestionsMock.mockClear(); + }); + + it("should call hideChatView on escape key", () => { + const state = createChatState(); + handleChatKey({ name: "escape" }, {} as any, state, callbacks); + expect(callbacks.hideChatView).toHaveBeenCalled(); + }); + + it("should navigate stories with Cmd+j/k", () => { + const state = createChatState(); + handleChatKey({ name: "j", super: true }, {} as any, state, callbacks); + expect(callbacks.navigateStory).toHaveBeenCalledWith(1); + + handleChatKey({ name: "k", super: true }, {} as any, state, callbacks); + expect(callbacks.navigateStory).toHaveBeenCalledWith(-1); + }); + + it("should focus input when typing printable character with suggestions present", () => { + const state = createChatState({ suggestions: ["question 1", "question 2"], inputFocused: false }); + handleChatKey({ name: "a", sequence: "a" }, {} as any, state, callbacks); + + expect(state.input.focus).toHaveBeenCalled(); + expect(state.suggestions.suggestions).toEqual([]); + expect(state.suggestions.selectedIndex).toBe(-1); + }); + + it("should focus input when typing printable character with NO suggestions (bug fix)", () => { + // This tests the bug fix: user should be able to type even when suggestions are empty + // (e.g., after MAX_FOLLOW_UP_ROUNDS is reached) + const state = createChatState({ suggestions: [], inputFocused: false }); + handleChatKey({ name: "h", sequence: "h" }, {} as any, state, callbacks); + + expect(state.input.focus).toHaveBeenCalled(); + }); + + it("should not re-focus input if already focused", () => { + const state = createChatState({ suggestions: [], inputFocused: true }); + handleChatKey({ name: "h", sequence: "h" }, {} as any, state, callbacks); + + // Focus should not be called since it's already focused + expect(state.input.focus).not.toHaveBeenCalled(); + }); + + it("should navigate suggestions with up/down keys when input is empty", () => { + const state = createChatState({ suggestions: ["q1", "q2", "q3"], inputFocused: false, inputText: "" }); + state.suggestions.selectedIndex = 2; // Start at last suggestion + + handleChatKey({ name: "up" }, {} as any, state, callbacks); + expect(state.suggestions.selectedIndex).toBe(1); + + handleChatKey({ name: "down" }, {} as any, state, callbacks); + expect(state.suggestions.selectedIndex).toBe(2); + }); + + it("should focus input when navigating down past last suggestion", () => { + const state = createChatState({ suggestions: ["q1", "q2"], inputFocused: false, inputText: "" }); + state.suggestions.selectedIndex = 1; // At last suggestion + + handleChatKey({ name: "down" }, {} as any, state, callbacks); + + expect(state.suggestions.selectedIndex).toBe(-1); + expect(state.input.focus).toHaveBeenCalled(); + }); + + it("should call selectSuggestion on enter when suggestion is selected", () => { + const state = createChatState({ suggestions: ["q1"], inputFocused: false, inputText: "" }); + state.suggestions.selectedIndex = 0; + + handleChatKey({ name: "return" }, {} as any, state, callbacks); + expect(callbacks.selectSuggestion).toHaveBeenCalled(); + }); + + it("should call sendChatMessage on enter when input has text", () => { + const state = createChatState({ suggestions: [], inputFocused: true, inputText: "hello" }); + + handleChatKey({ name: "return" }, {} as any, state, callbacks); + expect(callbacks.sendChatMessage).toHaveBeenCalled(); + }); + + it("should not call sendChatMessage on shift+enter (for newline)", () => { + const state = createChatState({ suggestions: [], inputFocused: true, inputText: "hello" }); + + handleChatKey({ name: "return", shift: true }, {} as any, state, callbacks); + expect(callbacks.sendChatMessage).not.toHaveBeenCalled(); + }); + + it("should insert suggestion on tab and focus input", () => { + const state = createChatState({ suggestions: ["What is the summary?"], inputFocused: false }); + state.suggestions.selectedIndex = 0; + + handleChatKey({ name: "tab" }, {} as any, state, callbacks); + + expect(state.input.focus).toHaveBeenCalled(); + expect(state.input.clear).toHaveBeenCalled(); + expect(state.input.insertText).toHaveBeenCalledWith("What is the summary? "); + expect(state.suggestions.suggestions).toEqual([]); + }); + + it("should unhide suggestions when executing a slash command", () => { + const state = createChatState({ suggestions: ["q1"], inputFocused: true, inputText: "/clear" }); + state.suggestions.hidden = true; + state.slashCommands.isVisible = true; + const handler = mock(() => {}); + getSelectedCommandMock.mockReturnValue({ + name: "clear", + description: "Clear chat history", + handler, + }); + + handleChatKey({ name: "return" }, {} as any, state, callbacks); + + expect(state.suggestions.hidden).toBe(false); + expect(renderSuggestionsMock).toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); + }); +});