generated from nhcarrigan/template
feat: initial prototype
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 47s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 47s
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-terminal: #0f0f1a;
|
||||
--accent-primary: #e94560;
|
||||
--accent-secondary: #ff6b9d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: #2a2a4a;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hikari - Claude Code Assistant</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { CHARACTER_STATES, type CharacterState } from "$lib/types/states";
|
||||
|
||||
function createCharacterStore() {
|
||||
const { subscribe, set, update } = writable<CharacterState>("idle");
|
||||
|
||||
let stateTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setState: (state: CharacterState) => {
|
||||
if (stateTimeout) {
|
||||
clearTimeout(stateTimeout);
|
||||
stateTimeout = null;
|
||||
}
|
||||
set(state);
|
||||
},
|
||||
setTemporaryState: (state: CharacterState, durationMs: number = 2000) => {
|
||||
if (stateTimeout) {
|
||||
clearTimeout(stateTimeout);
|
||||
}
|
||||
set(state);
|
||||
stateTimeout = setTimeout(() => {
|
||||
set("idle");
|
||||
stateTimeout = null;
|
||||
}, durationMs);
|
||||
},
|
||||
reset: () => {
|
||||
if (stateTimeout) {
|
||||
clearTimeout(stateTimeout);
|
||||
stateTimeout = null;
|
||||
}
|
||||
set("idle");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const characterState = createCharacterStore();
|
||||
|
||||
export const characterInfo = derived(characterState, ($state) => CHARACTER_STATES[$state]);
|
||||
@@ -0,0 +1,131 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
PermissionRequest,
|
||||
ClaudeStreamMessage,
|
||||
} from "$lib/types/messages";
|
||||
|
||||
export interface TerminalLine {
|
||||
id: string;
|
||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
function createClaudeStore() {
|
||||
const connectionStatus = writable<ConnectionStatus>("disconnected");
|
||||
const sessionId = writable<string | null>(null);
|
||||
const currentWorkingDirectory = writable<string>("");
|
||||
const terminalLines = writable<TerminalLine[]>([]);
|
||||
const pendingPermission = writable<PermissionRequest | null>(null);
|
||||
const isProcessing = writable<boolean>(false);
|
||||
const grantedTools = writable<Set<string>>(new Set());
|
||||
const pendingRetryMessage = writable<string | null>(null);
|
||||
|
||||
let lineIdCounter = 0;
|
||||
|
||||
function generateLineId(): string {
|
||||
return `line-${Date.now()}-${lineIdCounter++}`;
|
||||
}
|
||||
|
||||
return {
|
||||
connectionStatus: { subscribe: connectionStatus.subscribe },
|
||||
sessionId: { subscribe: sessionId.subscribe },
|
||||
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
||||
terminalLines: { subscribe: terminalLines.subscribe },
|
||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||
isProcessing: { subscribe: isProcessing.subscribe },
|
||||
grantedTools: { subscribe: grantedTools.subscribe },
|
||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||
|
||||
setConnectionStatus: (status: ConnectionStatus) => connectionStatus.set(status),
|
||||
setSessionId: (id: string | null) => sessionId.set(id),
|
||||
setWorkingDirectory: (dir: string) => currentWorkingDirectory.set(dir),
|
||||
setProcessing: (processing: boolean) => isProcessing.set(processing),
|
||||
|
||||
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
||||
const line: TerminalLine = {
|
||||
id: generateLineId(),
|
||||
type,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
};
|
||||
terminalLines.update((lines) => [...lines, line]);
|
||||
return line.id;
|
||||
},
|
||||
|
||||
updateLine: (id: string, content: string) => {
|
||||
terminalLines.update((lines) =>
|
||||
lines.map((line) => (line.id === id ? { ...line, content } : line))
|
||||
);
|
||||
},
|
||||
|
||||
appendToLine: (id: string, additionalContent: string) => {
|
||||
terminalLines.update((lines) =>
|
||||
lines.map((line) =>
|
||||
line.id === id ? { ...line, content: line.content + additionalContent } : line
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
clearTerminal: () => terminalLines.set([]),
|
||||
|
||||
getConversationHistory: (): string => {
|
||||
let lines: TerminalLine[] = [];
|
||||
terminalLines.subscribe((l) => (lines = l))();
|
||||
|
||||
// Filter to just user and assistant messages, skip system/tool noise
|
||||
const relevantLines = lines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
if (relevantLines.length === 0) return "";
|
||||
|
||||
return relevantLines
|
||||
.map((line) => {
|
||||
const role = line.type === "user" ? "User" : "Assistant";
|
||||
return `${role}: ${line.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
},
|
||||
|
||||
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
||||
clearPermission: () => pendingPermission.set(null),
|
||||
|
||||
grantTool: (toolName: string) => {
|
||||
grantedTools.update((tools) => {
|
||||
const newTools = new Set(tools);
|
||||
newTools.add(toolName);
|
||||
return newTools;
|
||||
});
|
||||
},
|
||||
|
||||
getGrantedTools: (): string[] => {
|
||||
let tools: string[] = [];
|
||||
grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
||||
return tools;
|
||||
},
|
||||
|
||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||
|
||||
reset: () => {
|
||||
connectionStatus.set("disconnected");
|
||||
sessionId.set(null);
|
||||
currentWorkingDirectory.set("");
|
||||
terminalLines.set([]);
|
||||
pendingPermission.set(null);
|
||||
isProcessing.set(false);
|
||||
grantedTools.set(new Set());
|
||||
pendingRetryMessage.set(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const claudeStore = createClaudeStore();
|
||||
|
||||
export const hasPermissionPending = derived(
|
||||
claudeStore.pendingPermission,
|
||||
($permission) => $permission !== null
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
|
||||
interface StateChangePayload {
|
||||
state: CharacterState;
|
||||
tool_name: string | null;
|
||||
}
|
||||
|
||||
interface OutputPayload {
|
||||
line_type: string;
|
||||
content: string;
|
||||
tool_name: string | null;
|
||||
}
|
||||
|
||||
export async function initializeTauriListeners() {
|
||||
await listen<string>("claude:connection", (event) => {
|
||||
const status = event.payload as ConnectionStatus;
|
||||
claudeStore.setConnectionStatus(status);
|
||||
|
||||
if (status === "connected") {
|
||||
claudeStore.addLine("system", "Connected to Claude Code");
|
||||
characterState.setState("idle");
|
||||
} else if (status === "disconnected") {
|
||||
claudeStore.addLine("system", "Disconnected from Claude Code");
|
||||
characterState.setState("idle");
|
||||
} else if (status === "error") {
|
||||
claudeStore.addLine("error", "Connection error");
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
});
|
||||
|
||||
await listen<StateChangePayload>("claude:state", (event) => {
|
||||
const { state } = event.payload;
|
||||
|
||||
const stateMap: Record<string, CharacterState> = {
|
||||
idle: "idle",
|
||||
thinking: "thinking",
|
||||
typing: "typing",
|
||||
searching: "searching",
|
||||
coding: "coding",
|
||||
mcp: "mcp",
|
||||
permission: "permission",
|
||||
success: "success",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||
|
||||
if (mappedState === "success" || mappedState === "error") {
|
||||
characterState.setTemporaryState(mappedState, 3000);
|
||||
} else {
|
||||
characterState.setState(mappedState);
|
||||
}
|
||||
});
|
||||
|
||||
await listen<OutputPayload>("claude:output", (event) => {
|
||||
const { line_type, content, tool_name } = event.payload;
|
||||
claudeStore.addLine(
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined
|
||||
);
|
||||
});
|
||||
|
||||
await listen<string>("claude:stream", (event) => {
|
||||
// no-op
|
||||
});
|
||||
|
||||
await listen<string>("claude:session", (event) => {
|
||||
claudeStore.setSessionId(event.payload);
|
||||
claudeStore.addLine("system", `Session: ${event.payload.substring(0, 8)}...`);
|
||||
});
|
||||
|
||||
await listen<string>("claude:cwd", (event) => {
|
||||
claudeStore.setWorkingDirectory(event.payload);
|
||||
});
|
||||
|
||||
await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
||||
const { id, tool_name, tool_input, description } = event.payload;
|
||||
claudeStore.requestPermission({
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||
});
|
||||
|
||||
console.log("Tauri event listeners initialized");
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export interface SystemInitMessage {
|
||||
type: "system";
|
||||
subtype: "init";
|
||||
session_id: string;
|
||||
cwd: string;
|
||||
tools: string[];
|
||||
mcp_servers?: string[];
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SystemCompactMessage {
|
||||
type: "system";
|
||||
subtype: "compact_boundary";
|
||||
}
|
||||
|
||||
export type SystemMessage = SystemInitMessage | SystemCompactMessage;
|
||||
|
||||
export interface TextContentBlock {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ThinkingContentBlock {
|
||||
type: "thinking";
|
||||
thinking: string;
|
||||
}
|
||||
|
||||
export interface ToolUseContentBlock {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolResultContentBlock {
|
||||
type: "tool_result";
|
||||
tool_use_id: string;
|
||||
content: string;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
export type ContentBlock =
|
||||
| TextContentBlock
|
||||
| ThinkingContentBlock
|
||||
| ToolUseContentBlock
|
||||
| ToolResultContentBlock;
|
||||
|
||||
export interface AssistantMessage {
|
||||
type: "assistant";
|
||||
message: {
|
||||
content: ContentBlock[];
|
||||
model?: string;
|
||||
stop_reason?: string;
|
||||
};
|
||||
parent_tool_use_id?: string;
|
||||
}
|
||||
|
||||
export interface UserMessage {
|
||||
type: "user";
|
||||
message: {
|
||||
content: ContentBlock[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamEvent {
|
||||
type: "stream_event";
|
||||
event: {
|
||||
type: string;
|
||||
index?: number;
|
||||
content_block?: ContentBlock;
|
||||
delta?: {
|
||||
type: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface PermissionDenial {
|
||||
tool_name: string;
|
||||
tool_use_id: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ResultMessage {
|
||||
type: "result";
|
||||
subtype: "success" | "error_max_turns" | "error_tool" | "error_api" | "error_unknown";
|
||||
result?: string;
|
||||
duration_ms?: number;
|
||||
num_turns?: number;
|
||||
total_cost_usd?: number;
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
permission_denials?: PermissionDenial[];
|
||||
}
|
||||
|
||||
export type ClaudeStreamMessage =
|
||||
| SystemMessage
|
||||
| AssistantMessage
|
||||
| UserMessage
|
||||
| StreamEvent
|
||||
| ResultMessage;
|
||||
|
||||
export interface PermissionRequest {
|
||||
id: string;
|
||||
tool: string;
|
||||
description: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PermissionPromptEvent {
|
||||
id: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||
@@ -0,0 +1,74 @@
|
||||
export type CharacterState =
|
||||
| "idle"
|
||||
| "thinking"
|
||||
| "typing"
|
||||
| "searching"
|
||||
| "coding"
|
||||
| "mcp"
|
||||
| "permission"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
export interface CharacterStateInfo {
|
||||
state: CharacterState;
|
||||
label: string;
|
||||
description: string;
|
||||
spriteFile: string;
|
||||
}
|
||||
|
||||
export const CHARACTER_STATES: Record<CharacterState, CharacterStateInfo> = {
|
||||
idle: {
|
||||
state: "idle",
|
||||
label: "Ready",
|
||||
description: "Waiting for your command~",
|
||||
spriteFile: "idle.png",
|
||||
},
|
||||
thinking: {
|
||||
state: "thinking",
|
||||
label: "Thinking",
|
||||
description: "Hmm, let me think about this...",
|
||||
spriteFile: "thinking.png",
|
||||
},
|
||||
typing: {
|
||||
state: "typing",
|
||||
label: "Typing",
|
||||
description: "Writing response...",
|
||||
spriteFile: "typing.png",
|
||||
},
|
||||
searching: {
|
||||
state: "searching",
|
||||
label: "Searching",
|
||||
description: "Looking through files...",
|
||||
spriteFile: "searching.png",
|
||||
},
|
||||
coding: {
|
||||
state: "coding",
|
||||
label: "Coding",
|
||||
description: "Writing some code!",
|
||||
spriteFile: "coding.png",
|
||||
},
|
||||
mcp: {
|
||||
state: "mcp",
|
||||
label: "Using Tools",
|
||||
description: "Connecting to external tools...",
|
||||
spriteFile: "mcp.png",
|
||||
},
|
||||
permission: {
|
||||
state: "permission",
|
||||
label: "Permission Needed",
|
||||
description: "May I do this?",
|
||||
spriteFile: "permission.png",
|
||||
},
|
||||
success: {
|
||||
state: "success",
|
||||
label: "Done!",
|
||||
description: "Task completed successfully!",
|
||||
spriteFile: "success.png",
|
||||
},
|
||||
error: {
|
||||
state: "error",
|
||||
label: "Oops",
|
||||
description: "Something went wrong...",
|
||||
spriteFile: "error.png",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import type { ClaudeStreamMessage, ToolUseContentBlock } from "$lib/types/messages";
|
||||
|
||||
const SEARCH_TOOLS = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||
const CODING_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
||||
|
||||
function getToolCategory(toolName: string): CharacterState {
|
||||
if (SEARCH_TOOLS.includes(toolName)) {
|
||||
return "searching";
|
||||
}
|
||||
|
||||
if (CODING_TOOLS.includes(toolName)) {
|
||||
return "coding";
|
||||
}
|
||||
|
||||
if (toolName.startsWith("mcp__")) {
|
||||
return "mcp";
|
||||
}
|
||||
|
||||
if (toolName === "Task") {
|
||||
return "thinking";
|
||||
}
|
||||
|
||||
return "typing";
|
||||
}
|
||||
|
||||
export function mapMessageToState(message: ClaudeStreamMessage): CharacterState | null {
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
if (message.subtype === "init") {
|
||||
return "idle";
|
||||
}
|
||||
return null;
|
||||
|
||||
case "assistant": {
|
||||
const toolUses = message.message.content.filter(
|
||||
(block): block is ToolUseContentBlock => block.type === "tool_use"
|
||||
);
|
||||
|
||||
if (toolUses.length > 0) {
|
||||
const lastTool = toolUses[toolUses.length - 1];
|
||||
return getToolCategory(lastTool.name);
|
||||
}
|
||||
|
||||
const hasText = message.message.content.some((block) => block.type === "text");
|
||||
if (hasText) {
|
||||
return "typing";
|
||||
}
|
||||
|
||||
const hasThinking = message.message.content.some((block) => block.type === "thinking");
|
||||
if (hasThinking) {
|
||||
return "thinking";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
case "stream_event": {
|
||||
const event = message.event;
|
||||
|
||||
if (event.type === "content_block_start") {
|
||||
if (event.content_block?.type === "thinking") {
|
||||
return "thinking";
|
||||
}
|
||||
if (event.content_block?.type === "text") {
|
||||
return "typing";
|
||||
}
|
||||
if (event.content_block?.type === "tool_use") {
|
||||
const toolBlock = event.content_block as ToolUseContentBlock;
|
||||
return getToolCategory(toolBlock.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "content_block_delta") {
|
||||
if (event.delta?.type === "thinking_delta") {
|
||||
return "thinking";
|
||||
}
|
||||
if (event.delta?.type === "text_delta") {
|
||||
return "typing";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
case "result":
|
||||
if (message.subtype === "success") {
|
||||
return "success";
|
||||
}
|
||||
if (message.subtype.startsWith("error")) {
|
||||
return "error";
|
||||
}
|
||||
return null;
|
||||
|
||||
case "user":
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTextFromMessage(message: ClaudeStreamMessage): string | null {
|
||||
if (message.type === "assistant") {
|
||||
const textBlocks = message.message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => (block as { type: "text"; text: string }).text);
|
||||
|
||||
return textBlocks.length > 0 ? textBlocks.join("\n") : null;
|
||||
}
|
||||
|
||||
if (message.type === "stream_event" && message.event.delta?.text) {
|
||||
return message.event.delta.text;
|
||||
}
|
||||
|
||||
if (message.type === "result" && message.result) {
|
||||
return message.result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractToolInfo(
|
||||
message: ClaudeStreamMessage
|
||||
): { name: string; input: Record<string, unknown> }[] {
|
||||
if (message.type !== "assistant") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return message.message.content
|
||||
.filter((block): block is ToolUseContentBlock => block.type === "tool_use")
|
||||
.map((block) => ({
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div id="app">
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { initializeTauriListeners } from "$lib/tauri";
|
||||
import Terminal from "$lib/components/Terminal.svelte";
|
||||
import InputBar from "$lib/components/InputBar.svelte";
|
||||
import StatusBar from "$lib/components/StatusBar.svelte";
|
||||
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
|
||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||
|
||||
onMount(async () => {
|
||||
await initializeTauriListeners();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
||||
<StatusBar />
|
||||
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Left panel: Character display -->
|
||||
<div class="character-panel w-1/3 flex flex-col items-center justify-center border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50">
|
||||
<AnimeGirl />
|
||||
</div>
|
||||
|
||||
<!-- Right panel: Terminal and input -->
|
||||
<div class="terminal-panel flex-1 flex flex-col">
|
||||
<Terminal />
|
||||
<InputBar />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<PermissionModal />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-container {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.character-panel {
|
||||
min-width: 320px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-primary) 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user