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
49 changes: 49 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
streamAIResponse,
generateSuggestions,
generateFollowUpQuestions,
cancelChatStream,
resetChatServiceClients,
setProvider,
type ChatServiceState,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()) {
Expand Down
5 changes: 5 additions & 0 deletions src/components/ChatInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
21 changes: 21 additions & 0 deletions src/components/ChatPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +28,7 @@ export interface ChatMessage {
export interface ChatPanelCallbacks {
onOpenStoryUrl: () => void;
onSubmit: () => void;
onClearChat: () => void;
}

export interface ChatPanelState {
Expand All @@ -30,6 +37,7 @@ export interface ChatPanelState {
content: BoxRenderable;
input: TextareaRenderable;
suggestions: SuggestionsState;
slashCommands: SlashCommandsState;
messages: ChatMessage[];
isActive: boolean;
// Typing indicator state
Expand Down Expand Up @@ -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,
Expand All @@ -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);

Expand All @@ -103,6 +123,7 @@ export function createChatPanel(
content,
input: chatInputState.input,
suggestions: suggestionsState,
slashCommands: slashCommandsState,
messages: [],
isActive: true,
isTyping: false,
Expand Down
3 changes: 2 additions & 1 deletion src/components/ShortcutsBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
167 changes: 167 additions & 0 deletions src/components/SlashCommands.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 17 additions & 6 deletions src/components/Suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
});
}
Expand All @@ -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;
Expand Down Expand Up @@ -161,5 +171,6 @@ export function initSuggestionsState(container: BoxRenderable): SuggestionsState
selectedIndex: -1,
loading: false,
loadingFrame: 0,
hidden: false,
};
}
Loading