generated from nhcarrigan/template
feat: CLI v2.1.68–v2.1.74 compatibility updates (#221)
## Summary This PR brings Hikari Desktop up to full compatibility with Claude Code CLI versions v2.1.68 through v2.1.74, implementing all changelog items audited in issues #200–#218. ## Changes ### Bug Fixes - Remove deprecated Claude Opus 4.0 and 4.1 models from the model selector - Auto-migrate users pinned to deprecated models to Opus 4.6 ### New Features - Add cron tool support (`CronCreate`, `CronDelete`, `CronList`) with character state mapping and `CLAUDE_CODE_DISABLE_CRON` settings toggle - Handle `EnterWorktree` and `ExitWorktree` tools in character state mapping and tool display - Add CLI update check with npm registry indicator in the version bar - Add `agent_type` field and support the Agent tool rename from CLI v2.1.69 - Consume `worktree` field from status line hook events - Display per-agent model override in the agent monitor tree - Expose Claude Code CLI built-in slash commands (`/simplify`, `/loop`, `/batch`, `/memory`, `/context`) in the command menu with CLI badges - Add `includeGitInstructions` toggle in settings - Add `ENABLE_CLAUDEAI_MCP_SERVERS` opt-out setting - Linkify MCP binary file paths (PDFs, audio, Office docs) in markdown output - Add auto-memory panel, `/memory` slash command shortcut, and unified toast notification system - Toast notifications for `WorktreeCreate` and `WorktreeRemove` hook events - Sort session resume list by most recent activity, with most recent user message as preview - Convert WSL Linux paths to Windows UNC paths when opening binary files via `open_binary_file` command - Expose `autoMemoryDirectory` setting in ConfigSidebar (Agent Settings section) - Add `/context` as a CLI built-in in the slash command menu - Expose `modelOverrides` setting as a JSON textarea in ConfigSidebar (for AWS Bedrock, Google Vertex, etc.) > **Note:** The CLI update check commit does not have a corresponding issue — it was a bonus addition during the audit sprint. ## Closes Closes #200 Closes #201 Closes #202 Closes #205 Closes #206 Closes #207 Closes #208 Closes #209 Closes #210 Closes #211 Closes #212 Closes #213 Closes #214 Closes #215 Closes #216 Closes #217 Closes #218 Reviewed-on: #221 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #221.
This commit is contained in:
@@ -1,202 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
|
||||
let achievements = $state<AchievementUnlockedEvent[]>([]);
|
||||
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
|
||||
let showNotification = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||
achievements.push(event.payload);
|
||||
if (!showNotification) {
|
||||
showNext();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function showNext() {
|
||||
if (achievements.length > 0) {
|
||||
currentAchievement = achievements.shift() || null;
|
||||
showNotification = true;
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "from-yellow-400 to-orange-500";
|
||||
case "epic":
|
||||
return "from-purple-400 to-pink-500";
|
||||
case "rare":
|
||||
return "from-blue-400 to-indigo-500";
|
||||
default:
|
||||
return "from-green-400 to-emerald-500";
|
||||
}
|
||||
}
|
||||
|
||||
function getAchievementRarity(id: string): string {
|
||||
// Determine rarity based on achievement ID
|
||||
if (id === "TokenMaster") return "legendary";
|
||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||
if (
|
||||
[
|
||||
"BlossomingCoder",
|
||||
"CodeWizard",
|
||||
"MasterBuilder",
|
||||
"EnduranceChamp",
|
||||
"DeepDive",
|
||||
"CreativeCoder",
|
||||
].includes(id)
|
||||
)
|
||||
return "rare";
|
||||
return "common";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showNotification && currentAchievement}
|
||||
<div
|
||||
class="fixed top-20 right-4 z-50 max-w-sm"
|
||||
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Backdrop with animated gradient border -->
|
||||
<div class="relative p-[2px] rounded-lg overflow-hidden">
|
||||
<!-- Animated gradient border -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} animate-pulse"
|
||||
></div>
|
||||
|
||||
<!-- Main notification content -->
|
||||
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||
<button
|
||||
onclick={dismiss}
|
||||
onkeydown={(e) => e.key === "Enter" && dismiss()}
|
||||
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Icon with animated sparkles -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
|
||||
|
||||
<!-- Sparkle animations -->
|
||||
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
||||
<div
|
||||
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
|
||||
✨
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<h3
|
||||
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||
>
|
||||
Achievement Unlocked!
|
||||
</h3>
|
||||
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||
{currentAchievement.achievement.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{currentAchievement.achievement.description}
|
||||
</p>
|
||||
|
||||
<!-- Rarity badge -->
|
||||
<div class="mt-2 inline-flex items-center">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} text-white capitalize"
|
||||
>
|
||||
{getAchievementRarity(currentAchievement.achievement.id)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Celebration confetti effect (CSS only) -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
||||
<div
|
||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} rounded-full animate-fall"
|
||||
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
|
||||
2}s; animation-duration: {2 + Math.random() * 2}s;"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(400px) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fall {
|
||||
animation: fall linear infinite;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
</style>
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* AchievementNotification Component Tests
|
||||
*
|
||||
* Tests the rarity classification and colour mapping logic used by the
|
||||
* AchievementNotification component.
|
||||
*
|
||||
* What this component does:
|
||||
* - Listens for "achievement:unlocked" Tauri events
|
||||
* - Queues and displays achievement notifications one at a time
|
||||
* - Each notification shows the achievement's name, icon, description, and rarity
|
||||
* - A gradient border and badge colour correspond to the achievement's rarity
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Achievement notification slides in from the right
|
||||
* - [ ] Notification auto-dismisses after 5 seconds
|
||||
* - [ ] Dismiss button works immediately
|
||||
* - [ ] Multiple achievements queue and display sequentially
|
||||
* - [ ] Legendary achievements have a yellow-orange gradient
|
||||
* - [ ] Epic achievements have a purple-pink gradient
|
||||
* - [ ] Rare achievements have a blue-indigo gradient
|
||||
* - [ ] Common achievements have a green-emerald gradient
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
function getAchievementRarity(id: string): string {
|
||||
if (id === "TokenMaster") return "legendary";
|
||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||
if (
|
||||
[
|
||||
"BlossomingCoder",
|
||||
"CodeWizard",
|
||||
"MasterBuilder",
|
||||
"EnduranceChamp",
|
||||
"DeepDive",
|
||||
"CreativeCoder",
|
||||
].includes(id)
|
||||
)
|
||||
return "rare";
|
||||
return "common";
|
||||
}
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "from-yellow-400 to-orange-500";
|
||||
case "epic":
|
||||
return "from-purple-400 to-pink-500";
|
||||
case "rare":
|
||||
return "from-blue-400 to-indigo-500";
|
||||
default:
|
||||
return "from-green-400 to-emerald-500";
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getAchievementRarity", () => {
|
||||
describe("legendary tier", () => {
|
||||
it("classifies TokenMaster as legendary", () => {
|
||||
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("epic tier", () => {
|
||||
it("classifies CodeMachine as epic", () => {
|
||||
expect(getAchievementRarity("CodeMachine")).toBe("epic");
|
||||
});
|
||||
|
||||
it("classifies Unstoppable as epic", () => {
|
||||
expect(getAchievementRarity("Unstoppable")).toBe("epic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rare tier", () => {
|
||||
it("classifies BlossomingCoder as rare", () => {
|
||||
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies CodeWizard as rare", () => {
|
||||
expect(getAchievementRarity("CodeWizard")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies MasterBuilder as rare", () => {
|
||||
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies EnduranceChamp as rare", () => {
|
||||
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies DeepDive as rare", () => {
|
||||
expect(getAchievementRarity("DeepDive")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies CreativeCoder as rare", () => {
|
||||
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
|
||||
});
|
||||
});
|
||||
|
||||
describe("common tier", () => {
|
||||
it("classifies unknown IDs as common", () => {
|
||||
expect(getAchievementRarity("FirstChat")).toBe("common");
|
||||
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
|
||||
expect(getAchievementRarity("")).toBe("common");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRarityColor", () => {
|
||||
it("returns yellow-to-orange gradient for legendary", () => {
|
||||
expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500");
|
||||
});
|
||||
|
||||
it("returns purple-to-pink gradient for epic", () => {
|
||||
expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500");
|
||||
});
|
||||
|
||||
it("returns blue-to-indigo gradient for rare", () => {
|
||||
expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500");
|
||||
});
|
||||
|
||||
it("returns green-to-emerald gradient for common", () => {
|
||||
expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500");
|
||||
});
|
||||
|
||||
it("falls back to green-to-emerald gradient for unknown rarities", () => {
|
||||
expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500");
|
||||
expect(getRarityColor("")).toBe("from-green-400 to-emerald-500");
|
||||
});
|
||||
|
||||
describe("end-to-end rarity pipeline", () => {
|
||||
it("produces the correct colour for a legendary achievement", () => {
|
||||
const color = getRarityColor(getAchievementRarity("TokenMaster"));
|
||||
expect(color).toBe("from-yellow-400 to-orange-500");
|
||||
});
|
||||
|
||||
it("produces the correct colour for an epic achievement", () => {
|
||||
const color = getRarityColor(getAchievementRarity("CodeMachine"));
|
||||
expect(color).toBe("from-purple-400 to-pink-500");
|
||||
});
|
||||
|
||||
it("produces the correct colour for a rare achievement", () => {
|
||||
const color = getRarityColor(getAchievementRarity("CodeWizard"));
|
||||
expect(color).toBe("from-blue-400 to-indigo-500");
|
||||
});
|
||||
|
||||
it("produces the correct colour for a common achievement", () => {
|
||||
const color = getRarityColor(getAchievementRarity("FirstChat"));
|
||||
expect(color).toBe("from-green-400 to-emerald-500");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -282,8 +282,9 @@
|
||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||
agent.status
|
||||
)}"
|
||||
title={agent.agentId ? `ID: ${agent.agentId}` : undefined}
|
||||
>
|
||||
{getSubagentTypeLabel(agent.subagentType)}
|
||||
{getSubagentTypeLabel(agent.agentType ?? agent.subagentType)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
@@ -308,6 +309,13 @@
|
||||
{agent.description}
|
||||
</p>
|
||||
|
||||
<!-- Model override badge -->
|
||||
{#if agent.model}
|
||||
<p class="mt-0.5 text-[10px] text-purple-400 truncate" title="Model: {agent.model}">
|
||||
✦ {agent.model}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
{#if agent.status === "running"}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||
const SUPPORTED_CLI_VERSION = "2.1.74";
|
||||
|
||||
let installedVersion = $state("Loading...");
|
||||
let latestNpmVersion = $state<string | null>(null);
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const aParts = a.split(".").map(Number);
|
||||
@@ -32,6 +33,15 @@
|
||||
return "current";
|
||||
});
|
||||
|
||||
let updateAvailable = $derived.by(() => {
|
||||
if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||
return false;
|
||||
}
|
||||
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||
if (!semverMatch) return false;
|
||||
return compareVersions(semverMatch[1], latestNpmVersion) < 0;
|
||||
});
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const result = await invoke<string>("get_claude_version");
|
||||
@@ -42,13 +52,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatestNpmVersion() {
|
||||
try {
|
||||
const result = await invoke<string>("check_cli_latest_version");
|
||||
latestNpmVersion = result;
|
||||
} catch (error) {
|
||||
console.error("Failed to check latest CLI version:", error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchVersion();
|
||||
fetchLatestNpmVersion();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="cli-versions">
|
||||
<div class="cli-version">
|
||||
<div
|
||||
class="cli-version {updateAvailable ? 'update-available' : ''}"
|
||||
title={updateAvailable
|
||||
? `Update available: ${latestNpmVersion} — run: npm install -g @anthropic-ai/claude-code`
|
||||
: "Installed CLI version"}
|
||||
>
|
||||
<svg
|
||||
class="terminal-icon"
|
||||
width="14"
|
||||
@@ -64,6 +89,22 @@
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span class="version-text">CLI {displayVersion}</span>
|
||||
{#if updateAvailable}
|
||||
<svg
|
||||
class="update-icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="17 11 12 6 7 11" />
|
||||
<line x1="12" y1="6" x2="12" y2="18" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
||||
@@ -135,6 +176,27 @@
|
||||
color: var(--error-color, #f44336);
|
||||
}
|
||||
|
||||
.cli-version.update-available {
|
||||
border-color: var(--warning-color, #ff9800);
|
||||
color: var(--warning-color, #ff9800);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.update-icon {
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||
const SUPPORTED_CLI_VERSION = "2.1.74";
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const aParts = a.split(".").map(Number);
|
||||
@@ -41,7 +41,7 @@ describe("SUPPORTED_CLI_VERSION", () => {
|
||||
});
|
||||
|
||||
it("matches the expected audited version", () => {
|
||||
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
|
||||
expect(SUPPORTED_CLI_VERSION).toBe("2.1.74");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,7 +128,55 @@ describe("compareVersions", () => {
|
||||
});
|
||||
|
||||
it("returns 0 for exactly the supported version", () => {
|
||||
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
|
||||
expect(compareVersions("2.1.74", SUPPORTED_CLI_VERSION)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors the updateAvailable derived logic in CliVersion.svelte
|
||||
function isUpdateAvailable(installedVersion: string, latestNpmVersion: string | null): boolean {
|
||||
if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||
return false;
|
||||
}
|
||||
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||
if (!semverMatch) return false;
|
||||
return compareVersions(semverMatch[1], latestNpmVersion) < 0;
|
||||
}
|
||||
|
||||
describe("updateAvailable", () => {
|
||||
it("returns false when latestNpmVersion is null", () => {
|
||||
expect(isUpdateAvailable("2.1.70", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when installed is Loading...", () => {
|
||||
expect(isUpdateAvailable("Loading...", "2.1.74")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when installed is Unknown", () => {
|
||||
expect(isUpdateAvailable("Unknown", "2.1.74")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when installed equals latest", () => {
|
||||
expect(isUpdateAvailable("2.1.74", "2.1.74")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when installed is ahead of latest", () => {
|
||||
expect(isUpdateAvailable("2.1.75", "2.1.74")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when installed is behind latest", () => {
|
||||
expect(isUpdateAvailable("2.1.70", "2.1.74")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when installed has a lower minor version", () => {
|
||||
expect(isUpdateAvailable("2.0.99", "2.1.74")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles version strings with extra info like '2.1.70 (build 123)'", () => {
|
||||
expect(isUpdateAvailable("2.1.70 (build 123)", "2.1.74")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unparseable installed version", () => {
|
||||
expect(isUpdateAvailable("not-a-version", "2.1.74")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
disable_cron: false,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: null,
|
||||
model_overrides: null,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
@@ -78,6 +83,8 @@
|
||||
let customUiFontPathInput = $state("");
|
||||
let customUiFontFamilyInput = $state("");
|
||||
let customUiFontStatus: string | null = $state(null);
|
||||
let modelOverridesJson = $state("");
|
||||
let modelOverridesError: string | null = $state(null);
|
||||
|
||||
interface AuthStatus {
|
||||
is_logged_in: boolean;
|
||||
@@ -107,6 +114,7 @@
|
||||
customFontFamilyInput = c.custom_font_family ?? "";
|
||||
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
||||
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
||||
modelOverridesJson = c.model_overrides ? JSON.stringify(c.model_overrides, null, 2) : "";
|
||||
});
|
||||
|
||||
configStore.isSidebarOpen.subscribe((open) => {
|
||||
@@ -137,11 +145,6 @@
|
||||
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
||||
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||
// Legacy (Claude 3.x)
|
||||
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
|
||||
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
|
||||
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
|
||||
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
|
||||
];
|
||||
|
||||
const commonTools = [
|
||||
@@ -197,6 +200,18 @@
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
modelOverridesError = null;
|
||||
try {
|
||||
if (modelOverridesJson.trim()) {
|
||||
config.model_overrides = JSON.parse(modelOverridesJson) as Record<string, string>;
|
||||
} else {
|
||||
config.model_overrides = null;
|
||||
}
|
||||
} catch {
|
||||
modelOverridesError = "Invalid JSON — please check your model overrides.";
|
||||
isSaving = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await configStore.saveConfig(config);
|
||||
configStore.closeSidebar();
|
||||
@@ -554,6 +569,38 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Disable Cron Scheduling -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.disable_cron}
|
||||
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-[var(--text-primary)]">Disable cron scheduling</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_CRON=1</code> to prevent Claude from scheduling
|
||||
recurring tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Include Git Instructions -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.include_git_instructions}
|
||||
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-[var(--text-primary)]">Include git instructions</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
When disabled, sets <code class="font-mono">CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1</code> to
|
||||
remove Claude's built-in commit and PR workflow guidance from its system prompt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Max Output Tokens -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
|
||||
@@ -572,6 +619,47 @@
|
||||
being cut off mid-reply
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-memory Directory -->
|
||||
<div class="mb-4">
|
||||
<label for="auto-memory-dir" class="block text-sm text-[var(--text-primary)] mb-1">
|
||||
Auto-memory directory <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="auto-memory-dir"
|
||||
type="text"
|
||||
placeholder="Leave blank to use default"
|
||||
bind:value={config.auto_memory_directory}
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Custom directory for auto-memory storage. Passed via
|
||||
<code class="font-mono">--settings autoMemoryDirectory</code>. Leave blank to use the
|
||||
default (working directory).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Overrides -->
|
||||
<div class="mb-4">
|
||||
<label for="model-overrides" class="block text-sm text-[var(--text-primary)] mb-1">
|
||||
Model overrides <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="model-overrides"
|
||||
rows={4}
|
||||
placeholder={'{\n "claude-opus-4-6": "arn:aws:bedrock:..."\n}'}
|
||||
bind:value={modelOverridesJson}
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] font-mono resize-y"
|
||||
></textarea>
|
||||
{#if modelOverridesError}
|
||||
<p class="text-xs text-red-500 mt-1">{modelOverridesError}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
JSON map of model names to provider-specific IDs (for AWS Bedrock, Google Vertex, etc.).
|
||||
Passed via <code class="font-mono">--settings modelOverrides</code>. Leave blank to use
|
||||
defaults.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Greeting Section -->
|
||||
@@ -629,6 +717,22 @@
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] font-mono text-sm focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Enable Claude.ai MCP Servers -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.enable_claudeai_mcp_servers}
|
||||
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-[var(--text-primary)]">Enable Claude.ai MCP servers</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
When disabled, sets <code class="font-mono">ENABLE_CLAUDEAI_MCP_SERVERS=false</code> to prevent
|
||||
Claude Code from connecting to MCP servers configured in Claude.ai.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Auto-Granted Tools Section -->
|
||||
|
||||
@@ -402,6 +402,10 @@ User: ${formattedMessage}`;
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import hljs from "highlight.js";
|
||||
import { onMount } from "svelte";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { linkifyFilePaths } from "$lib/utils/filePaths";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
@@ -113,7 +115,8 @@
|
||||
let parsedHtml = $derived.by(() => {
|
||||
try {
|
||||
const html = marked.parse(content) as string;
|
||||
return processSpoilers(html);
|
||||
const withSpoilers = processSpoilers(html);
|
||||
return linkifyFilePaths(withSpoilers);
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
@@ -140,9 +143,18 @@
|
||||
function handleLinkClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const anchor = target.closest("a");
|
||||
if (anchor?.href) {
|
||||
event.preventDefault();
|
||||
openUrl(anchor.href);
|
||||
if (!anchor) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const filePath = anchor.dataset.filepath;
|
||||
if (filePath) {
|
||||
void invoke("open_binary_file", { path: filePath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.href) {
|
||||
void openUrl(anchor.href);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,4 +465,27 @@
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.markdown-content :global(.file-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
color: var(--accent-primary, #f472b6);
|
||||
text-decoration: none;
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--accent-primary) 8%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.4em;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.875em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.markdown-content :global(.file-link:hover) {
|
||||
background: color-mix(in srgb, var(--accent-primary) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 60%, transparent);
|
||||
color: var(--accent-secondary, #e879f9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* - [ ] Code blocks render with syntax highlighting and a copy button
|
||||
* - [ ] ||spoiler text|| renders as a hidden span revealed on click
|
||||
* - [ ] Search query highlights matching text in non-code content
|
||||
* - [ ] Links open in the system browser via the Tauri opener
|
||||
* - [ ] Regular links open in the system browser via the Tauri opener
|
||||
* - [ ] Binary file links invoke open_binary_file (WSL-path-aware) instead of openPath
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, onClose }: Props = $props();
|
||||
|
||||
interface MemoryFileInfo {
|
||||
path: string;
|
||||
heading: string | null;
|
||||
@@ -17,7 +25,6 @@
|
||||
let fileContent: string = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let isPanelOpen = $state(false);
|
||||
|
||||
async function loadMemoryFiles() {
|
||||
isLoading = true;
|
||||
@@ -58,37 +65,20 @@
|
||||
return file.heading ?? getFileName(file.path);
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
isPanelOpen = !isPanelOpen;
|
||||
if (isPanelOpen && memoryFiles.length === 0) {
|
||||
loadMemoryFiles();
|
||||
}
|
||||
async function sendMemoryCommand() {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
await invoke("send_prompt", { conversationId, message: "/memory" });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Don't load on mount - only when panel is opened
|
||||
$effect(() => {
|
||||
if (isOpen && memoryFiles.length === 0) {
|
||||
loadMemoryFiles();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
|
||||
<svg
|
||||
class="icon"
|
||||
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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span class="label">Memory</span>
|
||||
</button>
|
||||
|
||||
{#if isPanelOpen}
|
||||
{#if isOpen}
|
||||
<div class="memory-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-title">
|
||||
@@ -108,22 +98,56 @@
|
||||
</svg>
|
||||
<h3>Memory Files</h3>
|
||||
</div>
|
||||
<button class="close-btn" onclick={togglePanel} title="Close">
|
||||
<svg
|
||||
class="close-icon"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button onclick={sendMemoryCommand} class="action-btn" title="Send /memory to Claude">
|
||||
<svg
|
||||
class="action-icon"
|
||||
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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick={loadMemoryFiles} class="action-btn" title="Refresh">
|
||||
<svg
|
||||
class="action-icon"
|
||||
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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close-btn" onclick={onClose} title="Close">
|
||||
<svg
|
||||
class="close-icon"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
@@ -230,34 +254,6 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.memory-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.memory-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memory-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -300,6 +296,32 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
import { memoryBrowserStore } from "$lib/stores/memoryBrowser";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import AboutPanel from "./AboutPanel.svelte";
|
||||
@@ -24,6 +25,7 @@
|
||||
import ChangelogPanel from "./ChangelogPanel.svelte";
|
||||
import TaskLoopPanel from "./TaskLoopPanel.svelte";
|
||||
import WorkflowPanel from "./WorkflowPanel.svelte";
|
||||
import MemoryBrowserPanel from "./MemoryBrowserPanel.svelte";
|
||||
import { injectTextStore } from "$lib/stores/projectContext";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
@@ -69,6 +71,10 @@
|
||||
let showChangelog = $state(false);
|
||||
let showTaskLoop = $state(false);
|
||||
let showWorkflowPanel = $state(false);
|
||||
let showMemoryPanel = $state(false);
|
||||
memoryBrowserStore.subscribe((s) => {
|
||||
showMemoryPanel = s.isOpen;
|
||||
});
|
||||
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
@@ -176,6 +182,19 @@
|
||||
<span>Session History</span>
|
||||
</button>
|
||||
|
||||
<!-- Memory Manager -->
|
||||
<button onclick={menuAction(() => memoryBrowserStore.open())} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Memory Manager</span>
|
||||
</button>
|
||||
|
||||
<!-- To-Do List -->
|
||||
<button onclick={menuAction(() => (showTodoPanel = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -547,6 +566,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showMemoryPanel}
|
||||
<MemoryBrowserPanel isOpen={showMemoryPanel} onClose={() => memoryBrowserStore.close()} />
|
||||
{/if}
|
||||
|
||||
{#if showWorkflowPanel}
|
||||
<WorkflowPanel
|
||||
onClose={() => (showWorkflowPanel = false)}
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
allowed_tools: [...new Set([...newGrantedTools, ...config.auto_granted_tools])],
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
>
|
||||
<span class="command-name">/{command.name}</span>
|
||||
<span class="command-description">{command.description}</span>
|
||||
{#if command.source === "cli"}
|
||||
<span class="cli-badge">CLI</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -82,5 +85,19 @@
|
||||
.command-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cli-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--accent-primary) 15%, transparent);
|
||||
color: var(--accent-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
let connectionStatus: ConnectionStatus = $state("disconnected");
|
||||
let workingDirectory = $state("");
|
||||
let worktreeInfo: import("$lib/types/worktree").WorktreeInfo | null = $state(null);
|
||||
let selectedDirectory = $state("/home/naomi");
|
||||
let isConnecting = $state(false);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
@@ -87,6 +88,11 @@
|
||||
task_loop_auto_commit: false,
|
||||
task_loop_commit_prefix: "feat",
|
||||
task_loop_include_summary: false,
|
||||
disable_cron: false,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: null,
|
||||
model_overrides: null,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -115,6 +121,10 @@
|
||||
workingDirectory = dir;
|
||||
});
|
||||
|
||||
claudeStore.worktreeInfo.subscribe((info) => {
|
||||
worktreeInfo = info;
|
||||
});
|
||||
|
||||
claudeStore.grantedTools.subscribe((tools) => {
|
||||
grantedToolsList = Array.from(tools);
|
||||
});
|
||||
@@ -163,6 +173,10 @@
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||
include_git_instructions: currentConfig.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: currentConfig.auto_memory_directory || null,
|
||||
model_overrides: currentConfig.model_overrides || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,6 +334,10 @@
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||
include_git_instructions: currentConfig.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: currentConfig.auto_memory_directory || null,
|
||||
model_overrides: currentConfig.model_overrides || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -392,6 +410,22 @@
|
||||
{workingDirectory}
|
||||
</div>
|
||||
{/if}
|
||||
{#if worktreeInfo}
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-xs"
|
||||
title="Worktree: {worktreeInfo.name} | Base: {worktreeInfo.original_repo_directory}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
{worktreeInfo.branch}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600">cwd:</span>
|
||||
|
||||
@@ -218,6 +218,10 @@
|
||||
use_worktree: cfg.use_worktree ?? false,
|
||||
disable_1m_context: cfg.disable_1m_context ?? false,
|
||||
max_output_tokens: cfg.max_output_tokens ?? null,
|
||||
include_git_instructions: cfg.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: cfg.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: cfg.auto_memory_directory || null,
|
||||
model_overrides: cfg.model_overrides || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { toastStore, getAchievementRarity, getRarityColour } from "$lib/stores/toasts";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
|
||||
const toasts = toastStore;
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||
toastStore.addAchievement(event.payload.achievement);
|
||||
});
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="fixed top-20 right-4 z-50 flex flex-col gap-3 items-end">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div in:fly={{ x: 300, duration: 500, easing: cubicOut }} out:fade={{ duration: 300 }}>
|
||||
{#if toast.kind === "info"}
|
||||
<!-- Info toast -->
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg p-3 shadow-lg flex items-center gap-2 max-w-sm"
|
||||
>
|
||||
<span class="text-xl shrink-0">{toast.icon}</span>
|
||||
<span class="text-sm text-[var(--text-primary)] flex-1">{toast.message}</span>
|
||||
<button
|
||||
onclick={() => toastStore.remove(toast.id)}
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" 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>
|
||||
{:else if toast.kind === "achievement"}
|
||||
{@const rarity = getAchievementRarity(toast.achievement.id)}
|
||||
{@const colour = getRarityColour(rarity)}
|
||||
<!-- Achievement toast -->
|
||||
<div class="relative p-[2px] rounded-lg overflow-hidden max-w-sm">
|
||||
<!-- Animated gradient border -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r {colour} animate-pulse"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||
<button
|
||||
onclick={() => toastStore.remove(toast.id)}
|
||||
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<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 class="flex items-start gap-4">
|
||||
<!-- Icon with animated sparkles -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="text-5xl animate-bounce">{toast.achievement.icon}</div>
|
||||
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
||||
<div
|
||||
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400"
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<h3
|
||||
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||
>
|
||||
Achievement Unlocked!
|
||||
</h3>
|
||||
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||
{toast.achievement.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{toast.achievement.description}
|
||||
</p>
|
||||
|
||||
<!-- Rarity badge -->
|
||||
<div class="mt-2 inline-flex items-center">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {colour} text-white capitalize"
|
||||
>
|
||||
{rarity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confetti particles -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
||||
<div
|
||||
class="absolute w-2 h-2 bg-gradient-to-br {colour} rounded-full animate-fall"
|
||||
style="left: {(confettiIndex * 11) % 100}%; animation-delay: {(confettiIndex *
|
||||
0.3) %
|
||||
2}s; animation-duration: {2 + ((confettiIndex * 0.25) % 2)}s;"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if toast.kind === "update"}
|
||||
<!-- Update toast -->
|
||||
<div
|
||||
class="bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg p-4 shadow-lg max-w-sm"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
onclick={() => openUrl(toast.releaseUrl)}
|
||||
class="text-[var(--accent-primary)] font-mono hover:underline text-sm"
|
||||
>
|
||||
{toast.latestVersion}
|
||||
</button>
|
||||
<p class="text-[var(--text-muted)] text-xs mt-1">
|
||||
Current version: {toast.currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => toastStore.remove(toast.id)}
|
||||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" 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>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(400px) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fall {
|
||||
animation: fall linear infinite;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<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="btn-trans-gradient px-3 py-1.5 rounded text-sm">
|
||||
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}
|
||||
@@ -108,6 +108,10 @@
|
||||
allowed_tools: grantedToolsList,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user