feat: add discord rich presence (#105)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
CI / Lint & Test (push) Successful in 16m5s
CI / Build Linux (push) Successful in 19m33s
CI / Build Windows (cross-compile) (push) Successful in 29m9s

### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #105
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #105.
This commit is contained in:
2026-02-05 16:09:40 -08:00
committed by Naomi Carrigan
parent e4288248b1
commit a72f2afaff
19 changed files with 529 additions and 15 deletions
+7 -3
View File
@@ -8,9 +8,13 @@ import {
} from "./slashCommands";
// Mock all external dependencies
vi.mock("svelte/store", () => ({
get: vi.fn(),
}));
vi.mock("svelte/store", async (importOriginal) => {
const actual = await importOriginal<typeof import("svelte/store")>();
return {
...actual,
get: vi.fn(),
};
});
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
+25 -1
View File
@@ -2,8 +2,10 @@ import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { setSkipNextGreeting } from "$lib/tauri";
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
import { searchState } from "$lib/stores/search";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
export interface SlashCommand {
name: string;
@@ -51,6 +53,17 @@ async function changeDirectory(path: string): Promise<void> {
},
});
// Update Discord RPC when reconnecting after directory change
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -105,6 +118,17 @@ async function startNewConversation(): Promise<void> {
},
});
// Update Discord RPC when starting new conversation
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
claudeStore.addLine("system", "New conversation started!");
characterState.setState("idle");
} catch (error) {
+25
View File
@@ -51,6 +51,7 @@
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
});
let showCustomThemeEditor = $state(false);
@@ -967,6 +968,30 @@
</div>
</section>
<!-- Discord Rich Presence Section -->
<section class="pt-6 pb-6 border-t border-[var(--border-color)]">
<h3 class="text-lg font-semibold text-[var(--accent-primary)] mb-4 flex items-center gap-2">
<span>🎮</span>
<span>Discord Rich Presence</span>
</h3>
<!-- Enable/Disable Discord RPC -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.discord_rpc_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Show activity in Discord</span>
</label>
</div>
<div class="text-xs text-[var(--text-tertiary)]">
Display your current conversation session name and model in Discord when enabled.
</div>
</section>
<!-- Save Button -->
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
<button
+13 -1
View File
@@ -6,7 +6,7 @@
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
import { clipboardStore } from "$lib/stores/clipboard";
import {
setShouldRestoreHistory,
@@ -26,6 +26,7 @@
type SlashCommand,
} from "$lib/commands/slashCommands";
import { configStore, isStreamerMode } from "$lib/stores/config";
import { conversationsStore } from "$lib/stores/conversations";
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
@@ -350,6 +351,17 @@ User: ${formattedMessage}`;
working_dir: workingDir,
},
});
// Update Discord RPC when reconnecting
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
} catch (reconnectError) {
console.error("Failed to auto-reconnect:", reconnectError);
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
+14
View File
@@ -4,6 +4,9 @@
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages";
import { updateDiscordRpc } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let isVisible = $state(false);
let permission: PermissionRequest | null = $state(null);
@@ -64,6 +67,17 @@
},
});
// Update Discord RPC when reconnecting after permission grant
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
+12
View File
@@ -30,6 +30,7 @@
createSummary,
sanitizeForJson,
} from "$lib/utils/conversationUtils";
import { updateDiscordRpc } from "$lib/tauri";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -86,6 +87,7 @@
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
});
let streamerModeActive = $state(false);
@@ -165,6 +167,16 @@
allowed_tools: allAllowedTools,
},
});
// Update Discord RPC when a new session starts
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
currentConfig.model || "claude",
activeConversation.startedAt
);
}
} catch (error) {
console.error("Failed to start Claude:", error);
claudeStore.addLine("error", `Connection failed: ${error}`);
@@ -5,6 +5,9 @@
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { UserQuestionEvent } from "$lib/types/messages";
import { updateDiscordRpc } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let isVisible = $state(false);
let question: UserQuestionEvent | null = $state(null);
@@ -98,6 +101,17 @@
},
});
// Update Discord RPC when reconnecting after answering question
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
if (conversationHistory) {
+2
View File
@@ -192,6 +192,7 @@ describe("config store", () => {
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -237,6 +238,7 @@ describe("config store", () => {
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
};
expect(config.model).toBeNull();
+3
View File
@@ -44,6 +44,8 @@ export interface HikariConfig {
session_cost_budget: number | null;
budget_action: BudgetAction;
budget_warning_threshold: number;
// Discord RPC settings
discord_rpc_enabled: boolean;
}
const defaultConfig: HikariConfig = {
@@ -83,6 +85,7 @@ const defaultConfig: HikariConfig = {
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
};
function createConfigStore() {
+2
View File
@@ -35,6 +35,7 @@ export interface Conversation {
lastActivityAt: Date;
attachments: Attachment[];
summary: ConversationSummary | null;
startedAt: Date;
}
function createConversationsStore() {
@@ -72,6 +73,7 @@ function createConversationsStore() {
lastActivityAt: new Date(),
attachments: [],
summary: null,
startedAt: new Date(),
};
}
+6 -2
View File
@@ -2,6 +2,7 @@ import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { costTrackingStore } from "./costTracking";
import { configStore } from "./config";
export type ContextWarning = "moderate" | "high" | "critical";
export type BudgetType = "token" | "cost";
@@ -133,7 +134,7 @@ export function formatTokenCount(tokens: number): string {
}
// Derived store for formatted display values
export const formattedStats = derived(stats, ($stats) => {
export const formattedStats = derived([stats, configStore.config], ([$stats, $config]) => {
const formatNumber = (num: number) => num.toLocaleString();
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const formatDuration = (seconds: number) => {
@@ -164,6 +165,9 @@ export const formattedStats = derived(stats, ($stats) => {
}));
};
// Use the model from stats if available, otherwise fall back to the configured model
const currentModel = $stats.model ?? $config.model ?? "No model selected";
return {
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
totalInputTokens: formatNumber($stats.total_input_tokens),
@@ -173,7 +177,7 @@ export const formattedStats = derived(stats, ($stats) => {
sessionInputTokens: formatNumber($stats.session_input_tokens),
sessionOutputTokens: formatNumber($stats.session_output_tokens),
sessionCost: formatCost($stats.session_cost_usd),
model: $stats.model || "No model selected",
model: currentModel,
// New formatted fields
messagesTotal: formatNumber($stats.messages_exchanged),
+70
View File
@@ -373,3 +373,73 @@ export function cleanupTauriListeners() {
// Cleanup notification rules
cleanupNotificationRules();
}
export async function initializeDiscordRpc() {
const config = configStore.getConfig();
if (config.discord_rpc_enabled) {
try {
const startedAt = new Date();
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
const model = config.model || "claude";
await invoke("log_discord_rpc", {
message: `[FRONTEND] Attempting to initialize Discord RPC: session='Idle', model='${model}', timestamp=${startedAtUnixSeconds}`,
});
console.log("Initializing Discord RPC with initial activity:", {
session_name: "Idle",
model,
started_at: startedAtUnixSeconds,
});
await invoke("init_discord_rpc", {
sessionName: "Idle",
model,
startedAt: startedAtUnixSeconds,
});
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC initialized successfully!",
});
console.log("Discord RPC initialized successfully with initial presence");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await invoke("log_discord_rpc", {
message: `[FRONTEND] ERROR: Failed to initialize Discord RPC: ${errorMessage}`,
});
console.error("Failed to initialize Discord RPC:", error);
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
}
} else {
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
});
}
}
export async function updateDiscordRpc(sessionName: string, model: string, startedAt: Date) {
const config = configStore.getConfig();
if (!config.discord_rpc_enabled) {
return;
}
try {
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
await invoke("update_discord_rpc", {
sessionName: sessionName,
model,
startedAt: startedAtUnixSeconds,
});
} catch (error) {
console.error("Failed to update Discord RPC:", error);
}
}
export async function stopDiscordRpc() {
try {
await invoke("stop_discord_rpc");
} catch (error) {
console.error("Failed to stop Discord RPC:", error);
}
}
+29 -1
View File
@@ -2,7 +2,13 @@
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import {
initializeTauriListeners,
cleanupTauriListeners,
initializeDiscordRpc,
stopDiscordRpc,
updateDiscordRpc,
} from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
@@ -57,6 +63,24 @@
}
});
// Get reactive references to conversation stores
const activeConversationId = conversationsStore.activeConversationId;
const conversations = conversationsStore.conversations;
// Update Discord RPC when active conversation or model changes
$effect(() => {
// Access stores directly (without get()) to create reactive dependencies
const activeId = $activeConversationId;
const convs = $conversations;
const activeConv = activeId ? convs.get(activeId) : null;
const config = configStore.getConfig();
const model = config.model || "claude";
if (activeConv && config.discord_rpc_enabled) {
updateDiscordRpc(activeConv.name, model, activeConv.startedAt);
}
});
// Window size constants
const COMPACT_WIDTH = 280;
const COMPACT_HEIGHT = 400;
@@ -356,6 +380,9 @@
const window = getCurrentWindow();
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
}
// Initialize Discord RPC
await initializeDiscordRpc();
}
});
@@ -363,6 +390,7 @@
if (initialized) {
cleanupTauriListeners();
cleanupNotificationSync();
stopDiscordRpc();
window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false;
}