feat: initial prototype
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 47s

This commit is contained in:
2026-01-14 20:56:28 -08:00
parent daf1bfecb8
commit f393dfb359
68 changed files with 9391 additions and 12 deletions
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import { characterState, characterInfo } from "$lib/stores/character";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
let currentState: CharacterState = $state("idle");
let info: CharacterStateInfo = $state({
state: "idle",
label: "Ready",
description: "Waiting for your command~",
spriteFile: "idle.png",
});
characterState.subscribe((state) => {
currentState = state;
});
characterInfo.subscribe((i) => {
info = i;
});
function getAnimationClass(): string {
switch (currentState) {
case "thinking":
return "animate-thinking";
case "typing":
return "animate-typing";
case "searching":
return "animate-searching";
case "success":
return "animate-celebrate";
case "error":
return "animate-shake";
default:
return "animate-idle";
}
}
function getBackgroundGlow(): string {
switch (currentState) {
case "thinking":
return "shadow-thinking";
case "typing":
return "shadow-typing";
case "searching":
return "shadow-searching";
case "coding":
return "shadow-coding";
case "mcp":
return "shadow-mcp";
case "success":
return "shadow-success";
case "error":
return "shadow-error";
default:
return "";
}
}
</script>
<div class="anime-girl-container flex flex-col items-center justify-end h-full p-4">
<div class="character-frame relative {getBackgroundGlow()} w-full max-w-md">
<div class="sprite-container {getAnimationClass()}">
<img
src="/sprites/{info.spriteFile}"
alt="Hikari - {info.label}"
class="character-sprite w-full h-auto object-contain"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.src = "/sprites/placeholder.svg";
}}
/>
</div>
<div class="state-indicator absolute -bottom-2 left-1/2 transform -translate-x-1/2">
<div
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
>
{info.label}
</div>
</div>
</div>
<div class="speech-bubble mt-4 max-w-xs">
<div class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]">
<div class="absolute -top-2 left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-8 border-r-8 border-b-8 border-transparent border-b-[var(--bg-secondary)]"></div>
<p class="text-sm text-gray-300 text-center italic">{info.description}</p>
</div>
</div>
</div>
<style>
.character-frame {
border-radius: 50%;
transition: box-shadow 0.3s ease;
}
.shadow-thinking {
box-shadow: 0 0 30px rgba(147, 51, 234, 0.5);
}
.shadow-typing {
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
}
.shadow-searching {
box-shadow: 0 0 30px rgba(234, 179, 8, 0.5);
}
.shadow-coding {
box-shadow: 0 0 30px rgba(34, 197, 94, 0.5);
}
.shadow-mcp {
box-shadow: 0 0 30px rgba(236, 72, 153, 0.5);
}
.shadow-success {
box-shadow: 0 0 30px rgba(16, 185, 129, 0.5);
}
.shadow-error {
box-shadow: 0 0 30px rgba(239, 68, 68, 0.5);
}
@keyframes idle-bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@keyframes thinking-sway {
0%, 100% {
transform: rotate(-2deg);
}
50% {
transform: rotate(2deg);
}
}
@keyframes typing-bounce {
0%, 100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-3px) scale(1.02);
}
}
@keyframes searching-look {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
@keyframes celebrate {
0%, 100% {
transform: scale(1) rotate(0deg);
}
25% {
transform: scale(1.1) rotate(-5deg);
}
50% {
transform: scale(1.1) rotate(5deg);
}
75% {
transform: scale(1.05) rotate(-3deg);
}
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-5px);
}
20%, 40%, 60%, 80% {
transform: translateX(5px);
}
}
.animate-idle {
animation: idle-bob 3s ease-in-out infinite;
}
.animate-thinking {
animation: thinking-sway 2s ease-in-out infinite;
}
.animate-typing {
animation: typing-bounce 0.5s ease-in-out infinite;
}
.animate-searching {
animation: searching-look 1.5s ease-in-out infinite;
}
.animate-celebrate {
animation: celebrate 0.8s ease-in-out;
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
let inputValue = $state("");
let isSubmitting = $state(false);
let isConnected = $state(false);
claudeStore.connectionStatus.subscribe((status) => {
isConnected = status === "connected";
});
async function handleSubmit(event: Event) {
event.preventDefault();
const message = inputValue.trim();
if (!message || isSubmitting || !isConnected) return;
isSubmitting = true;
inputValue = "";
claudeStore.addLine("user", message);
characterState.setState("thinking");
try {
await invoke("send_prompt", { message });
} catch (error) {
console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
characterState.setTemporaryState("error", 3000);
} finally {
isSubmitting = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event);
}
}
</script>
<form onsubmit={handleSubmit} class="input-bar flex gap-3 items-end">
<div class="flex-1 relative">
<textarea
bind:value={inputValue}
onkeydown={handleKeyDown}
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
disabled={!isConnected || isSubmitting}
rows={1}
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-white placeholder-gray-500 resize-none
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200"
></textarea>
</div>
<button
type="submit"
disabled={!isConnected || isSubmitting || !inputValue.trim()}
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
text-white font-medium rounded-lg
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 transform hover:scale-105 active:scale-95"
>
{#if isSubmitting}
<span class="inline-block animate-spin"></span>
{:else}
Send
{/if}
</button>
</form>
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages";
let isVisible = $state(false);
let permission: PermissionRequest | null = $state(null);
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
hasPermissionPending.subscribe((pending) => {
isVisible = pending;
});
claudeStore.pendingPermission.subscribe((perm) => {
permission = perm;
if (perm) {
characterState.setState("permission");
}
});
claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools);
});
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDirectory = dir;
});
async function handleApproveAndReconnect() {
if (permission) {
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
const approvedTool = permission.tool;
const toolInput = permission.input;
claudeStore.grantTool(approvedTool);
const newGrantedTools = [...grantedToolsList, approvedTool];
claudeStore.addLine("system", `Permission granted for: ${approvedTool}. Reconnecting with context...`);
claudeStore.clearPermission();
// Stop current session and reconnect with new permissions
try {
await invoke("stop_claude");
// Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
await invoke("start_claude", {
workingDir: workingDirectory || "/home/naomi",
allowedTools: newGrantedTools,
});
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far:
${conversationHistory}
The last action that was blocked was: ${approvedTool} with input:
${JSON.stringify(toolInput, null, 2)}
Please continue where we left off and retry that action now that you have permission.`;
await invoke("send_prompt", { message: contextMessage });
}
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
}
}
characterState.setTemporaryState("success", 2000);
}
function handleDismiss() {
claudeStore.clearPermission();
claudeStore.addLine("system", "Permission request dismissed");
characterState.setTemporaryState("idle", 1000);
}
function formatInput(input: Record<string, unknown>): string {
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
function isToolAlreadyGranted(toolName: string): boolean {
return grantedToolsList.includes(toolName);
}
</script>
{#if isVisible && permission}
<div class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm">
<div class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<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>
</div>
</div>
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
<p class="text-sm text-amber-300">
This action was automatically blocked. Approve to allow this tool for future requests.
</p>
</div>
<div class="mb-4">
<div class="text-sm text-gray-400 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">
<span>{permission.tool}</span>
{#if isToolAlreadyGranted(permission.tool)}
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">Already Granted</span>
{/if}
</div>
</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">
{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>
<pre class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-gray-300 text-xs overflow-x-auto max-h-32">{formatInput(permission.input)}</pre>
</div>
{/if}
<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"
>
Dismiss
</button>
<button
onclick={handleApproveAndReconnect}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium"
>
Allow & Reconnect
</button>
</div>
</div>
</div>
{/if}
+169
View File
@@ -0,0 +1,169 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { open } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import { claudeStore } from "$lib/stores/claude";
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
const DISCORD_URL = "https://chat.nhcarrigan.com";
let connectionStatus: ConnectionStatus = $state("disconnected");
let workingDirectory = $state("");
let selectedDirectory = $state("/home/naomi");
let isConnecting = $state(false);
let grantedToolsList: string[] = $state([]);
let appVersion = $state("");
onMount(async () => {
appVersion = await getVersion();
});
claudeStore.connectionStatus.subscribe((status) => {
connectionStatus = status;
isConnecting = status === "connecting";
});
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDirectory = dir;
});
claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools);
});
async function handleBrowse() {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: selectedDirectory,
title: "Select Working Directory",
});
if (selected && typeof selected === "string") {
selectedDirectory = selected;
}
} catch (error) {
console.error("Failed to open directory picker:", error);
}
}
async function handleConnect() {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
try {
// Pass granted tools to Claude so they're pre-approved
await invoke("start_claude", {
workingDir: targetDir,
allowedTools: grantedToolsList.length > 0 ? grantedToolsList : null,
});
} catch (error) {
console.error("Failed to start Claude:", error);
claudeStore.addLine("error", `Connection failed: ${error}`);
}
}
async function handleDisconnect() {
try {
await invoke("stop_claude");
} catch (error) {
console.error("Failed to stop Claude:", error);
}
}
function getStatusColor(): string {
switch (connectionStatus) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500 animate-pulse";
case "error":
return "bg-red-500";
default:
return "bg-gray-500";
}
}
function getStatusText(): string {
switch (connectionStatus) {
case "connected":
return "Connected";
case "connecting":
return "Connecting...";
case "error":
return "Error";
default:
return "Disconnected";
}
}
</script>
<div class="status-bar flex items-center justify-between px-4 py-2 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div class="w-2.5 h-2.5 rounded-full {getStatusColor()}"></div>
<span class="text-sm text-gray-300">{getStatusText()}</span>
</div>
{#if connectionStatus === "connected"}
{#if workingDirectory}
<div class="text-sm text-gray-500">
<span class="text-gray-600">cwd:</span> {workingDirectory}
</div>
{/if}
{:else}
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">cwd:</span>
<input
type="text"
bind:value={selectedDirectory}
disabled={isConnecting}
class="px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-md text-gray-300 w-64 focus:outline-none focus:border-[var(--accent-primary)] disabled:opacity-50"
placeholder="/path/to/project"
/>
<button
onclick={handleBrowse}
disabled={isConnecting}
class="px-2 py-1 text-sm bg-[var(--bg-primary)] hover:bg-[var(--bg-hover)] border border-[var(--border-color)] text-gray-400 rounded-md transition-colors disabled:opacity-50"
title="Browse..."
>
...
</button>
</div>
{/if}
</div>
<div class="flex items-center gap-3">
<button
onclick={() => openUrl(DISCORD_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Join our Discord"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</button>
{#if appVersion}
<span class="text-xs text-gray-600">v{appVersion}</span>
{/if}
{#if connectionStatus === "connected"}
<button
onclick={handleDisconnect}
class="px-3 py-1 text-sm bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-md transition-colors"
>
Disconnect
</button>
{:else}
<button
onclick={handleConnect}
disabled={isConnecting}
class="px-3 py-1 text-sm bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-md transition-colors disabled:opacity-50"
>
{isConnecting ? "Connecting..." : "Connect"}
</button>
{/if}
</div>
</div>
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { onMount, afterUpdate } from "svelte";
let terminalElement: HTMLDivElement;
let shouldAutoScroll = true;
let lines: TerminalLine[] = [];
claudeStore.terminalLines.subscribe((value) => {
lines = value;
});
function handleScroll() {
if (!terminalElement) return;
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
}
afterUpdate(() => {
if (shouldAutoScroll && terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
});
function getLineClass(type: string): string {
switch (type) {
case "user":
return "text-cyan-400";
case "assistant":
return "text-gray-100";
case "system":
return "text-gray-500 italic";
case "tool":
return "text-purple-400";
case "error":
return "text-red-400";
default:
return "text-gray-300";
}
}
function getLinePrefix(type: string): string {
switch (type) {
case "user":
return ">";
case "assistant":
return "";
case "system":
return "[system]";
case "tool":
return "[tool]";
case "error":
return "[error]";
default:
return "";
}
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<div
class="terminal-container flex-1 overflow-hidden rounded-lg bg-[var(--bg-terminal)] border border-[var(--border-color)]"
>
<div class="terminal-header flex items-center gap-2 px-4 py-2 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]">
<div class="flex gap-1.5">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<span class="text-sm text-gray-400 ml-2">Terminal</span>
</div>
<div
bind:this={terminalElement}
onscroll={handleScroll}
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
>
{#if lines.length === 0}
<div class="text-gray-500 italic">
Waiting for Claude... Type a message below to start!
</div>
{:else}
{#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)}">
<span class="text-gray-600 text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if getLinePrefix(line.type)}
<span class="text-gray-500 mr-2">{getLinePrefix(line.type)}</span>
{/if}
{#if line.toolName}
<span class="text-purple-300 mr-2">[{line.toolName}]</span>
{/if}
<span class="whitespace-pre-wrap">{line.content}</span>
</div>
{/each}
{/if}
</div>
</div>
<style>
.terminal-content {
scrollbar-width: thin;
scrollbar-color: var(--border-color) var(--bg-terminal);
}
</style>