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
+3 -1
View File
@@ -5,4 +5,6 @@
# Ignore binary files >:(
*.png binary
*.jpg binary
*.jpg binary
*.icons binary
*.ico binary
+10
View File
@@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"svelte.svelte-vscode",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"svelte.enable-ts-plugin": true
}
+118 -11
View File
@@ -1,24 +1,131 @@
# New Repository Template
# Hikari Desktop
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
A Linux desktop application that wraps Claude Code with an anime girl character that reacts to Claude's activities in real-time.
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
## Features
## Readme
- Visual character that reflects Claude's current state (thinking, typing, searching, coding, etc.)
- Terminal-style output display
- Permission prompts with approve/deny interface
- Real-time state detection from Claude Code's NDJSON stream
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
## Installation
<!-- # Project Name
### 1. Install Claude Code
Project Description
Hikari Desktop requires Claude Code to be installed and authenticated:
## Live Version
```bash
npm install -g @anthropic-ai/claude-code
claude # Follow the prompts to authenticate
```
This page is currently deployed. [View the live website.]
### 2. Install Runtime Dependencies
**Debian/Ubuntu:**
```bash
sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 xdg-utils
```
**Fedora:**
```bash
sudo dnf install webkit2gtk4.1 gtk3 libappindicator-gtk3 xdg-utils
```
**Arch Linux:**
```bash
sudo pacman -S webkit2gtk-4.1 gtk3 libappindicator-gtk3 xdg-utils
```
| Package | Purpose |
|---------|---------|
| webkit2gtk-4.1 | WebView rendering (app UI) |
| gtk3 | Window management and native widgets |
| libappindicator | System tray support |
| xdg-utils | Opening URLs/files with default applications |
### 3. Install Hikari Desktop
Download the latest release for your distribution:
**AppImage** (any distro):
```bash
chmod +x hikari-desktop_*.AppImage
./hikari-desktop_*.AppImage
```
**Debian/Ubuntu:**
```bash
sudo dpkg -i hikari-desktop_*.deb
```
**Fedora:**
```bash
sudo rpm -i hikari-desktop-*.rpm
```
## Character States
| State | Trigger |
|-------|---------|
| Idle | Waiting for user input |
| Thinking | Processing/API call in progress |
| Typing | Streaming text output |
| Searching | Using Read/Glob/Grep tools |
| Coding | Using Edit/Write tools |
| MCP | Running MCP tool calls |
| Permission | Permission prompt needed |
| Success | Task completed |
| Error | Error occurred |
## Building from Source
### Prerequisites
- Node.js and pnpm
- Rust toolchain
### Build
```bash
# Install dependencies
pnpm install
# Development mode
pnpm run dev
# Build for Linux
pnpm tauri build
```
## Architecture
```
Linux (Tauri App)
├── Svelte Frontend
│ ├── AnimeGirl (sprites + animations)
│ ├── Terminal (output display)
│ ├── InputBar (user input)
│ └── PermissionModal (approve/deny)
└── Rust Backend
├── Process Manager (spawn & communicate)
└── State Parser (NDJSON → character state)
│ stdin/stdout (NDJSON stream)
claude -p --output-format stream-json --input-format stream-json
```
## Tech Stack
- **Tauri 2.x** - Desktop framework with Rust backend
- **Svelte 5** - Reactive frontend with runes
- **Tailwind CSS** - Styling
- **Tokio** - Async runtime for process management
## Feedback and Bugs
If you have feedback or a bug report, please feel free to open a GitHub issue!
If you have feedback or a bug report, please feel free to open a ticket request in our [Discord](https://chat.nhcarrigan.com)!
## Contributing
@@ -36,4 +143,4 @@ Copyright held by Naomi Carrigan.
## Contact
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. -->
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "hikari-desktop",
"version": "0.1.0",
"description": "",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-shell": "^2.3.4"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}
+1501
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5303
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "hikari-desktop"
version = "0.1.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
[lib]
name = "hikari_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
parking_lot = "0.12"
uuid = { version = "1", features = ["v4"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"opener:default",
"shell:allow-spawn",
"shell:allow-stdin-write",
"shell:allow-kill"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+44
View File
@@ -0,0 +1,44 @@
use tauri::{AppHandle, State};
use crate::wsl_bridge::SharedBridge;
#[tauri::command]
pub async fn start_claude(
app: AppHandle,
bridge: State<'_, SharedBridge>,
working_dir: String,
allowed_tools: Option<Vec<String>>,
) -> Result<(), String> {
let mut bridge = bridge.lock();
bridge.start(app, &working_dir, allowed_tools.unwrap_or_default())
}
#[tauri::command]
pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> {
let mut bridge = bridge.lock();
bridge.stop(&app);
Ok(())
}
#[tauri::command]
pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> {
let mut bridge = bridge.lock();
bridge.send_message(&message)
}
#[tauri::command]
pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result<bool, String> {
let bridge = bridge.lock();
Ok(bridge.is_running())
}
#[tauri::command]
pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<String, String> {
let bridge = bridge.lock();
Ok(bridge.get_working_directory().to_string())
}
#[tauri::command]
pub async fn select_wsl_directory() -> Result<String, String> {
Ok("/home".to_string())
}
+27
View File
@@ -0,0 +1,27 @@
mod commands;
mod types;
mod wsl_bridge;
use commands::*;
use wsl_bridge::create_shared_bridge;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let bridge = create_shared_bridge();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.manage(bridge)
.invoke_handler(tauri::generate_handler![
start_claude,
stop_claude,
send_prompt,
is_claude_running,
get_working_directory,
select_wsl_directory,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hikari_desktop_lib::run()
}
+188
View File
@@ -0,0 +1,188 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CharacterState {
Idle,
Thinking,
Typing,
Searching,
Coding,
Mcp,
Permission,
Success,
Error,
}
impl Default for CharacterState {
fn default() -> Self {
CharacterState::Idle
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionStatus {
Disconnected,
Connecting,
Connected,
Error,
}
impl Default for ConnectionStatus {
fn default() -> Self {
ConnectionStatus::Disconnected
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalLine {
pub id: String,
#[serde(rename = "type")]
pub line_type: String,
pub content: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRequest {
pub id: String,
pub tool: String,
pub description: String,
pub input: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionDenial {
pub tool_name: String,
pub tool_use_id: String,
pub tool_input: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ClaudeMessage {
#[serde(rename = "system")]
System {
subtype: String,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
tools: Option<Vec<String>>,
},
#[serde(rename = "assistant")]
Assistant {
message: AssistantMessageContent,
#[serde(default)]
parent_tool_use_id: Option<String>,
},
#[serde(rename = "user")]
User { message: UserMessageContent },
#[serde(rename = "stream_event")]
StreamEvent { event: StreamEventData },
#[serde(rename = "result")]
Result {
subtype: String,
#[serde(default)]
result: Option<String>,
#[serde(default)]
duration_ms: Option<u64>,
#[serde(default)]
num_turns: Option<u32>,
#[serde(default)]
permission_denials: Option<Vec<PermissionDenial>>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssistantMessageContent {
pub content: Vec<ContentBlock>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub stop_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserMessageContent {
pub content: Vec<ContentBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "thinking")]
Thinking { thinking: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: serde_json::Value,
#[serde(default)]
is_error: Option<bool>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamEventData {
#[serde(rename = "type")]
pub event_type: String,
#[serde(default)]
pub index: Option<u32>,
#[serde(default)]
pub content_block: Option<ContentBlockStart>,
#[serde(default)]
pub delta: Option<DeltaContent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentBlockStart {
#[serde(rename = "type")]
pub block_type: String,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeltaContent {
#[serde(rename = "type")]
pub delta_type: String,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub thinking: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateChangeEvent {
pub state: CharacterState,
pub tool_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputEvent {
pub line_type: String,
pub content: String,
pub tool_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub id: String,
pub tool_name: String,
pub tool_input: serde_json::Value,
pub description: String,
}
+467
View File
@@ -0,0 +1,467 @@
use parking_lot::Mutex;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::Arc;
use std::thread;
use tauri::{AppHandle, Emitter};
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent};
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
fn detect_wsl() -> bool {
// Check /proc/version for WSL indicators
if let Ok(version) = std::fs::read_to_string("/proc/version") {
let version_lower = version.to_lowercase();
if version_lower.contains("microsoft") || version_lower.contains("wsl") {
return true;
}
}
// Fallback: check for WSLInterop
if std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() {
return true;
}
// Check for WSL environment variable
if std::env::var("WSL_DISTRO_NAME").is_ok() {
return true;
}
false
}
fn find_claude_binary() -> Option<String> {
// Check common installation locations for claude
let home = std::env::var("HOME").ok()?;
let paths_to_check = [
format!("{}/.local/bin/claude", home),
format!("{}/.claude/local/claude", home),
"/usr/local/bin/claude".to_string(),
"/usr/bin/claude".to_string(),
];
for path in &paths_to_check {
if std::path::Path::new(path).exists() {
return Some(path.clone());
}
}
// Fall back to checking PATH via which
if let Ok(output) = Command::new("which").arg("claude").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
}
None
}
pub struct WslBridge {
process: Option<Child>,
stdin: Option<ChildStdin>,
working_directory: String,
session_id: Option<String>,
}
impl WslBridge {
pub fn new() -> Self {
WslBridge {
process: None,
stdin: None,
working_directory: String::new(),
session_id: None,
}
}
pub fn start(&mut self, app: AppHandle, working_dir: &str, allowed_tools: Vec<String>) -> Result<(), String> {
if self.process.is_some() {
return Err("Process already running".to_string());
}
self.working_directory = working_dir.to_string();
emit_connection_status(&app, ConnectionStatus::Connecting);
// Detect if we're running inside WSL or on Windows
let is_wsl = detect_wsl();
eprintln!("[DEBUG] is_wsl: {}", is_wsl);
eprintln!("[DEBUG] allowed_tools: {:?}", allowed_tools);
let mut command = if is_wsl {
// Running inside WSL - call claude directly
// Try to find claude in common locations since GUI apps may not inherit shell PATH
let claude_path = find_claude_binary()
.ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?;
eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path);
cmd.args([
"--output-format", "stream-json",
"--input-format", "stream-json",
"--verbose",
]);
// Add allowed tools if any
for tool in &allowed_tools {
cmd.args(["--allowedTools", tool]);
}
cmd.current_dir(working_dir);
cmd
} else {
// Running on Windows - use wsl to call claude
eprintln!("[DEBUG] Windows path - using wsl");
let mut cmd = Command::new("wsl");
let mut args = vec![
"--cd".to_string(), working_dir.to_string(),
"--".to_string(), "claude".to_string(),
"--output-format".to_string(), "stream-json".to_string(),
"--input-format".to_string(), "stream-json".to_string(),
"--verbose".to_string(),
];
// Add allowed tools if any
for tool in &allowed_tools {
args.push("--allowedTools".to_string());
args.push(tool.clone());
}
cmd.args(&args);
cmd
};
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn().map_err(|e| {
eprintln!("[DEBUG] Spawn error: {:?}", e);
format!("Failed to spawn process: {}", e)
})?;
let stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
self.stdin = stdin;
self.process = Some(child);
if let Some(stdout) = stdout {
let app_clone = app.clone();
thread::spawn(move || {
handle_stdout(stdout, app_clone);
});
}
if let Some(stderr) = stderr {
let app_clone = app.clone();
thread::spawn(move || {
handle_stderr(stderr, app_clone);
});
}
emit_connection_status(&app, ConnectionStatus::Connected);
Ok(())
}
pub fn send_message(&mut self, message: &str) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
let input = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "text",
"text": message
}]
}
});
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
stdin
.write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(())
}
pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.take() {
let _ = process.kill();
let _ = process.wait();
}
self.stdin = None;
self.session_id = None;
emit_connection_status(app, ConnectionStatus::Disconnected);
}
pub fn is_running(&self) -> bool {
self.process.is_some()
}
pub fn get_working_directory(&self) -> &str {
&self.working_directory
}
}
impl Default for WslBridge {
fn default() -> Self {
Self::new()
}
}
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app) {
eprintln!("Error processing line: {}", e);
}
}
Err(e) => {
eprintln!("Error reading stdout: {}", e);
break;
}
_ => {}
}
}
emit_connection_status(&app, ConnectionStatus::Disconnected);
}
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
let reader = BufReader::new(stderr);
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
let _ = app.emit("claude:output", OutputEvent {
line_type: "error".to_string(),
content: line,
tool_name: None,
});
}
Err(_) => break,
_ => {}
}
}
}
fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
match &message {
ClaudeMessage::System { subtype, session_id, cwd, .. } => {
if subtype == "init" {
if let Some(id) = session_id {
let _ = app.emit("claude:session", id.clone());
}
if let Some(dir) = cwd {
let _ = app.emit("claude:cwd", dir.clone());
}
emit_state_change(app, CharacterState::Idle, None);
}
}
ClaudeMessage::Assistant { message, .. } => {
let mut state = CharacterState::Typing;
let mut tool_name = None;
for block in &message.content {
match block {
ContentBlock::ToolUse { name, input, .. } => {
tool_name = Some(name.clone());
state = get_tool_state(name);
let desc = format_tool_description(name, input);
let _ = app.emit("claude:output", OutputEvent {
line_type: "tool".to_string(),
content: desc,
tool_name: Some(name.clone()),
});
}
ContentBlock::Text { text } => {
let _ = app.emit("claude:output", OutputEvent {
line_type: "assistant".to_string(),
content: text.clone(),
tool_name: None,
});
}
ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking;
let _ = app.emit("claude:output", OutputEvent {
line_type: "system".to_string(),
content: format!("[Thinking] {}", thinking),
tool_name: None,
});
}
_ => {}
}
}
emit_state_change(app, state, tool_name);
}
ClaudeMessage::StreamEvent { event } => {
if event.event_type == "content_block_start" {
if let Some(block) = &event.content_block {
let state = match block.block_type.as_str() {
"thinking" => CharacterState::Thinking,
"text" => CharacterState::Typing,
"tool_use" => {
if let Some(name) = &block.name {
get_tool_state(name)
} else {
CharacterState::Typing
}
}
_ => CharacterState::Typing,
};
emit_state_change(app, state, block.name.clone());
}
} else if event.event_type == "content_block_delta" {
if let Some(delta) = &event.delta {
if let Some(text) = &delta.text {
let _ = app.emit("claude:stream", text.clone());
}
}
}
}
ClaudeMessage::Result { subtype, result, permission_denials, .. } => {
let state = if subtype == "success" {
CharacterState::Success
} else {
CharacterState::Error
};
// Only emit error results - success content is already sent via Assistant message
if subtype != "success" {
if let Some(text) = result {
let _ = app.emit("claude:output", OutputEvent {
line_type: "error".to_string(),
content: text.clone(),
tool_name: None,
});
}
}
// Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials {
for denial in denials {
let description = format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit("claude:permission", PermissionPromptEvent {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
});
}
// Show permission state if there were denials
if !denials.is_empty() {
emit_state_change(app, CharacterState::Permission, None);
return Ok(());
}
}
emit_state_change(app, state, None);
}
ClaudeMessage::User { .. } => {
emit_state_change(app, CharacterState::Thinking, None);
}
}
Ok(())
}
fn get_tool_state(tool_name: &str) -> CharacterState {
if SEARCH_TOOLS.contains(&tool_name) {
CharacterState::Searching
} else if CODING_TOOLS.contains(&tool_name) {
CharacterState::Coding
} else if tool_name.starts_with("mcp__") {
CharacterState::Mcp
} else if tool_name == "Task" {
CharacterState::Thinking
} else {
CharacterState::Typing
}
}
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
match name {
"Read" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
format!("Reading file: {}", path)
} else {
"Reading file...".to_string()
}
}
"Glob" => {
if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) {
format!("Searching for files: {}", pattern)
} else {
"Searching for files...".to_string()
}
}
"Grep" => {
if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) {
format!("Searching for: {}", pattern)
} else {
"Searching in files...".to_string()
}
}
"Edit" | "Write" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
format!("Editing: {}", path)
} else {
"Editing file...".to_string()
}
}
"Bash" => {
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
let truncated = if cmd.len() > 50 {
format!("{}...", &cmd[..50])
} else {
cmd.to_string()
};
format!("Running: {}", truncated)
} else {
"Running command...".to_string()
}
}
_ => format!("Using tool: {}", name),
}
}
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>) {
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name });
}
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus) {
let _ = app.emit("claude:connection", status);
}
pub type SharedBridge = Arc<Mutex<WslBridge>>;
pub fn create_shared_bridge() -> SharedBridge {
Arc::new(Mutex::new(WslBridge::new()))
}
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "0.1.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "Hikari - Claude Code Assistant",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+53
View File
@@ -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);
}
+13
View File
@@ -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>
+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>
+40
View File
@@ -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]);
+131
View File
@@ -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
);
+93
View File
@@ -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");
}
+120
View File
@@ -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";
+74
View File
@@ -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",
},
};
+136
View File
@@ -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,
}));
}
+9
View File
@@ -0,0 +1,9 @@
<script>
import "../app.css";
let { children } = $props();
</script>
<div id="app">
{@render children()}
</div>
+5
View File
@@ -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;
+47
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+33
View File
@@ -0,0 +1,33 @@
# Hikari Sprites
Place your anime girl sprites here! Each state needs a PNG image.
## Required Files
| Filename | State | Description |
|----------|-------|-------------|
| `idle.png` | Idle | Relaxed, waiting pose |
| `thinking.png` | Thinking | Hand on chin, contemplative |
| `typing.png` | Typing | Hands on keyboard, focused |
| `searching.png` | Searching | With magnifying glass or looking around |
| `coding.png` | Coding | Intense focus, maybe with glasses |
| `mcp.png` | MCP Tools | Magical aura, tech vibes |
| `permission.png` | Permission | Questioning look, curious expression |
| `success.png` | Success | Celebrating, happy! |
| `error.png` | Error | Concerned, sympathetic |
## Recommended Specs
- **Size**: 512x512 pixels minimum (will be scaled down as needed)
- **Format**: PNG with transparency
- **Style**: Anime/manga style character
- **Background**: Transparent
## Bonus: Animation Frames
For animated states, you can add numbered frames:
- `typing_1.png`, `typing_2.png`, `typing_3.png`
- `thinking_1.png`, `thinking_2.png`
- etc.
The app will cycle through frames if multiple are detected!
Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+37
View File
@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#e94560;stop-opacity:0.2" />
<stop offset="100%" style="stop-color:#ff6b9d;stop-opacity:0.2" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="100" cy="100" r="90" fill="url(#bgGrad)" stroke="#e94560" stroke-width="2"/>
<!-- Simple face outline -->
<ellipse cx="100" cy="85" rx="45" ry="50" fill="none" stroke="#ff6b9d" stroke-width="2"/>
<!-- Eyes -->
<ellipse cx="80" cy="75" rx="8" ry="10" fill="#e94560"/>
<ellipse cx="120" cy="75" rx="8" ry="10" fill="#e94560"/>
<circle cx="82" cy="73" r="3" fill="white"/>
<circle cx="122" cy="73" r="3" fill="white"/>
<!-- Smile -->
<path d="M 80 100 Q 100 115 120 100" fill="none" stroke="#e94560" stroke-width="2" stroke-linecap="round"/>
<!-- Hair outline -->
<path d="M 55 60 Q 50 30 100 25 Q 150 30 145 60" fill="none" stroke="#ff6b9d" stroke-width="2"/>
<path d="M 55 60 Q 45 80 50 100" fill="none" stroke="#ff6b9d" stroke-width="2"/>
<path d="M 145 60 Q 155 80 150 100" fill="none" stroke="#ff6b9d" stroke-width="2"/>
<!-- Sparkles -->
<text x="40" y="50" font-size="16"></text>
<text x="150" y="50" font-size="16"></text>
<!-- Text -->
<text x="100" y="170" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#a0a0a0">
Add your sprite!
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+18
View File
@@ -0,0 +1,18 @@
// 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
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: "index.html",
}),
},
};
export default config;
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [tailwindcss(), sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));