Compare commits

...

6 Commits

Author SHA1 Message Date
hikari 59c7652f3e feat: add /search command to highlight matches in conversation
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 54s
CI / Lint & Test (pull_request) Failing after 5m52s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
Closes #32
2026-01-21 20:19:46 -08:00
hikari 19ca7b7c6e feat: add /cd command to change working directory 2026-01-21 19:46:54 -08:00
hikari b954158ea7 fix: allow spaces in tab names
Closes #52
2026-01-21 19:00:08 -08:00
hikari ab3f6700f6 fix: open markdown links in user's default browser
Closes #54
2026-01-21 18:01:21 -08:00
naomi 947e56ef41 feat: naomi did too much at once (#53)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m10s
CI / Build Linux (push) Successful in 16m47s
CI / Build Windows (cross-compile) (push) Successful in 26m36s
- feat: add slash commands
- feat: toggle window always on top
- fix: save settings button closes settings panel
- feat: input history (both text and commands)
- feat: add keyboard shortcuts
- feat: add confirmation modal when closing connected tabs
- fix: better text colours in light mode
- fix: handle multiple tabs requesting permission

Closes #6
Closes #13
Closes #21
Closes #28

Reviewed-on: #53
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-21 17:38:36 -08:00
naomi 9fe4e8a48a feat: add markdown renderer and code block highlighting (#50)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 56s
CI / Lint & Test (push) Successful in 14m14s
CI / Build Linux (push) Successful in 16m45s
CI / Build Windows (cross-compile) (push) Successful in 26m51s
### Explanation

_No response_

### Issue

Closes #33 Closes #31

### Attestations

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

### Dependencies

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

### Style

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

### Tests

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

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #50
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-21 11:28:09 -08:00
27 changed files with 1701 additions and 140 deletions
+1
View File
@@ -2,6 +2,7 @@ build/
.svelte-kit/ .svelte-kit/
dist/ dist/
src-tauri/target/ src-tauri/target/
src-tauri/gen/
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
pnpm-lock.yaml pnpm-lock.yaml
+4 -2
View File
@@ -25,11 +25,13 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4", "@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-notification": "^2", "highlight.js": "^11.11.1",
"@tauri-apps/plugin-os": "^2" "marked": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
+19
View File
@@ -29,6 +29,12 @@ importers:
'@tauri-apps/plugin-store': '@tauri-apps/plugin-store':
specifier: ^2 specifier: ^2
version: 2.4.2 version: 2.4.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
marked:
specifier: ^17.0.1
version: 17.0.1
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.2 specifier: ^9.39.2
@@ -1185,6 +1191,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1372,6 +1382,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
mdn-data@2.12.2: mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
@@ -2780,6 +2795,8 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
highlight.js@11.11.1: {}
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
dependencies: dependencies:
'@exodus/bytes': 1.8.0 '@exodus/bytes': 1.8.0
@@ -2949,6 +2966,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.1: {}
mdn-data@2.12.2: {} mdn-data@2.12.2: {}
min-indent@1.0.1: {} min-indent@1.0.1: {}
+45
View File
@@ -105,6 +105,51 @@ pub async fn get_usage_stats(
manager.get_usage_stats(&conversation_id) manager.get_usage_stats(&conversation_id)
} }
#[tauri::command]
pub async fn validate_directory(path: String, current_dir: Option<String>) -> Result<String, String> {
use std::path::Path;
let path = Path::new(&path);
// Expand ~ to home directory
let expanded_path = if path.starts_with("~") {
if let Some(home) = std::env::var_os("HOME") {
let home_path = Path::new(&home);
if path == Path::new("~") {
home_path.to_path_buf()
} else {
home_path.join(path.strip_prefix("~").unwrap())
}
} else {
return Err("Could not determine home directory".to_string());
}
} else if path.is_relative() {
// Handle relative paths (., .., or any relative path) by resolving against current_dir
if let Some(ref cwd) = current_dir {
Path::new(cwd).join(path)
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
};
// Check if the path exists and is a directory
if !expanded_path.exists() {
return Err(format!("Directory does not exist: {}", expanded_path.display()));
}
if !expanded_path.is_dir() {
return Err(format!("Path is not a directory: {}", expanded_path.display()));
}
// Return the canonicalized (absolute) path
expanded_path
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.map_err(|e| format!("Failed to resolve path: {}", e))
}
#[tauri::command] #[tauri::command]
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> { pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> {
use chrono::Utc; use chrono::Utc;
+9
View File
@@ -22,6 +22,9 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub skip_greeting: bool, pub skip_greeting: bool,
#[serde(default)]
pub resume_session_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -55,6 +58,9 @@ pub struct HikariConfig {
#[serde(default = "default_notification_volume")] #[serde(default = "default_notification_volume")]
pub notification_volume: f32, pub notification_volume: f32,
#[serde(default)]
pub always_on_top: bool,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -70,6 +76,7 @@ impl Default for HikariConfig {
greeting_custom_prompt: None, greeting_custom_prompt: None,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false,
} }
} }
} }
@@ -109,6 +116,7 @@ mod tests {
assert_eq!(config.theme, Theme::Dark); assert_eq!(config.theme, Theme::Dark);
assert!(config.greeting_enabled); assert!(config.greeting_enabled);
assert!(config.greeting_custom_prompt.is_none()); assert!(config.greeting_custom_prompt.is_none());
assert!(!config.always_on_top);
} }
#[test] #[test]
@@ -124,6 +132,7 @@ mod tests {
greeting_custom_prompt: Some("Hello!".to_string()), greeting_custom_prompt: Some("Hello!".to_string()),
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: true,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
+1
View File
@@ -53,6 +53,7 @@ pub fn run() {
send_notify_send, send_notify_send,
send_wsl_notification, send_wsl_notification,
send_vbs_notification, send_vbs_notification,
validate_directory,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+14
View File
@@ -195,6 +195,13 @@ impl WslBridge {
cmd.args(["--mcp-config", mcp_path]); cmd.args(["--mcp-config", mcp_path]);
} }
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
cmd.args(["--resume", session_id]);
}
}
cmd.current_dir(working_dir); cmd.current_dir(working_dir);
// Set API key as environment variable if specified // Set API key as environment variable if specified
@@ -251,6 +258,13 @@ impl WslBridge {
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path)); claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
} }
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
claude_cmd.push_str(&format!(" --resume '{}'", session_id));
}
}
// Use bash -lc to load login profile (ensures PATH includes claude) // Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]); cmd.args(["-e", "bash", "-lc", &claude_cmd]);
+22
View File
@@ -6,6 +6,7 @@
--bg-secondary: #16213e; --bg-secondary: #16213e;
--bg-terminal: #0f0f1a; --bg-terminal: #0f0f1a;
--bg-hover: #2a2a4a; --bg-hover: #2a2a4a;
--bg-code: #1e1e2e;
--accent-primary: #e94560; --accent-primary: #e94560;
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #ffffff; --text-primary: #ffffff;
@@ -18,6 +19,16 @@
--terminal-tool: #c084fc; --terminal-tool: #c084fc;
--terminal-tool-name: #ddd6fe; --terminal-tool-name: #ddd6fe;
--terminal-error: #f87171; --terminal-error: #f87171;
/* Syntax highlighting colors (dark) */
--hljs-keyword: #f472b6;
--hljs-string: #a3e635;
--hljs-number: #fbbf24;
--hljs-comment: #6b7280;
--hljs-function: #c084fc;
--hljs-type: #22d3ee;
--hljs-variable: #fb923c;
--hljs-meta: #94a3b8;
} }
[data-theme="light"] { [data-theme="light"] {
@@ -25,6 +36,7 @@
--bg-secondary: #ffffff; --bg-secondary: #ffffff;
--bg-terminal: #f1f3f4; --bg-terminal: #f1f3f4;
--bg-hover: #e8e8e8; --bg-hover: #e8e8e8;
--bg-code: #f5f5f5;
--accent-primary: #e94560; --accent-primary: #e94560;
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #1a1a2e; --text-primary: #1a1a2e;
@@ -37,6 +49,16 @@
--terminal-tool: #7c3aed; --terminal-tool: #7c3aed;
--terminal-tool-name: #8b5cf6; --terminal-tool-name: #8b5cf6;
--terminal-error: #dc2626; --terminal-error: #dc2626;
/* Syntax highlighting colors (light) */
--hljs-keyword: #d946ef;
--hljs-string: #16a34a;
--hljs-number: #d97706;
--hljs-comment: #9ca3af;
--hljs-function: #7c3aed;
--hljs-type: #0891b2;
--hljs-variable: #ea580c;
--hljs-meta: #64748b;
} }
html, html,
+225
View File
@@ -0,0 +1,225 @@
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { setSkipNextGreeting } from "$lib/tauri";
import { searchState } from "$lib/stores/search";
export interface SlashCommand {
name: string;
description: string;
usage: string;
execute: (args: string) => Promise<void> | void;
}
async function changeDirectory(path: string): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
if (!path.trim()) {
const currentDir = get(claudeStore.currentWorkingDirectory);
claudeStore.addLine("system", `Current directory: ${currentDir}`);
return;
}
try {
characterState.setState("thinking");
claudeStore.addLine("system", `Changing directory to: ${path}`);
const currentDir = get(claudeStore.currentWorkingDirectory);
const validatedPath = await invoke<string>("validate_directory", { path, currentDir });
// Capture conversation history before disconnecting
const conversationHistory = claudeStore.getConversationHistory();
await invoke("stop_claude", { conversationId });
// Wait for clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
claudeStore.setWorkingDirectory(validatedPath);
setSkipNextGreeting(true);
await invoke("start_claude", {
conversationId,
options: {
working_dir: validatedPath,
},
});
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Restore context if there was conversation history
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just changed the working directory from ${currentDir} to ${validatedPath}. Here's our conversation so far:
${conversationHistory}
Please continue where we left off. You are now operating in the new directory.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
claudeStore.addLine("system", `Changed directory to: ${validatedPath}`);
characterState.setState("idle");
} catch (error) {
claudeStore.addLine("error", `Failed to change directory: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
async function startNewConversation(): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
try {
const workingDir = await invoke<string>("get_working_directory", {
conversationId,
});
claudeStore.addLine("system", "Starting new conversation...");
characterState.setState("thinking");
await invoke("interrupt_claude", { conversationId });
claudeStore.clearTerminal();
setSkipNextGreeting(true);
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
},
});
claudeStore.addLine("system", "New conversation started!");
characterState.setState("idle");
} catch (error) {
claudeStore.addLine("error", `Failed to start new conversation: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
export const slashCommands: SlashCommand[] = [
{
name: "cd",
description: "Change the working directory",
usage: "/cd <path>",
execute: changeDirectory,
},
{
name: "clear",
description: "Clear the terminal display (keeps conversation context)",
usage: "/clear",
execute: () => {
claudeStore.clearTerminal();
claudeStore.addLine("system", "Terminal cleared");
},
},
{
name: "new",
description: "Start a fresh conversation (resets context)",
usage: "/new",
execute: startNewConversation,
},
{
name: "help",
description: "Show available slash commands",
usage: "/help",
execute: () => {
const helpText = slashCommands
.map((cmd) => ` ${cmd.usage.padEnd(12)} - ${cmd.description}`)
.join("\n");
claudeStore.addLine("system", `Available commands:\n${helpText}`);
},
},
{
name: "search",
description: "Search within the conversation (use /search to clear)",
usage: "/search [query]",
execute: (args: string) => {
if (!args.trim()) {
searchState.clear();
claudeStore.addLine("system", "Search cleared");
return;
}
searchState.setQuery(args.trim());
claudeStore.addLine("system", `Searching for: "${args.trim()}"`);
},
},
{
name: "summarise",
description: "Get a summary of the entire conversation",
usage: "/summarise",
execute: async () => {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
try {
claudeStore.addLine("system", "Requesting conversation summary...");
await invoke("send_prompt", {
conversationId,
message:
"Please provide a comprehensive summary of our entire conversation so far, including the key topics we've discussed, decisions made, and any important context.",
});
} catch (error) {
claudeStore.addLine("error", `Failed to request summary: ${error}`);
}
},
},
];
export function parseSlashCommand(input: string): {
command: SlashCommand | null;
args: string;
} {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) {
return { command: null, args: "" };
}
const parts = trimmed.slice(1).split(/\s+/);
const commandName = parts[0]?.toLowerCase();
const args = parts.slice(1).join(" ");
const command = slashCommands.find((cmd) => cmd.name.toLowerCase() === commandName);
return { command: command || null, args };
}
export function getMatchingCommands(input: string): SlashCommand[] {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) {
return [];
}
const partial = trimmed.slice(1).toLowerCase();
if (partial === "") {
return slashCommands;
}
return slashCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(partial));
}
export function isSlashCommand(input: string): boolean {
return input.trim().startsWith("/");
}
+15 -13
View File
@@ -40,10 +40,12 @@
tabindex="-1" tabindex="-1"
> >
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 id="about-title" class="text-xl font-semibold text-gray-100">About Hikari Desktop</h2> <h2 id="about-title" class="text-xl font-semibold text-[var(--text-primary)]">
About Hikari Desktop
</h2>
<button <button
onclick={onClose} onclick={onClose}
class="p-1 text-gray-500 hover:text-gray-300 transition-colors" class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close" aria-label="Close"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -59,16 +61,16 @@
<div class="space-y-4 text-sm"> <div class="space-y-4 text-sm">
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">What is Hikari Desktop?</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">What is Hikari Desktop?</h3>
<p class="text-gray-400"> <p class="text-[var(--text-secondary)]">
Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your
desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience. desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience.
</p> </p>
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">Version</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">Version</h3>
<p class="text-gray-400 mb-1"> <p class="text-[var(--text-secondary)] mb-1">
{appVersion || "Loading..."} {appVersion || "Loading..."}
</p> </p>
<button <button
@@ -80,7 +82,7 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">Source Code</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">Source Code</h3>
<button <button
onclick={() => openUrl(links.source)} onclick={() => openUrl(links.source)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -90,8 +92,8 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">Support & Community</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">Support & Community</h3>
<p class="text-gray-400 mb-1">Found a bug or have a suggestion?</p> <p class="text-[var(--text-secondary)] mb-1">Found a bug or have a suggestion?</p>
<button <button
onclick={() => openUrl(links.discord)} onclick={() => openUrl(links.discord)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -101,7 +103,7 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">Built with 💕 by</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">Built with 💕 by</h3>
<button <button
onclick={() => openUrl(links.website)} onclick={() => openUrl(links.website)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -111,8 +113,8 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">License</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">License</h3>
<p class="text-gray-400 mb-1"> <p class="text-[var(--text-secondary)] mb-1">
This project is open source and available under our license terms. This project is open source and available under our license terms.
</p> </p>
<button <button
@@ -124,7 +126,7 @@
</div> </div>
<div class="pt-4 mt-4 border-t border-[var(--border-color)]"> <div class="pt-4 mt-4 border-t border-[var(--border-color)]">
<p class="text-xs text-gray-500 text-center"> <p class="text-xs text-[var(--text-tertiary)] text-center">
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved. Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
</p> </p>
</div> </div>
@@ -0,0 +1,107 @@
<script lang="ts">
interface Props {
isOpen: boolean;
tabName: string;
onConfirm: () => void;
onCancel: () => void;
}
const { isOpen, tabName, onConfirm, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Enter") {
event.preventDefault();
onConfirm();
} else if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Connected Tab?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
The tab "{tabName}" is currently connected to Claude. Are you sure you want to close
it? This will disconnect the session.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onConfirm}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Tab
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
+55 -22
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { configStore, type HikariConfig, type Theme } from "$lib/stores/config"; import { configStore, type HikariConfig, type Theme } from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
model: null, model: null,
@@ -13,6 +14,7 @@
greeting_custom_prompt: null, greeting_custom_prompt: null,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false,
}); });
let isOpen = $state(false); let isOpen = $state(false);
@@ -61,6 +63,7 @@
saveError = null; saveError = null;
try { try {
await configStore.saveConfig(config); await configStore.saveConfig(config);
configStore.closeSidebar();
} catch { } catch {
// Error is handled by the store // Error is handled by the store
} finally { } finally {
@@ -95,6 +98,13 @@
function importFromSession() { function importFromSession() {
config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])]; config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])];
} }
async function handleAlwaysOnTopChange(enabled: boolean) {
config.always_on_top = enabled;
const window = getCurrentWindow();
await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled });
}
</script> </script>
<!-- Backdrop --> <!-- Backdrop -->
@@ -121,7 +131,7 @@
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<button <button
onclick={configStore.closeSidebar} onclick={configStore.closeSidebar}
class="p-1 text-gray-400 hover:text-white transition-colors" class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close settings" aria-label="Close settings"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -149,7 +159,7 @@
<!-- Model Selection --> <!-- Model Selection -->
<div class="mb-4"> <div class="mb-4">
<label for="model" class="block text-sm text-gray-400 mb-1">Model</label> <label for="model" class="block text-sm text-[var(--text-secondary)] mb-1">Model</label>
<select <select
id="model" id="model"
bind:value={config.model} bind:value={config.model}
@@ -163,8 +173,8 @@
<!-- API Key --> <!-- API Key -->
<div class="mb-4"> <div class="mb-4">
<label for="api-key" class="block text-sm text-gray-400 mb-1"> <label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
API Key <span class="text-gray-600">(optional override)</span> API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
</label> </label>
<div class="relative"> <div class="relative">
<input <input
@@ -177,7 +187,7 @@
<button <button
type="button" type="button"
onclick={() => (showApiKey = !showApiKey)} onclick={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
aria-label={showApiKey ? "Hide API key" : "Show API key"} aria-label={showApiKey ? "Hide API key" : "Show API key"}
> >
{#if showApiKey} {#if showApiKey}
@@ -211,7 +221,7 @@
<!-- Custom Instructions --> <!-- Custom Instructions -->
<div class="mb-4"> <div class="mb-4">
<label for="instructions" class="block text-sm text-gray-400 mb-1" <label for="instructions" class="block text-sm text-[var(--text-secondary)] mb-1"
>Custom Instructions</label >Custom Instructions</label
> >
<textarea <textarea
@@ -238,9 +248,9 @@
bind:checked={config.greeting_enabled} bind:checked={config.greeting_enabled}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]" class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/> />
<span class="text-sm text-gray-300">Send greeting on connect</span> <span class="text-sm text-[var(--text-primary)]">Send greeting on connect</span>
</label> </label>
<p class="text-xs text-gray-500 mt-1 ml-7"> <p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Automatically greet you when a session starts with time-based messages Automatically greet you when a session starts with time-based messages
</p> </p>
</div> </div>
@@ -248,8 +258,8 @@
<!-- Custom Greeting Prompt --> <!-- Custom Greeting Prompt -->
{#if config.greeting_enabled} {#if config.greeting_enabled}
<div class="mb-4"> <div class="mb-4">
<label for="greeting-prompt" class="block text-sm text-gray-400 mb-1"> <label for="greeting-prompt" class="block text-sm text-[var(--text-secondary)] mb-1">
Custom Greeting Prompt <span class="text-gray-600">(optional)</span> Custom Greeting Prompt <span class="text-[var(--text-tertiary)]">(optional)</span>
</label> </label>
<textarea <textarea
id="greeting-prompt" id="greeting-prompt"
@@ -268,8 +278,8 @@
MCP Servers MCP Servers
</h3> </h3>
<div class="mb-2"> <div class="mb-2">
<label for="mcp-config" class="block text-sm text-gray-400 mb-1"> <label for="mcp-config" class="block text-sm text-[var(--text-secondary)] mb-1">
Server Configuration <span class="text-gray-600">(JSON)</span> Server Configuration <span class="text-[var(--text-tertiary)]">(JSON)</span>
</label> </label>
<textarea <textarea
id="mcp-config" id="mcp-config"
@@ -286,14 +296,14 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Auto-Granted Tools Auto-Granted Tools
</h3> </h3>
<p class="text-xs text-gray-500 mb-3"> <p class="text-xs text-[var(--text-tertiary)] mb-3">
These tools will be pre-approved for every session (no permission prompts). These tools will be pre-approved for every session (no permission prompts).
</p> </p>
<!-- Common tools checkboxes --> <!-- Common tools checkboxes -->
<div class="grid grid-cols-2 gap-2 mb-3"> <div class="grid grid-cols-2 gap-2 mb-3">
{#each commonTools as tool (tool)} {#each commonTools as tool (tool)}
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> <label class="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={config.auto_granted_tools.includes(tool)} checked={config.auto_granted_tools.includes(tool)}
@@ -309,7 +319,7 @@
{#if grantedTools.length > 0} {#if grantedTools.length > 0}
<div class="mb-3"> <div class="mb-3">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500">Session-granted tools:</span> <span class="text-xs text-[var(--text-tertiary)]">Session-granted tools:</span>
<button <button
onclick={importFromSession} onclick={importFromSession}
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors" class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors"
@@ -332,7 +342,7 @@
<!-- Custom tools list --> <!-- Custom tools list -->
{#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0} {#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0}
<div class="mb-3"> <div class="mb-3">
<span class="text-xs text-gray-500 block mb-2">Custom tools:</span> <span class="text-xs text-[var(--text-tertiary)] block mb-2">Custom tools:</span>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)} {#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)}
<span <span
@@ -341,7 +351,7 @@
{tool} {tool}
<button <button
onclick={() => removeTool(tool)} onclick={() => removeTool(tool)}
class="text-gray-500 hover:text-red-400" class="text-[var(--text-tertiary)] hover:text-red-400"
aria-label="Remove {tool}" aria-label="Remove {tool}"
> >
× ×
@@ -381,7 +391,7 @@
onclick={() => handleThemeChange("dark")} onclick={() => handleThemeChange("dark")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark' class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white' ? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}" : 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
> >
Dark Dark
</button> </button>
@@ -389,13 +399,36 @@
onclick={() => handleThemeChange("light")} onclick={() => handleThemeChange("light")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light' class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white' ? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}" : 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
> >
Light Light
</button> </button>
</div> </div>
</section> </section>
<!-- Window Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Window
</h3>
<!-- Always on Top Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.always_on_top}
onchange={(e) => handleAlwaysOnTopChange(e.currentTarget.checked)}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Always on top</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Keep the window above other windows
</p>
</div>
</section>
<!-- Notifications Section --> <!-- Notifications Section -->
<section class="mb-6"> <section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -410,13 +443,13 @@
bind:checked={config.notifications_enabled} bind:checked={config.notifications_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2" class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/> />
<span class="text-sm text-gray-300">Enable sound notifications</span> <span class="text-sm text-[var(--text-primary)]">Enable sound notifications</span>
</label> </label>
</div> </div>
<!-- Volume Control --> <!-- Volume Control -->
<div class="mb-4"> <div class="mb-4">
<label for="notification-volume" class="block text-sm text-gray-400 mb-2"> <label for="notification-volume" class="block text-sm text-[var(--text-secondary)] mb-2">
Notification Volume Notification Volume
</label> </label>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -436,7 +469,7 @@
</div> </div>
</div> </div>
<div class="text-xs text-gray-500"> <div class="text-xs text-[var(--text-tertiary)]">
Sound notifications will play when I complete tasks, encounter errors, or need permissions. Sound notifications will play when I complete tasks, encounter errors, or need permissions.
</div> </div>
</section> </section>
+76 -59
View File
@@ -3,49 +3,35 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Conversation } from "$lib/stores/conversations"; import type { Conversation } from "$lib/stores/conversations";
import { SvelteMap } from "svelte/reactivity"; import { SvelteMap } from "svelte/reactivity";
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
let conversations: Map<string, Conversation> = new Map(); // Use store subscriptions with $ syntax
let activeConversationId: string | null = null; const conversations = $derived(claudeStore.conversations);
let editingTabId: string | null = null; const activeConversationId = $derived(claudeStore.activeConversationId);
let editingName = "";
// Track which conversation actually has the Claude connection let editingTabId = $state<string | null>(null);
let connectedConversationId: string | null = null; let editingName = $state("");
// Track last seen message count for each conversation // Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>(); let lastSeenMessageCount = new SvelteMap<string, number>();
claudeStore.conversations.subscribe((convs) => { // Confirmation modal state
conversations = convs; let showConfirmModal = $state(false);
let tabToClose = $state<string | null>(null);
let tabToCloseName = $state("");
// Update the last seen count for the active conversation // Update last seen count when active conversation changes
if (activeConversationId) { $effect(() => {
const activeConv = convs.get(activeConversationId); if ($activeConversationId) {
const activeConv = $conversations.get($activeConversationId);
if (activeConv) { if (activeConv) {
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length); lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length);
// Trigger reactivity
lastSeenMessageCount = lastSeenMessageCount;
} }
} }
}); });
claudeStore.activeConversationId.subscribe((id) => {
activeConversationId = id;
});
// Find the connected conversation
$: {
let foundConnected = false;
for (const [id, conv] of conversations) {
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
connectedConversationId = id;
foundConnected = true;
break;
}
}
if (!foundConnected) {
connectedConversationId = null;
}
}
function createNewTab() { function createNewTab() {
claudeStore.createConversation(); claudeStore.createConversation();
} }
@@ -57,7 +43,7 @@
await claudeStore.switchConversation(id); await claudeStore.switchConversation(id);
// Mark messages as seen when switching to this tab // Mark messages as seen when switching to this tab
const conv = conversations.get(id); const conv = $conversations.get(id);
if (conv) { if (conv) {
lastSeenMessageCount.set(id, conv.terminalLines.length); lastSeenMessageCount.set(id, conv.terminalLines.length);
// Trigger reactivity // Trigger reactivity
@@ -67,11 +53,35 @@
function deleteTab(id: string, event: MouseEvent) { function deleteTab(id: string, event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
if (conversations.size > 1) { if ($conversations.size > 1) {
claudeStore.deleteConversation(id); const conversation = $conversations.get(id);
if (conversation && conversation.connectionStatus === "connected") {
// Show confirmation modal for connected tabs
tabToClose = id;
tabToCloseName = conversation.name;
showConfirmModal = true;
} else {
// Close disconnected tabs immediately
claudeStore.deleteConversation(id);
}
} }
} }
function confirmCloseTab() {
if (tabToClose) {
claudeStore.deleteConversation(tabToClose);
}
showConfirmModal = false;
tabToClose = null;
tabToCloseName = "";
}
function cancelCloseTab() {
showConfirmModal = false;
tabToClose = null;
tabToCloseName = "";
}
function startEditing(id: string, name: string, event: MouseEvent) { function startEditing(id: string, name: string, event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
editingTabId = id; editingTabId = id;
@@ -105,7 +115,7 @@
} }
function hasUnreadMessages(id: string, conversation: Conversation): boolean { function hasUnreadMessages(id: string, conversation: Conversation): boolean {
if (id === activeConversationId) return false; // Active tab never has unread if (id === $activeConversationId) return false; // Active tab never has unread
const lastSeen = lastSeenMessageCount.get(id) || 0; const lastSeen = lastSeenMessageCount.get(id) || 0;
return conversation.terminalLines.length > lastSeen; return conversation.terminalLines.length > lastSeen;
} }
@@ -116,6 +126,8 @@
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
editingTabId = null; editingTabId = null;
editingName = ""; editingName = "";
} else if (event.key === " ") {
event.stopPropagation();
} }
} }
@@ -137,15 +149,24 @@
// Ctrl/Cmd + W: Close current tab // Ctrl/Cmd + W: Close current tab
else if ((event.ctrlKey || event.metaKey) && event.key === "w") { else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
event.preventDefault(); event.preventDefault();
if (activeConversationId && conversations.size > 1) { if ($activeConversationId && $conversations.size > 1) {
claudeStore.deleteConversation(activeConversationId); const conversation = $conversations.get($activeConversationId);
if (conversation && conversation.connectionStatus === "connected") {
// Show confirmation modal for connected tabs
tabToClose = $activeConversationId;
tabToCloseName = conversation.name;
showConfirmModal = true;
} else {
// Close disconnected tabs immediately
claudeStore.deleteConversation($activeConversationId);
}
} }
} }
// Ctrl/Cmd + Tab: Next tab // Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
const tabs = Array.from(conversations.keys()); const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === activeConversationId); const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) { if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length; const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]); claudeStore.switchConversation(tabs[nextIndex]);
@@ -154,8 +175,8 @@
// Ctrl/Cmd + Shift + Tab: Previous tab // Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault(); event.preventDefault();
const tabs = Array.from(conversations.keys()); const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === activeConversationId); const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) { if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length; const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]); claudeStore.switchConversation(tabs[prevIndex]);
@@ -171,17 +192,17 @@
<div <div
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]" class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
> >
{#each Array.from(conversations.entries()) as [id, conversation] (id)} {#each Array.from($conversations.entries()) as [id, conversation] (id)}
<div <div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
{id === activeConversationId {id === $activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]' ? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}" : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)} onclick={() => switchTab(id)}
onkeydown={(e) => handleTabKeydown(id, e)} onkeydown={(e) => handleTabKeydown(id, e)}
role="tab" role="tab"
tabindex={0} tabindex={0}
aria-selected={id === activeConversationId} aria-selected={id === $activeConversationId}
> >
{#if editingTabId === id} {#if editingTabId === id}
<input <input
@@ -196,37 +217,26 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}" class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId && title="Connection: {conversation.connectionStatus}"
connectedConversationId
? ' (Another tab is connected)'
: ''}"
></div> ></div>
<span <span
class="text-sm pr-6 max-w-[150px] truncate" class="text-sm pr-2 max-w-[150px] truncate"
ondblclick={(e) => startEditing(id, conversation.name, e)} ondblclick={(e) => startEditing(id, conversation.name, e)}
role="button" role="button"
tabindex={-1} tabindex={-1}
> >
{conversation.name} {conversation.name}
</span> </span>
{#if id !== activeConversationId && id === connectedConversationId}
<span
class="text-xs text-[var(--text-tertiary)]"
title="This tab has the Claude connection"
>
(connected)
</span>
{/if}
{#if hasUnreadMessages(id, conversation)} {#if hasUnreadMessages(id, conversation)}
<div <div
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse pointer-events-none"
title="New messages" title="New messages"
></div> ></div>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if conversations.size > 1} {#if $conversations.size > 1}
<button <button
onclick={(e) => deleteTab(id, e)} onclick={(e) => deleteTab(id, e)}
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity" class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
@@ -268,6 +278,13 @@
</button> </button>
</div> </div>
<CloseTabConfirmModal
isOpen={showConfirmModal}
tabName={tabToCloseName}
onConfirm={confirmCloseTab}
onCancel={cancelCloseTab}
/>
<style> <style>
.terminal-tabs { .terminal-tabs {
min-height: 36px; min-height: 36px;
+8 -13
View File
@@ -43,14 +43,7 @@
"🔒 Grant tool permissions as needed for security", "🔒 Grant tool permissions as needed for security",
"📌 Pin important conversations for quick access", "📌 Pin important conversations for quick access",
"🎨 Customize your theme and preferences in Settings", "🎨 Customize your theme and preferences in Settings",
], "⌨️ Check the keyboard icon for available shortcuts",
},
{
title: "Keyboard Shortcuts",
items: [
"Ctrl/Cmd + Enter: Send message",
"Ctrl/Cmd + K: Clear chat (when supported)",
"Escape: Close modals and panels",
], ],
}, },
]; ];
@@ -72,10 +65,12 @@
tabindex="-1" tabindex="-1"
> >
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]"> <div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<h2 id="help-title" class="text-xl font-semibold text-gray-100">How to Use Hikari Desktop</h2> <h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
How to Use Hikari Desktop
</h2>
<button <button
onclick={onClose} onclick={onClose}
class="p-1 text-gray-500 hover:text-gray-300 transition-colors" class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close" aria-label="Close"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -92,8 +87,8 @@
<div class="overflow-y-auto flex-1 p-6 space-y-6"> <div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each sections as section (section.title)} {#each sections as section (section.title)}
<div> <div>
<h3 class="font-medium text-gray-200 mb-3">{section.title}</h3> <h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
<ul class="space-y-2 text-sm text-gray-400"> <ul class="space-y-2 text-sm text-[var(--text-secondary)]">
{#each section.items as item (item)} {#each section.items as item (item)}
<li class="flex items-start"> <li class="flex items-start">
<span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span> <span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span>
@@ -105,7 +100,7 @@
{/each} {/each}
<div class="pt-4 border-t border-[var(--border-color)]"> <div class="pt-4 border-t border-[var(--border-color)]">
<p class="text-sm text-gray-500"> <p class="text-sm text-[var(--text-tertiary)]">
<strong>Need more help?</strong> Join our Discord community for support and updates! <strong>Need more help?</strong> Join our Discord community for support and updates!
</p> </p>
</div> </div>
+175 -3
View File
@@ -13,13 +13,64 @@
clearHistoryRestore, clearHistoryRestore,
} from "$lib/stores/historyRestore"; } from "$lib/stores/historyRestore";
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte"; import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
import { getCurrentMode } from "$lib/stores/messageMode"; import { getCurrentMode } from "$lib/stores/messageMode";
import { formatMessageWithMode } from "$lib/types/messageMode"; import { formatMessageWithMode } from "$lib/types/messageMode";
import {
parseSlashCommand,
getMatchingCommands,
isSlashCommand,
type SlashCommand,
} from "$lib/commands/slashCommands";
const INPUT_HISTORY_KEY = "hikari-input-history";
const MAX_HISTORY_SIZE = 100;
let inputValue = $state(""); let inputValue = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let isConnected = $state(false); let isConnected = $state(false);
let isProcessing = $state(false); let isProcessing = $state(false);
let showCommandMenu = $state(false);
let matchingCommands = $state<SlashCommand[]>([]);
let selectedCommandIndex = $state(0);
// Input history state
let inputHistory = $state<string[]>([]);
let historyIndex = $state(-1);
let tempInput = $state("");
// Load history from localStorage on init
function loadHistory(): string[] {
try {
const stored = localStorage.getItem(INPUT_HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveHistory(history: string[]) {
try {
localStorage.setItem(INPUT_HISTORY_KEY, JSON.stringify(history));
} catch {
// Ignore storage errors
}
}
function addToHistory(input: string) {
const trimmed = input.trim();
if (!trimmed) return;
// Don't add duplicates of the most recent entry
if (inputHistory.length > 0 && inputHistory[0] === trimmed) return;
// Add to front of history
inputHistory = [trimmed, ...inputHistory.slice(0, MAX_HISTORY_SIZE - 1)];
saveHistory(inputHistory);
}
// Initialize history on mount
inputHistory = loadHistory();
claudeStore.connectionStatus.subscribe((status) => { claudeStore.connectionStatus.subscribe((status) => {
isConnected = status === "connected"; isConnected = status === "connected";
@@ -29,11 +80,70 @@
isProcessing = processing; isProcessing = processing;
}); });
function handleInputChange() {
// Reset history navigation when user types
historyIndex = -1;
tempInput = "";
if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
selectedCommandIndex = 0;
} else {
showCommandMenu = false;
matchingCommands = [];
}
}
function selectCommand(command: SlashCommand) {
inputValue = `/${command.name} `;
showCommandMenu = false;
matchingCommands = [];
}
async function executeSlashCommand(): Promise<boolean> {
const { command, args } = parseSlashCommand(inputValue);
if (command) {
inputValue = "";
showCommandMenu = false;
matchingCommands = [];
await command.execute(args);
return true;
}
return false;
}
async function handleSubmit(event: Event) { async function handleSubmit(event: Event) {
event.preventDefault(); event.preventDefault();
const message = inputValue.trim(); const message = inputValue.trim();
if (!message || isSubmitting || !isConnected) return; if (!message || isSubmitting) return;
// Check for slash commands first (these work even when disconnected)
if (isSlashCommand(message)) {
// Add slash commands to history too
addToHistory(message);
historyIndex = -1;
tempInput = "";
const wasCommand = await executeSlashCommand();
if (wasCommand) return;
// If it started with / but wasn't a valid command, show error
claudeStore.addLine(
"error",
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
);
inputValue = "";
return;
}
// Regular messages require connection
if (!isConnected) return;
// Add to history before clearing
addToHistory(message);
historyIndex = -1;
tempInput = "";
isSubmitting = true; isSubmitting = true;
inputValue = ""; inputValue = "";
@@ -139,6 +249,60 @@ User: ${formattedMessage}`;
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
// Handle command menu navigation
if (showCommandMenu && matchingCommands.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
selectedCommandIndex = (selectedCommandIndex + 1) % matchingCommands.length;
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
selectedCommandIndex =
(selectedCommandIndex - 1 + matchingCommands.length) % matchingCommands.length;
return;
}
if (event.key === "Tab") {
event.preventDefault();
const selected = matchingCommands[selectedCommandIndex];
if (selected) {
selectCommand(selected);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
showCommandMenu = false;
return;
}
}
// Handle input history navigation (when command menu is closed)
if (event.key === "ArrowUp" && inputHistory.length > 0) {
event.preventDefault();
if (historyIndex === -1) {
// Save current input before navigating history
tempInput = inputValue;
}
if (historyIndex < inputHistory.length - 1) {
historyIndex++;
inputValue = inputHistory[historyIndex];
}
return;
}
if (event.key === "ArrowDown" && historyIndex >= 0) {
event.preventDefault();
historyIndex--;
if (historyIndex === -1) {
// Restore the temp input when going back to current
inputValue = tempInput;
} else {
inputValue = inputHistory[historyIndex];
}
return;
}
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event); handleSubmit(event);
} }
@@ -152,11 +316,19 @@ User: ${formattedMessage}`;
<div class="input-row flex gap-3 items-end"> <div class="input-row flex gap-3 items-end">
<div class="flex-1 relative"> <div class="flex-1 relative">
<SlashCommandMenu
commands={matchingCommands}
selectedIndex={selectedCommandIndex}
onSelect={selectCommand}
/>
<textarea <textarea
bind:value={inputValue} bind:value={inputValue}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."} oninput={handleInputChange}
disabled={!isConnected || isSubmitting} placeholder={isConnected
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
disabled={isSubmitting}
rows={1} rows={1}
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
@@ -0,0 +1,177 @@
<script lang="ts">
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const shortcuts = [
{
category: "General",
items: [
{ keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" },
],
},
{
category: "Chat",
items: [
{ keys: ["Enter"], description: "Send message" },
{ keys: ["Shift", "Enter"], description: "New line in message" },
{ keys: ["Ctrl", "C"], description: "Interrupt/stop response" },
{ keys: ["↑"], description: "Previous input from history" },
{ keys: ["↓"], description: "Next input from history" },
],
},
{
category: "Slash Commands",
items: [
{ keys: ["↑", "↓"], description: "Navigate command menu" },
{ keys: ["Tab"], description: "Complete selected command" },
{ keys: ["Escape"], description: "Close command menu" },
],
},
{
category: "Permission Prompts",
items: [
{ keys: ["Enter"], description: "Allow & reconnect" },
{ keys: ["Escape"], description: "Dismiss" },
],
},
];
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="shortcuts-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-[var(--accent-primary)]/20 flex items-center justify-center"
>
<svg
class="w-5 h-5 text-[var(--accent-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</div>
<h2 id="shortcuts-title" class="text-xl font-semibold text-[var(--text-primary)]">
Keyboard Shortcuts
</h2>
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each shortcuts as section (section.category)}
<div>
<h3
class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3"
>
{section.category}
</h3>
<div class="space-y-2">
{#each section.items as item (item.description)}
<div
class="flex items-center justify-between py-2 px-3 bg-[var(--bg-secondary)] rounded-lg"
>
<span class="text-sm text-[var(--text-primary)]">{item.description}</span>
<div class="flex items-center gap-1">
{#each item.keys as key, i (key)}
{#if i > 0}
<span class="text-[var(--text-secondary)] text-xs">+</span>
{/if}
<kbd
class="px-2 py-1 text-xs font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] shadow-sm min-w-[24px] text-center"
>
{key}
</kbd>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
+352
View File
@@ -0,0 +1,352 @@
<script lang="ts">
import { marked } from "marked";
import hljs from "highlight.js";
import { onMount } from "svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
interface Props {
content: string;
searchQuery?: string;
}
let { content, searchQuery = "" }: Props = $props();
let containerElement: HTMLDivElement;
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
return `<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
};
renderer.codespan = ({ text }) => {
return `<code class="hljs-inline">${text}</code>`;
};
marked.setOptions({
renderer,
gfm: true,
breaks: true,
});
function processSpoilers(html: string): string {
const codeBlockPlaceholders: string[] = [];
// Temporarily replace code blocks and inline code with placeholders
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Apply spoiler transformation only to non-code content
processed = processed.replace(
/\|\|(.+?)\|\|/g,
'<span class="spoiler" role="button" tabindex="0">$1</span>'
);
// Restore code blocks
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function highlightSearchMatches(html: string, query: string): string {
if (!query) return html;
const codeBlockPlaceholders: string[] = [];
const tagPlaceholders: string[] = [];
// Temporarily replace code blocks with placeholders (don't highlight in code)
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Temporarily replace all HTML tags with placeholders
processed = processed.replace(/<[^>]+>/g, (match) => {
tagPlaceholders.push(match);
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
});
// Apply search highlighting to text content
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
// Restore HTML tags
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
return tagPlaceholders[parseInt(index)];
});
// Restore code blocks
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function renderMarkdown(text: string): string {
try {
const html = marked.parse(text) as string;
const withSpoilers = processSpoilers(html);
return highlightSearchMatches(withSpoilers, searchQuery);
} catch {
return text;
}
}
function handleSpoilerClick(event: Event) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler")) {
target.classList.toggle("revealed");
}
}
function handleSpoilerKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler") && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
target.classList.toggle("revealed");
}
}
function handleLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const anchor = target.closest("a");
if (anchor?.href) {
event.preventDefault();
openUrl(anchor.href);
}
}
onMount(() => {
if (containerElement) {
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
}
});
</script>
<div
bind:this={containerElement}
class="markdown-content"
onclick={(e) => {
handleSpoilerClick(e);
handleLinkClick(e);
}}
onkeydown={handleSpoilerKeydown}
role="presentation"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
{@html renderMarkdown(content)}
</div>
<style>
.markdown-content {
line-height: 1.6;
}
.markdown-content :global(p) {
margin: 0.5em 0;
}
.markdown-content :global(p:first-child) {
margin-top: 0;
}
.markdown-content :global(p:last-child) {
margin-bottom: 0;
}
.markdown-content :global(.hljs-code-block) {
background: var(--bg-code, #1e1e2e);
border-radius: 6px;
padding: 1em;
margin: 0.75em 0;
overflow-x: auto;
border: 1px solid var(--border-color);
}
.markdown-content :global(.hljs-code-block code) {
background: transparent;
padding: 0;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(.hljs-inline) {
background: var(--bg-code, #1e1e2e);
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(ul),
.markdown-content :global(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-content :global(li) {
margin: 0.25em 0;
}
.markdown-content :global(blockquote) {
border-left: 3px solid var(--border-color);
margin: 0.75em 0;
padding-left: 1em;
color: var(--text-secondary);
}
.markdown-content :global(a) {
color: var(--accent-primary, #f472b6);
text-decoration: underline;
}
.markdown-content :global(a:hover) {
color: var(--accent-secondary, #e879f9);
}
.markdown-content :global(strong) {
font-weight: 600;
}
.markdown-content :global(h1),
.markdown-content :global(h2),
.markdown-content :global(h3),
.markdown-content :global(h4) {
margin: 1em 0 0.5em 0;
font-weight: 600;
}
.markdown-content :global(h1:first-child),
.markdown-content :global(h2:first-child),
.markdown-content :global(h3:first-child),
.markdown-content :global(h4:first-child) {
margin-top: 0;
}
.markdown-content :global(hr) {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
.markdown-content :global(table) {
border-collapse: collapse;
margin: 0.75em 0;
width: 100%;
}
.markdown-content :global(th),
.markdown-content :global(td) {
border: 1px solid var(--border-color);
padding: 0.5em;
text-align: left;
}
.markdown-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
}
/* Highlight.js theme colors - using CSS variables for light/dark mode support */
.markdown-content :global(.hljs) {
color: var(--text-primary);
}
.markdown-content :global(.hljs-keyword),
.markdown-content :global(.hljs-selector-tag),
.markdown-content :global(.hljs-built_in),
.markdown-content :global(.hljs-name) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-string),
.markdown-content :global(.hljs-attr),
.markdown-content :global(.hljs-symbol),
.markdown-content :global(.hljs-bullet) {
color: var(--hljs-string);
}
.markdown-content :global(.hljs-number),
.markdown-content :global(.hljs-literal) {
color: var(--hljs-number);
}
.markdown-content :global(.hljs-comment),
.markdown-content :global(.hljs-quote) {
color: var(--hljs-comment);
font-style: italic;
}
.markdown-content :global(.hljs-function),
.markdown-content :global(.hljs-title) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-type),
.markdown-content :global(.hljs-class) {
color: var(--hljs-type);
}
.markdown-content :global(.hljs-variable),
.markdown-content :global(.hljs-template-variable) {
color: var(--hljs-variable);
}
.markdown-content :global(.hljs-meta) {
color: var(--hljs-meta);
}
.markdown-content :global(.hljs-tag) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-attribute) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-params) {
color: var(--text-primary);
}
/* Spoiler tag styles */
.markdown-content :global(.spoiler) {
background: var(--text-primary);
color: transparent;
border-radius: 4px;
padding: 0 0.25em;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.markdown-content :global(.spoiler:hover) {
opacity: 0.8;
}
.markdown-content :global(.spoiler:focus) {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.markdown-content :global(.spoiler.revealed) {
background: var(--bg-hover);
color: var(--text-primary);
user-select: text;
}
.markdown-content :global(.search-highlight) {
background-color: var(--search-highlight, #fbbf24);
color: var(--search-highlight-text, #000);
border-radius: 2px;
padding: 0 2px;
}
</style>
+22 -8
View File
@@ -109,8 +109,22 @@ Please continue where we left off and retry that action now that you have permis
function isToolAlreadyGranted(toolName: string): boolean { function isToolAlreadyGranted(toolName: string): boolean {
return grantedToolsList.includes(toolName); return grantedToolsList.includes(toolName);
} }
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !permission) return;
if (event.key === "Enter") {
event.preventDefault();
handleApproveAndReconnect();
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
</script> </script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && permission} {#if isVisible && permission}
<div <div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm" class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
@@ -123,8 +137,8 @@ Please continue where we left off and retry that action now that you have permis
<span class="text-xl">🔐</span> <span class="text-xl">🔐</span>
</div> </div>
<div> <div>
<h2 class="text-lg font-semibold text-white">Permission Blocked</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
<p class="text-sm text-gray-400">Hikari tried to use a restricted tool</p> <p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
</div> </div>
</div> </div>
@@ -135,7 +149,7 @@ Please continue where we left off and retry that action now that you have permis
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-gray-400 mb-1">Tool</div> <div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
<div <div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between" class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
> >
@@ -149,17 +163,17 @@ Please continue where we left off and retry that action now that you have permis
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-gray-400 mb-1">Description</div> <div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-gray-300"> <div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
{permission.description} {permission.description}
</div> </div>
</div> </div>
{#if Object.keys(permission.input).length > 0} {#if Object.keys(permission.input).length > 0}
<div class="mb-6"> <div class="mb-6">
<div class="text-sm text-gray-400 mb-1">Details</div> <div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
<pre <pre
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-gray-300 text-xs overflow-x-auto max-h-32">{formatInput( class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
permission.input permission.input
)}</pre> )}</pre>
</div> </div>
@@ -168,7 +182,7 @@ Please continue where we left off and retry that action now that you have permis
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
onclick={handleDismiss} onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-gray-400 rounded-lg transition-colors font-medium" class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
> >
Dismiss Dismiss
</button> </button>
@@ -0,0 +1,86 @@
<script lang="ts">
import type { SlashCommand } from "$lib/commands/slashCommands";
interface Props {
commands: SlashCommand[];
selectedIndex: number;
onSelect: (command: SlashCommand) => void;
}
let { commands, selectedIndex, onSelect }: Props = $props();
</script>
{#if commands.length > 0}
<div class="slash-command-menu">
<div class="menu-header">Commands</div>
{#each commands as command, index (command.name)}
<button
type="button"
class="menu-item"
class:selected={index === selectedIndex}
onclick={() => onSelect(command)}
onmouseenter={() => (selectedIndex = index)}
>
<span class="command-name">/{command.name}</span>
<span class="command-description">{command.description}</span>
</button>
{/each}
</div>
{/if}
<style>
.slash-command-menu {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.2);
z-index: 100;
}
.menu-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.menu-item:hover,
.menu-item.selected {
background: var(--bg-tertiary);
}
.command-name {
font-family: monospace;
font-weight: 600;
color: var(--accent-primary);
min-width: 80px;
}
.command-description {
color: var(--text-secondary);
font-size: 13px;
}
</style>
+27
View File
@@ -17,6 +17,7 @@
import StatsDisplay from "./StatsDisplay.svelte"; import StatsDisplay from "./StatsDisplay.svelte";
import AboutPanel from "./AboutPanel.svelte"; import AboutPanel from "./AboutPanel.svelte";
import HelpPanel from "./HelpPanel.svelte"; import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements"; import { achievementProgress } from "$lib/stores/achievements";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -31,6 +32,7 @@
let showStats = $state(false); let showStats = $state(false);
let showAbout = $state(false); let showAbout = $state(false);
let showHelp = $state(false); let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
const progress = $derived($achievementProgress); const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
model: null, model: null,
@@ -43,6 +45,7 @@
greeting_custom_prompt: null, greeting_custom_prompt: null,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.5, notification_volume: 0.5,
always_on_top: false,
}); });
onMount(async () => { onMount(async () => {
@@ -271,6 +274,26 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => (showKeyboardShortcuts = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
title="Keyboard Shortcuts"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</button>
<button <button
onclick={() => (showHelp = true)} onclick={() => (showHelp = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors" class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
@@ -340,3 +363,7 @@
{#if showHelp} {#if showHelp}
<HelpPanel onClose={() => (showHelp = false)} /> <HelpPanel onClose={() => (showHelp = false)} />
{/if} {/if}
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
+44 -1
View File
@@ -2,10 +2,17 @@
import { claudeStore, type TerminalLine } from "$lib/stores/claude"; import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate } from "svelte"; import { afterUpdate } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte"; import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
let terminalElement: HTMLDivElement; let terminalElement: HTMLDivElement;
let shouldAutoScroll = true; let shouldAutoScroll = true;
let lines: TerminalLine[] = []; let lines: TerminalLine[] = [];
let currentSearchQuery = "";
searchQuery.subscribe((value) => {
currentSearchQuery = value;
});
claudeStore.terminalLines.subscribe((value) => { claudeStore.terminalLines.subscribe((value) => {
lines = value; lines = value;
@@ -63,6 +70,30 @@
minute: "2-digit", minute: "2-digit",
}); });
} }
function highlightText(text: string, query: string): string {
if (!query) return text;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
return text.replace(regex, '<mark class="search-highlight">$1</mark>');
}
$: {
if (currentSearchQuery && lines.length > 0) {
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedQuery, "gi");
let totalMatches = 0;
for (const line of lines) {
const matches = line.content.match(regex);
if (matches) {
totalMatches += matches.length;
}
}
searchState.setMatchCount(totalMatches);
} else {
searchState.setMatchCount(0);
}
}
</script> </script>
<div <div
@@ -100,7 +131,12 @@
{#if line.toolName} {#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span> <span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if} {/if}
<span class="whitespace-pre-wrap">{line.content}</span> {#if line.type === "assistant"}
<Markdown content={line.content} searchQuery={currentSearchQuery} />
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Search highlighting on internal terminal content -->
<span class="whitespace-pre-wrap">{@html highlightText(line.content, currentSearchQuery)}</span>
{/if}
</div> </div>
{/each} {/each}
{/if} {/if}
@@ -157,4 +193,11 @@
.terminal-header-text { .terminal-header-text {
color: var(--text-secondary); color: var(--text-secondary);
} }
:global(.search-highlight) {
background-color: var(--search-highlight, #fbbf24);
color: var(--search-highlight-text, #000);
border-radius: 2px;
padding: 0 2px;
}
</style> </style>
+4 -2
View File
@@ -47,6 +47,8 @@ export const claudeStore = {
getConversationHistory: conversationsStore.getConversationHistory, getConversationHistory: conversationsStore.getConversationHistory,
requestPermission: conversationsStore.requestPermission, requestPermission: conversationsStore.requestPermission,
clearPermission: conversationsStore.clearPermission, clearPermission: conversationsStore.clearPermission,
requestPermissionForConversation: conversationsStore.requestPermissionForConversation,
clearPermissionForConversation: conversationsStore.clearPermissionForConversation,
grantTool: conversationsStore.grantTool, grantTool: conversationsStore.grantTool,
revokeAllTools: conversationsStore.revokeAllTools, revokeAllTools: conversationsStore.revokeAllTools,
isToolGranted: conversationsStore.isToolGranted, isToolGranted: conversationsStore.isToolGranted,
@@ -83,8 +85,8 @@ export const claudeStore = {
}; };
export const hasPermissionPending = derived( export const hasPermissionPending = derived(
claudeStore.pendingPermission, claudeStore.activeConversation,
($permission) => $permission !== null ($conversation) => $conversation?.pendingPermission !== null
); );
// Derived store to check if Claude is currently processing (can be interrupted) // Derived store to check if Claude is currently processing (can be interrupted)
+2
View File
@@ -14,6 +14,7 @@ export interface HikariConfig {
greeting_custom_prompt: string | null; greeting_custom_prompt: string | null;
notifications_enabled: boolean; notifications_enabled: boolean;
notification_volume: number; notification_volume: number;
always_on_top: boolean;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -27,6 +28,7 @@ const defaultConfig: HikariConfig = {
greeting_custom_prompt: null, greeting_custom_prompt: null,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false,
}; };
function createConfigStore() { function createConfigStore() {
+52 -3
View File
@@ -14,6 +14,7 @@ export interface Conversation {
characterState: CharacterState; characterState: CharacterState;
isProcessing: boolean; isProcessing: boolean;
grantedTools: Set<string>; grantedTools: Set<string>;
pendingPermission: PermissionRequest | null;
createdAt: Date; createdAt: Date;
lastActivityAt: Date; lastActivityAt: Date;
} }
@@ -21,7 +22,6 @@ export interface Conversation {
function createConversationsStore() { function createConversationsStore() {
const conversations = writable<Map<string, Conversation>>(new Map()); const conversations = writable<Map<string, Conversation>>(new Map());
const activeConversationId = writable<string | null>(null); const activeConversationId = writable<string | null>(null);
const pendingPermission = writable<PermissionRequest | null>(null);
const pendingRetryMessage = writable<string | null>(null); const pendingRetryMessage = writable<string | null>(null);
let conversationCounter = 0; let conversationCounter = 0;
@@ -47,6 +47,7 @@ function createConversationsStore() {
characterState: "idle", characterState: "idle",
isProcessing: false, isProcessing: false,
grantedTools: new Set(), grantedTools: new Set(),
pendingPermission: null,
createdAt: new Date(), createdAt: new Date(),
lastActivityAt: new Date(), lastActivityAt: new Date(),
}; };
@@ -93,6 +94,10 @@ function createConversationsStore() {
activeConversation, activeConversation,
($conv) => $conv?.grantedTools || new Set<string>() ($conv) => $conv?.grantedTools || new Set<string>()
); );
const pendingPermission = derived(
activeConversation,
($conv) => $conv?.pendingPermission || null
);
return { return {
// Expose derived stores for compatibility // Expose derived stores for compatibility
@@ -148,8 +153,52 @@ function createConversationsStore() {
return convs; return convs;
}); });
}, },
requestPermission: (request: PermissionRequest) => pendingPermission.set(request), requestPermission: (request: PermissionRequest) => {
clearPermission: () => pendingPermission.set(null), const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = request;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearPermission: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermission = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
requestPermissionForConversation: (conversationId: string, request: PermissionRequest) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = request;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearPermissionForConversation: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermission = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
// Conversation management // Conversation management
+68
View File
@@ -0,0 +1,68 @@
import { writable, derived } from "svelte/store";
interface SearchState {
query: string;
isActive: boolean;
matchCount: number;
currentMatchIndex: number;
}
const initialState: SearchState = {
query: "",
isActive: false,
matchCount: 0,
currentMatchIndex: 0,
};
const searchStore = writable<SearchState>(initialState);
export const searchState = {
subscribe: searchStore.subscribe,
setQuery: (query: string) => {
searchStore.update((state) => ({
...state,
query,
isActive: query.length > 0,
currentMatchIndex: 0,
}));
},
setMatchCount: (count: number) => {
searchStore.update((state) => ({
...state,
matchCount: count,
}));
},
nextMatch: () => {
searchStore.update((state) => ({
...state,
currentMatchIndex:
state.matchCount > 0
? (state.currentMatchIndex + 1) % state.matchCount
: 0,
}));
},
previousMatch: () => {
searchStore.update((state) => ({
...state,
currentMatchIndex:
state.matchCount > 0
? (state.currentMatchIndex - 1 + state.matchCount) % state.matchCount
: 0,
}));
},
clear: () => {
searchStore.set(initialState);
},
};
export const isSearchActive = derived(
searchStore,
($search) => $search.isActive
);
export const searchQuery = derived(searchStore, ($search) => $search.query);
+15 -13
View File
@@ -292,25 +292,27 @@ export async function initializeTauriListeners() {
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => { const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description, conversation_id } = event.payload; const { id, tool_name, tool_input, description, conversation_id } = event.payload;
// Only process permission requests for the active conversation // Store permission request for the specific conversation
const activeConversationId = get(claudeStore.activeConversationId); if (conversation_id) {
if (conversation_id === activeConversationId) { claudeStore.requestPermissionForConversation(conversation_id, {
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({ claudeStore.requestPermission({
id, id,
tool: tool_name, tool: tool_name,
description, description,
input: tool_input, input: tool_input,
}); });
}
// Always store the permission message to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else if (conversation_id === activeConversationId) {
claudeStore.addLine("system", `Permission requested for: ${tool_name}`); claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
} }
}); });
+76 -1
View File
@@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri"; import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme } from "$lib/stores/config"; import { configStore, applyTheme } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications"; import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import "$lib/notifications/testNotifications"; import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte"; import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte"; import InputBar from "$lib/components/InputBar.svelte";
@@ -17,6 +21,67 @@
let initialized = false; let initialized = false;
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
// Global keyboard shortcuts
function handleGlobalKeydown(event: KeyboardEvent) {
// Don't trigger shortcuts when typing in inputs (except for specific ones)
const target = event.target as HTMLElement;
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
// Escape closes panels (always works)
if (event.key === "Escape") {
// Check if any panels are open and close them
if (achievementPanelOpen) {
achievementPanelOpen = false;
event.preventDefault();
return;
}
// ConfigSidebar handles its own escape via store
if (get(configStore.isSidebarOpen)) {
configStore.closeSidebar();
event.preventDefault();
return;
}
}
// Skip other shortcuts if user is typing in an input
if (isInputFocused) return;
// Ctrl+L - Clear terminal
if (event.ctrlKey && event.key === "l") {
event.preventDefault();
claudeStore.clearTerminal();
return;
}
// Ctrl+, - Open settings
if (event.ctrlKey && event.key === ",") {
event.preventDefault();
configStore.openSidebar();
return;
}
// Ctrl+C - Interrupt (only when processing)
if (event.ctrlKey && event.key === "c") {
if (get(isClaudeProcessing)) {
event.preventDefault();
handleInterrupt();
return;
}
}
}
async function handleInterrupt() {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
} catch (error) {
console.error("Failed to interrupt:", error);
}
}
onMount(async () => { onMount(async () => {
if (!initialized) { if (!initialized) {
initialized = true; initialized = true;
@@ -27,12 +92,21 @@
await initializeTauriListeners(); await initializeTauriListeners();
await configStore.loadConfig(); await configStore.loadConfig();
// Apply saved theme on startup // Apply saved settings on startup
const config = configStore.getConfig(); const config = configStore.getConfig();
applyTheme(config.theme); applyTheme(config.theme);
// Apply always-on-top setting
if (config.always_on_top) {
const window = getCurrentWindow();
await window.setAlwaysOnTop(true);
}
// Initialize notification settings sync // Initialize notification settings sync
initNotificationSync(); initNotificationSync();
// Add global keyboard shortcut listener
window.addEventListener("keydown", handleGlobalKeydown);
} }
}); });
@@ -40,6 +114,7 @@
if (initialized) { if (initialized) {
cleanupTauriListeners(); cleanupTauriListeners();
cleanupNotificationSync(); cleanupNotificationSync();
window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false; initialized = false;
} }
}); });