feat: project context panel for persistent PROJECT/REQUIREMENTS/ROADMAP/STATE files (#188)

- Add projectContext store with load/save via existing Tauri file commands
- Add ProjectContextPanel modal with tabbed editor, file-exists badges, and templates
- Add injectTextStore signal so StatusBar can inject content directly into InputBar
- Add PROJECT_CONTEXT_SYSTEM_ADDENDUM auto-appended to custom_instructions on connect
- Add open-book button to StatusBar to open the panel
- Add PROJECT.md example file for the hikari-desktop project itself
- 34 tests covering all store methods, exports, and signal behaviour
This commit is contained in:
2026-03-06 12:09:30 -08:00
committed by Naomi Carrigan
parent 1ae440659c
commit 78dc838f36
6 changed files with 754 additions and 2 deletions
+45
View File
@@ -0,0 +1,45 @@
# Project Overview
## What is this project?
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character companion (Hikari) who appears on screen. It provides a rich UI for interacting with Claude Code, including conversation management, agent monitoring, cost tracking, and more.
The app was inspired by a Hatsune Miku mod for the ship AI in _The Outer Worlds_ — the idea of an AI assistant with an anime girl avatar that you can actually _see_.
## Goals
- Provide a beautiful, personalised interface for Claude Code
- Surface real-time status (thinking, typing, searching, etc.) through animated character sprites
- Track costs, context usage, and agent activity across sessions
- Support power-user workflows: multi-tab conversations, todo lists, git integration, MCP server management, session compaction, and more
- Build a foundation for autonomous task execution (agent orchestration, PRD-driven workflows)
## Tech Stack
- **Frontend**: Svelte 5 + TypeScript + Tailwind CSS
- **Backend**: Rust (Tauri v2)
- **Build**: Vite + pnpm
- **Testing**: Vitest (frontend) + cargo test (backend)
- **Linting**: ESLint + Prettier (frontend) + Clippy (backend)
- **IPC**: Tauri commands + events between Rust and Svelte
## Architecture
```
hikari-desktop/
├── src/ # Svelte frontend
│ └── lib/
│ ├── components/ # UI components (panels, modals, status bar)
│ ├── stores/ # Svelte stores (state management)
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Utility functions
├── src-tauri/ # Rust backend
│ └── src/
│ ├── commands.rs # Tauri command handlers
│ ├── wsl_bridge.rs # Claude Code process management
│ ├── types.rs # Shared types & CharacterState enum
│ └── stats.rs # Cost tracking
└── public/ # Static assets (sprites, sounds)
```
Claude Code is launched as a child process via `WslBridge`, communicating via `--output-format stream-json` (NDJSON). Messages flow from the Rust backend to the Svelte frontend via Tauri events.
+9
View File
@@ -37,6 +37,7 @@
import DraftPanel from "$lib/components/DraftPanel.svelte";
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
import { draftsStore } from "$lib/stores/drafts";
import { injectTextStore } from "$lib/stores/projectContext";
import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -178,6 +179,14 @@
}
});
// Project context injection — set by StatusBar via injectTextStore signal.
injectTextStore.subscribe((text) => {
if (text === null) return;
inputValue = inputValue.trim() ? text + "\n\n" + inputValue : text;
userHasTyped = true;
injectTextStore.set(null);
});
function clearInput() {
inputValue = "";
const activeId = get(claudeStore.activeConversationId);
@@ -0,0 +1,225 @@
<script lang="ts">
import { onMount } from "svelte";
import {
projectContextStore,
PROJECT_FILE_NAMES,
type ProjectFile,
} from "$lib/stores/projectContext";
interface Props {
onClose: () => void;
onInject: (content: string) => void;
workingDirectory: string;
}
const { onClose, onInject, workingDirectory }: Props = $props();
const PROJECT_FILES: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
const contents = $derived(projectContextStore.contents);
const isLoading = $derived(projectContextStore.isLoading);
const isSaving = $derived(projectContextStore.isSaving);
const activeFile = $derived(projectContextStore.activeFile);
let editContent = $state("");
let hasUnsavedChanges = $state(false);
onMount(() => {
projectContextStore.loadAll(workingDirectory);
});
$effect(() => {
const file = $activeFile;
const fileContent = $contents[file];
editContent = fileContent ?? projectContextStore.getTemplate(file);
hasUnsavedChanges = false;
});
function handleTabSwitch(file: ProjectFile): void {
projectContextStore.setActiveFile(file);
}
function handleUseTemplate(): void {
editContent = projectContextStore.getTemplate($activeFile);
hasUnsavedChanges = true;
}
function handleInject(): void {
onInject(editContent);
}
async function handleSave(): Promise<void> {
const saved = await projectContextStore.saveFile($activeFile, editContent, workingDirectory);
if (saved) {
hasUnsavedChanges = false;
}
}
function handleTextChange(event: Event): void {
editContent = (event.target as HTMLTextAreaElement).value;
hasUnsavedChanges = true;
}
function fileExists(file: ProjectFile): boolean {
return $contents[file] !== null;
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="project-context-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<h2 id="project-context-title" class="text-xl font-semibold text-[var(--text-primary)]">
Project Context
</h2>
{#if $isLoading[$activeFile]}
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
{:else if fileExists($activeFile)}
<span
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
>
✓ File exists
</span>
{:else}
<span
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
✗ Not created
</span>
{/if}
{#if hasUnsavedChanges}
<span class="text-xs text-[var(--text-tertiary)] italic">Unsaved changes</span>
{/if}
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Tab bar -->
<div class="flex border-b border-[var(--border-color)] px-4">
{#each PROJECT_FILES as file (file)}
<button
onclick={() => handleTabSwitch(file)}
class="px-4 py-2 text-sm font-medium transition-colors relative {$activeFile === file
? 'text-[var(--accent-primary)] border-b-2 border-[var(--accent-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
>
{PROJECT_FILE_NAMES[file]}
{#if !fileExists(file)}
<span class="ml-1 text-amber-500"></span>
{/if}
</button>
{/each}
</div>
<!-- Editor area -->
<div class="flex-1 overflow-hidden p-4 min-h-0">
<textarea
value={editContent}
oninput={handleTextChange}
class="w-full h-full min-h-[400px] bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 font-mono text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
placeholder="File content will appear here..."
spellcheck="false"
></textarea>
</div>
<!-- Footer -->
<div
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
>
<div class="text-xs text-[var(--text-tertiary)]">
<span class="font-mono">{workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}</span>
</div>
<div class="flex items-center gap-2">
<button
onclick={handleUseTemplate}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Use Template
</button>
<button
onclick={handleInject}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Inject into Prompt
</button>
<button
onclick={handleSave}
disabled={$isSaving[$activeFile]}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if $isSaving[$activeFile]}
Saving...
{:else}
Save
{/if}
</button>
</div>
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
textarea {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: transparent;
}
textarea::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
+33 -2
View File
@@ -30,6 +30,8 @@
import CastPanel from "./CastPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte";
import ProjectContextPanel from "./ProjectContextPanel.svelte";
import { injectTextStore, PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
import { conversationsStore } from "$lib/stores/conversations";
import {
generateContextInjection,
@@ -62,6 +64,7 @@
let showCastPanel = $state(false);
let showPluginPanel = $state(false);
let showMcpPanel = $state(false);
let showProjectContext = $state(false);
let isSummarising = $state(false);
let showWorkspaceTrust = $state(false);
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
@@ -185,7 +188,8 @@
working_dir: targetDir,
model: currentConfig.model || null,
api_key: currentConfig.api_key || null,
custom_instructions: currentConfig.custom_instructions || null,
custom_instructions:
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
@@ -300,6 +304,10 @@
onToggleAchievements();
}
function handleInjectContext(content: string): void {
injectTextStore.set(content);
}
async function handleCompactConversation() {
const activeId = get(conversationsStore.activeConversationId);
if (!activeId) return;
@@ -345,7 +353,8 @@
working_dir: workingDirectory || selectedDirectory,
model: currentConfig.model || null,
api_key: currentConfig.api_key || null,
custom_instructions: currentConfig.custom_instructions || null,
custom_instructions:
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
@@ -564,6 +573,20 @@
/>
</svg>
</button>
<button
onclick={() => (showProjectContext = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Project Context"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 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>
</button>
<button
onclick={toggleEditor}
disabled={connectionStatus !== "connected"}
@@ -827,6 +850,14 @@
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
{#if showProjectContext}
<ProjectContextPanel
onClose={() => (showProjectContext = false)}
onInject={handleInjectContext}
workingDirectory={workingDirectory || selectedDirectory}
/>
{/if}
{#if showWorkspaceTrust && pendingHookInfo}
<WorkspaceTrustModal
hookInfo={pendingHookInfo}
+287
View File
@@ -0,0 +1,287 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { setMockInvokeResult } from "../../../vitest.setup";
import {
projectContextStore,
PROJECT_FILE_NAMES,
PROJECT_TEMPLATES,
PROJECT_CONTEXT_SYSTEM_ADDENDUM,
injectTextStore,
type ProjectFile,
} from "./projectContext";
describe("PROJECT_FILE_NAMES", () => {
it("maps all four project file types", () => {
expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md");
expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md");
expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md");
expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md");
});
});
describe("PROJECT_TEMPLATES", () => {
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
it.each(files)("returns a non-empty template for %s", (file) => {
expect(PROJECT_TEMPLATES[file]).toBeTruthy();
expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0);
});
});
describe("projectContextStore", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("initial state", () => {
it("has null contents for all files", () => {
const state = get(projectContextStore.contents);
expect(state.PROJECT).toBeNull();
expect(state.REQUIREMENTS).toBeNull();
expect(state.ROADMAP).toBeNull();
expect(state.STATE).toBeNull();
});
it("has false isLoading for all files", () => {
const state = get(projectContextStore.isLoading);
expect(state.PROJECT).toBe(false);
expect(state.REQUIREMENTS).toBe(false);
expect(state.ROADMAP).toBe(false);
expect(state.STATE).toBe(false);
});
it("has false isSaving for all files", () => {
const state = get(projectContextStore.isSaving);
expect(state.PROJECT).toBe(false);
expect(state.REQUIREMENTS).toBe(false);
expect(state.ROADMAP).toBe(false);
expect(state.STATE).toBe(false);
});
it("has PROJECT as the default activeFile", () => {
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
});
it("exposes all expected methods", () => {
expect(typeof projectContextStore.loadFile).toBe("function");
expect(typeof projectContextStore.saveFile).toBe("function");
expect(typeof projectContextStore.loadAll).toBe("function");
expect(typeof projectContextStore.setActiveFile).toBe("function");
expect(typeof projectContextStore.getTemplate).toBe("function");
});
});
describe("loadFile", () => {
it("calls read_file_content with the correct path", async () => {
setMockInvokeResult("read_file_content", "# Project\n\nContent here");
await projectContextStore.loadFile("PROJECT", "/home/naomi/myproject");
expect(invoke).toHaveBeenCalledWith("read_file_content", {
path: "/home/naomi/myproject/PROJECT.md",
});
});
it("updates contents store with file content on success", async () => {
const content = "# My Project\n\nDescription here";
setMockInvokeResult("read_file_content", content);
await projectContextStore.loadFile("PROJECT", "/home/naomi/myproject");
expect(get(projectContextStore.contents).PROJECT).toBe(content);
});
it("sets content to null when file does not exist", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("read_file_content", new Error("File not found"));
await projectContextStore.loadFile("REQUIREMENTS", "/home/naomi/myproject");
expect(get(projectContextStore.contents).REQUIREMENTS).toBeNull();
consoleSpy.mockRestore();
});
it("sets isLoading to false after completion", async () => {
setMockInvokeResult("read_file_content", "content");
await projectContextStore.loadFile("ROADMAP", "/home/naomi/myproject");
expect(get(projectContextStore.isLoading).ROADMAP).toBe(false);
});
it("sets isLoading to false even on error", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("read_file_content", new Error("Read error"));
await projectContextStore.loadFile("STATE", "/home/naomi/myproject");
expect(get(projectContextStore.isLoading).STATE).toBe(false);
consoleSpy.mockRestore();
});
it("uses correct filename for each file type", async () => {
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
for (const file of files) {
setMockInvokeResult("read_file_content", `content for ${file}`);
await projectContextStore.loadFile(file, "/wd");
expect(invoke).toHaveBeenCalledWith("read_file_content", {
path: `/wd/${PROJECT_FILE_NAMES[file]}`,
});
}
});
});
describe("saveFile", () => {
it("calls write_file_content with the correct path and content", async () => {
setMockInvokeResult("write_file_content", undefined);
await projectContextStore.saveFile("PROJECT", "# New content", "/home/naomi/myproject");
expect(invoke).toHaveBeenCalledWith("write_file_content", {
path: "/home/naomi/myproject/PROJECT.md",
content: "# New content",
});
});
it("returns true on success", async () => {
setMockInvokeResult("write_file_content", undefined);
const result = await projectContextStore.saveFile(
"PROJECT",
"# Content",
"/home/naomi/myproject"
);
expect(result).toBe(true);
});
it("updates contents store with saved content on success", async () => {
setMockInvokeResult("write_file_content", undefined);
const newContent = "# Updated Project\n\nNew content";
await projectContextStore.saveFile("REQUIREMENTS", newContent, "/home/naomi/myproject");
expect(get(projectContextStore.contents).REQUIREMENTS).toBe(newContent);
});
it("returns false and logs error on failure", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("write_file_content", new Error("Write failed"));
const result = await projectContextStore.saveFile(
"ROADMAP",
"content",
"/home/naomi/myproject"
);
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to save project context file:",
expect.any(Error)
);
consoleSpy.mockRestore();
});
it("sets isSaving to false after completion", async () => {
setMockInvokeResult("write_file_content", undefined);
await projectContextStore.saveFile("STATE", "content", "/home/naomi/myproject");
expect(get(projectContextStore.isSaving).STATE).toBe(false);
});
it("sets isSaving to false even on error", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("write_file_content", new Error("Error"));
await projectContextStore.saveFile("PROJECT", "content", "/home/naomi/myproject");
expect(get(projectContextStore.isSaving).PROJECT).toBe(false);
consoleSpy.mockRestore();
});
});
describe("loadAll", () => {
it("loads all four files in parallel", async () => {
setMockInvokeResult("read_file_content", "file content");
await projectContextStore.loadAll("/home/naomi/myproject");
const calls = vi.mocked(invoke).mock.calls.filter(([cmd]) => cmd === "read_file_content");
const paths = calls.map(([, args]) => (args as { path: string }).path);
expect(paths).toContain("/home/naomi/myproject/PROJECT.md");
expect(paths).toContain("/home/naomi/myproject/REQUIREMENTS.md");
expect(paths).toContain("/home/naomi/myproject/ROADMAP.md");
expect(paths).toContain("/home/naomi/myproject/STATE.md");
});
it("sets all files isLoading to false after completion", async () => {
setMockInvokeResult("read_file_content", "content");
await projectContextStore.loadAll("/home/naomi/myproject");
const loadingState = get(projectContextStore.isLoading);
expect(loadingState.PROJECT).toBe(false);
expect(loadingState.REQUIREMENTS).toBe(false);
expect(loadingState.ROADMAP).toBe(false);
expect(loadingState.STATE).toBe(false);
});
});
describe("setActiveFile", () => {
it("updates the activeFile store", () => {
projectContextStore.setActiveFile("REQUIREMENTS");
expect(get(projectContextStore.activeFile)).toBe("REQUIREMENTS");
projectContextStore.setActiveFile("STATE");
expect(get(projectContextStore.activeFile)).toBe("STATE");
projectContextStore.setActiveFile("PROJECT");
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
});
});
describe("getTemplate", () => {
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
it.each(files)("returns a non-empty string for %s", (file) => {
const template = projectContextStore.getTemplate(file);
expect(typeof template).toBe("string");
expect(template.length).toBeGreaterThan(0);
});
it("returns distinct templates for each file type", () => {
const templates = files.map((f) => projectContextStore.getTemplate(f));
const uniqueTemplates = new Set(templates);
expect(uniqueTemplates.size).toBe(files.length);
});
});
});
describe("PROJECT_CONTEXT_SYSTEM_ADDENDUM", () => {
it("is a non-empty string", () => {
expect(typeof PROJECT_CONTEXT_SYSTEM_ADDENDUM).toBe("string");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM.length).toBeGreaterThan(0);
});
it("mentions all four context file names", () => {
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md");
});
});
describe("injectTextStore", () => {
it("initialises to null", () => {
expect(get(injectTextStore)).toBeNull();
});
it("can be set and read", () => {
injectTextStore.set("hello world");
expect(get(injectTextStore)).toBe("hello world");
injectTextStore.set(null);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { writable } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE";
export const PROJECT_FILE_NAMES: Record<ProjectFile, string> = {
PROJECT: "PROJECT.md",
REQUIREMENTS: "REQUIREMENTS.md",
ROADMAP: "ROADMAP.md",
STATE: "STATE.md",
};
export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
PROJECT: `# Project Overview
## What is this project?
## Goals
## Tech Stack
## Architecture
`,
REQUIREMENTS: `# Requirements
## Functional Requirements
## Non-Functional Requirements
## Out of Scope
`,
ROADMAP: `# Roadmap
## Current Sprint
## Next Sprint
## Backlog
## Completed
`,
STATE: `# Current State
## Last Updated
## What's Working
## In Progress
## Known Issues
## Next Steps
`,
};
const PROJECT_FILES = Object.keys(PROJECT_FILE_NAMES) as ProjectFile[];
function createProjectContextStore() {
const contents = writable<Record<ProjectFile, string | null>>({
PROJECT: null,
REQUIREMENTS: null,
ROADMAP: null,
STATE: null,
});
const isLoading = writable<Record<ProjectFile, boolean>>({
PROJECT: false,
REQUIREMENTS: false,
ROADMAP: false,
STATE: false,
});
const isSaving = writable<Record<ProjectFile, boolean>>({
PROJECT: false,
REQUIREMENTS: false,
ROADMAP: false,
STATE: false,
});
const activeFile = writable<ProjectFile>("PROJECT");
async function loadFile(file: ProjectFile, workingDirectory: string): Promise<void> {
isLoading.update((state) => ({ ...state, [file]: true }));
try {
const path = `${workingDirectory}/${PROJECT_FILE_NAMES[file]}`;
const content = await invoke<string>("read_file_content", { path });
contents.update((state) => ({ ...state, [file]: content }));
} catch {
contents.update((state) => ({ ...state, [file]: null }));
} finally {
isLoading.update((state) => ({ ...state, [file]: false }));
}
}
async function saveFile(
file: ProjectFile,
content: string,
workingDirectory: string
): Promise<boolean> {
isSaving.update((state) => ({ ...state, [file]: true }));
try {
const path = `${workingDirectory}/${PROJECT_FILE_NAMES[file]}`;
await invoke("write_file_content", { path, content });
contents.update((state) => ({ ...state, [file]: content }));
return true;
} catch (error) {
console.error("Failed to save project context file:", error);
return false;
} finally {
isSaving.update((state) => ({ ...state, [file]: false }));
}
}
async function loadAll(workingDirectory: string): Promise<void> {
await Promise.all(PROJECT_FILES.map((file) => loadFile(file, workingDirectory)));
}
function setActiveFile(file: ProjectFile): void {
activeFile.set(file);
}
function getTemplate(file: ProjectFile): string {
return PROJECT_TEMPLATES[file];
}
return {
contents: { subscribe: contents.subscribe },
isLoading: { subscribe: isLoading.subscribe },
isSaving: { subscribe: isSaving.subscribe },
activeFile: { subscribe: activeFile.subscribe },
loadFile,
saveFile,
loadAll,
setActiveFile,
getTemplate,
};
}
export const projectContextStore = createProjectContextStore();
// Signal store for injecting context into the active InputBar.
// StatusBar sets this; InputBar subscribes and applies it to inputValue directly,
// then resets it to null so the signal only fires once.
export const injectTextStore = writable<string | null>(null);
// Appended silently to custom_instructions at connection time (never saved to config).
// Mirrors how CLAUDE.md works natively — Claude checks the files itself if they exist.
export const PROJECT_CONTEXT_SYSTEM_ADDENDUM = `
---
The following project context files may exist in your working directory. If they exist, read and refer to them as needed:
- PROJECT.md — project overview, goals, and architecture
- REQUIREMENTS.md — functional and non-functional requirements
- ROADMAP.md — current sprint, backlog, and completed work
- STATE.md — current state, known issues, and next steps`;