diff --git a/.story_kit/specs/functional/PROJECT_MANAGEMENT.md b/.story_kit/specs/functional/PROJECT_MANAGEMENT.md index 1763f48..12e0368 100644 --- a/.story_kit/specs/functional/PROJECT_MANAGEMENT.md +++ b/.story_kit/specs/functional/PROJECT_MANAGEMENT.md @@ -12,7 +12,7 @@ The application operates in two primary states regarding project context: ## 2. Selection Logic * **Trigger:** User initiates "Open Project". -* **Mechanism:** Native OS Directory Picker (via `tauri-plugin-dialog`). +* **Mechanism:** Path entry in the selection screen. * **Validation:** * The backend receives the selected path. * The backend verifies: @@ -20,7 +20,18 @@ The application operates in two primary states regarding project context: 2. Path is a directory. 3. Path is readable. * If valid -> State transitions to **Active**. - * If invalid -> Error returned to UI, State remains **Idle**. + * If invalid because the path does not exist: + * The backend creates the directory. + * The backend scaffolds the Story Kit metadata under the new project root: + * `.story_kit/README.md` + * `.story_kit/specs/README.md` + * `.story_kit/specs/00_CONTEXT.md` + * `.story_kit/specs/tech/STACK.md` + * `.story_kit/specs/functional/` (directory) + * `.story_kit/stories/archive/` (directory) + * If scaffolding succeeds -> State transitions to **Active**. + * If scaffolding fails -> Error returned to UI, State remains **Idle**. + * If invalid for other reasons -> Error returned to UI, State remains **Idle**. ## 3. Security Boundaries * Once a project is selected, the `SessionState` struct in Rust locks onto this path. diff --git a/.story_kit/stories/25_auto_scaffold_story_kit.md b/.story_kit/stories/25_auto_scaffold_story_kit.md new file mode 100644 index 0000000..6e3290a --- /dev/null +++ b/.story_kit/stories/25_auto_scaffold_story_kit.md @@ -0,0 +1,24 @@ +# Story 25: Auto-Scaffold Story Kit Metadata on New Projects + +## User Story +As a user, I want the app to automatically scaffold the `.story_kit` directory when I open a path that doesn't exist, so new projects are ready for the Story Kit workflow immediately. + +## Acceptance Criteria +- When I enter a non-existent project path and press Enter/Open, the app creates the directory. +- The app also creates the `.story_kit` directory under the new project root. +- The `.story_kit` structure includes: + - `README.md` (the Story Kit workflow instructions) + - `specs/` + - `README.md` + - `00_CONTEXT.md` + - `tech/STACK.md` + - `functional/` (created, even if empty) + - `stories/` + - `archive/` +- The project opens successfully after scaffolding completes. +- If any scaffolding step fails, the UI shows a clear error message and does not open the project. + +## Out of Scope +- Creating any `src/` files or application code. +- Populating project-specific content beyond the standard Story Kit templates. +- Prompting the user for metadata (e.g., project name, description, stack choices). \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 99b9fb6..a845471 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,172 +6,175 @@ import { usePathCompletion } from "./components/selection/usePathCompletion"; import "./App.css"; function App() { - const [projectPath, setProjectPath] = React.useState(null); - const [errorMsg, setErrorMsg] = React.useState(null); - const [pathInput, setPathInput] = React.useState(""); - const [isOpening, setIsOpening] = React.useState(false); - const [knownProjects, setKnownProjects] = React.useState([]); - const [homeDir, setHomeDir] = React.useState(null); + const [projectPath, setProjectPath] = React.useState(null); + const [errorMsg, setErrorMsg] = React.useState(null); + const [pathInput, setPathInput] = React.useState(""); + const [isOpening, setIsOpening] = React.useState(false); + const [knownProjects, setKnownProjects] = React.useState([]); + const [homeDir, setHomeDir] = React.useState(null); - React.useEffect(() => { - api - .getKnownProjects() - .then((projects) => setKnownProjects(projects)) - .catch((error) => console.error(error)); - }, []); + React.useEffect(() => { + api + .getKnownProjects() + .then((projects) => setKnownProjects(projects)) + .catch((error) => console.error(error)); + }, []); - React.useEffect(() => { - let active = true; - api - .getHomeDirectory() - .then((home) => { - if (!active) return; - setHomeDir(home); - setPathInput((current) => { - if (current.trim()) { - return current; - } - const initial = home.endsWith("/") ? home : `${home}/`; - return initial; - }); - }) - .catch((error) => { - console.error(error); - }); + React.useEffect(() => { + let active = true; + api + .getHomeDirectory() + .then((home) => { + if (!active) return; + setHomeDir(home); + setPathInput((current) => { + if (current.trim()) { + return current; + } + const initial = home.endsWith("/") ? home : `${home}/`; + return initial; + }); + }) + .catch((error) => { + console.error(error); + }); - return () => { - active = false; - }; - }, []); + return () => { + active = false; + }; + }, []); - const { - matchList, - selectedMatch, - suggestionTail, - completionError, - currentPartial, - setSelectedMatch, - acceptSelectedMatch, - acceptMatch, - closeSuggestions, - } = usePathCompletion({ - pathInput, - setPathInput, - homeDir, - listDirectoryAbsolute: api.listDirectoryAbsolute, - }); + const { + matchList, + selectedMatch, + suggestionTail, + completionError, + currentPartial, + setSelectedMatch, + acceptSelectedMatch, + acceptMatch, + closeSuggestions, + } = usePathCompletion({ + pathInput, + setPathInput, + homeDir, + listDirectoryAbsolute: api.listDirectoryAbsolute, + }); - async function openProject(path: string) { - if (!path.trim()) { - setErrorMsg("Please enter a project path."); - return; - } + async function openProject(path: string) { + const trimmedPath = path.trim(); + if (!trimmedPath) { + setErrorMsg("Please enter a project path."); + return; + } - try { - setErrorMsg(null); - setIsOpening(true); - const confirmedPath = await api.openProject(path.trim()); - setProjectPath(confirmedPath); - } catch (e) { - console.error(e); - const message = - e instanceof Error - ? e.message - : typeof e === "string" - ? e - : "An error occurred opening the project."; - setErrorMsg(message); - } finally { - setIsOpening(false); - } - } + try { + setErrorMsg(null); + setIsOpening(true); + const confirmedPath = await api.openProject(trimmedPath); + setProjectPath(confirmedPath); + } catch (e) { + console.error(e); + const message = + e instanceof Error + ? e.message + : typeof e === "string" + ? e + : "An error occurred opening the project."; - function handleOpen() { - void openProject(pathInput); - } + setErrorMsg(message); + } finally { + setIsOpening(false); + } + } - async function handleForgetProject(path: string) { - try { - await api.forgetKnownProject(path); - setKnownProjects((prev) => prev.filter((p) => p !== path)); - } catch (error) { - console.error(error); - } - } + function handleOpen() { + void openProject(pathInput); + } - async function closeProject() { - try { - await api.closeProject(); - setProjectPath(null); - } catch (e) { - console.error(e); - } - } + async function handleForgetProject(path: string) { + try { + await api.forgetKnownProject(path); + setKnownProjects((prev) => prev.filter((p) => p !== path)); + } catch (error) { + console.error(error); + } + } - function handlePathInputKeyDown( - event: React.KeyboardEvent, - ) { - if (event.key === "ArrowDown") { - if (matchList.length > 0) { - event.preventDefault(); - setSelectedMatch((selectedMatch + 1) % matchList.length); - } - } else if (event.key === "ArrowUp") { - if (matchList.length > 0) { - event.preventDefault(); - setSelectedMatch( - (selectedMatch - 1 + matchList.length) % matchList.length, - ); - } - } else if (event.key === "Tab") { - if (matchList.length > 0) { - event.preventDefault(); - acceptSelectedMatch(); - } - } else if (event.key === "Escape") { - event.preventDefault(); - closeSuggestions(); - } else if (event.key === "Enter") { - handleOpen(); - } - } + async function closeProject() { + try { + await api.closeProject(); + setProjectPath(null); + } catch (e) { + console.error(e); + } + } - return ( -
- {!projectPath ? ( - - ) : ( -
- -
- )} + function handlePathInputKeyDown( + event: React.KeyboardEvent, + ) { + if (event.key === "ArrowDown") { + if (matchList.length > 0) { + event.preventDefault(); + setSelectedMatch((selectedMatch + 1) % matchList.length); + } + } else if (event.key === "ArrowUp") { + if (matchList.length > 0) { + event.preventDefault(); + setSelectedMatch( + (selectedMatch - 1 + matchList.length) % matchList.length, + ); + } + } else if (event.key === "Tab") { + if (matchList.length > 0) { + event.preventDefault(); + acceptSelectedMatch(); + } + } else if (event.key === "Escape") { + event.preventDefault(); + closeSuggestions(); + } else if (event.key === "Enter") { + handleOpen(); + } + } - {errorMsg && ( -
-

Error: {errorMsg}

-
- )} -
- ); + return ( +
+ {!projectPath ? ( + + ) : ( +
+ +
+ )} + + {errorMsg && ( +
+

Error: {errorMsg}

+
+ )} +
+ ); } export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8f0378a..2fa846c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,293 +1,300 @@ export type WsRequest = - | { - type: "chat"; - messages: Message[]; - config: ProviderConfig; - } - | { - type: "cancel"; - }; + | { + type: "chat"; + messages: Message[]; + config: ProviderConfig; + } + | { + type: "cancel"; + }; export type WsResponse = - | { type: "token"; content: string } - | { type: "update"; messages: Message[] } - | { type: "error"; message: string }; + | { type: "token"; content: string } + | { type: "update"; messages: Message[] } + | { type: "error"; message: string }; export interface ProviderConfig { - provider: string; - model: string; - base_url?: string; - enable_tools?: boolean; + provider: string; + model: string; + base_url?: string; + enable_tools?: boolean; } export type Role = "system" | "user" | "assistant" | "tool"; export interface ToolCall { - id?: string; - type: string; - function: { - name: string; - arguments: string; - }; + id?: string; + type: string; + function: { + name: string; + arguments: string; + }; } export interface Message { - role: Role; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; + role: Role; + content: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; } export interface FileEntry { - name: string; - kind: "file" | "dir"; + name: string; + kind: "file" | "dir"; } export interface SearchResult { - path: string; - matches: number; + path: string; + matches: number; } export interface CommandOutput { - stdout: string; - stderr: string; - exit_code: number; + stdout: string; + stderr: string; + exit_code: number; } const DEFAULT_API_BASE = "/api"; const DEFAULT_WS_PATH = "/ws"; function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { - return `${baseUrl}${path}`; + return `${baseUrl}${path}`; } async function requestJson( - path: string, - options: RequestInit = {}, - baseUrl = DEFAULT_API_BASE, + path: string, + options: RequestInit = {}, + baseUrl = DEFAULT_API_BASE, ): Promise { - const res = await fetch(buildApiUrl(path, baseUrl), { - headers: { - "Content-Type": "application/json", - ...(options.headers ?? {}), - }, - ...options, - }); + const res = await fetch(buildApiUrl(path, baseUrl), { + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, + }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Request failed (${res.status})`); - } + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } - return res.json() as Promise; + return res.json() as Promise; } export const api = { - getCurrentProject(baseUrl?: string) { - return requestJson("/project", {}, baseUrl); - }, - getKnownProjects(baseUrl?: string) { - return requestJson("/projects", {}, baseUrl); - }, - forgetKnownProject(path: string, baseUrl?: string) { - return requestJson( - "/projects/forget", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - openProject(path: string, baseUrl?: string) { - return requestJson( - "/project", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - closeProject(baseUrl?: string) { - return requestJson("/project", { method: "DELETE" }, baseUrl); - }, - getModelPreference(baseUrl?: string) { - return requestJson("/model", {}, baseUrl); - }, - setModelPreference(model: string, baseUrl?: string) { - return requestJson( - "/model", - { method: "POST", body: JSON.stringify({ model }) }, - baseUrl, - ); - }, - getOllamaModels(baseUrlParam?: string, baseUrl?: string) { - const url = new URL( - buildApiUrl("/ollama/models", baseUrl), - window.location.origin, - ); - if (baseUrlParam) { - url.searchParams.set("base_url", baseUrlParam); - } - return requestJson(url.pathname + url.search, {}, ""); - }, - getAnthropicApiKeyExists(baseUrl?: string) { - return requestJson("/anthropic/key/exists", {}, baseUrl); - }, - getAnthropicModels(baseUrl?: string) { - return requestJson("/anthropic/models", {}, baseUrl); - }, - setAnthropicApiKey(api_key: string, baseUrl?: string) { - return requestJson( - "/anthropic/key", - { method: "POST", body: JSON.stringify({ api_key }) }, - baseUrl, - ); - }, - readFile(path: string, baseUrl?: string) { - return requestJson( - "/fs/read", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - writeFile(path: string, content: string, baseUrl?: string) { - return requestJson( - "/fs/write", - { method: "POST", body: JSON.stringify({ path, content }) }, - baseUrl, - ); - }, - listDirectory(path: string, baseUrl?: string) { - return requestJson( - "/fs/list", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - listDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/list/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - getHomeDirectory(baseUrl?: string) { - return requestJson("/io/fs/home", {}, baseUrl); - }, - searchFiles(query: string, baseUrl?: string) { - return requestJson( - "/fs/search", - { method: "POST", body: JSON.stringify({ query }) }, - baseUrl, - ); - }, - execShell(command: string, args: string[], baseUrl?: string) { - return requestJson( - "/shell/exec", - { method: "POST", body: JSON.stringify({ command, args }) }, - baseUrl, - ); - }, - cancelChat(baseUrl?: string) { - return requestJson("/chat/cancel", { method: "POST" }, baseUrl); - }, + getCurrentProject(baseUrl?: string) { + return requestJson("/project", {}, baseUrl); + }, + getKnownProjects(baseUrl?: string) { + return requestJson("/projects", {}, baseUrl); + }, + forgetKnownProject(path: string, baseUrl?: string) { + return requestJson( + "/projects/forget", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + openProject(path: string, baseUrl?: string) { + return requestJson( + "/project", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + closeProject(baseUrl?: string) { + return requestJson("/project", { method: "DELETE" }, baseUrl); + }, + getModelPreference(baseUrl?: string) { + return requestJson("/model", {}, baseUrl); + }, + setModelPreference(model: string, baseUrl?: string) { + return requestJson( + "/model", + { method: "POST", body: JSON.stringify({ model }) }, + baseUrl, + ); + }, + getOllamaModels(baseUrlParam?: string, baseUrl?: string) { + const url = new URL( + buildApiUrl("/ollama/models", baseUrl), + window.location.origin, + ); + if (baseUrlParam) { + url.searchParams.set("base_url", baseUrlParam); + } + return requestJson(url.pathname + url.search, {}, ""); + }, + getAnthropicApiKeyExists(baseUrl?: string) { + return requestJson("/anthropic/key/exists", {}, baseUrl); + }, + getAnthropicModels(baseUrl?: string) { + return requestJson("/anthropic/models", {}, baseUrl); + }, + setAnthropicApiKey(api_key: string, baseUrl?: string) { + return requestJson( + "/anthropic/key", + { method: "POST", body: JSON.stringify({ api_key }) }, + baseUrl, + ); + }, + readFile(path: string, baseUrl?: string) { + return requestJson( + "/fs/read", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + writeFile(path: string, content: string, baseUrl?: string) { + return requestJson( + "/fs/write", + { method: "POST", body: JSON.stringify({ path, content }) }, + baseUrl, + ); + }, + listDirectory(path: string, baseUrl?: string) { + return requestJson( + "/fs/list", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + listDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/list/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + createDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/create/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + getHomeDirectory(baseUrl?: string) { + return requestJson("/io/fs/home", {}, baseUrl); + }, + searchFiles(query: string, baseUrl?: string) { + return requestJson( + "/fs/search", + { method: "POST", body: JSON.stringify({ query }) }, + baseUrl, + ); + }, + execShell(command: string, args: string[], baseUrl?: string) { + return requestJson( + "/shell/exec", + { method: "POST", body: JSON.stringify({ command, args }) }, + baseUrl, + ); + }, + cancelChat(baseUrl?: string) { + return requestJson("/chat/cancel", { method: "POST" }, baseUrl); + }, }; export class ChatWebSocket { - private static sharedSocket: WebSocket | null = null; - private static refCount = 0; - private socket?: WebSocket; - private onToken?: (content: string) => void; - private onUpdate?: (messages: Message[]) => void; - private onError?: (message: string) => void; - private connected = false; - private closeTimer?: number; + private static sharedSocket: WebSocket | null = null; + private static refCount = 0; + private socket?: WebSocket; + private onToken?: (content: string) => void; + private onUpdate?: (messages: Message[]) => void; + private onError?: (message: string) => void; + private connected = false; + private closeTimer?: number; - connect( - handlers: { - onToken?: (content: string) => void; - onUpdate?: (messages: Message[]) => void; - onError?: (message: string) => void; - }, - wsPath = DEFAULT_WS_PATH, - ) { - this.onToken = handlers.onToken; - this.onUpdate = handlers.onUpdate; - this.onError = handlers.onError; + connect( + handlers: { + onToken?: (content: string) => void; + onUpdate?: (messages: Message[]) => void; + onError?: (message: string) => void; + }, + wsPath = DEFAULT_WS_PATH, + ) { + this.onToken = handlers.onToken; + this.onUpdate = handlers.onUpdate; + this.onError = handlers.onError; - if (this.connected) { - return; - } - this.connected = true; - ChatWebSocket.refCount += 1; + if (this.connected) { + return; + } + this.connected = true; + ChatWebSocket.refCount += 1; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const wsHost = import.meta.env.DEV - ? "127.0.0.1:3001" - : window.location.host; - const wsUrl = `${protocol}://${wsHost}${wsPath}`; + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const wsHost = import.meta.env.DEV + ? "127.0.0.1:3001" + : window.location.host; + const wsUrl = `${protocol}://${wsHost}${wsPath}`; - if ( - !ChatWebSocket.sharedSocket || - ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || - ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING - ) { - ChatWebSocket.sharedSocket = new WebSocket(wsUrl); - } - this.socket = ChatWebSocket.sharedSocket; + if ( + !ChatWebSocket.sharedSocket || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING + ) { + ChatWebSocket.sharedSocket = new WebSocket(wsUrl); + } + this.socket = ChatWebSocket.sharedSocket; - this.socket.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as WsResponse; - if (data.type === "token") this.onToken?.(data.content); - if (data.type === "update") this.onUpdate?.(data.messages); - if (data.type === "error") this.onError?.(data.message); - } catch (err) { - this.onError?.(String(err)); - } - }; + this.socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as WsResponse; + if (data.type === "token") this.onToken?.(data.content); + if (data.type === "update") this.onUpdate?.(data.messages); + if (data.type === "error") this.onError?.(data.message); + } catch (err) { + this.onError?.(String(err)); + } + }; - this.socket.onerror = () => { - this.onError?.("WebSocket error"); - }; - } + this.socket.onerror = () => { + this.onError?.("WebSocket error"); + }; + } - sendChat(messages: Message[], config: ProviderConfig) { - this.send({ type: "chat", messages, config }); - } + sendChat(messages: Message[], config: ProviderConfig) { + this.send({ type: "chat", messages, config }); + } - cancel() { - this.send({ type: "cancel" }); - } + cancel() { + this.send({ type: "cancel" }); + } - close() { - if (!this.connected) return; - this.connected = false; - ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); + close() { + if (!this.connected) return; + this.connected = false; + ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); - if (import.meta.env.DEV) { - if (this.closeTimer) { - window.clearTimeout(this.closeTimer); - } - this.closeTimer = window.setTimeout(() => { - if (ChatWebSocket.refCount === 0) { - ChatWebSocket.sharedSocket?.close(); - ChatWebSocket.sharedSocket = null; - } - this.socket = ChatWebSocket.sharedSocket ?? undefined; - this.closeTimer = undefined; - }, 250); - return; - } + if (import.meta.env.DEV) { + if (this.closeTimer) { + window.clearTimeout(this.closeTimer); + } + this.closeTimer = window.setTimeout(() => { + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + this.closeTimer = undefined; + }, 250); + return; + } - if (ChatWebSocket.refCount === 0) { - ChatWebSocket.sharedSocket?.close(); - ChatWebSocket.sharedSocket = null; - } - this.socket = ChatWebSocket.sharedSocket ?? undefined; - } + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + } - private send(payload: WsRequest) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - this.onError?.("WebSocket is not connected"); - return; - } - this.socket.send(JSON.stringify(payload)); - } + private send(payload: WsRequest) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + this.onError?.("WebSocket is not connected"); + return; + } + this.socket.send(JSON.stringify(payload)); + } } diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 78941a6..22e4214 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -8,930 +8,930 @@ import type { Message, ProviderConfig, ToolCall } from "../types"; const { useCallback, useEffect, useRef, useState } = React; interface ChatProps { - projectPath: string; - onCloseProject: () => void; + projectPath: string; + onCloseProject: () => void; } export function Chat({ projectPath, onCloseProject }: ChatProps) { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const [model, setModel] = useState("llama3.1"); - const [enableTools, setEnableTools] = useState(true); - const [availableModels, setAvailableModels] = useState([]); - const [claudeModels, setClaudeModels] = useState([]); - const [streamingContent, setStreamingContent] = useState(""); - const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); - const [apiKeyInput, setApiKeyInput] = useState(""); - const [hasAnthropicKey, setHasAnthropicKey] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [model, setModel] = useState("llama3.1"); + const [enableTools, setEnableTools] = useState(true); + const [availableModels, setAvailableModels] = useState([]); + const [claudeModels, setClaudeModels] = useState([]); + const [streamingContent, setStreamingContent] = useState(""); + const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [hasAnthropicKey, setHasAnthropicKey] = useState(false); - const wsRef = useRef(null); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const scrollContainerRef = useRef(null); - const shouldAutoScrollRef = useRef(true); - const lastScrollTopRef = useRef(0); - const userScrolledUpRef = useRef(false); - const pendingMessageRef = useRef(""); + const wsRef = useRef(null); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const scrollContainerRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + const lastScrollTopRef = useRef(0); + const userScrolledUpRef = useRef(false); + const pendingMessageRef = useRef(""); - const estimateTokens = (text: string): number => Math.ceil(text.length / 4); + const estimateTokens = (text: string): number => Math.ceil(text.length / 4); - const getContextWindowSize = (modelName: string): number => { - if (modelName.startsWith("claude-")) return 200000; - if (modelName.includes("llama3")) return 8192; - if (modelName.includes("qwen2.5")) return 32768; - if (modelName.includes("deepseek")) return 16384; - return 8192; - }; + const getContextWindowSize = (modelName: string): number => { + if (modelName.startsWith("claude-")) return 200000; + if (modelName.includes("llama3")) return 8192; + if (modelName.includes("qwen2.5")) return 32768; + if (modelName.includes("deepseek")) return 16384; + return 8192; + }; - const calculateContextUsage = (): { - used: number; - total: number; - percentage: number; - } => { - let totalTokens = 0; + const calculateContextUsage = (): { + used: number; + total: number; + percentage: number; + } => { + let totalTokens = 0; - totalTokens += 200; + totalTokens += 200; - for (const msg of messages) { - totalTokens += estimateTokens(msg.content); - if (msg.tool_calls) { - totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); - } - } + for (const msg of messages) { + totalTokens += estimateTokens(msg.content); + if (msg.tool_calls) { + totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); + } + } - if (streamingContent) { - totalTokens += estimateTokens(streamingContent); - } + if (streamingContent) { + totalTokens += estimateTokens(streamingContent); + } - const contextWindow = getContextWindowSize(model); - const percentage = Math.round((totalTokens / contextWindow) * 100); + const contextWindow = getContextWindowSize(model); + const percentage = Math.round((totalTokens / contextWindow) * 100); - return { - used: totalTokens, - total: contextWindow, - percentage, - }; - }; + return { + used: totalTokens, + total: contextWindow, + percentage, + }; + }; - const contextUsage = calculateContextUsage(); + const contextUsage = calculateContextUsage(); - const getContextEmoji = (percentage: number): string => { - if (percentage >= 90) return "πŸ”΄"; - if (percentage >= 75) return "🟑"; - return "🟒"; - }; + const getContextEmoji = (percentage: number): string => { + if (percentage >= 90) return "πŸ”΄"; + if (percentage >= 75) return "🟑"; + return "🟒"; + }; - useEffect(() => { - api - .getOllamaModels() - .then(async (models) => { - if (models.length > 0) { - const sortedModels = models.sort((a, b) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ); - setAvailableModels(sortedModels); + useEffect(() => { + api + .getOllamaModels() + .then(async (models) => { + if (models.length > 0) { + const sortedModels = models.sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + setAvailableModels(sortedModels); - try { - const savedModel = await api.getModelPreference(); - if (savedModel) { - setModel(savedModel); - } else if (sortedModels.length > 0) { - setModel(sortedModels[0]); - } - } catch (e) { - console.error(e); - } - } - }) - .catch((err) => console.error(err)); + try { + const savedModel = await api.getModelPreference(); + if (savedModel) { + setModel(savedModel); + } else if (sortedModels.length > 0) { + setModel(sortedModels[0]); + } + } catch (e) { + console.error(e); + } + } + }) + .catch((err) => console.error(err)); - api - .getAnthropicApiKeyExists() - .then((exists) => { - setHasAnthropicKey(exists); - }) - .catch((err) => { - console.error(err); - setHasAnthropicKey(false); - }); + api + .getAnthropicApiKeyExists() + .then((exists) => { + setHasAnthropicKey(exists); + }) + .catch((err) => { + console.error(err); + setHasAnthropicKey(false); + }); - api - .getAnthropicModels() - .then((models) => { - if (models.length > 0) { - const sortedModels = models.sort((a, b) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ); - setClaudeModels(sortedModels); - } else { - setClaudeModels([]); - } - }) - .catch((err) => { - console.error(err); - setClaudeModels([]); - }); - }, []); + api + .getAnthropicModels() + .then((models) => { + if (models.length > 0) { + const sortedModels = models.sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + setClaudeModels(sortedModels); + } else { + setClaudeModels([]); + } + }) + .catch((err) => { + console.error(err); + setClaudeModels([]); + }); + }, []); - useEffect(() => { - const ws = new ChatWebSocket(); - wsRef.current = ws; + useEffect(() => { + const ws = new ChatWebSocket(); + wsRef.current = ws; - ws.connect({ - onToken: (content) => { - setStreamingContent((prev: string) => prev + content); - }, - onUpdate: (history) => { - setMessages(history); - setStreamingContent(""); - const last = history[history.length - 1]; - if (last?.role === "assistant" && !last.tool_calls) { - setLoading(false); - } - }, - onError: (message) => { - console.error("WebSocket error:", message); - setLoading(false); - }, - }); + ws.connect({ + onToken: (content) => { + setStreamingContent((prev: string) => prev + content); + }, + onUpdate: (history) => { + setMessages(history); + setStreamingContent(""); + const last = history[history.length - 1]; + if (last?.role === "assistant" && !last.tool_calls) { + setLoading(false); + } + }, + onError: (message) => { + console.error("WebSocket error:", message); + setLoading(false); + }, + }); - return () => { - ws.close(); - wsRef.current = null; - }; - }, []); + return () => { + ws.close(); + wsRef.current = null; + }; + }, []); - const scrollToBottom = useCallback(() => { - const element = scrollContainerRef.current; - if (element) { - element.scrollTop = element.scrollHeight; - lastScrollTopRef.current = element.scrollHeight; - } - }, []); + const scrollToBottom = useCallback(() => { + const element = scrollContainerRef.current; + if (element) { + element.scrollTop = element.scrollHeight; + lastScrollTopRef.current = element.scrollHeight; + } + }, []); - const handleScroll = () => { - const element = scrollContainerRef.current; - if (!element) return; + const handleScroll = () => { + const element = scrollContainerRef.current; + if (!element) return; - const currentScrollTop = element.scrollTop; - const isAtBottom = - element.scrollHeight - element.scrollTop - element.clientHeight < 5; + const currentScrollTop = element.scrollTop; + const isAtBottom = + element.scrollHeight - element.scrollTop - element.clientHeight < 5; - if (currentScrollTop < lastScrollTopRef.current) { - userScrolledUpRef.current = true; - shouldAutoScrollRef.current = false; - } + if (currentScrollTop < lastScrollTopRef.current) { + userScrolledUpRef.current = true; + shouldAutoScrollRef.current = false; + } - if (isAtBottom) { - userScrolledUpRef.current = false; - shouldAutoScrollRef.current = true; - } + if (isAtBottom) { + userScrolledUpRef.current = false; + shouldAutoScrollRef.current = true; + } - lastScrollTopRef.current = currentScrollTop; - }; + lastScrollTopRef.current = currentScrollTop; + }; - const autoScrollKey = messages.length + streamingContent.length; + const autoScrollKey = messages.length + streamingContent.length; - useEffect(() => { - if ( - autoScrollKey >= 0 && - shouldAutoScrollRef.current && - !userScrolledUpRef.current - ) { - scrollToBottom(); - } - }, [autoScrollKey, scrollToBottom]); + useEffect(() => { + if ( + autoScrollKey >= 0 && + shouldAutoScrollRef.current && + !userScrolledUpRef.current + ) { + scrollToBottom(); + } + }, [autoScrollKey, scrollToBottom]); - useEffect(() => { - inputRef.current?.focus(); - }, []); + useEffect(() => { + inputRef.current?.focus(); + }, []); - const cancelGeneration = async () => { - try { - wsRef.current?.cancel(); - await api.cancelChat(); + const cancelGeneration = async () => { + try { + wsRef.current?.cancel(); + await api.cancelChat(); - if (streamingContent) { - setMessages((prev: Message[]) => [ - ...prev, - { role: "assistant", content: streamingContent }, - ]); - setStreamingContent(""); - } + if (streamingContent) { + setMessages((prev: Message[]) => [ + ...prev, + { role: "assistant", content: streamingContent }, + ]); + setStreamingContent(""); + } - setLoading(false); - } catch (e) { - console.error("Failed to cancel chat:", e); - } - }; + setLoading(false); + } catch (e) { + console.error("Failed to cancel chat:", e); + } + }; - const sendMessage = async (messageOverride?: string) => { - const messageToSend = messageOverride ?? input; - if (!messageToSend.trim() || loading) return; + const sendMessage = async (messageOverride?: string) => { + const messageToSend = messageOverride ?? input; + if (!messageToSend.trim() || loading) return; - if (model.startsWith("claude-")) { - const hasKey = await api.getAnthropicApiKeyExists(); - if (!hasKey) { - pendingMessageRef.current = messageToSend; - setShowApiKeyDialog(true); - return; - } - } + if (model.startsWith("claude-")) { + const hasKey = await api.getAnthropicApiKeyExists(); + if (!hasKey) { + pendingMessageRef.current = messageToSend; + setShowApiKeyDialog(true); + return; + } + } - const userMsg: Message = { role: "user", content: messageToSend }; - const newHistory = [...messages, userMsg]; + const userMsg: Message = { role: "user", content: messageToSend }; + const newHistory = [...messages, userMsg]; - setMessages(newHistory); - if (!messageOverride || messageOverride === input) { - setInput(""); - } - setLoading(true); - setStreamingContent(""); + setMessages(newHistory); + if (!messageOverride || messageOverride === input) { + setInput(""); + } + setLoading(true); + setStreamingContent(""); - try { - const config: ProviderConfig = { - provider: model.startsWith("claude-") ? "anthropic" : "ollama", - model, - base_url: "http://localhost:11434", - enable_tools: enableTools, - }; + try { + const config: ProviderConfig = { + provider: model.startsWith("claude-") ? "anthropic" : "ollama", + model, + base_url: "http://localhost:11434", + enable_tools: enableTools, + }; - wsRef.current?.sendChat(newHistory, config); - } catch (e) { - console.error("Chat error:", e); - const errorMessage = String(e); - if (!errorMessage.includes("Chat cancelled by user")) { - setMessages((prev: Message[]) => [ - ...prev, - { role: "assistant", content: `**Error:** ${e}` }, - ]); - } - setLoading(false); - } - }; + wsRef.current?.sendChat(newHistory, config); + } catch (e) { + console.error("Chat error:", e); + const errorMessage = String(e); + if (!errorMessage.includes("Chat cancelled by user")) { + setMessages((prev: Message[]) => [ + ...prev, + { role: "assistant", content: `**Error:** ${e}` }, + ]); + } + setLoading(false); + } + }; - const handleSaveApiKey = async () => { - if (!apiKeyInput.trim()) return; + const handleSaveApiKey = async () => { + if (!apiKeyInput.trim()) return; - try { - await api.setAnthropicApiKey(apiKeyInput); - setShowApiKeyDialog(false); - setApiKeyInput(""); + try { + await api.setAnthropicApiKey(apiKeyInput); + setShowApiKeyDialog(false); + setApiKeyInput(""); - const pendingMessage = pendingMessageRef.current; - pendingMessageRef.current = ""; + const pendingMessage = pendingMessageRef.current; + pendingMessageRef.current = ""; - if (pendingMessage.trim()) { - sendMessage(pendingMessage); - } - } catch (e) { - console.error("Failed to save API key:", e); - alert(`Failed to save API key: ${e}`); - } - }; + if (pendingMessage.trim()) { + sendMessage(pendingMessage); + } + } catch (e) { + console.error("Failed to save API key:", e); + alert(`Failed to save API key: ${e}`); + } + }; - const clearSession = async () => { - const confirmed = window.confirm( - "Are you sure? This will clear all messages and reset the conversation context.", - ); + const clearSession = async () => { + const confirmed = window.confirm( + "Are you sure? This will clear all messages and reset the conversation context.", + ); - if (confirmed) { - try { - await api.cancelChat(); - wsRef.current?.cancel(); - } catch (e) { - console.error("Failed to cancel chat:", e); - } + if (confirmed) { + try { + await api.cancelChat(); + wsRef.current?.cancel(); + } catch (e) { + console.error("Failed to cancel chat:", e); + } - setMessages([]); - setStreamingContent(""); - setLoading(false); - } - }; + setMessages([]); + setStreamingContent(""); + setLoading(false); + } + }; - return ( -
-
-
-
- {projectPath} -
- -
+ return ( +
+
+
+
+ {projectPath} +
+ +
-
-
- {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} - % -
+
+
+ {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} + % +
- - {availableModels.length > 0 || claudeModels.length > 0 ? ( - - ) : ( - { - const newModel = e.target.value; - setModel(newModel); - api.setModelPreference(newModel).catch(console.error); - }} - placeholder="Model" - style={{ - padding: "6px 12px", - borderRadius: "99px", - border: "none", - fontSize: "0.9em", - background: "#2f2f2f", - color: "#ececec", - outline: "none", - }} - /> - )} - -
-
+ + {availableModels.length > 0 || claudeModels.length > 0 ? ( + + ) : ( + { + const newModel = e.target.value; + setModel(newModel); + api.setModelPreference(newModel).catch(console.error); + }} + placeholder="Model" + style={{ + padding: "6px 12px", + borderRadius: "99px", + border: "none", + fontSize: "0.9em", + background: "#2f2f2f", + color: "#ececec", + outline: "none", + }} + /> + )} + +
+
-
-
- {messages.map((msg: Message, idx: number) => ( -
-
- {msg.role === "user" ? ( - msg.content - ) : msg.role === "tool" ? ( -
- - β–Ά - - Tool Output - {msg.tool_call_id && ` (${msg.tool_call_id})`} - - -
-                      {msg.content}
-                    
-
- ) : ( -
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {msg.content} - -
- )} +
+
+ {messages.map((msg: Message, idx: number) => ( +
+
+ {msg.role === "user" ? ( + msg.content + ) : msg.role === "tool" ? ( +
+ + β–Ά + + Tool Output + {msg.tool_call_id && ` (${msg.tool_call_id})`} + + +
+											{msg.content}
+										
+
+ ) : ( +
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {msg.content} + +
+ )} - {msg.tool_calls && ( -
- {msg.tool_calls.map((tc: ToolCall, i: number) => { - let argsSummary = ""; - try { - const args = JSON.parse(tc.function.arguments); - const firstKey = Object.keys(args)[0]; - if (firstKey && args[firstKey]) { - argsSummary = String(args[firstKey]); - if (argsSummary.length > 50) { - argsSummary = `${argsSummary.substring(0, 47)}...`; - } - } - } catch (_e) { - // ignore - } + {msg.tool_calls && ( +
+ {msg.tool_calls.map((tc: ToolCall, i: number) => { + let argsSummary = ""; + try { + const args = JSON.parse(tc.function.arguments); + const firstKey = Object.keys(args)[0]; + if (firstKey && args[firstKey]) { + argsSummary = String(args[firstKey]); + if (argsSummary.length > 50) { + argsSummary = `${argsSummary.substring(0, 47)}...`; + } + } + } catch (_e) { + // ignore + } - return ( -
- β–Ά - - {tc.function.name} - {argsSummary && `(${argsSummary})`} - -
- ); - })} -
- )} -
-
- ))} - {loading && streamingContent && ( -
-
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {streamingContent} - -
-
- )} - {loading && !streamingContent && ( -
- Thinking... -
- )} -
-
-
+ return ( +
+ β–Ά + + {tc.function.name} + {argsSummary && `(${argsSummary})`} + +
+ ); + })} +
+ )} +
+
+ ))} + {loading && streamingContent && ( +
+
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {streamingContent} + +
+
+ )} + {loading && !streamingContent && ( +
+ Thinking... +
+ )} +
+
+
-
-
- setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - sendMessage(); - } - }} - placeholder="Send a message..." - style={{ - flex: 1, - padding: "14px 20px", - borderRadius: "24px", - border: "1px solid #333", - outline: "none", - fontSize: "1rem", - fontWeight: "500", - background: "#2f2f2f", - color: "#ececec", - boxShadow: "0 2px 6px rgba(0,0,0,0.02)", - }} - /> - -
-
+
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + sendMessage(); + } + }} + placeholder="Send a message..." + style={{ + flex: 1, + padding: "14px 20px", + borderRadius: "24px", + border: "1px solid #333", + outline: "none", + fontSize: "1rem", + fontWeight: "500", + background: "#2f2f2f", + color: "#ececec", + boxShadow: "0 2px 6px rgba(0,0,0,0.02)", + }} + /> + +
+
- {showApiKeyDialog && ( -
-
-

- Enter Anthropic API Key -

-

- To use Claude models, please enter your Anthropic API key. Your - key will be stored server-side and reused across sessions. -

- setApiKeyInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} - placeholder="sk-ant-..." - style={{ - width: "100%", - padding: "12px", - borderRadius: "8px", - border: "1px solid #555", - backgroundColor: "#1a1a1a", - color: "#ececec", - fontSize: "1em", - marginBottom: "20px", - outline: "none", - }} - /> -
- - -
-
-
- )} -
- ); + {showApiKeyDialog && ( +
+
+

+ Enter Anthropic API Key +

+

+ To use Claude models, please enter your Anthropic API key. Your + key will be stored server-side and reused across sessions. +

+ setApiKeyInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} + placeholder="sk-ant-..." + style={{ + width: "100%", + padding: "12px", + borderRadius: "8px", + border: "1px solid #555", + backgroundColor: "#1a1a1a", + color: "#ececec", + fontSize: "1em", + marginBottom: "20px", + outline: "none", + }} + /> +
+ + +
+
+
+ )} +
+ ); } diff --git a/frontend/src/components/selection/ProjectPathInput.tsx b/frontend/src/components/selection/ProjectPathInput.tsx index 3a79163..c4dc5ea 100644 --- a/frontend/src/components/selection/ProjectPathInput.tsx +++ b/frontend/src/components/selection/ProjectPathInput.tsx @@ -1,170 +1,170 @@ export interface ProjectPathMatch { - name: string; - path: string; + name: string; + path: string; } export interface ProjectPathInputProps { - value: string; - onChange: (value: string) => void; - onKeyDown: (event: React.KeyboardEvent) => void; - suggestionTail: string; - matchList: ProjectPathMatch[]; - selectedMatch: number; - onSelectMatch: (index: number) => void; - onAcceptMatch: (path: string) => void; - onCloseSuggestions: () => void; - currentPartial: string; + value: string; + onChange: (value: string) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + suggestionTail: string; + matchList: ProjectPathMatch[]; + selectedMatch: number; + onSelectMatch: (index: number) => void; + onAcceptMatch: (path: string) => void; + onCloseSuggestions: () => void; + currentPartial: string; } function renderHighlightedMatch(text: string, query: string) { - if (!query) return text; - let qIndex = 0; - const lowerQuery = query.toLowerCase(); - const counts = new Map(); - return text.split("").map((char) => { - const isMatch = - qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex]; - if (isMatch) { - qIndex += 1; - } - const count = counts.get(char) ?? 0; - counts.set(char, count + 1); - return ( - - {char} - - ); - }); + if (!query) return text; + let qIndex = 0; + const lowerQuery = query.toLowerCase(); + const counts = new Map(); + return text.split("").map((char) => { + const isMatch = + qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex]; + if (isMatch) { + qIndex += 1; + } + const count = counts.get(char) ?? 0; + counts.set(char, count + 1); + return ( + + {char} + + ); + }); } export function ProjectPathInput({ - value, - onChange, - onKeyDown, - suggestionTail, - matchList, - selectedMatch, - onSelectMatch, - onAcceptMatch, - onCloseSuggestions, - currentPartial, + value, + onChange, + onKeyDown, + suggestionTail, + matchList, + selectedMatch, + onSelectMatch, + onAcceptMatch, + onCloseSuggestions, + currentPartial, }: ProjectPathInputProps) { - return ( -
-
- {value} - {suggestionTail} -
- onChange(event.target.value)} - onKeyDown={onKeyDown} - style={{ - width: "100%", - padding: "10px", - fontFamily: "monospace", - background: "transparent", - position: "relative", - zIndex: 1, - }} - /> - {matchList.length > 0 && ( -
-
- -
- {matchList.map((match, index) => { - const isSelected = index === selectedMatch; - return ( - - ); - })} -
- )} -
- ); + return ( +
+
+ {value} + {suggestionTail} +
+ onChange(event.target.value)} + onKeyDown={onKeyDown} + style={{ + width: "100%", + padding: "10px", + fontFamily: "monospace", + background: "transparent", + position: "relative", + zIndex: 1, + }} + /> + {matchList.length > 0 && ( +
+
+ +
+ {matchList.map((match, index) => { + const isSelected = index === selectedMatch; + return ( + + ); + })} +
+ )} +
+ ); } diff --git a/frontend/src/components/selection/RecentProjectsList.tsx b/frontend/src/components/selection/RecentProjectsList.tsx index f01120a..f236227 100644 --- a/frontend/src/components/selection/RecentProjectsList.tsx +++ b/frontend/src/components/selection/RecentProjectsList.tsx @@ -1,66 +1,66 @@ export interface RecentProjectsListProps { - projects: string[]; - onOpenProject: (path: string) => void; - onForgetProject: (path: string) => void; + projects: string[]; + onOpenProject: (path: string) => void; + onForgetProject: (path: string) => void; } export function RecentProjectsList({ - projects, - onOpenProject, - onForgetProject, + projects, + onOpenProject, + onForgetProject, }: RecentProjectsListProps) { - return ( -
-
Recent projects
-
    - {projects.map((project) => { - const displayName = - project.split("/").filter(Boolean).pop() ?? project; - return ( -
  • -
    - - -
    -
  • - ); - })} -
-
- ); + return ( +
+
Recent projects
+
    + {projects.map((project) => { + const displayName = + project.split("/").filter(Boolean).pop() ?? project; + return ( +
  • +
    + + +
    +
  • + ); + })} +
+
+ ); } diff --git a/frontend/src/components/selection/SelectionScreen.tsx b/frontend/src/components/selection/SelectionScreen.tsx index 3f407a7..ab3adc2 100644 --- a/frontend/src/components/selection/SelectionScreen.tsx +++ b/frontend/src/components/selection/SelectionScreen.tsx @@ -3,97 +3,114 @@ import { ProjectPathInput } from "./ProjectPathInput.tsx"; import { RecentProjectsList } from "./RecentProjectsList.tsx"; export interface RecentProjectMatch { - name: string; - path: string; + name: string; + path: string; } export interface SelectionScreenProps { - knownProjects: string[]; - onOpenProject: (path: string) => void; - onForgetProject: (path: string) => void; - pathInput: string; - onPathInputChange: (value: string) => void; - onPathInputKeyDown: (event: KeyboardEvent) => void; - isOpening: boolean; - suggestionTail: string; - matchList: RecentProjectMatch[]; - selectedMatch: number; - onSelectMatch: (index: number) => void; - onAcceptMatch: (path: string) => void; - onCloseSuggestions: () => void; - completionError: string | null; - currentPartial: string; + knownProjects: string[]; + onOpenProject: (path: string) => void; + onForgetProject: (path: string) => void; + pathInput: string; + homeDir?: string | null; + onPathInputChange: (value: string) => void; + onPathInputKeyDown: (event: KeyboardEvent) => void; + isOpening: boolean; + suggestionTail: string; + matchList: RecentProjectMatch[]; + selectedMatch: number; + onSelectMatch: (index: number) => void; + onAcceptMatch: (path: string) => void; + onCloseSuggestions: () => void; + completionError: string | null; + currentPartial: string; } export function SelectionScreen({ - knownProjects, - onOpenProject, - onForgetProject, - pathInput, - onPathInputChange, - onPathInputKeyDown, - isOpening, - suggestionTail, - matchList, - selectedMatch, - onSelectMatch, - onAcceptMatch, - onCloseSuggestions, - completionError, - currentPartial, + knownProjects, + onOpenProject, + onForgetProject, + pathInput, + homeDir, + onPathInputChange, + onPathInputKeyDown, + isOpening, + suggestionTail, + matchList, + selectedMatch, + onSelectMatch, + onAcceptMatch, + onCloseSuggestions, + completionError, + currentPartial, }: SelectionScreenProps) { - return ( -
-

AI Code Assistant

-

Paste or complete a project path to start.

+ const resolvedHomeDir = homeDir + ? homeDir.endsWith("/") + ? homeDir + : `${homeDir}/` + : ""; + return ( +
+

StorkIt

+

Paste or complete a project path to start.

- {knownProjects.length > 0 && ( - - )} + {knownProjects.length > 0 && ( + + )} - + -
- -
- Press Tab to complete the next path segment -
-
+
+ + +
+ Press Tab to complete the next path segment +
+
- {completionError && ( -
{completionError}
- )} -
- ); + {completionError && ( +
{completionError}
+ )} +
+ ); } diff --git a/frontend/src/components/selection/usePathCompletion.ts b/frontend/src/components/selection/usePathCompletion.ts index f1ac1eb..83468aa 100644 --- a/frontend/src/components/selection/usePathCompletion.ts +++ b/frontend/src/components/selection/usePathCompletion.ts @@ -1,192 +1,192 @@ import * as React from "react"; export interface FileEntry { - name: string; - kind: "file" | "dir"; + name: string; + kind: "file" | "dir"; } export interface ProjectPathMatch { - name: string; - path: string; + name: string; + path: string; } export interface UsePathCompletionArgs { - pathInput: string; - setPathInput: (value: string) => void; - homeDir: string | null; - listDirectoryAbsolute: (path: string) => Promise; - debounceMs?: number; + pathInput: string; + setPathInput: (value: string) => void; + homeDir: string | null; + listDirectoryAbsolute: (path: string) => Promise; + debounceMs?: number; } export interface UsePathCompletionResult { - matchList: ProjectPathMatch[]; - selectedMatch: number; - suggestionTail: string; - completionError: string | null; - currentPartial: string; - setSelectedMatch: (index: number) => void; - acceptSelectedMatch: () => void; - acceptMatch: (path: string) => void; - closeSuggestions: () => void; + matchList: ProjectPathMatch[]; + selectedMatch: number; + suggestionTail: string; + completionError: string | null; + currentPartial: string; + setSelectedMatch: (index: number) => void; + acceptSelectedMatch: () => void; + acceptMatch: (path: string) => void; + closeSuggestions: () => void; } function isFuzzyMatch(candidate: string, query: string) { - if (!query) return true; - const lowerCandidate = candidate.toLowerCase(); - const lowerQuery = query.toLowerCase(); - let idx = 0; - for (const char of lowerQuery) { - idx = lowerCandidate.indexOf(char, idx); - if (idx === -1) return false; - idx += 1; - } - return true; + if (!query) return true; + const lowerCandidate = candidate.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let idx = 0; + for (const char of lowerQuery) { + idx = lowerCandidate.indexOf(char, idx); + if (idx === -1) return false; + idx += 1; + } + return true; } function getCurrentPartial(input: string) { - const trimmed = input.trim(); - if (!trimmed) return ""; - if (trimmed.endsWith("/")) return ""; - const idx = trimmed.lastIndexOf("/"); - return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; + const trimmed = input.trim(); + if (!trimmed) return ""; + if (trimmed.endsWith("/")) return ""; + const idx = trimmed.lastIndexOf("/"); + return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; } export function usePathCompletion({ - pathInput, - setPathInput, - homeDir, - listDirectoryAbsolute, - debounceMs = 60, + pathInput, + setPathInput, + homeDir, + listDirectoryAbsolute, + debounceMs = 60, }: UsePathCompletionArgs): UsePathCompletionResult { - const [matchList, setMatchList] = React.useState([]); - const [selectedMatch, setSelectedMatch] = React.useState(0); - const [suggestionTail, setSuggestionTail] = React.useState(""); - const [completionError, setCompletionError] = React.useState( - null, - ); + const [matchList, setMatchList] = React.useState([]); + const [selectedMatch, setSelectedMatch] = React.useState(0); + const [suggestionTail, setSuggestionTail] = React.useState(""); + const [completionError, setCompletionError] = React.useState( + null, + ); - React.useEffect(() => { - let active = true; + React.useEffect(() => { + let active = true; - async function computeSuggestion() { - setCompletionError(null); - setSuggestionTail(""); - setMatchList([]); - setSelectedMatch(0); + async function computeSuggestion() { + setCompletionError(null); + setSuggestionTail(""); + setMatchList([]); + setSelectedMatch(0); - const trimmed = pathInput.trim(); - if (!trimmed) { - return; - } + const trimmed = pathInput.trim(); + if (!trimmed) { + return; + } - const endsWithSlash = trimmed.endsWith("/"); - let dir = trimmed; - let partial = ""; + const endsWithSlash = trimmed.endsWith("/"); + let dir = trimmed; + let partial = ""; - if (!endsWithSlash) { - const idx = trimmed.lastIndexOf("/"); - if (idx >= 0) { - dir = trimmed.slice(0, idx + 1); - partial = trimmed.slice(idx + 1); - } else { - dir = ""; - partial = trimmed; - } - } + if (!endsWithSlash) { + const idx = trimmed.lastIndexOf("/"); + if (idx >= 0) { + dir = trimmed.slice(0, idx + 1); + partial = trimmed.slice(idx + 1); + } else { + dir = ""; + partial = trimmed; + } + } - if (!dir) { - if (homeDir) { - dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`; - } else { - return; - } - } + if (!dir) { + if (homeDir) { + dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`; + } else { + return; + } + } - const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, ""); - const entries = await listDirectoryAbsolute(dirForListing); - if (!active) return; + const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, ""); + const entries = await listDirectoryAbsolute(dirForListing); + if (!active) return; - const matches = entries - .filter((entry) => entry.kind === "dir") - .filter((entry) => isFuzzyMatch(entry.name, partial)) - .sort((a, b) => a.name.localeCompare(b.name)) - .slice(0, 8); + const matches = entries + .filter((entry) => entry.kind === "dir") + .filter((entry) => isFuzzyMatch(entry.name, partial)) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, 8); - if (matches.length === 0) { - return; - } + if (matches.length === 0) { + return; + } - const basePrefix = dir.endsWith("/") ? dir : `${dir}/`; - const list = matches.map((entry) => ({ - name: entry.name, - path: `${basePrefix}${entry.name}/`, - })); - setMatchList(list); - } + const basePrefix = dir.endsWith("/") ? dir : `${dir}/`; + const list = matches.map((entry) => ({ + name: entry.name, + path: `${basePrefix}${entry.name}/`, + })); + setMatchList(list); + } - const debounceId = window.setTimeout(() => { - computeSuggestion().catch((error) => { - console.error(error); - if (!active) return; - setCompletionError( - error instanceof Error - ? error.message - : "Failed to compute suggestion.", - ); - }); - }, debounceMs); + const debounceId = window.setTimeout(() => { + computeSuggestion().catch((error) => { + console.error(error); + if (!active) return; + setCompletionError( + error instanceof Error + ? error.message + : "Failed to compute suggestion.", + ); + }); + }, debounceMs); - return () => { - active = false; - window.clearTimeout(debounceId); - }; - }, [pathInput, homeDir, listDirectoryAbsolute, debounceMs]); + return () => { + active = false; + window.clearTimeout(debounceId); + }; + }, [pathInput, homeDir, listDirectoryAbsolute, debounceMs]); - React.useEffect(() => { - if (matchList.length === 0) { - setSuggestionTail(""); - return; - } - const index = Math.min(selectedMatch, matchList.length - 1); - const next = matchList[index]; - const trimmed = pathInput.trim(); - if (next.path.startsWith(trimmed)) { - setSuggestionTail(next.path.slice(trimmed.length)); - } else { - setSuggestionTail(""); - } - }, [matchList, selectedMatch, pathInput]); + React.useEffect(() => { + if (matchList.length === 0) { + setSuggestionTail(""); + return; + } + const index = Math.min(selectedMatch, matchList.length - 1); + const next = matchList[index]; + const trimmed = pathInput.trim(); + if (next.path.startsWith(trimmed)) { + setSuggestionTail(next.path.slice(trimmed.length)); + } else { + setSuggestionTail(""); + } + }, [matchList, selectedMatch, pathInput]); - const acceptMatch = React.useCallback( - (path: string) => { - setPathInput(path); - }, - [setPathInput], - ); + const acceptMatch = React.useCallback( + (path: string) => { + setPathInput(path); + }, + [setPathInput], + ); - const acceptSelectedMatch = React.useCallback(() => { - const next = matchList[selectedMatch]?.path; - if (next) { - setPathInput(next); - } - }, [matchList, selectedMatch, setPathInput]); + const acceptSelectedMatch = React.useCallback(() => { + const next = matchList[selectedMatch]?.path; + if (next) { + setPathInput(next); + } + }, [matchList, selectedMatch, setPathInput]); - const closeSuggestions = React.useCallback(() => { - setMatchList([]); - setSelectedMatch(0); - setSuggestionTail(""); - setCompletionError(null); - }, []); + const closeSuggestions = React.useCallback(() => { + setMatchList([]); + setSelectedMatch(0); + setSuggestionTail(""); + setCompletionError(null); + }, []); - return { - matchList, - selectedMatch, - suggestionTail, - completionError, - currentPartial: getCurrentPartial(pathInput), - setSelectedMatch, - acceptSelectedMatch, - acceptMatch, - closeSuggestions, - }; + return { + matchList, + selectedMatch, + suggestionTail, + completionError, + currentPartial: getCurrentPartial(pathInput), + setSelectedMatch, + acceptSelectedMatch, + acceptMatch, + closeSuggestions, + }; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 77df5b5..a90798a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client"; import App from "./App"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , + + + , ); diff --git a/server/src/http/io.rs b/server/src/http/io.rs index b97bf20..a1ecb1a 100644 --- a/server/src/http/io.rs +++ b/server/src/http/io.rs @@ -25,6 +25,11 @@ struct SearchPayload { query: String, } +#[derive(Deserialize, Object)] +struct CreateDirectoryPayload { + pub path: String, +} + #[derive(Deserialize, Object)] struct ExecShellPayload { pub command: String, @@ -79,6 +84,18 @@ impl IoApi { Ok(Json(entries)) } + /// Create a directory at an absolute path. + #[oai(path = "/io/fs/create/absolute", method = "post")] + async fn create_directory_absolute( + &self, + payload: Json, + ) -> OpenApiResult> { + io_fs::create_directory_absolute(payload.0.path) + .await + .map_err(bad_request)?; + Ok(Json(true)) + } + /// Get the user's home directory. #[oai(path = "/io/fs/home", method = "get")] async fn get_home_directory(&self) -> OpenApiResult> { diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index b6d4479..fa969ca 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -3,12 +3,380 @@ use crate::store::StoreOps; use serde::Serialize; use serde_json::json; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const KEY_LAST_PROJECT: &str = "last_project_path"; const KEY_SELECTED_MODEL: &str = "selected_model"; const KEY_KNOWN_PROJECTS: &str = "known_projects"; +const STORY_KIT_README: &str = r#"# Story Kit: The Story-Driven Spec Workflow (SDSW) + +**Target Audience:** Large Language Models (LLMs) acting as Senior Engineers. +**Goal:** To maintain long-term project coherence, prevent context window exhaustion, and ensure high-quality, testable code generation in large software projects. + +--- + +## 1. The Philosophy + +We treat the codebase as the implementation of a **"Living Specification."** driven by **User Stories** +Instead of ephemeral chat prompts ("Fix this", "Add that"), we work through persistent artifacts. +* **Stories** define the *Change*. +* **Specs** define the *Truth*. +* **Code** defines the *Reality*. + +**The Golden Rule:** You are not allowed to write code until the Spec reflects the new reality requested by the Story. + +--- + +## 2. Directory Structure + +When initializing a new project under this workflow, create the following structure immediately: + +```text +project_root/ + .story_kit + |-- README.md # This document + β”œβ”€β”€ stories/ # The "Inbox" of feature requests. + β”œβ”€β”€ specs/ # The "Brain" of the project. + β”‚ β”œβ”€β”€ README.md # Explains this workflow to future sessions. + β”‚ β”œβ”€β”€ 00_CONTEXT.md # High-level goals, domain definition, and glossary. + β”‚ β”œβ”€β”€ tech/ # Implementation details (Stack, Architecture, Constraints). + β”‚ β”‚ └── STACK.md # The "Constitution" (Languages, Libs, Patterns). + β”‚ └── functional/ # Domain logic (Platform-agnostic behavior). + β”‚ β”œβ”€β”€ 01_CORE.md + β”‚ └── ... +└── src/ # The Code. +``` + +--- + +## 3. The Cycle (The "Loop") + +When the user asks for a feature, follow this 4-step loop strictly: + +### Step 1: The Story (Ingest) +* **User Input:** "I want the robot to dance." +* **Action:** Create a file `stories/XX_robot_dance.md`. +* **Content:** + * **User Story:** "As a user, I want..." + * **Acceptance Criteria:** Bullet points of observable success. + * **Out of scope:** Things that are out of scope so that the LLM doesn't go crazy +* **Git:** Make a local feature branch for the story, named from the story (e.g., `feature/story-33-camera-format-auto-selection`). You must create and switch to the feature branch before making any edits. + +### Step 2: The Spec (Digest) +* **Action:** Update the files in `specs/`. +* **Logic:** + * Does `specs/functional/LOCOMOTION.md` exist? If no, create it. + * Add the "Dance" state to the state machine definition in the spec. + * Check `specs/tech/STACK.md`: Do we have an approved animation library? If no, propose adding one to the Stack or reject the feature. +* **Output:** Show the user the diff of the Spec. **Wait for approval.** + +### Step 3: The Implementation (Code) +* **Action:** Write the code to match the *Spec* (not just the Story). +* **Constraint:** adhere strictly to `specs/tech/STACK.md` (e.g., if it says "No `unwrap()`", you must not use `unwrap()`). + +### Step 4: Verification (Close) +* **Action:** Write a test case that maps directly to the Acceptance Criteria in the Story. +* **Action:** Run compilation and make sure it succeeds without errors. Consult `specs/tech/STACK.md` and run all required linters listed there (treat warnings as errors). Run tests and make sure they all pass before proceeding. Ask questions here if needed. +* **Action:** Do not accept stories yourself. Ask the user if they accept the story. If they agree, move the story file to `stories/archive/`. Tell the user they should commit (this gives them the chance to exclude files via .gitignore if necessary). +* **Action:** When the user accepts: + 1. Move the story file to `stories/archive/` (e.g., `mv stories/XX_story_name.md stories/archive/`) + 2. Commit both changes to the feature branch + 3. Perform the squash merge: `git merge --squash feature/story-name` + 4. Commit to master with a comprehensive commit message + 5. Delete the feature branch: `git branch -D feature/story-name` +* **Important:** Do NOT mark acceptance criteria as complete before user acceptance. Only mark them complete when the user explicitly accepts the story. + +**CRITICAL - NO SUMMARY DOCUMENTS:** +* **NEVER** create a separate summary document (e.g., `STORY_XX_SUMMARY.md`, `IMPLEMENTATION_NOTES.md`, etc.) +* **NEVER** write terminal output to a markdown file for "documentation purposes" +* The `specs/` folder IS the documentation. Keep it updated after each story. +* If you find yourself typing `cat << 'EOF' > SUMMARY.md` or similar, **STOP IMMEDIATELY**. +* The only files that should exist after story completion: + * Updated code in `src/` + * Updated specs in `specs/` + * Archived story in `stories/archive/` + +--- + + +## 3.5. Bug Workflow (Simplified Path) + +Not everything needs to be a full story. Simple bugs can skip the story process: + +### When to Use Bug Workflow +* Defects in existing functionality (not new features) +* State inconsistencies or data corruption +* UI glitches that don't require spec changes +* Performance issues with known fixes + +### Bug Process +1. **Document Bug:** Create `bugs/bug-N-short-description.md` with: + * **Symptom:** What the user observes + * **Root Cause:** Technical explanation (if known) + * **Reproduction Steps:** How to trigger the bug + * **Proposed Fix:** Brief technical approach + * **Workaround:** Temporary solution if available +2. **Fix Immediately:** Make minimal code changes to fix the bug +3. **Archive:** Move fixed bugs to `bugs/archive/` when complete +4. **No Spec Update Needed:** Unless the bug reveals a spec deficiency + +### Bug vs Story +* **Bug:** Existing functionality is broken β†’ Fix it +* **Story:** New functionality is needed β†’ Spec it, then build it +* **Spike:** Uncertainty/feasibility discovery β†’ Run spike workflow + +--- + +## 3.6. Spike Workflow (Research Path) + +Not everything needs a story or bug fix. Spikes are time-boxed investigations to reduce uncertainty. + +### When to Use a Spike +* Unclear root cause or feasibility +* Need to compare libraries/encoders/formats +* Need to validate performance constraints + +### Spike Process +1. **Document Spike:** Create `spikes/spike-N-short-description.md` with: + * **Question:** What you need to answer + * **Hypothesis:** What you expect to be true + * **Timebox:** Strict limit for the research + * **Investigation Plan:** Steps/tools to use + * **Findings:** Evidence and observations + * **Recommendation:** Next step (Story, Bug, or No Action) +2. **Execute Research:** Stay within the timebox. No production code changes. +3. **Escalate if Needed:** If implementation is required, open a Story or Bug and follow that workflow. +4. **Archive:** Move completed spikes to `spikes/archive/`. + +### Spike Output +* Decision and evidence, not production code +* Specs updated only if the spike changes system truth + +--- + +## 4. Context Reset Protocol + +When the LLM context window fills up (or the chat gets slow/confused): +1. **Stop Coding.** +2. **Instruction:** Tell the user to open a new chat. +3. **Handoff:** The only context the new LLM needs is in the `specs/` folder. + * *Prompt for New Session:* "I am working on Project X. Read `specs/00_CONTEXT.md` and `specs/tech/STACK.md`. Then look at `stories/` to see what is pending." + + +--- + +## 5. Setup Instructions (For the LLM) + +If a user hands you this document and says "Apply this process to my project": + +1. **Analyze the Request:** Ask for the high-level goal ("What are we building?") and the tech preferences ("Rust or Python?"). +2. **Git Check:** Check if the directory is a git repository (`git status`). If not, run `git init`. +3. **Scaffold:** Run commands to create the `specs/` and `stories/` folders. +4. **Draft Context:** Write `specs/00_CONTEXT.md` based on the user's answer. +5. **Draft Stack:** Write `specs/tech/STACK.md` based on best practices for that language. +6. **Wait:** Ask the user for "Story #1". + +--- + +## 6. Code Quality Tools + +**MANDATORY:** Before completing Step 4 (Verification) of any story, you MUST run all applicable linters and fix ALL errors and warnings. Zero tolerance for warnings or errors. + +**AUTO-RUN CHECKS:** Always run the required lint/test/build checks as soon as relevant changes are made. Do not ask for permission to run themβ€”run them automatically and fix any failures. + +**ALWAYS FIX DIAGNOSTICS:** At every stage, you must proactively fix all errors and warnings without waiting for user confirmation. Do not pause to ask whether to fix diagnosticsβ€”fix them immediately as part of the workflow. + +### TypeScript/JavaScript: Biome + +* **Tool:** [Biome](https://biomejs.dev/) - Fast formatter and linter +* **Check Command:** `npx @biomejs/biome check src/` +* **Fix Command:** `npx @biomejs/biome check --write src/` +* **Unsafe Fixes:** `npx @biomejs/biome check --write --unsafe src/` +* **Configuration:** `biome.json` in project root +* **When to Run:** + * After every code change to TypeScript/React files + * Before committing any frontend changes + * During Step 4 (Verification) - must show 0 errors, 0 warnings + +**Biome Rules to Follow:** +* No `any` types (use proper TypeScript types or `unknown`) +* No array index as `key` in React (use stable IDs) +* No assignments in expressions (extract to separate statements) +* All buttons must have explicit `type` prop (`button`, `submit`, or `reset`) +* Mouse events must be accompanied by keyboard events for accessibility +* Use template literals instead of string concatenation +* Import types with `import type { }` syntax +* Organize imports automatically + +"#; + +const STORY_KIT_SPECS_README: &str = r#"# Project Specs + +This folder contains the "Living Specification" for the project. It serves as the source of truth for all AI sessions. + +## Structure + +* **00_CONTEXT.md**: The high-level overview, goals, domain definition, and glossary. Start here. +* **tech/**: Implementation details, including the Tech Stack, Architecture, and Constraints. + * **STACK.md**: The technical "Constitution" (Languages, Libraries, Patterns). +* **functional/**: Domain logic and behavior descriptions, platform-agnostic. + * **01_CORE.md**: Core functional specifications. + +## Usage for LLMs + +1. **Always read 00_CONTEXT.md** and **tech/STACK.md** at the beginning of a session. +2. Before writing code, ensure the spec in this folder reflects the desired reality. +3. If a Story changes behavior, update the spec *first*, get approval, then write code. +"#; + +const STORY_KIT_CONTEXT: &str = r#"# Project Context + +## High-Level Goal +To build a standalone **Agentic AI Code Assistant** application as a single Rust binary that serves a Vite/React web UI and exposes a WebSocket API. The assistant will facilitate a "Story-Driven Spec Workflow" (SDSW) for software development. Unlike a passive chat interface, this assistant acts as an **Agent**, capable of using tools to read the filesystem, execute shell commands, manage git repositories, and modify code directly to implement features. + +## Core Features +1. **Chat Interface:** A conversational UI for the user to interact with the AI assistant. +2. **Agentic Tool Bridge:** A robust system mapping LLM "Tool Calls" to native Rust functions. + * **Filesystem:** Read/Write access (scoped to the target project). + * **Search:** High-performance file searching (ripgrep-style) and content retrieval. + * **Shell Integration:** Ability to execute approved commands (e.g., `cargo`, `npm`, `git`) to run tests, linters, and version control. +3. **Workflow Management:** Specialized tools to manage the SDSW lifecycle: + * Ingesting stories. + * Updating specs. + * Implementing code. + * Verifying results (running tests). +4. **LLM Integration:** Connection to an LLM backend to drive the intelligence and tool selection. + * **Remote:** Support for major APIs (Anthropic Claude, Google Gemini, OpenAI, etc). + * **Local:** Support for local inference via Ollama. + +## Domain Definition +* **User:** A software engineer using the assistant to build a project. +* **Target Project:** The local software project the user is working on. +* **Agent:** The AI entity that receives prompts and decides which **Tools** to invoke to solve the problem. +* **Tool:** A discrete function exposed to the Agent (e.g., `run_shell_command`, `write_file`, `search_project`). +* **Story:** A unit of work defining a change (Feature Request). +* **Spec:** A persistent documentation artifact defining the current truth of the system. + +## Glossary +* **SDSW:** Story-Driven Spec Workflow. +* **Web Server Binary:** The Rust binary that serves the Vite/React frontend and exposes the WebSocket API. +* **Living Spec:** The collection of Markdown files in `.story_kit/` that define the project. +* **Tool Call:** A structured request from the LLM to execute a specific native function. +"#; + +const STORY_KIT_STACK: &str = r#"# Tech Stack & Constraints + +## Overview +This project is a standalone Rust **web server binary** that serves a Vite/React frontend and exposes a **WebSocket API**. The built frontend assets are packaged with the binary (in a `frontend` directory) and served as static files. It functions as an **Agentic Code Assistant** capable of safely executing tools on the host system. + +## Core Stack +* **Backend:** Rust (Web Server) + * **MSRV:** Stable (latest) + * **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints. +* **Frontend:** TypeScript + React + * **Build Tool:** Vite + * **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules) + * **State Management:** React Context / Hooks + * **Chat UI:** Rendered Markdown with syntax highlighting. + +## Agent Architecture +The application follows a **Tool-Use (Function Calling)** architecture: +1. **Frontend:** Collects user input and sends it to the LLM. +2. **LLM:** Decides to generate text OR request a **Tool Call** (e.g., `execute_shell`, `read_file`). +3. **Web Server Backend (The "Hand"):** + * Intercepts Tool Calls. + * Validates the request against the **Safety Policy**. + * Executes the native code (File I/O, Shell Process, Search). + * Returns the output (stdout/stderr/file content) to the LLM. + * **Streaming:** The backend sends real-time updates over WebSocket to keep the UI responsive during long-running Agent tasks. + +## LLM Provider Abstraction +To support both Remote and Local models, the system implements a `ModelProvider` abstraction layer. + +* **Strategy:** + * Abstract the differences between API formats (OpenAI-compatible vs Anthropic vs Gemini). + * Normalize "Tool Use" definitions, as each provider handles function calling schemas differently. +* **Supported Providers:** + * **Ollama:** Local inference (e.g., Llama 3, DeepSeek Coder) for privacy and offline usage. + * **Anthropic:** Claude 3.5 models (Sonnet, Haiku) via API for coding tasks (Story 12). +* **Provider Selection:** + * Automatic detection based on model name prefix: + * `claude-` β†’ Anthropic API + * Otherwise β†’ Ollama + * Single unified model dropdown with section headers ("Anthropic", "Ollama") +* **API Key Management:** + * Anthropic API key stored server-side and persisted securely + * On first use of Claude model, user prompted to enter API key + * Key persists across sessions (no re-entry needed) + +## Tooling Capabilities + +### 1. Filesystem (Native) +* **Scope:** Strictly limited to the user-selected `project_root`. +* **Operations:** Read, Write, List, Delete. +* **Constraint:** Modifications to `.git/` are strictly forbidden via file APIs (use Git tools instead). + +### 2. Shell Execution +* **Library:** `tokio::process` for async execution. +* **Constraint:** We do **not** run an interactive shell (repl). We run discrete, stateless commands. +* **Allowlist:** The agent may only execute specific binaries: + * `git` + * `cargo`, `rustc`, `rustfmt`, `clippy` + * `npm`, `node`, `yarn`, `pnpm`, `bun` + * `ls`, `find`, `grep` (if not using internal search) + * `mkdir`, `rm`, `touch`, `mv`, `cp` + +### 3. Search & Navigation +* **Library:** `ignore` (by BurntSushi) + `grep` logic. +* **Behavior:** + * Must respect `.gitignore` files automatically. + * Must be performant (parallel traversal). + +## Coding Standards + +### Rust +* **Style:** `rustfmt` standard. +* **Linter:** `clippy` - Must pass with 0 warnings before merging. +* **Error Handling:** Custom `AppError` type deriving `thiserror`. All Commands return `Result`. +* **Concurrency:** Heavy tools (Search, Shell) must run on `tokio` threads to avoid blocking the UI. +* **Quality Gates:** + * `cargo clippy --all-targets --all-features` must show 0 errors, 0 warnings + * `cargo check` must succeed + * `cargo test` must pass all tests + +### TypeScript / React +* **Style:** Biome formatter (replaces Prettier/ESLint). +* **Linter:** Biome - Must pass with 0 errors, 0 warnings before merging. +* **Types:** Shared types with Rust (via `tauri-specta` or manual interface matching) are preferred to ensure type safety across the bridge. +* **Quality Gates:** + * `npx @biomejs/biome check src/` must show 0 errors, 0 warnings + * `npm run build` must succeed + * No `any` types allowed (use proper types or `unknown`) + * React keys must use stable IDs, not array indices + * All buttons must have explicit `type` attribute + +## Libraries (Approved) +* **Rust:** + * `serde`, `serde_json`: Serialization. + * `ignore`: Fast recursive directory iteration respecting gitignore. + * `walkdir`: Simple directory traversal. + * `tokio`: Async runtime. + * `reqwest`: For LLM API calls (Anthropic, Ollama). + * `eventsource-stream`: For Server-Sent Events (Anthropic streaming). + * `uuid`: For unique message IDs. + * `chrono`: For timestamps. + * `poem`: HTTP server framework. + * `poem-openapi`: OpenAPI (Swagger) for non-streaming HTTP APIs. +* **JavaScript:** + * `react-markdown`: For rendering chat responses. + +## Safety & Sandbox +1. **Project Scope:** The application must strictly enforce that it does not read/write outside the `project_root` selected by the user. +2. **Human in the Loop:** + * Shell commands that modify state (non-readonly) should ideally require a UI confirmation (configurable). + * File writes must be confirmed or revertible."#; + pub fn get_home_directory() -> Result { let home = homedir::my_home() .map_err(|e| format!("Failed to resolve home directory: {e}"))? @@ -48,6 +416,49 @@ async fn validate_project_path(path: PathBuf) -> Result<(), String> { .map_err(|e| format!("Task failed: {}", e))? } +fn write_file_if_missing(path: &Path, content: &str) -> Result<(), String> { + if path.exists() { + return Ok(()); + } + fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e))?; + Ok(()) +} + +fn scaffold_story_kit(root: &Path) -> Result<(), String> { + let story_kit_root = root.join(".story_kit"); + let specs_root = story_kit_root.join("specs"); + let tech_root = specs_root.join("tech"); + let functional_root = specs_root.join("functional"); + let stories_root = story_kit_root.join("stories"); + let archive_root = stories_root.join("archive"); + + fs::create_dir_all(&tech_root).map_err(|e| format!("Failed to create specs/tech: {}", e))?; + fs::create_dir_all(&functional_root) + .map_err(|e| format!("Failed to create specs/functional: {}", e))?; + fs::create_dir_all(&archive_root) + .map_err(|e| format!("Failed to create stories/archive: {}", e))?; + + write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?; + write_file_if_missing(&specs_root.join("README.md"), STORY_KIT_SPECS_README)?; + write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?; + write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?; + + Ok(()) +} + +async fn ensure_project_root_with_story_kit(path: PathBuf) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + if !path.exists() { + fs::create_dir_all(&path) + .map_err(|e| format!("Failed to create project directory: {}", e))?; + scaffold_story_kit(&path)?; + } + Ok(()) + }) + .await + .map_err(|e| format!("Task failed: {}", e))? +} + pub async fn open_project( path: String, state: &SessionState, @@ -55,6 +466,7 @@ pub async fn open_project( ) -> Result { let p = PathBuf::from(&path); + ensure_project_root_with_story_kit(p.clone()).await?; validate_project_path(p.clone()).await?; { @@ -236,3 +648,13 @@ pub async fn list_directory_absolute(path: String) -> Result, Str let full_path = PathBuf::from(path); list_directory_impl(full_path).await } + +pub async fn create_directory_absolute(path: String) -> Result { + let full_path = PathBuf::from(path); + tokio::task::spawn_blocking(move || { + fs::create_dir_all(&full_path).map_err(|e| format!("Failed to create directory: {}", e))?; + Ok(true) + }) + .await + .map_err(|e| format!("Task failed: {}", e))? +}