generated from nhcarrigan/template
feat: Claude CLI 2.1.50–2.1.53 audit (#171)
## Summary This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review. ### New Features - **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly - **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active - **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead) - **Org UUID in account info** — exposes the org UUID from Claude auth status ### Bug Fixes - **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164) - **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166) - **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165) - **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169) ### Maintenance - Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162) - Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163) - Expose org UUID from `claude auth status` (Closes #160) - Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import) - Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147) - Run `cargo update` to bring Cargo.lock up to date ### Closes Closes #160 Closes #162 Closes #163 Closes #164 Closes #165 Closes #166 Closes #167 Closes #168 Closes #169 Closes #81 Closes #82 Closes #83 Closes #84 Closes #85 Closes #86 Closes #87 Closes #90 Closes #91 Closes #93 Closes #94 Closes #95 Closes #96 Closes #97 Closes #98 Closes #99 Closes #101 Closes #141 Closes #142 Closes #143 Closes #145 Closes #146 Closes #147 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #171 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #171.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const SUPPORTED_CLI_VERSION = "2.1.50";
|
||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||
|
||||
let installedVersion = $state("Loading...");
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState, characterInfo } from "$lib/stores/character";
|
||||
import { isStreamerMode } from "$lib/stores/config";
|
||||
import { isStreamerMode, configStore } from "$lib/stores/config";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||
@@ -14,6 +14,9 @@
|
||||
|
||||
let { onExpand }: Props = $props();
|
||||
|
||||
const configValues = configStore.config;
|
||||
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
|
||||
|
||||
let inputValue = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let isConnected = $state(false);
|
||||
@@ -150,7 +153,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="compact-container {getStateGlow()}">
|
||||
<div
|
||||
class="compact-container {getStateGlow()}"
|
||||
style={hasBackgroundImage ? "background: transparent !important;" : ""}
|
||||
>
|
||||
<!-- Character sprite (smaller) -->
|
||||
<div class="compact-character">
|
||||
<div class="sprite-wrapper {getAnimationClass()}">
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import CostSummary from "./CostSummary.svelte";
|
||||
|
||||
let config: HikariConfig = $state({
|
||||
@@ -55,6 +56,9 @@
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
@@ -62,6 +66,7 @@
|
||||
interface AuthStatus {
|
||||
is_logged_in: boolean;
|
||||
email: string | null;
|
||||
org_id: string | null;
|
||||
org_name: string | null;
|
||||
api_key_source: string | null;
|
||||
api_provider: string | null;
|
||||
@@ -240,6 +245,20 @@
|
||||
await window.setAlwaysOnTop(enabled);
|
||||
await configStore.updateConfig({ always_on_top: enabled });
|
||||
}
|
||||
|
||||
async function pickBackgroundImage() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif"] }],
|
||||
});
|
||||
if (selected) {
|
||||
config.background_image_path = selected;
|
||||
}
|
||||
}
|
||||
|
||||
function clearBackgroundImage() {
|
||||
config.background_image_path = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
@@ -319,6 +338,14 @@
|
||||
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authStatus.org_id}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org UUID</dt>
|
||||
<dd class="text-[var(--text-secondary)] font-mono text-[10px] break-all">
|
||||
{authStatus.org_id}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authStatus.api_key_source}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
|
||||
@@ -905,6 +932,52 @@
|
||||
expanded/collapsed to see reasoning details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Background Image -->
|
||||
<div class="mb-4">
|
||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
|
||||
{#if config.background_image_path}
|
||||
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
|
||||
{config.background_image_path.split("/").pop()}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={pickBackgroundImage}
|
||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
{config.background_image_path ? "Change Image" : "Choose Image"}
|
||||
</button>
|
||||
{#if config.background_image_path}
|
||||
<button
|
||||
onclick={clearBackgroundImage}
|
||||
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
||||
title="Remove background image"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.background_image_path}
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{Math.round(config.background_image_opacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="bg-opacity"
|
||||
type="range"
|
||||
bind:value={config.background_image_opacity}
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.05"
|
||||
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Window Section -->
|
||||
|
||||
@@ -12,6 +12,25 @@
|
||||
let editingTabId = $state<string | null>(null);
|
||||
let editingName = $state("");
|
||||
|
||||
// Tab order for pointer-drag reordering
|
||||
let tabOrder = $state<string[]>([]);
|
||||
let draggedId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
let dragStartX = 0;
|
||||
let isDragging = false;
|
||||
let wasDragged = false;
|
||||
let tabsRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// Keep tabOrder in sync with conversations map (add new, remove deleted)
|
||||
$effect(() => {
|
||||
const currentIds = Array.from($conversations.keys());
|
||||
const validIds = tabOrder.filter((id) => currentIds.includes(id));
|
||||
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
|
||||
if (validIds.length !== tabOrder.length || newIds.length > 0) {
|
||||
tabOrder = [...validIds, ...newIds];
|
||||
}
|
||||
});
|
||||
|
||||
// Track last seen message count for each conversation
|
||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||
|
||||
@@ -138,8 +157,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTabClick(id: string) {
|
||||
if (wasDragged) {
|
||||
wasDragged = false;
|
||||
return;
|
||||
}
|
||||
await switchTab(id);
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent, id: string) {
|
||||
if (editingTabId === id) return;
|
||||
draggedId = id;
|
||||
dragStartX = event.clientX;
|
||||
isDragging = false;
|
||||
wasDragged = false;
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
|
||||
isDragging = true;
|
||||
}
|
||||
if (!isDragging || !tabsRef) return;
|
||||
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
|
||||
dragOverId = null;
|
||||
for (const tab of tabs) {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
||||
const tabId = tab.dataset.tabId;
|
||||
if (tabId && tabId !== id) {
|
||||
dragOverId = tabId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
|
||||
const order = [...tabOrder];
|
||||
const fromIndex = order.indexOf(draggedId);
|
||||
const toIndex = order.indexOf(dragOverId);
|
||||
order.splice(fromIndex, 1);
|
||||
order.splice(toIndex, 0, draggedId);
|
||||
tabOrder = order;
|
||||
wasDragged = true;
|
||||
}
|
||||
draggedId = null;
|
||||
dragOverId = null;
|
||||
isDragging = false;
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
onMount(() => {
|
||||
// Initialise all conversations as seen on mount so that remounting
|
||||
// this component (e.g. after closing the file editor) doesn't falsely
|
||||
// mark existing messages as unread.
|
||||
for (const [id, conversation] of $conversations) {
|
||||
lastSeenMessageCount.set(id, conversation.terminalLines.length);
|
||||
}
|
||||
lastSeenMessageCount = lastSeenMessageCount;
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// Ctrl/Cmd + T: New tab
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
||||
@@ -165,21 +249,19 @@
|
||||
// Ctrl/Cmd + Tab: Next tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[nextIndex]);
|
||||
const nextIndex = (currentIndex + 1) % tabOrder.length;
|
||||
claudeStore.switchConversation(tabOrder[nextIndex]);
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[prevIndex]);
|
||||
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
|
||||
claudeStore.switchConversation(tabOrder[prevIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,15 +272,22 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={tabsRef}
|
||||
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 tabOrder
|
||||
.filter((id) => $conversations.has(id))
|
||||
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
|
||||
<div
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
||||
data-tab-id={id}
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
|
||||
{id === $activeConversationId
|
||||
? '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'}"
|
||||
onclick={() => switchTab(id)}
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
|
||||
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
|
||||
{draggedId === id ? 'dragging' : ''}"
|
||||
onpointerdown={(e) => handlePointerDown(e, id)}
|
||||
onclick={() => handleTabClick(id)}
|
||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||
role="tab"
|
||||
tabindex={0}
|
||||
@@ -211,7 +300,7 @@
|
||||
onblur={saveTabName}
|
||||
onkeydown={handleKeydown}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -296,5 +385,20 @@
|
||||
|
||||
.tab-item {
|
||||
min-width: 100px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
border-left: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,7 +35,12 @@
|
||||
};
|
||||
|
||||
renderer.codespan = ({ text }) => {
|
||||
return `<code class="hljs-inline">${text}</code>`;
|
||||
const escaped = text.replace(/</g, "<").replace(/>/g, ">");
|
||||
return `<code class="hljs-inline">${escaped}</code>`;
|
||||
};
|
||||
|
||||
renderer.html = ({ text }) => {
|
||||
return text.replace(/</g, "<").replace(/>/g, ">");
|
||||
};
|
||||
|
||||
marked.setOptions({
|
||||
|
||||
@@ -190,10 +190,13 @@
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
<label
|
||||
for="mcp-new-name"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Server Name</label
|
||||
>
|
||||
<input
|
||||
id="mcp-new-name"
|
||||
type="text"
|
||||
bind:value={newServerName}
|
||||
placeholder="my-server"
|
||||
@@ -201,10 +204,13 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
<label
|
||||
for="mcp-new-transport"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Transport</label
|
||||
>
|
||||
<select
|
||||
id="mcp-new-transport"
|
||||
bind:value={newServerTransport}
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
@@ -214,10 +220,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
|
||||
<label
|
||||
for="mcp-new-url"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>
|
||||
{newServerTransport === "stdio" ? "Command" : "URL"}
|
||||
</label>
|
||||
<input
|
||||
id="mcp-new-url"
|
||||
type="text"
|
||||
bind:value={newServerUrl}
|
||||
placeholder={newServerTransport === "stdio"
|
||||
@@ -266,6 +276,7 @@
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each servers as server (server.name)}
|
||||
{@const TransportIcon = getTransportIcon(server.transport)}
|
||||
<button
|
||||
onclick={() => loadServerDetails(server.name)}
|
||||
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
|
||||
@@ -274,10 +285,7 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(server.transport)}
|
||||
class="w-4 h-4 {getTransportColor(server.transport)}"
|
||||
/>
|
||||
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
|
||||
{server.name}
|
||||
{#if server.status}
|
||||
{#if server.status.includes("Connected")}
|
||||
@@ -323,25 +331,19 @@
|
||||
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else}
|
||||
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Name</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
|
||||
</div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Transport</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(selectedServer.transport)}
|
||||
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
|
||||
/>
|
||||
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
|
||||
{selectedServer.transport.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -349,9 +351,7 @@
|
||||
<!-- URL or Command -->
|
||||
{#if selectedServer.url}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>URL</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
@@ -362,9 +362,7 @@
|
||||
|
||||
{#if selectedServer.command}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Command</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
@@ -376,9 +374,9 @@
|
||||
<!-- Environment Variables -->
|
||||
{#if selectedServer.env}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Environment</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
Environment
|
||||
</p>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
|
||||
selectedServer.env,
|
||||
@@ -391,9 +389,9 @@
|
||||
<!-- Full Server Details -->
|
||||
{#if serverDetails}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Full Details</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
Full Details
|
||||
</p>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
|
||||
</div>
|
||||
@@ -416,18 +414,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -430,18 +430,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
} from "$lib/utils/conversationUtils";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
@@ -61,6 +63,8 @@
|
||||
let showPluginPanel = $state(false);
|
||||
let showMcpPanel = $state(false);
|
||||
let isSummarising = $state(false);
|
||||
let showWorkspaceTrust = $state(false);
|
||||
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
@@ -103,6 +107,9 @@
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -156,11 +163,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (isConnecting || connectionStatus === "connected") return;
|
||||
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
|
||||
async function doConnect(targetDir: string) {
|
||||
// Combine session-granted tools with config auto-granted tools
|
||||
const allAllowedTools = [
|
||||
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
||||
@@ -200,6 +203,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (isConnecting || connectionStatus === "connected") return;
|
||||
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
|
||||
if (currentConfig.trusted_workspaces?.includes(targetDir)) {
|
||||
await doConnect(targetDir);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hookInfo = await invoke<WorkspaceHookInfo>("check_workspace_hooks", {
|
||||
workingDir: targetDir,
|
||||
});
|
||||
|
||||
if (hookInfo.has_concerns) {
|
||||
pendingHookInfo = hookInfo;
|
||||
showWorkspaceTrust = true;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail open: if we can't check hooks, proceed with connection
|
||||
console.error("Failed to check workspace hooks:", error);
|
||||
}
|
||||
|
||||
await doConnect(targetDir);
|
||||
}
|
||||
|
||||
async function handleTrustAndConnect() {
|
||||
showWorkspaceTrust = false;
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
pendingHookInfo = null;
|
||||
const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false;
|
||||
if (!alreadyTrusted) {
|
||||
await configStore.updateConfig({
|
||||
trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir],
|
||||
});
|
||||
}
|
||||
doConnect(targetDir);
|
||||
}
|
||||
|
||||
function handleCancelConnect() {
|
||||
showWorkspaceTrust = false;
|
||||
pendingHookInfo = null;
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
@@ -771,6 +820,14 @@
|
||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showWorkspaceTrust && pendingHookInfo}
|
||||
<WorkspaceTrustModal
|
||||
hookInfo={pendingHookInfo}
|
||||
onTrust={handleTrustAndConnect}
|
||||
onCancel={handleCancelConnect}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Responsive status bar styling */
|
||||
.status-bar {
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||
|
||||
interface Props {
|
||||
hookInfo: WorkspaceHookInfo;
|
||||
onTrust: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { hookInfo, onTrust, onCancel }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
characterState.setState("permission");
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onclick={onCancel}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-400 flex-shrink-0"
|
||||
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>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Workspace Trust Required</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
This workspace contains configuration that can execute code on your system. Review what was
|
||||
found before connecting.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
{#if hookInfo.hook_types.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
Hooks (run shell commands automatically):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.hook_types as hookType (hookType)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• {hookType}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hookInfo.mcp_servers.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
MCP servers (run as local processes with system access):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.mcp_servers as server (server)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• {server}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hookInfo.custom_commands.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
Custom slash commands (can execute arbitrary instructions):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.custom_commands as cmd (cmd)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• /{cmd}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-6">
|
||||
Only connect to workspaces you trust. Trusting this workspace will remember your choice for
|
||||
future sessions.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onTrust}
|
||||
class="px-4 py-2 text-sm bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/30 rounded-md transition-colors"
|
||||
>
|
||||
Trust and Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,9 +30,9 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="dialog-overlay" onclick={onCancel}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||
<h2 class="dialog-title">{title}</h2>
|
||||
<p class="dialog-message">{message}</p>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="menu-overlay"
|
||||
onclick={onClose}
|
||||
@@ -87,7 +87,7 @@
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="dialog-overlay" onclick={onCancel}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||
<h2 class="dialog-title">{title}</h2>
|
||||
|
||||
|
||||
@@ -196,6 +196,9 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -244,6 +247,9 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -791,6 +797,9 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface HikariConfig {
|
||||
use_worktree: boolean;
|
||||
// Disable 1M context window
|
||||
disable_1m_context: boolean;
|
||||
// Workspaces the user has explicitly trusted
|
||||
trusted_workspaces: string[];
|
||||
// Background image settings
|
||||
background_image_path: string | null;
|
||||
background_image_opacity: number;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -93,6 +98,9 @@ const defaultConfig: HikariConfig = {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
|
||||
@@ -174,6 +174,13 @@ export interface Attachment {
|
||||
previewUrl?: string; // For images, a data URL or object URL for preview
|
||||
}
|
||||
|
||||
export interface WorkspaceHookInfo {
|
||||
has_concerns: boolean;
|
||||
hook_types: string[];
|
||||
mcp_servers: string[];
|
||||
custom_commands: string[];
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
|
||||
Reference in New Issue
Block a user