generated from nhcarrigan/template
feat: another wave of features (#61)
## Explanation This PR bundles several user-facing improvements and feature additions for the v0.3.0 release, including quality-of-life improvements to the UI, new slash commands, better state persistence, and auto-update checking. ## Included Changes - **Resizable chat input** with drag handle (#58 partial) - **Arrow key navigation fix** - cursor keys now navigate text when user has typed input (#58) - **Scroll position persistence** per conversation tab - **/skill command** for invoking Claude Code skills (#57) - **Stats persistence fix** - stats now persist across session changes, only reset on disconnect (#59) - **Auto-update checker** on startup (#17) - **Resizable character panel** with full-height sprites (#10) - **Font size and zoom settings** with keyboard shortcuts (Ctrl++/Ctrl+-/Ctrl+0) (#19) ## Closes Closes #10, #17, #19, #57, #58, #59 ## Attestations - [x] I have read and agree to the Code of Conduct - [x] I have read and agree to the Community Guidelines - [x] My contribution complies with the Contributor Covenant - [x] I have run the linter and resolved any errors - [x] My pull request uses an appropriate title, matching the conventional commit standards - [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request - [x] All new and existing tests pass locally with my changes - [x] Code coverage remains at or above the configured threshold ## Documentation N/A - Internal app features ## Versioning Minor - My pull request introduces new non-breaking features. --- ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #61 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #61.
This commit is contained in:
@@ -183,6 +183,61 @@ export const slashCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skill",
|
||||
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
||||
usage: "/skill [name] [data]",
|
||||
execute: async (args: string) => {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const skillName = parts[0];
|
||||
const skillData = parts.slice(1).join(" ");
|
||||
|
||||
// If no skill name provided, list available skills
|
||||
if (!skillName) {
|
||||
try {
|
||||
const skills = await invoke<string[]>("list_skills");
|
||||
if (skills.length === 0) {
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
"No skills found in ~/.claude/skills/\nCreate a skill by adding a folder with a SKILL.md file."
|
||||
);
|
||||
} else {
|
||||
const skillList = skills.map((s) => ` • ${s}`).join("\n");
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
`Available skills:\n${skillList}\n\nUsage: /skill <skill-name> [data]`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to list skills: ${error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
claudeStore.addLine("system", `Invoking skill: ${skillName}`);
|
||||
characterState.setState("thinking");
|
||||
|
||||
const message = skillData
|
||||
? `Please run the /${skillName} skill with the following data:\n\n${skillData}`
|
||||
: `Please run the /${skillName} skill.`;
|
||||
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
claudeStore.addLine("error", `Failed to invoke skill: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function parseSlashCommand(input: string): {
|
||||
|
||||
@@ -57,30 +57,34 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="anime-girl-container flex flex-col items-center justify-end h-full p-4">
|
||||
<div class="character-frame relative {getBackgroundGlow()} w-full max-w-md">
|
||||
<div class="sprite-container {getAnimationClass()}">
|
||||
<div
|
||||
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
|
||||
>
|
||||
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
|
||||
<img
|
||||
src="/sprites/{info.spriteFile}"
|
||||
alt="Hikari - {info.label}"
|
||||
class="character-sprite w-full h-auto object-contain"
|
||||
class="character-sprite h-full w-auto max-w-full object-contain"
|
||||
onerror={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.src = "/sprites/placeholder.svg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-indicator absolute -bottom-2 left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
|
||||
>
|
||||
{info.label}
|
||||
</div>
|
||||
<div class="state-indicator mt-2">
|
||||
<div
|
||||
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
|
||||
>
|
||||
{info.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="speech-bubble mt-4 max-w-xs">
|
||||
<div class="speech-bubble mt-2 max-w-xs flex-shrink-0">
|
||||
<div
|
||||
class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { configStore, type HikariConfig, type Theme } from "$lib/stores/config";
|
||||
import {
|
||||
configStore,
|
||||
type HikariConfig,
|
||||
type Theme,
|
||||
applyFontSize,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE,
|
||||
} from "$lib/stores/config";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
@@ -15,6 +23,9 @@
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
});
|
||||
|
||||
let isOpen = $state(false);
|
||||
@@ -386,23 +397,61 @@
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Appearance
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => handleThemeChange("dark")}
|
||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("light")}
|
||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
|
||||
<!-- Theme Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => handleThemeChange("dark")}
|
||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("light")}
|
||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font Size -->
|
||||
<div class="mb-4">
|
||||
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||
Terminal Font Size
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="font-size"
|
||||
type="range"
|
||||
bind:value={config.font_size}
|
||||
oninput={() => applyFontSize(config.font_size)}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step="1"
|
||||
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<span class="text-sm text-gray-300 w-12 text-right">{config.font_size}px</span>
|
||||
<button
|
||||
onclick={() => {
|
||||
config.font_size = DEFAULT_FONT_SIZE;
|
||||
applyFontSize(DEFAULT_FONT_SIZE);
|
||||
}}
|
||||
class="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent-primary)] text-[var(--text-secondary)] transition-colors"
|
||||
title="Reset to default (14px)"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -427,6 +476,21 @@
|
||||
Keep the window above other windows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Update Checks Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.update_checks_enabled}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Check for updates on startup</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Notify when a new version is available
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications Section -->
|
||||
|
||||
@@ -38,6 +38,38 @@
|
||||
let inputHistory = $state<string[]>([]);
|
||||
let historyIndex = $state(-1);
|
||||
let tempInput = $state("");
|
||||
let userHasTyped = $state(false); // Track if user manually typed (vs history navigation)
|
||||
|
||||
// Textarea resize state
|
||||
let textareaHeight = $state(48);
|
||||
const MIN_HEIGHT = 48;
|
||||
const MAX_HEIGHT = 200;
|
||||
let isResizing = $state(false);
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
|
||||
function handleResizeStart(event: MouseEvent) {
|
||||
isResizing = true;
|
||||
startY = event.clientY;
|
||||
startHeight = textareaHeight;
|
||||
document.addEventListener("mousemove", handleResizeMove);
|
||||
document.addEventListener("mouseup", handleResizeEnd);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleResizeMove(event: MouseEvent) {
|
||||
if (!isResizing) return;
|
||||
// Dragging up (negative deltaY) should increase height
|
||||
const deltaY = startY - event.clientY;
|
||||
const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + deltaY));
|
||||
textareaHeight = newHeight;
|
||||
}
|
||||
|
||||
function handleResizeEnd() {
|
||||
isResizing = false;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}
|
||||
|
||||
// Load history from localStorage on init
|
||||
function loadHistory(): string[] {
|
||||
@@ -81,6 +113,13 @@
|
||||
});
|
||||
|
||||
function handleInputChange() {
|
||||
// If input is empty, allow history navigation again
|
||||
// Otherwise, mark that user has manually typed
|
||||
if (inputValue === "") {
|
||||
userHasTyped = false;
|
||||
} else {
|
||||
userHasTyped = true;
|
||||
}
|
||||
// Reset history navigation when user types
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
@@ -125,6 +164,7 @@
|
||||
addToHistory(message);
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
userHasTyped = false;
|
||||
|
||||
const wasCommand = await executeSlashCommand();
|
||||
if (wasCommand) return;
|
||||
@@ -144,6 +184,7 @@
|
||||
addToHistory(message);
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
userHasTyped = false;
|
||||
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
@@ -277,8 +318,9 @@ User: ${formattedMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input history navigation (when command menu is closed)
|
||||
if (event.key === "ArrowUp" && inputHistory.length > 0) {
|
||||
// Handle input history navigation (when command menu is closed AND user hasn't typed)
|
||||
// If user has typed something, let arrow keys navigate the cursor instead
|
||||
if (event.key === "ArrowUp" && inputHistory.length > 0 && !userHasTyped) {
|
||||
event.preventDefault();
|
||||
if (historyIndex === -1) {
|
||||
// Save current input before navigating history
|
||||
@@ -291,12 +333,13 @@ User: ${formattedMessage}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" && historyIndex >= 0) {
|
||||
if (event.key === "ArrowDown" && historyIndex >= 0 && !userHasTyped) {
|
||||
event.preventDefault();
|
||||
historyIndex--;
|
||||
if (historyIndex === -1) {
|
||||
// Restore the temp input when going back to current
|
||||
inputValue = tempInput;
|
||||
userHasTyped = false; // Reset since we're back to empty/temp state
|
||||
} else {
|
||||
inputValue = inputHistory[historyIndex];
|
||||
}
|
||||
@@ -314,13 +357,15 @@ User: ${formattedMessage}`;
|
||||
<MessageModeSelector />
|
||||
</div>
|
||||
|
||||
<div class="input-row flex gap-3 items-end">
|
||||
<div class="flex-1 relative">
|
||||
<div class="input-row">
|
||||
<div class="textarea-wrapper">
|
||||
<SlashCommandMenu
|
||||
commands={matchingCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
onSelect={selectCommand}
|
||||
/>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
|
||||
<textarea
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeyDown}
|
||||
@@ -330,41 +375,39 @@ User: ${formattedMessage}`;
|
||||
: "Connect to Claude first..."}
|
||||
disabled={isSubmitting}
|
||||
rows={1}
|
||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
|
||||
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
|
||||
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200"
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if isProcessing}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleInterrupt}
|
||||
class="px-6 py-3 bg-red-600 hover:bg-red-700
|
||||
text-white font-medium rounded-lg
|
||||
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
||||
title="Interrupt the current response (Ctrl+C)"
|
||||
>
|
||||
<span class="font-bold">■</span> Stop
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
||||
text-white font-medium rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<span class="inline-block animate-spin">⏳</span>
|
||||
{:else}
|
||||
Send
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="button-wrapper">
|
||||
{#if isProcessing}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleInterrupt}
|
||||
class="send-button bg-red-600 hover:bg-red-700"
|
||||
title="Interrupt the current response (Ctrl+C)"
|
||||
>
|
||||
<span class="font-bold">■</span> Stop
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<span class="inline-block animate-spin">⏳</span>
|
||||
{:else}
|
||||
Send
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -386,4 +429,61 @@ User: ${formattedMessage}`;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.textarea-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
height: 6px;
|
||||
cursor: ns-resize;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resize-handle::before {
|
||||
content: "";
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.resize-handle:hover::before {
|
||||
opacity: 1;
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
padding: 0 24px;
|
||||
height: 48px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.send-button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.5,
|
||||
always_on_top: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||
import { afterUpdate } from "svelte";
|
||||
import { afterUpdate, tick } from "svelte";
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import HighlightedText from "./HighlightedText.svelte";
|
||||
@@ -10,6 +10,8 @@
|
||||
let shouldAutoScroll = true;
|
||||
let lines: TerminalLine[] = [];
|
||||
let currentSearchQuery = "";
|
||||
let currentConversationId: string | null = null;
|
||||
let isRestoringScroll = false;
|
||||
|
||||
searchQuery.subscribe((value) => {
|
||||
currentSearchQuery = value;
|
||||
@@ -19,14 +21,46 @@
|
||||
lines = value;
|
||||
});
|
||||
|
||||
claudeStore.activeConversationId.subscribe(async (newId) => {
|
||||
if (!newId) return;
|
||||
|
||||
// Save current conversation's scroll position before switching
|
||||
if (currentConversationId && currentConversationId !== newId && terminalElement) {
|
||||
const position = shouldAutoScroll ? -1 : terminalElement.scrollTop;
|
||||
claudeStore.saveScrollPosition(currentConversationId, position);
|
||||
}
|
||||
|
||||
currentConversationId = newId;
|
||||
|
||||
// Restore scroll position for the new conversation after DOM updates
|
||||
await tick();
|
||||
if (terminalElement) {
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
isRestoringScroll = true;
|
||||
if (savedPosition === -1) {
|
||||
// Auto-scroll to bottom
|
||||
shouldAutoScroll = true;
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
} else {
|
||||
// Restore to saved position
|
||||
shouldAutoScroll = false;
|
||||
terminalElement.scrollTop = savedPosition;
|
||||
}
|
||||
// Small delay to prevent the scroll handler from overriding our restore
|
||||
setTimeout(() => {
|
||||
isRestoringScroll = false;
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
if (!terminalElement) return;
|
||||
if (!terminalElement || isRestoringScroll) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
||||
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
if (shouldAutoScroll && terminalElement) {
|
||||
if (shouldAutoScroll && terminalElement && !isRestoringScroll) {
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
@@ -109,7 +143,8 @@
|
||||
<div
|
||||
bind:this={terminalElement}
|
||||
onscroll={handleScroll}
|
||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm"
|
||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
||||
style="font-size: var(--terminal-font-size, 14px);"
|
||||
>
|
||||
{#if lines.length === 0}
|
||||
<div class="terminal-waiting italic">
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { UpdateInfo } from "$lib/types/messages";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let updateInfo = $state<UpdateInfo | null>(null);
|
||||
let dismissed = $state(false);
|
||||
|
||||
export async function checkForUpdates() {
|
||||
// Check if update checks are enabled
|
||||
const config = configStore.getConfig();
|
||||
if (!config.update_checks_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await invoke<UpdateInfo>("check_for_updates");
|
||||
if (info.has_update) {
|
||||
updateInfo = info;
|
||||
dismissed = false;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error("Failed to check for updates:", errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
dismissed = true;
|
||||
}
|
||||
|
||||
async function openRelease() {
|
||||
if (updateInfo?.release_url) {
|
||||
await openUrl(updateInfo.release_url);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if updateInfo && !dismissed}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-2xl">🎉</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
|
||||
<p class="text-[var(--text-secondary)] text-sm mb-2">
|
||||
A new version of Hikari Desktop is available:
|
||||
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
|
||||
</p>
|
||||
<p class="text-[var(--text-muted)] text-xs mb-3">
|
||||
Current version: {updateInfo.current_version}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={openRelease}
|
||||
class="px-3 py-1.5 bg-[var(--accent-primary)] text-white rounded text-sm hover:brightness-110 transition-all"
|
||||
>
|
||||
View Release
|
||||
</button>
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -64,6 +64,8 @@ export const claudeStore = {
|
||||
deleteConversation: conversationsStore.deleteConversation,
|
||||
switchConversation: conversationsStore.switchConversation,
|
||||
renameConversation: conversationsStore.renameConversation,
|
||||
saveScrollPosition: conversationsStore.saveScrollPosition,
|
||||
getScrollPosition: conversationsStore.getScrollPosition,
|
||||
|
||||
getGrantedTools: (): string[] => {
|
||||
let tools: string[] = [];
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface HikariConfig {
|
||||
notifications_enabled: boolean;
|
||||
notification_volume: number;
|
||||
always_on_top: boolean;
|
||||
update_checks_enabled: boolean;
|
||||
character_panel_width: number | null;
|
||||
font_size: number;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -29,6 +32,9 @@ const defaultConfig: HikariConfig = {
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
@@ -89,6 +95,33 @@ function createConfigStore() {
|
||||
applyTheme(theme);
|
||||
},
|
||||
|
||||
setFontSize: async (size: number) => {
|
||||
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||
await updateConfig({ font_size: clampedSize });
|
||||
applyFontSize(clampedSize);
|
||||
},
|
||||
|
||||
increaseFontSize: async () => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
|
||||
await updateConfig({ font_size: newSize });
|
||||
applyFontSize(newSize);
|
||||
},
|
||||
|
||||
decreaseFontSize: async () => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
|
||||
await updateConfig({ font_size: newSize });
|
||||
applyFontSize(newSize);
|
||||
},
|
||||
|
||||
resetFontSize: async () => {
|
||||
await updateConfig({ font_size: DEFAULT_FONT_SIZE });
|
||||
applyFontSize(DEFAULT_FONT_SIZE);
|
||||
},
|
||||
|
||||
addAutoGrantedTool: async (tool: string) => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
@@ -119,6 +152,23 @@ export function applyTheme(theme: Theme) {
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_FONT_SIZE = 10;
|
||||
const MAX_FONT_SIZE = 24;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
|
||||
export function applyFontSize(size: number) {
|
||||
if (typeof document !== "undefined") {
|
||||
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||
document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
export function clampFontSize(size: number): number {
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||
}
|
||||
|
||||
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
||||
|
||||
export const configStore = createConfigStore();
|
||||
|
||||
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Conversation {
|
||||
grantedTools: Set<string>;
|
||||
pendingPermission: PermissionRequest | null;
|
||||
pendingQuestion: UserQuestionEvent | null;
|
||||
scrollPosition: number;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
@@ -55,6 +56,7 @@ function createConversationsStore() {
|
||||
grantedTools: new Set(),
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
};
|
||||
@@ -106,6 +108,7 @@ function createConversationsStore() {
|
||||
($conv) => $conv?.pendingPermission || null
|
||||
);
|
||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
||||
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||
|
||||
return {
|
||||
// Expose derived stores for compatibility
|
||||
@@ -118,6 +121,7 @@ function createConversationsStore() {
|
||||
isProcessing: { subscribe: isProcessing.subscribe },
|
||||
grantedTools: { subscribe: grantedTools.subscribe },
|
||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||
scrollPosition: { subscribe: scrollPosition.subscribe },
|
||||
|
||||
// New conversation-specific stores
|
||||
conversations: { subscribe: conversations.subscribe },
|
||||
@@ -325,6 +329,22 @@ function createConversationsStore() {
|
||||
});
|
||||
},
|
||||
|
||||
saveScrollPosition: (id: string, position: number) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(id);
|
||||
if (conv) {
|
||||
conv.scrollPosition = position;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
getScrollPosition: (id: string): number => {
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(id);
|
||||
return conv?.scrollPosition ?? -1;
|
||||
},
|
||||
|
||||
// Methods that operate on the active conversation
|
||||
setSessionId: (id: string | null) => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -141,3 +141,11 @@ export interface UserQuestionEvent {
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||
|
||||
export interface UpdateInfo {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
has_update: boolean;
|
||||
release_url: string;
|
||||
release_notes?: string;
|
||||
}
|
||||
|
||||
+81
-4
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
||||
import { configStore, applyTheme } from "$lib/stores/config";
|
||||
import { configStore, applyTheme, applyFontSize } from "$lib/stores/config";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
@@ -18,10 +18,41 @@
|
||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
|
||||
|
||||
let initialized = false;
|
||||
let updateNotification: UpdateNotification;
|
||||
let achievementPanelOpen = $state(false);
|
||||
|
||||
// Resizable panel state
|
||||
let panelWidth = $state(320); // Default width in pixels
|
||||
let isResizing = $state(false);
|
||||
const MIN_PANEL_WIDTH = 200;
|
||||
const MAX_PANEL_WIDTH = 600;
|
||||
|
||||
function startResize(event: MouseEvent) {
|
||||
isResizing = true;
|
||||
event.preventDefault();
|
||||
document.addEventListener("mousemove", handleResize);
|
||||
document.addEventListener("mouseup", stopResize);
|
||||
}
|
||||
|
||||
function handleResize(event: MouseEvent) {
|
||||
if (!isResizing) return;
|
||||
const newWidth = event.clientX;
|
||||
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (isResizing) {
|
||||
isResizing = false;
|
||||
document.removeEventListener("mousemove", handleResize);
|
||||
document.removeEventListener("mouseup", stopResize);
|
||||
// Save the panel width to config
|
||||
configStore.updateConfig({ character_panel_width: panelWidth });
|
||||
}
|
||||
}
|
||||
|
||||
// Global keyboard shortcuts
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// Don't trigger shortcuts when typing in inputs (except for specific ones)
|
||||
@@ -69,6 +100,27 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl++ or Ctrl+= - Increase font size
|
||||
if (event.ctrlKey && (event.key === "+" || event.key === "=")) {
|
||||
event.preventDefault();
|
||||
configStore.increaseFontSize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+- - Decrease font size
|
||||
if (event.ctrlKey && event.key === "-") {
|
||||
event.preventDefault();
|
||||
configStore.decreaseFontSize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+0 - Reset font size
|
||||
if (event.ctrlKey && event.key === "0") {
|
||||
event.preventDefault();
|
||||
configStore.resetFontSize();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInterrupt() {
|
||||
@@ -96,6 +148,7 @@
|
||||
// Apply saved settings on startup
|
||||
const config = configStore.getConfig();
|
||||
applyTheme(config.theme);
|
||||
applyFontSize(config.font_size);
|
||||
|
||||
// Apply always-on-top setting
|
||||
if (config.always_on_top) {
|
||||
@@ -103,11 +156,21 @@
|
||||
await window.setAlwaysOnTop(true);
|
||||
}
|
||||
|
||||
// Load saved panel width
|
||||
if (config.character_panel_width) {
|
||||
panelWidth = config.character_panel_width;
|
||||
}
|
||||
|
||||
// Initialize notification settings sync
|
||||
initNotificationSync();
|
||||
|
||||
// Add global keyboard shortcut listener
|
||||
window.addEventListener("keydown", handleGlobalKeydown);
|
||||
|
||||
// Check for updates on startup
|
||||
if (config.update_checks_enabled) {
|
||||
updateNotification?.checkForUpdates();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -127,13 +190,22 @@
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Left panel: Character display -->
|
||||
<div
|
||||
class="character-panel w-1/3 flex flex-col items-center justify-center border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50"
|
||||
class="character-panel flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
|
||||
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
|
||||
>
|
||||
<AnimeGirl />
|
||||
</div>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
|
||||
class:bg-[var(--accent-primary)]={isResizing}
|
||||
onmousedown={startResize}
|
||||
></div>
|
||||
|
||||
<!-- Right panel: Terminal and input -->
|
||||
<div class="terminal-panel flex-1 flex flex-col">
|
||||
<div class="terminal-panel flex-1 flex flex-col min-w-0">
|
||||
<Terminal />
|
||||
<InputBar />
|
||||
</div>
|
||||
@@ -147,6 +219,7 @@
|
||||
bind:isOpen={achievementPanelOpen}
|
||||
onClose={() => (achievementPanelOpen = false)}
|
||||
/>
|
||||
<UpdateNotification bind:this={updateNotification} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -161,7 +234,11 @@
|
||||
}
|
||||
|
||||
.character-panel {
|
||||
min-width: 320px;
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle:active {
|
||||
width: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user