generated from nhcarrigan/template
4134e11c88
## Summary This PR implements all tickets filed from the CLI v2.1.74 → v2.1.80 changelog audit (issues #223–#232). ### Changes by Issue - **#223** — `feat: handle Elicitation and ElicitationResult hook events` New `ElicitationModal.svelte` component, Rust parsing for `[Elicitation Hook]` and `[ElicitationResult Hook]`, new store methods, and TypeScript event types. - **#224** — `feat: handle StopFailure hook event for API error turns` Rust parsing for `[StopFailure Hook]`; frontend shows error toast + error character state. - **#225** — `feat: handle PostCompact hook event` Rust parsing for `[PostCompact Hook]`; frontend shows info toast + success character state. - **#226** — `feat: expose --name CLI flag as session name at startup` Added `session_name` field to `ClaudeStartOptions`; `StatusBar.doConnect()` passes the conversation name. - **#227** — `fix: tighten startup watchdog and correct misleading comment` Startup watchdog tightened from 60 s → 30 s; corrected a comment that said "5 minutes" whilst the code used 60 seconds. - **#228** — `fix: document cost estimation review and update default model fallback` Default model fallback updated from `claude-sonnet-4-5-20250929` → `claude-sonnet-4-6`; added doc comment explaining why char-based estimation is unaffected by v2.1.75 token overcounting fix. - **#229** — `chore: update supported CLI version constant to 2.1.80` `SUPPORTED_CLI_VERSION` bumped in `CliVersion.svelte`. - **#230** — `feat: surface memory file last-modified timestamps in MemoryBrowserPanel` Backend populates `last_modified` Unix timestamp; frontend formats and displays it per file. - **#231** — `feat: update max_output_tokens upper bound and helper text for 128k` Input max raised to 128 000; placeholder and helper text updated to reflect model-dependent defaults and 128 k ceiling for Opus/Sonnet 4.6. - **#232** — `fix: document non-streaming fallback compatibility with mid-session watchdog` Added doc comment above `STUCK_TIMEOUT` explaining the 5-minute watchdog is intentionally larger than the CLI's 2-minute non-streaming API fallback. --- ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #233 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
535 lines
13 KiB
Svelte
535 lines
13 KiB
Svelte
<script lang="ts">
|
|
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;
|
|
last_modified?: string; // Unix timestamp in seconds as a string, optional for backwards compat
|
|
}
|
|
|
|
interface MemoryFilesResponse {
|
|
files: MemoryFileInfo[];
|
|
}
|
|
|
|
let memoryFiles: MemoryFileInfo[] = $state([]);
|
|
let selectedFile: string | null = $state(null);
|
|
let fileContent: string = $state("");
|
|
let isLoading = $state(false);
|
|
let error: string | null = $state(null);
|
|
|
|
async function loadMemoryFiles() {
|
|
isLoading = true;
|
|
error = null;
|
|
try {
|
|
const response = await invoke<MemoryFilesResponse>("list_memory_files");
|
|
memoryFiles = response.files;
|
|
} catch (e) {
|
|
error = `Failed to load memory files: ${e}`;
|
|
console.error(error);
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadFileContent(filePath: string) {
|
|
isLoading = true;
|
|
error = null;
|
|
try {
|
|
// Use our backend command instead of Tauri plugin to handle WSL paths
|
|
const content = await invoke<string>("read_file_content", { path: filePath });
|
|
fileContent = content;
|
|
selectedFile = filePath;
|
|
} catch (e) {
|
|
error = `Failed to read file: ${e}`;
|
|
console.error(error);
|
|
fileContent = "";
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
function getFileName(path: string): string {
|
|
return path.split("/").pop() || path;
|
|
}
|
|
|
|
function getDisplayName(file: MemoryFileInfo): string {
|
|
return file.heading ?? getFileName(file.path);
|
|
}
|
|
|
|
function formatLastModified(ts: string | undefined): string {
|
|
if (!ts) return "";
|
|
const date = new Date(Number(ts) * 1000);
|
|
return date.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
async function sendMemoryCommand() {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) return;
|
|
await invoke("send_prompt", { conversationId, message: "/memory" });
|
|
}
|
|
|
|
$effect(() => {
|
|
if (isOpen && memoryFiles.length === 0) {
|
|
loadMemoryFiles();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{#if isOpen}
|
|
<div class="memory-panel">
|
|
<div class="panel-header">
|
|
<div class="header-title">
|
|
<svg
|
|
class="header-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>
|
|
<h3>Memory Files</h3>
|
|
</div>
|
|
<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">
|
|
{#if isLoading && memoryFiles.length === 0}
|
|
<div class="loading">
|
|
<svg
|
|
class="spinner"
|
|
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>
|
|
Loading memory files...
|
|
</div>
|
|
{:else if error}
|
|
<div class="error">
|
|
<p>{error}</p>
|
|
<button class="retry-btn" onclick={loadMemoryFiles}>Retry</button>
|
|
</div>
|
|
{:else if memoryFiles.length === 0}
|
|
<div class="empty">
|
|
<p>No memory files found.</p>
|
|
<p class="hint">
|
|
Memory files are created automatically as I learn from our conversations!
|
|
</p>
|
|
</div>
|
|
{:else}
|
|
<div class="panel-layout">
|
|
<div class="file-list">
|
|
{#each memoryFiles as file (file.path)}
|
|
<button
|
|
class="file-item"
|
|
class:active={selectedFile === file.path}
|
|
onclick={() => loadFileContent(file.path)}
|
|
title={getFileName(file.path)}
|
|
>
|
|
<svg
|
|
class="file-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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
<div class="file-info">
|
|
<span class="file-name">{getDisplayName(file)}</span>
|
|
{#if file.last_modified}
|
|
<span class="file-date">{formatLastModified(file.last_modified)}</span>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="file-viewer">
|
|
{#if selectedFile && fileContent}
|
|
<div class="viewer-header">
|
|
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
|
|
<h4>{getDisplayName(activeFile)}</h4>
|
|
{#if activeFile.heading}
|
|
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
<div class="viewer-content">
|
|
<Markdown content={fileContent} />
|
|
</div>
|
|
{:else if selectedFile && isLoading}
|
|
<div class="loading-file">
|
|
<svg
|
|
class="spinner"
|
|
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>
|
|
Loading file...
|
|
</div>
|
|
{:else}
|
|
<div class="no-selection">
|
|
<p>Select a memory file to view its contents</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.memory-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
width: 600px;
|
|
height: 100vh;
|
|
background: var(--bg-primary);
|
|
border-left: 1px solid var(--border-color);
|
|
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.header-icon {
|
|
width: 1.5rem;
|
|
height: 1.5rem;
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.panel-header h3 {
|
|
margin: 0;
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
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;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.close-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.close-icon {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
}
|
|
|
|
.panel-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.loading,
|
|
.error,
|
|
.empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 1rem;
|
|
padding: 3rem 1.5rem;
|
|
text-align: center;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.spinner {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.error p {
|
|
color: var(--terminal-error, #f87171);
|
|
}
|
|
|
|
.retry-btn {
|
|
padding: 0.5rem 1rem;
|
|
background: var(--accent-primary);
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: white;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.retry-btn:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.hint {
|
|
font-size: 0.875rem;
|
|
font-style: italic;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.panel-layout {
|
|
display: grid;
|
|
grid-template-columns: 200px 1fr;
|
|
gap: 1.5rem;
|
|
height: 100%;
|
|
}
|
|
|
|
.file-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.file-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.file-item:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.file-item.active {
|
|
background: var(--accent-primary);
|
|
border-color: var(--accent-primary);
|
|
color: white;
|
|
}
|
|
|
|
.file-icon {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.file-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.125rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.file-name {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.file-date {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.file-item.active .file-date {
|
|
color: rgba(255, 255, 255, 0.75);
|
|
}
|
|
|
|
.file-viewer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.viewer-header {
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.viewer-header h4 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.viewer-filename {
|
|
margin: 0.25rem 0 0;
|
|
font-size: 0.75rem;
|
|
color: var(--text-tertiary);
|
|
font-family: monospace;
|
|
}
|
|
|
|
.viewer-content {
|
|
flex: 1;
|
|
padding: 1.5rem;
|
|
overflow-y: auto;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.loading-file,
|
|
.no-selection {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 1rem;
|
|
padding: 3rem 1.5rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
</style>
|