generated from nhcarrigan/template
feat: naomi did too much at once #53
@@ -55,6 +55,9 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default = "default_notification_volume")]
|
||||
pub notification_volume: f32,
|
||||
|
||||
#[serde(default)]
|
||||
pub always_on_top: bool,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -70,6 +73,7 @@ impl Default for HikariConfig {
|
||||
greeting_custom_prompt: None,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +113,7 @@ mod tests {
|
||||
assert_eq!(config.theme, Theme::Dark);
|
||||
assert!(config.greeting_enabled);
|
||||
assert!(config.greeting_custom_prompt.is_none());
|
||||
assert!(!config.always_on_top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,6 +129,7 @@ mod tests {
|
||||
greeting_custom_prompt: Some("Hello!".to_string()),
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
usage: string;
|
||||
execute: (args: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
async function startNewConversation(): Promise<void> {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const workingDir = await invoke<string>("get_working_directory", {
|
||||
conversationId,
|
||||
});
|
||||
|
||||
claudeStore.addLine("system", "Starting new conversation...");
|
||||
characterState.setState("thinking");
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
|
||||
claudeStore.clearTerminal();
|
||||
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
},
|
||||
});
|
||||
|
||||
claudeStore.addLine("system", "New conversation started!");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to start new conversation: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
}
|
||||
|
||||
export const slashCommands: SlashCommand[] = [
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear the terminal display (keeps conversation context)",
|
||||
usage: "/clear",
|
||||
execute: () => {
|
||||
claudeStore.clearTerminal();
|
||||
claudeStore.addLine("system", "Terminal cleared");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new",
|
||||
description: "Start a fresh conversation (resets context)",
|
||||
usage: "/new",
|
||||
execute: startNewConversation,
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
description: "Show available slash commands",
|
||||
usage: "/help",
|
||||
execute: () => {
|
||||
const helpText = slashCommands
|
||||
.map((cmd) => ` ${cmd.usage.padEnd(12)} - ${cmd.description}`)
|
||||
.join("\n");
|
||||
claudeStore.addLine("system", `Available commands:\n${helpText}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "summarise",
|
||||
description: "Get a summary of the entire conversation",
|
||||
usage: "/summarise",
|
||||
execute: async () => {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
claudeStore.addLine("system", "Requesting conversation summary...");
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message:
|
||||
"Please provide a comprehensive summary of our entire conversation so far, including the key topics we've discussed, decisions made, and any important context.",
|
||||
});
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to request summary: ${error}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function parseSlashCommand(input: string): {
|
||||
command: SlashCommand | null;
|
||||
args: string;
|
||||
} {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return { command: null, args: "" };
|
||||
}
|
||||
|
||||
const parts = trimmed.slice(1).split(/\s+/);
|
||||
const commandName = parts[0]?.toLowerCase();
|
||||
const args = parts.slice(1).join(" ");
|
||||
|
||||
const command = slashCommands.find((cmd) => cmd.name.toLowerCase() === commandName);
|
||||
|
||||
return { command: command || null, args };
|
||||
}
|
||||
|
||||
export function getMatchingCommands(input: string): SlashCommand[] {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const partial = trimmed.slice(1).toLowerCase();
|
||||
|
||||
if (partial === "") {
|
||||
return slashCommands;
|
||||
}
|
||||
|
||||
return slashCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(partial));
|
||||
}
|
||||
|
||||
export function isSlashCommand(input: string): boolean {
|
||||
return input.trim().startsWith("/");
|
||||
}
|
||||
@@ -40,10 +40,12 @@
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="about-title" class="text-xl font-semibold text-gray-100">About Hikari Desktop</h2>
|
||||
<h2 id="about-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
About Hikari Desktop
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -59,16 +61,16 @@
|
||||
|
||||
<div class="space-y-4 text-sm">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">What is Hikari Desktop?</h3>
|
||||
<p class="text-gray-400">
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">What is Hikari Desktop?</h3>
|
||||
<p class="text-[var(--text-secondary)]">
|
||||
Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your
|
||||
desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Version</h3>
|
||||
<p class="text-gray-400 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">Version</h3>
|
||||
<p class="text-[var(--text-secondary)] mb-1">
|
||||
{appVersion || "Loading..."}
|
||||
</p>
|
||||
<button
|
||||
@@ -80,7 +82,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Source Code</h3>
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">Source Code</h3>
|
||||
<button
|
||||
onclick={() => openUrl(links.source)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
@@ -90,8 +92,8 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Support & Community</h3>
|
||||
<p class="text-gray-400 mb-1">Found a bug or have a suggestion?</p>
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">Support & Community</h3>
|
||||
<p class="text-[var(--text-secondary)] mb-1">Found a bug or have a suggestion?</p>
|
||||
<button
|
||||
onclick={() => openUrl(links.discord)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
@@ -101,7 +103,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">Built with 💕 by</h3>
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">Built with 💕 by</h3>
|
||||
<button
|
||||
onclick={() => openUrl(links.website)}
|
||||
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
@@ -111,8 +113,8 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-2">License</h3>
|
||||
<p class="text-gray-400 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-2">License</h3>
|
||||
<p class="text-[var(--text-secondary)] mb-1">
|
||||
This project is open source and available under our license terms.
|
||||
</p>
|
||||
<button
|
||||
@@ -124,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-[var(--border-color)]">
|
||||
<p class="text-xs text-gray-500 text-center">
|
||||
<p class="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
tabName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, tabName, onConfirm, onCancel }: Props = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onCancel}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === " " && onCancel()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
Close Connected Tab?
|
||||
</h3>
|
||||
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
|
||||
The tab "{tabName}" is currently connected to Claude. Are you sure you want to close
|
||||
it? This will disconnect the session.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onConfirm}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
|
||||
>
|
||||
Close Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { configStore, type HikariConfig, type Theme } from "$lib/stores/config";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
let config: HikariConfig = $state({
|
||||
model: null,
|
||||
@@ -13,6 +14,7 @@
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
});
|
||||
|
||||
let isOpen = $state(false);
|
||||
@@ -61,6 +63,7 @@
|
||||
saveError = null;
|
||||
try {
|
||||
await configStore.saveConfig(config);
|
||||
configStore.closeSidebar();
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
} finally {
|
||||
@@ -95,6 +98,13 @@
|
||||
function importFromSession() {
|
||||
config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])];
|
||||
}
|
||||
|
||||
async function handleAlwaysOnTopChange(enabled: boolean) {
|
||||
config.always_on_top = enabled;
|
||||
const window = getCurrentWindow();
|
||||
await window.setAlwaysOnTop(enabled);
|
||||
await configStore.updateConfig({ always_on_top: enabled });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
@@ -121,7 +131,7 @@
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
|
||||
<button
|
||||
onclick={configStore.closeSidebar}
|
||||
class="p-1 text-gray-400 hover:text-white transition-colors"
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -149,7 +159,7 @@
|
||||
|
||||
<!-- Model Selection -->
|
||||
<div class="mb-4">
|
||||
<label for="model" class="block text-sm text-gray-400 mb-1">Model</label>
|
||||
<label for="model" class="block text-sm text-[var(--text-secondary)] mb-1">Model</label>
|
||||
<select
|
||||
id="model"
|
||||
bind:value={config.model}
|
||||
@@ -163,8 +173,8 @@
|
||||
|
||||
<!-- API Key -->
|
||||
<div class="mb-4">
|
||||
<label for="api-key" class="block text-sm text-gray-400 mb-1">
|
||||
API Key <span class="text-gray-600">(optional override)</span>
|
||||
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
@@ -177,7 +187,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showApiKey = !showApiKey)}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{#if showApiKey}
|
||||
@@ -211,7 +221,7 @@
|
||||
|
||||
<!-- Custom Instructions -->
|
||||
<div class="mb-4">
|
||||
<label for="instructions" class="block text-sm text-gray-400 mb-1"
|
||||
<label for="instructions" class="block text-sm text-[var(--text-secondary)] mb-1"
|
||||
>Custom Instructions</label
|
||||
>
|
||||
<textarea
|
||||
@@ -238,9 +248,9 @@
|
||||
bind:checked={config.greeting_enabled}
|
||||
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Send greeting on connect</span>
|
||||
<span class="text-sm text-[var(--text-primary)]">Send greeting on connect</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1 ml-7">
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Automatically greet you when a session starts with time-based messages
|
||||
</p>
|
||||
</div>
|
||||
@@ -248,8 +258,8 @@
|
||||
<!-- Custom Greeting Prompt -->
|
||||
{#if config.greeting_enabled}
|
||||
<div class="mb-4">
|
||||
<label for="greeting-prompt" class="block text-sm text-gray-400 mb-1">
|
||||
Custom Greeting Prompt <span class="text-gray-600">(optional)</span>
|
||||
<label for="greeting-prompt" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Custom Greeting Prompt <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="greeting-prompt"
|
||||
@@ -268,8 +278,8 @@
|
||||
MCP Servers
|
||||
</h3>
|
||||
<div class="mb-2">
|
||||
<label for="mcp-config" class="block text-sm text-gray-400 mb-1">
|
||||
Server Configuration <span class="text-gray-600">(JSON)</span>
|
||||
<label for="mcp-config" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Server Configuration <span class="text-[var(--text-tertiary)]">(JSON)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="mcp-config"
|
||||
@@ -286,14 +296,14 @@
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Auto-Granted Tools
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
<p class="text-xs text-[var(--text-tertiary)] mb-3">
|
||||
These tools will be pre-approved for every session (no permission prompts).
|
||||
</p>
|
||||
|
||||
<!-- Common tools checkboxes -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
{#each commonTools as tool (tool)}
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
||||
<label class="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_granted_tools.includes(tool)}
|
||||
@@ -309,7 +319,7 @@
|
||||
{#if grantedTools.length > 0}
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500">Session-granted tools:</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Session-granted tools:</span>
|
||||
<button
|
||||
onclick={importFromSession}
|
||||
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors"
|
||||
@@ -332,7 +342,7 @@
|
||||
<!-- Custom tools list -->
|
||||
{#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0}
|
||||
<div class="mb-3">
|
||||
<span class="text-xs text-gray-500 block mb-2">Custom tools:</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)] block mb-2">Custom tools:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)}
|
||||
<span
|
||||
@@ -341,7 +351,7 @@
|
||||
{tool}
|
||||
<button
|
||||
onclick={() => removeTool(tool)}
|
||||
class="text-gray-500 hover:text-red-400"
|
||||
class="text-[var(--text-tertiary)] hover:text-red-400"
|
||||
aria-label="Remove {tool}"
|
||||
>
|
||||
×
|
||||
@@ -381,7 +391,7 @@
|
||||
onclick={() => handleThemeChange("dark")}
|
||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}"
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
@@ -389,13 +399,36 @@
|
||||
onclick={() => handleThemeChange("light")}
|
||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}"
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Window Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Window
|
||||
</h3>
|
||||
|
||||
<!-- Always on Top Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.always_on_top}
|
||||
onchange={(e) => handleAlwaysOnTopChange(e.currentTarget.checked)}
|
||||
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)]">Always on top</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Keep the window above other windows
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
@@ -410,13 +443,13 @@
|
||||
bind:checked={config.notifications_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-gray-300">Enable sound notifications</span>
|
||||
<span class="text-sm text-[var(--text-primary)]">Enable sound notifications</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<div class="mb-4">
|
||||
<label for="notification-volume" class="block text-sm text-gray-400 mb-2">
|
||||
<label for="notification-volume" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||
Notification Volume
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -436,7 +469,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-[var(--text-tertiary)]">
|
||||
Sound notifications will play when I complete tasks, encounter errors, or need permissions.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,49 +3,35 @@
|
||||
import { onMount } from "svelte";
|
||||
import type { Conversation } from "$lib/stores/conversations";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
|
||||
|
||||
let conversations: Map<string, Conversation> = new Map();
|
||||
let activeConversationId: string | null = null;
|
||||
let editingTabId: string | null = null;
|
||||
let editingName = "";
|
||||
// Use store subscriptions with $ syntax
|
||||
const conversations = $derived(claudeStore.conversations);
|
||||
const activeConversationId = $derived(claudeStore.activeConversationId);
|
||||
|
||||
// Track which conversation actually has the Claude connection
|
||||
let connectedConversationId: string | null = null;
|
||||
let editingTabId = $state<string | null>(null);
|
||||
let editingName = $state("");
|
||||
|
||||
// Track last seen message count for each conversation
|
||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||
|
||||
claudeStore.conversations.subscribe((convs) => {
|
||||
conversations = convs;
|
||||
// Confirmation modal state
|
||||
let showConfirmModal = $state(false);
|
||||
let tabToClose = $state<string | null>(null);
|
||||
let tabToCloseName = $state("");
|
||||
|
||||
// Update the last seen count for the active conversation
|
||||
if (activeConversationId) {
|
||||
const activeConv = convs.get(activeConversationId);
|
||||
// Update last seen count when active conversation changes
|
||||
$effect(() => {
|
||||
if ($activeConversationId) {
|
||||
const activeConv = $conversations.get($activeConversationId);
|
||||
if (activeConv) {
|
||||
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
|
||||
lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length);
|
||||
// Trigger reactivity
|
||||
lastSeenMessageCount = lastSeenMessageCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
claudeStore.activeConversationId.subscribe((id) => {
|
||||
activeConversationId = id;
|
||||
});
|
||||
|
||||
// Find the connected conversation
|
||||
$: {
|
||||
let foundConnected = false;
|
||||
for (const [id, conv] of conversations) {
|
||||
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
|
||||
connectedConversationId = id;
|
||||
foundConnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundConnected) {
|
||||
connectedConversationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createNewTab() {
|
||||
claudeStore.createConversation();
|
||||
}
|
||||
@@ -57,7 +43,7 @@
|
||||
await claudeStore.switchConversation(id);
|
||||
|
||||
// Mark messages as seen when switching to this tab
|
||||
const conv = conversations.get(id);
|
||||
const conv = $conversations.get(id);
|
||||
if (conv) {
|
||||
lastSeenMessageCount.set(id, conv.terminalLines.length);
|
||||
// Trigger reactivity
|
||||
@@ -67,11 +53,35 @@
|
||||
|
||||
function deleteTab(id: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (conversations.size > 1) {
|
||||
claudeStore.deleteConversation(id);
|
||||
if ($conversations.size > 1) {
|
||||
const conversation = $conversations.get(id);
|
||||
if (conversation && conversation.connectionStatus === "connected") {
|
||||
// Show confirmation modal for connected tabs
|
||||
tabToClose = id;
|
||||
tabToCloseName = conversation.name;
|
||||
showConfirmModal = true;
|
||||
} else {
|
||||
// Close disconnected tabs immediately
|
||||
claudeStore.deleteConversation(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function confirmCloseTab() {
|
||||
if (tabToClose) {
|
||||
claudeStore.deleteConversation(tabToClose);
|
||||
}
|
||||
showConfirmModal = false;
|
||||
tabToClose = null;
|
||||
tabToCloseName = "";
|
||||
}
|
||||
|
||||
function cancelCloseTab() {
|
||||
showConfirmModal = false;
|
||||
tabToClose = null;
|
||||
tabToCloseName = "";
|
||||
}
|
||||
|
||||
function startEditing(id: string, name: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
editingTabId = id;
|
||||
@@ -105,7 +115,7 @@
|
||||
}
|
||||
|
||||
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
|
||||
if (id === activeConversationId) return false; // Active tab never has unread
|
||||
if (id === $activeConversationId) return false; // Active tab never has unread
|
||||
const lastSeen = lastSeenMessageCount.get(id) || 0;
|
||||
return conversation.terminalLines.length > lastSeen;
|
||||
}
|
||||
@@ -137,15 +147,24 @@
|
||||
// Ctrl/Cmd + W: Close current tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
|
||||
event.preventDefault();
|
||||
if (activeConversationId && conversations.size > 1) {
|
||||
claudeStore.deleteConversation(activeConversationId);
|
||||
if ($activeConversationId && $conversations.size > 1) {
|
||||
const conversation = $conversations.get($activeConversationId);
|
||||
if (conversation && conversation.connectionStatus === "connected") {
|
||||
// Show confirmation modal for connected tabs
|
||||
tabToClose = $activeConversationId;
|
||||
tabToCloseName = conversation.name;
|
||||
showConfirmModal = true;
|
||||
} else {
|
||||
// Close disconnected tabs immediately
|
||||
claudeStore.deleteConversation($activeConversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd + Tab: Next tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from(conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[nextIndex]);
|
||||
@@ -154,8 +173,8 @@
|
||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from(conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[prevIndex]);
|
||||
@@ -171,17 +190,17 @@
|
||||
<div
|
||||
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||
>
|
||||
{#each Array.from(conversations.entries()) as [id, conversation] (id)}
|
||||
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
|
||||
<div
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
||||
{id === activeConversationId
|
||||
{id === $activeConversationId
|
||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
||||
onclick={() => switchTab(id)}
|
||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||
role="tab"
|
||||
tabindex={0}
|
||||
aria-selected={id === activeConversationId}
|
||||
aria-selected={id === $activeConversationId}
|
||||
>
|
||||
{#if editingTabId === id}
|
||||
<input
|
||||
@@ -196,37 +215,26 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
|
||||
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
|
||||
connectedConversationId
|
||||
? ' (Another tab is connected)'
|
||||
: ''}"
|
||||
title="Connection: {conversation.connectionStatus}"
|
||||
></div>
|
||||
<span
|
||||
class="text-sm pr-6 max-w-[150px] truncate"
|
||||
class="text-sm pr-2 max-w-[150px] truncate"
|
||||
ondblclick={(e) => startEditing(id, conversation.name, e)}
|
||||
role="button"
|
||||
tabindex={-1}
|
||||
>
|
||||
{conversation.name}
|
||||
</span>
|
||||
{#if id !== activeConversationId && id === connectedConversationId}
|
||||
<span
|
||||
class="text-xs text-[var(--text-tertiary)]"
|
||||
title="This tab has the Claude connection"
|
||||
>
|
||||
(connected)
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasUnreadMessages(id, conversation)}
|
||||
<div
|
||||
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse"
|
||||
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse pointer-events-none"
|
||||
title="New messages"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if conversations.size > 1}
|
||||
{#if $conversations.size > 1}
|
||||
<button
|
||||
onclick={(e) => deleteTab(id, e)}
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@@ -268,6 +276,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CloseTabConfirmModal
|
||||
isOpen={showConfirmModal}
|
||||
tabName={tabToCloseName}
|
||||
onConfirm={confirmCloseTab}
|
||||
onCancel={cancelCloseTab}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.terminal-tabs {
|
||||
min-height: 36px;
|
||||
|
||||
@@ -43,14 +43,7 @@
|
||||
"🔒 Grant tool permissions as needed for security",
|
||||
"📌 Pin important conversations for quick access",
|
||||
"🎨 Customize your theme and preferences in Settings",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Keyboard Shortcuts",
|
||||
items: [
|
||||
"Ctrl/Cmd + Enter: Send message",
|
||||
"Ctrl/Cmd + K: Clear chat (when supported)",
|
||||
"Escape: Close modals and panels",
|
||||
"⌨️ Check the keyboard icon for available shortcuts",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -72,10 +65,12 @@
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="help-title" class="text-xl font-semibold text-gray-100">How to Use Hikari Desktop</h2>
|
||||
<h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
How to Use Hikari Desktop
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-gray-500 hover:text-gray-300 transition-colors"
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -92,8 +87,8 @@
|
||||
<div class="overflow-y-auto flex-1 p-6 space-y-6">
|
||||
{#each sections as section (section.title)}
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-200 mb-3">{section.title}</h3>
|
||||
<ul class="space-y-2 text-sm text-gray-400">
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
|
||||
<ul class="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
{#each section.items as item (item)}
|
||||
<li class="flex items-start">
|
||||
<span class="text-[var(--accent-primary)] mr-2 mt-0.5">•</span>
|
||||
@@ -105,7 +100,7 @@
|
||||
{/each}
|
||||
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-[var(--text-tertiary)]">
|
||||
<strong>Need more help?</strong> Join our Discord community for support and updates!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -13,13 +13,64 @@
|
||||
clearHistoryRestore,
|
||||
} from "$lib/stores/historyRestore";
|
||||
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
||||
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
|
||||
import { getCurrentMode } from "$lib/stores/messageMode";
|
||||
import { formatMessageWithMode } from "$lib/types/messageMode";
|
||||
import {
|
||||
parseSlashCommand,
|
||||
getMatchingCommands,
|
||||
isSlashCommand,
|
||||
type SlashCommand,
|
||||
} from "$lib/commands/slashCommands";
|
||||
|
||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||
const MAX_HISTORY_SIZE = 100;
|
||||
|
||||
let inputValue = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let isConnected = $state(false);
|
||||
let isProcessing = $state(false);
|
||||
let showCommandMenu = $state(false);
|
||||
let matchingCommands = $state<SlashCommand[]>([]);
|
||||
let selectedCommandIndex = $state(0);
|
||||
|
||||
// Input history state
|
||||
let inputHistory = $state<string[]>([]);
|
||||
let historyIndex = $state(-1);
|
||||
let tempInput = $state("");
|
||||
|
||||
// Load history from localStorage on init
|
||||
function loadHistory(): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(INPUT_HISTORY_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(history: string[]) {
|
||||
try {
|
||||
localStorage.setItem(INPUT_HISTORY_KEY, JSON.stringify(history));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function addToHistory(input: string) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Don't add duplicates of the most recent entry
|
||||
if (inputHistory.length > 0 && inputHistory[0] === trimmed) return;
|
||||
|
||||
// Add to front of history
|
||||
inputHistory = [trimmed, ...inputHistory.slice(0, MAX_HISTORY_SIZE - 1)];
|
||||
saveHistory(inputHistory);
|
||||
}
|
||||
|
||||
// Initialize history on mount
|
||||
inputHistory = loadHistory();
|
||||
|
||||
claudeStore.connectionStatus.subscribe((status) => {
|
||||
isConnected = status === "connected";
|
||||
@@ -29,11 +80,70 @@
|
||||
isProcessing = processing;
|
||||
});
|
||||
|
||||
function handleInputChange() {
|
||||
// Reset history navigation when user types
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
if (isSlashCommand(inputValue)) {
|
||||
matchingCommands = getMatchingCommands(inputValue);
|
||||
showCommandMenu = matchingCommands.length > 0;
|
||||
selectedCommandIndex = 0;
|
||||
} else {
|
||||
showCommandMenu = false;
|
||||
matchingCommands = [];
|
||||
}
|
||||
}
|
||||
|
||||
function selectCommand(command: SlashCommand) {
|
||||
inputValue = `/${command.name} `;
|
||||
showCommandMenu = false;
|
||||
matchingCommands = [];
|
||||
}
|
||||
|
||||
async function executeSlashCommand(): Promise<boolean> {
|
||||
const { command, args } = parseSlashCommand(inputValue);
|
||||
if (command) {
|
||||
inputValue = "";
|
||||
showCommandMenu = false;
|
||||
matchingCommands = [];
|
||||
await command.execute(args);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const message = inputValue.trim();
|
||||
if (!message || isSubmitting || !isConnected) return;
|
||||
if (!message || isSubmitting) return;
|
||||
|
||||
// Check for slash commands first (these work even when disconnected)
|
||||
if (isSlashCommand(message)) {
|
||||
// Add slash commands to history too
|
||||
addToHistory(message);
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
const wasCommand = await executeSlashCommand();
|
||||
if (wasCommand) return;
|
||||
// If it started with / but wasn't a valid command, show error
|
||||
claudeStore.addLine(
|
||||
"error",
|
||||
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
||||
);
|
||||
inputValue = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular messages require connection
|
||||
if (!isConnected) return;
|
||||
|
||||
// Add to history before clearing
|
||||
addToHistory(message);
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
@@ -139,6 +249,60 @@ User: ${formattedMessage}`;
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// Handle command menu navigation
|
||||
if (showCommandMenu && matchingCommands.length > 0) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
selectedCommandIndex = (selectedCommandIndex + 1) % matchingCommands.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
selectedCommandIndex =
|
||||
(selectedCommandIndex - 1 + matchingCommands.length) % matchingCommands.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const selected = matchingCommands[selectedCommandIndex];
|
||||
if (selected) {
|
||||
selectCommand(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
showCommandMenu = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input history navigation (when command menu is closed)
|
||||
if (event.key === "ArrowUp" && inputHistory.length > 0) {
|
||||
event.preventDefault();
|
||||
if (historyIndex === -1) {
|
||||
// Save current input before navigating history
|
||||
tempInput = inputValue;
|
||||
}
|
||||
if (historyIndex < inputHistory.length - 1) {
|
||||
historyIndex++;
|
||||
inputValue = inputHistory[historyIndex];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" && historyIndex >= 0) {
|
||||
event.preventDefault();
|
||||
historyIndex--;
|
||||
if (historyIndex === -1) {
|
||||
// Restore the temp input when going back to current
|
||||
inputValue = tempInput;
|
||||
} else {
|
||||
inputValue = inputHistory[historyIndex];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
handleSubmit(event);
|
||||
}
|
||||
@@ -152,11 +316,19 @@ User: ${formattedMessage}`;
|
||||
|
||||
<div class="input-row flex gap-3 items-end">
|
||||
<div class="flex-1 relative">
|
||||
<SlashCommandMenu
|
||||
commands={matchingCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
onSelect={selectCommand}
|
||||
/>
|
||||
<textarea
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeyDown}
|
||||
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
|
||||
disabled={!isConnected || isSubmitting}
|
||||
oninput={handleInputChange}
|
||||
placeholder={isConnected
|
||||
? "Ask Hikari anything... (type / for commands)"
|
||||
: "Connect to Claude first..."}
|
||||
disabled={isSubmitting}
|
||||
rows={1}
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
category: "General",
|
||||
items: [
|
||||
{ keys: ["Escape"], description: "Close modals and panels" },
|
||||
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
|
||||
{ keys: ["Ctrl", ","], description: "Open settings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Chat",
|
||||
items: [
|
||||
{ keys: ["Enter"], description: "Send message" },
|
||||
{ keys: ["Shift", "Enter"], description: "New line in message" },
|
||||
{ keys: ["Ctrl", "C"], description: "Interrupt/stop response" },
|
||||
{ keys: ["↑"], description: "Previous input from history" },
|
||||
{ keys: ["↓"], description: "Next input from history" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Slash Commands",
|
||||
items: [
|
||||
{ keys: ["↑", "↓"], description: "Navigate command menu" },
|
||||
{ keys: ["Tab"], description: "Complete selected command" },
|
||||
{ keys: ["Escape"], description: "Close command menu" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Permission Prompts",
|
||||
items: [
|
||||
{ keys: ["Enter"], description: "Allow & reconnect" },
|
||||
{ keys: ["Escape"], description: "Dismiss" },
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="shortcuts-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-[var(--accent-primary)]/20 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--accent-primary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 id="shortcuts-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 p-6 space-y-6">
|
||||
{#each shortcuts as section (section.category)}
|
||||
<div>
|
||||
<h3
|
||||
class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3"
|
||||
>
|
||||
{section.category}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each section.items as item (item.description)}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 bg-[var(--bg-secondary)] rounded-lg"
|
||||
>
|
||||
<span class="text-sm text-[var(--text-primary)]">{item.description}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#each item.keys as key, i (key)}
|
||||
{#if i > 0}
|
||||
<span class="text-[var(--text-secondary)] text-xs">+</span>
|
||||
{/if}
|
||||
<kbd
|
||||
class="px-2 py-1 text-xs font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] shadow-sm min-w-[24px] text-center"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -109,8 +109,22 @@ Please continue where we left off and retry that action now that you have permis
|
||||
function isToolAlreadyGranted(toolName: string): boolean {
|
||||
return grantedToolsList.includes(toolName);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isVisible || !permission) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleApproveAndReconnect();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleDismiss();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isVisible && permission}
|
||||
<div
|
||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||
@@ -123,8 +137,8 @@ Please continue where we left off and retry that action now that you have permis
|
||||
<span class="text-xl">🔐</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Permission Blocked</h2>
|
||||
<p class="text-sm text-gray-400">Hikari tried to use a restricted tool</p>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +149,7 @@ Please continue where we left off and retry that action now that you have permis
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-400 mb-1">Tool</div>
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
|
||||
<div
|
||||
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
|
||||
>
|
||||
@@ -149,17 +163,17 @@ Please continue where we left off and retry that action now that you have permis
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-400 mb-1">Description</div>
|
||||
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-gray-300">
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
|
||||
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
|
||||
{permission.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys(permission.input).length > 0}
|
||||
<div class="mb-6">
|
||||
<div class="text-sm text-gray-400 mb-1">Details</div>
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
|
||||
<pre
|
||||
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-gray-300 text-xs overflow-x-auto max-h-32">{formatInput(
|
||||
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
|
||||
permission.input
|
||||
)}</pre>
|
||||
</div>
|
||||
@@ -168,7 +182,7 @@ Please continue where we left off and retry that action now that you have permis
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleDismiss}
|
||||
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-gray-400 rounded-lg transition-colors font-medium"
|
||||
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { SlashCommand } from "$lib/commands/slashCommands";
|
||||
|
||||
interface Props {
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
}
|
||||
|
||||
let { commands, selectedIndex, onSelect }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if commands.length > 0}
|
||||
<div class="slash-command-menu">
|
||||
<div class="menu-header">Commands</div>
|
||||
{#each commands as command, index (command.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="menu-item"
|
||||
class:selected={index === selectedIndex}
|
||||
onclick={() => onSelect(command)}
|
||||
onmouseenter={() => (selectedIndex = index)}
|
||||
>
|
||||
<span class="command-name">/{command.name}</span>
|
||||
<span class="command-description">{command.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.slash-command-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover,
|
||||
.menu-item.selected {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.command-name {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.command-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import AboutPanel from "./AboutPanel.svelte";
|
||||
import HelpPanel from "./HelpPanel.svelte";
|
||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
@@ -31,6 +32,7 @@
|
||||
let showStats = $state(false);
|
||||
let showAbout = $state(false);
|
||||
let showHelp = $state(false);
|
||||
let showKeyboardShortcuts = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
@@ -43,6 +45,7 @@
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.5,
|
||||
always_on_top: false,
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
@@ -271,6 +274,26 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showKeyboardShortcuts = true)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
title="Keyboard Shortcuts"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showHelp = true)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
@@ -340,3 +363,7 @@
|
||||
{#if showHelp}
|
||||
<HelpPanel onClose={() => (showHelp = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showKeyboardShortcuts}
|
||||
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -47,6 +47,8 @@ export const claudeStore = {
|
||||
getConversationHistory: conversationsStore.getConversationHistory,
|
||||
requestPermission: conversationsStore.requestPermission,
|
||||
clearPermission: conversationsStore.clearPermission,
|
||||
requestPermissionForConversation: conversationsStore.requestPermissionForConversation,
|
||||
clearPermissionForConversation: conversationsStore.clearPermissionForConversation,
|
||||
grantTool: conversationsStore.grantTool,
|
||||
revokeAllTools: conversationsStore.revokeAllTools,
|
||||
isToolGranted: conversationsStore.isToolGranted,
|
||||
@@ -83,8 +85,8 @@ export const claudeStore = {
|
||||
};
|
||||
|
||||
export const hasPermissionPending = derived(
|
||||
claudeStore.pendingPermission,
|
||||
($permission) => $permission !== null
|
||||
claudeStore.activeConversation,
|
||||
($conversation) => $conversation?.pendingPermission !== null
|
||||
);
|
||||
|
||||
// Derived store to check if Claude is currently processing (can be interrupted)
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface HikariConfig {
|
||||
greeting_custom_prompt: string | null;
|
||||
notifications_enabled: boolean;
|
||||
notification_volume: number;
|
||||
always_on_top: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -27,6 +28,7 @@ const defaultConfig: HikariConfig = {
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Conversation {
|
||||
characterState: CharacterState;
|
||||
isProcessing: boolean;
|
||||
grantedTools: Set<string>;
|
||||
pendingPermission: PermissionRequest | null;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
@@ -21,7 +22,6 @@ export interface Conversation {
|
||||
function createConversationsStore() {
|
||||
const conversations = writable<Map<string, Conversation>>(new Map());
|
||||
const activeConversationId = writable<string | null>(null);
|
||||
const pendingPermission = writable<PermissionRequest | null>(null);
|
||||
const pendingRetryMessage = writable<string | null>(null);
|
||||
|
||||
let conversationCounter = 0;
|
||||
@@ -47,6 +47,7 @@ function createConversationsStore() {
|
||||
characterState: "idle",
|
||||
isProcessing: false,
|
||||
grantedTools: new Set(),
|
||||
pendingPermission: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
};
|
||||
@@ -93,6 +94,10 @@ function createConversationsStore() {
|
||||
activeConversation,
|
||||
($conv) => $conv?.grantedTools || new Set<string>()
|
||||
);
|
||||
const pendingPermission = derived(
|
||||
activeConversation,
|
||||
($conv) => $conv?.pendingPermission || null
|
||||
);
|
||||
|
||||
return {
|
||||
// Expose derived stores for compatibility
|
||||
@@ -148,8 +153,52 @@ function createConversationsStore() {
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
||||
clearPermission: () => pendingPermission.set(null),
|
||||
requestPermission: (request: PermissionRequest) => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermission = request;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
clearPermission: () => {
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return;
|
||||
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermission = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
requestPermissionForConversation: (conversationId: string, request: PermissionRequest) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermission = request;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
clearPermissionForConversation: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermission = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||
|
||||
// Conversation management
|
||||
|
||||
+15
-13
@@ -292,25 +292,27 @@ export async function initializeTauriListeners() {
|
||||
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
||||
const { id, tool_name, tool_input, description, conversation_id } = event.payload;
|
||||
|
||||
// Only process permission requests for the active conversation
|
||||
const activeConversationId = get(claudeStore.activeConversationId);
|
||||
if (conversation_id === activeConversationId) {
|
||||
// Store permission request for the specific conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.requestPermissionForConversation(conversation_id, {
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Permission requested for: ${tool_name}`
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.requestPermission({
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
}
|
||||
|
||||
// Always store the permission message to the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Permission requested for: ${tool_name}`
|
||||
);
|
||||
} else if (conversation_id === activeConversationId) {
|
||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||
}
|
||||
});
|
||||
|
||||
+76
-1
@@ -1,9 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
||||
import { configStore, applyTheme } from "$lib/stores/config";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import "$lib/notifications/testNotifications";
|
||||
import Terminal from "$lib/components/Terminal.svelte";
|
||||
import InputBar from "$lib/components/InputBar.svelte";
|
||||
@@ -17,6 +21,67 @@
|
||||
let initialized = false;
|
||||
let achievementPanelOpen = $state(false);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// Don't trigger shortcuts when typing in inputs (except for specific ones)
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
|
||||
|
||||
// Escape closes panels (always works)
|
||||
if (event.key === "Escape") {
|
||||
// Check if any panels are open and close them
|
||||
if (achievementPanelOpen) {
|
||||
achievementPanelOpen = false;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// ConfigSidebar handles its own escape via store
|
||||
if (get(configStore.isSidebarOpen)) {
|
||||
configStore.closeSidebar();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip other shortcuts if user is typing in an input
|
||||
if (isInputFocused) return;
|
||||
|
||||
// Ctrl+L - Clear terminal
|
||||
if (event.ctrlKey && event.key === "l") {
|
||||
event.preventDefault();
|
||||
claudeStore.clearTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+, - Open settings
|
||||
if (event.ctrlKey && event.key === ",") {
|
||||
event.preventDefault();
|
||||
configStore.openSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C - Interrupt (only when processing)
|
||||
if (event.ctrlKey && event.key === "c") {
|
||||
if (get(isClaudeProcessing)) {
|
||||
event.preventDefault();
|
||||
handleInterrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInterrupt() {
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted");
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
@@ -27,12 +92,21 @@
|
||||
await initializeTauriListeners();
|
||||
await configStore.loadConfig();
|
||||
|
||||
// Apply saved theme on startup
|
||||
// Apply saved settings on startup
|
||||
const config = configStore.getConfig();
|
||||
applyTheme(config.theme);
|
||||
|
||||
// Apply always-on-top setting
|
||||
if (config.always_on_top) {
|
||||
const window = getCurrentWindow();
|
||||
await window.setAlwaysOnTop(true);
|
||||
}
|
||||
|
||||
// Initialize notification settings sync
|
||||
initNotificationSync();
|
||||
|
||||
// Add global keyboard shortcut listener
|
||||
window.addEventListener("keydown", handleGlobalKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,6 +114,7 @@
|
||||
if (initialized) {
|
||||
cleanupTauriListeners();
|
||||
cleanupNotificationSync();
|
||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
initialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user