Project creation is workign

This commit is contained in:
Dave
2026-02-16 20:34:03 +00:00
parent 3be9088794
commit f1e5ac72e0
12 changed files with 2225 additions and 1724 deletions

View File

@@ -12,7 +12,7 @@ The application operates in two primary states regarding project context:
## 2. Selection Logic ## 2. Selection Logic
* **Trigger:** User initiates "Open Project". * **Trigger:** User initiates "Open Project".
* **Mechanism:** Native OS Directory Picker (via `tauri-plugin-dialog`). * **Mechanism:** Path entry in the selection screen.
* **Validation:** * **Validation:**
* The backend receives the selected path. * The backend receives the selected path.
* The backend verifies: * The backend verifies:
@@ -20,7 +20,18 @@ The application operates in two primary states regarding project context:
2. Path is a directory. 2. Path is a directory.
3. Path is readable. 3. Path is readable.
* If valid -> State transitions to **Active**. * 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 ## 3. Security Boundaries
* Once a project is selected, the `SessionState` struct in Rust locks onto this path. * Once a project is selected, the `SessionState` struct in Rust locks onto this path.

View File

@@ -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).

View File

@@ -6,172 +6,175 @@ import { usePathCompletion } from "./components/selection/usePathCompletion";
import "./App.css"; import "./App.css";
function App() { function App() {
const [projectPath, setProjectPath] = React.useState<string | null>(null); const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null); const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [pathInput, setPathInput] = React.useState(""); const [pathInput, setPathInput] = React.useState("");
const [isOpening, setIsOpening] = React.useState(false); const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]); const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
const [homeDir, setHomeDir] = React.useState<string | null>(null); const [homeDir, setHomeDir] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
api api
.getKnownProjects() .getKnownProjects()
.then((projects) => setKnownProjects(projects)) .then((projects) => setKnownProjects(projects))
.catch((error) => console.error(error)); .catch((error) => console.error(error));
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
let active = true; let active = true;
api api
.getHomeDirectory() .getHomeDirectory()
.then((home) => { .then((home) => {
if (!active) return; if (!active) return;
setHomeDir(home); setHomeDir(home);
setPathInput((current) => { setPathInput((current) => {
if (current.trim()) { if (current.trim()) {
return current; return current;
} }
const initial = home.endsWith("/") ? home : `${home}/`; const initial = home.endsWith("/") ? home : `${home}/`;
return initial; return initial;
}); });
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
}); });
return () => { return () => {
active = false; active = false;
}; };
}, []); }, []);
const { const {
matchList, matchList,
selectedMatch, selectedMatch,
suggestionTail, suggestionTail,
completionError, completionError,
currentPartial, currentPartial,
setSelectedMatch, setSelectedMatch,
acceptSelectedMatch, acceptSelectedMatch,
acceptMatch, acceptMatch,
closeSuggestions, closeSuggestions,
} = usePathCompletion({ } = usePathCompletion({
pathInput, pathInput,
setPathInput, setPathInput,
homeDir, homeDir,
listDirectoryAbsolute: api.listDirectoryAbsolute, listDirectoryAbsolute: api.listDirectoryAbsolute,
}); });
async function openProject(path: string) { async function openProject(path: string) {
if (!path.trim()) { const trimmedPath = path.trim();
setErrorMsg("Please enter a project path."); if (!trimmedPath) {
return; setErrorMsg("Please enter a project path.");
} return;
}
try { try {
setErrorMsg(null); setErrorMsg(null);
setIsOpening(true); setIsOpening(true);
const confirmedPath = await api.openProject(path.trim()); const confirmedPath = await api.openProject(trimmedPath);
setProjectPath(confirmedPath); setProjectPath(confirmedPath);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
const message = const message =
e instanceof Error e instanceof Error
? e.message ? e.message
: typeof e === "string" : typeof e === "string"
? e ? e
: "An error occurred opening the project."; : "An error occurred opening the project.";
setErrorMsg(message);
} finally {
setIsOpening(false);
}
}
function handleOpen() { setErrorMsg(message);
void openProject(pathInput); } finally {
} setIsOpening(false);
}
}
async function handleForgetProject(path: string) { function handleOpen() {
try { void openProject(pathInput);
await api.forgetKnownProject(path); }
setKnownProjects((prev) => prev.filter((p) => p !== path));
} catch (error) {
console.error(error);
}
}
async function closeProject() { async function handleForgetProject(path: string) {
try { try {
await api.closeProject(); await api.forgetKnownProject(path);
setProjectPath(null); setKnownProjects((prev) => prev.filter((p) => p !== path));
} catch (e) { } catch (error) {
console.error(e); console.error(error);
} }
} }
function handlePathInputKeyDown( async function closeProject() {
event: React.KeyboardEvent<HTMLInputElement>, try {
) { await api.closeProject();
if (event.key === "ArrowDown") { setProjectPath(null);
if (matchList.length > 0) { } catch (e) {
event.preventDefault(); console.error(e);
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();
}
}
return ( function handlePathInputKeyDown(
<main event: React.KeyboardEvent<HTMLInputElement>,
className="container" ) {
style={{ height: "100vh", padding: 0, maxWidth: "100%" }} if (event.key === "ArrowDown") {
> if (matchList.length > 0) {
{!projectPath ? ( event.preventDefault();
<SelectionScreen setSelectedMatch((selectedMatch + 1) % matchList.length);
knownProjects={knownProjects} }
onOpenProject={openProject} } else if (event.key === "ArrowUp") {
onForgetProject={handleForgetProject} if (matchList.length > 0) {
pathInput={pathInput} event.preventDefault();
onPathInputChange={setPathInput} setSelectedMatch(
onPathInputKeyDown={handlePathInputKeyDown} (selectedMatch - 1 + matchList.length) % matchList.length,
isOpening={isOpening} );
suggestionTail={suggestionTail} }
matchList={matchList} } else if (event.key === "Tab") {
selectedMatch={selectedMatch} if (matchList.length > 0) {
onSelectMatch={setSelectedMatch} event.preventDefault();
onAcceptMatch={acceptMatch} acceptSelectedMatch();
onCloseSuggestions={closeSuggestions} }
completionError={completionError} } else if (event.key === "Escape") {
currentPartial={currentPartial} event.preventDefault();
/> closeSuggestions();
) : ( } else if (event.key === "Enter") {
<div className="workspace" style={{ height: "100%" }}> handleOpen();
<Chat projectPath={projectPath} onCloseProject={closeProject} /> }
</div> }
)}
{errorMsg && ( return (
<div className="error-message" style={{ marginTop: "20px" }}> <main
<p style={{ color: "red" }}>Error: {errorMsg}</p> className="container"
</div> style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
)} >
</main> {!projectPath ? (
); <SelectionScreen
knownProjects={knownProjects}
onOpenProject={openProject}
onForgetProject={handleForgetProject}
pathInput={pathInput}
homeDir={homeDir}
onPathInputChange={setPathInput}
onPathInputKeyDown={handlePathInputKeyDown}
isOpening={isOpening}
suggestionTail={suggestionTail}
matchList={matchList}
selectedMatch={selectedMatch}
onSelectMatch={setSelectedMatch}
onAcceptMatch={acceptMatch}
onCloseSuggestions={closeSuggestions}
completionError={completionError}
currentPartial={currentPartial}
/>
) : (
<div className="workspace" style={{ height: "100%" }}>
<Chat projectPath={projectPath} onCloseProject={closeProject} />
</div>
)}
{errorMsg && (
<div className="error-message" style={{ marginTop: "20px" }}>
<p style={{ color: "red" }}>Error: {errorMsg}</p>
</div>
)}
</main>
);
} }
export default App; export default App;

View File

@@ -1,293 +1,300 @@
export type WsRequest = export type WsRequest =
| { | {
type: "chat"; type: "chat";
messages: Message[]; messages: Message[];
config: ProviderConfig; config: ProviderConfig;
} }
| { | {
type: "cancel"; type: "cancel";
}; };
export type WsResponse = export type WsResponse =
| { type: "token"; content: string } | { type: "token"; content: string }
| { type: "update"; messages: Message[] } | { type: "update"; messages: Message[] }
| { type: "error"; message: string }; | { type: "error"; message: string };
export interface ProviderConfig { export interface ProviderConfig {
provider: string; provider: string;
model: string; model: string;
base_url?: string; base_url?: string;
enable_tools?: boolean; enable_tools?: boolean;
} }
export type Role = "system" | "user" | "assistant" | "tool"; export type Role = "system" | "user" | "assistant" | "tool";
export interface ToolCall { export interface ToolCall {
id?: string; id?: string;
type: string; type: string;
function: { function: {
name: string; name: string;
arguments: string; arguments: string;
}; };
} }
export interface Message { export interface Message {
role: Role; role: Role;
content: string; content: string;
tool_calls?: ToolCall[]; tool_calls?: ToolCall[];
tool_call_id?: string; tool_call_id?: string;
} }
export interface FileEntry { export interface FileEntry {
name: string; name: string;
kind: "file" | "dir"; kind: "file" | "dir";
} }
export interface SearchResult { export interface SearchResult {
path: string; path: string;
matches: number; matches: number;
} }
export interface CommandOutput { export interface CommandOutput {
stdout: string; stdout: string;
stderr: string; stderr: string;
exit_code: number; exit_code: number;
} }
const DEFAULT_API_BASE = "/api"; const DEFAULT_API_BASE = "/api";
const DEFAULT_WS_PATH = "/ws"; const DEFAULT_WS_PATH = "/ws";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`; return `${baseUrl}${path}`;
} }
async function requestJson<T>( async function requestJson<T>(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
baseUrl = DEFAULT_API_BASE, baseUrl = DEFAULT_API_BASE,
): Promise<T> { ): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), { const res = await fetch(buildApiUrl(path, baseUrl), {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(options.headers ?? {}), ...(options.headers ?? {}),
}, },
...options, ...options,
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(text || `Request failed (${res.status})`); throw new Error(text || `Request failed (${res.status})`);
} }
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
export const api = { export const api = {
getCurrentProject(baseUrl?: string) { getCurrentProject(baseUrl?: string) {
return requestJson<string | null>("/project", {}, baseUrl); return requestJson<string | null>("/project", {}, baseUrl);
}, },
getKnownProjects(baseUrl?: string) { getKnownProjects(baseUrl?: string) {
return requestJson<string[]>("/projects", {}, baseUrl); return requestJson<string[]>("/projects", {}, baseUrl);
}, },
forgetKnownProject(path: string, baseUrl?: string) { forgetKnownProject(path: string, baseUrl?: string) {
return requestJson<boolean>( return requestJson<boolean>(
"/projects/forget", "/projects/forget",
{ method: "POST", body: JSON.stringify({ path }) }, { method: "POST", body: JSON.stringify({ path }) },
baseUrl, baseUrl,
); );
}, },
openProject(path: string, baseUrl?: string) { openProject(path: string, baseUrl?: string) {
return requestJson<string>( return requestJson<string>(
"/project", "/project",
{ method: "POST", body: JSON.stringify({ path }) }, { method: "POST", body: JSON.stringify({ path }) },
baseUrl, baseUrl,
); );
}, },
closeProject(baseUrl?: string) { closeProject(baseUrl?: string) {
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl); return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
}, },
getModelPreference(baseUrl?: string) { getModelPreference(baseUrl?: string) {
return requestJson<string | null>("/model", {}, baseUrl); return requestJson<string | null>("/model", {}, baseUrl);
}, },
setModelPreference(model: string, baseUrl?: string) { setModelPreference(model: string, baseUrl?: string) {
return requestJson<boolean>( return requestJson<boolean>(
"/model", "/model",
{ method: "POST", body: JSON.stringify({ model }) }, { method: "POST", body: JSON.stringify({ model }) },
baseUrl, baseUrl,
); );
}, },
getOllamaModels(baseUrlParam?: string, baseUrl?: string) { getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
const url = new URL( const url = new URL(
buildApiUrl("/ollama/models", baseUrl), buildApiUrl("/ollama/models", baseUrl),
window.location.origin, window.location.origin,
); );
if (baseUrlParam) { if (baseUrlParam) {
url.searchParams.set("base_url", baseUrlParam); url.searchParams.set("base_url", baseUrlParam);
} }
return requestJson<string[]>(url.pathname + url.search, {}, ""); return requestJson<string[]>(url.pathname + url.search, {}, "");
}, },
getAnthropicApiKeyExists(baseUrl?: string) { getAnthropicApiKeyExists(baseUrl?: string) {
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl); return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
}, },
getAnthropicModels(baseUrl?: string) { getAnthropicModels(baseUrl?: string) {
return requestJson<string[]>("/anthropic/models", {}, baseUrl); return requestJson<string[]>("/anthropic/models", {}, baseUrl);
}, },
setAnthropicApiKey(api_key: string, baseUrl?: string) { setAnthropicApiKey(api_key: string, baseUrl?: string) {
return requestJson<boolean>( return requestJson<boolean>(
"/anthropic/key", "/anthropic/key",
{ method: "POST", body: JSON.stringify({ api_key }) }, { method: "POST", body: JSON.stringify({ api_key }) },
baseUrl, baseUrl,
); );
}, },
readFile(path: string, baseUrl?: string) { readFile(path: string, baseUrl?: string) {
return requestJson<string>( return requestJson<string>(
"/fs/read", "/fs/read",
{ method: "POST", body: JSON.stringify({ path }) }, { method: "POST", body: JSON.stringify({ path }) },
baseUrl, baseUrl,
); );
}, },
writeFile(path: string, content: string, baseUrl?: string) { writeFile(path: string, content: string, baseUrl?: string) {
return requestJson<boolean>( return requestJson<boolean>(
"/fs/write", "/fs/write",
{ method: "POST", body: JSON.stringify({ path, content }) }, { method: "POST", body: JSON.stringify({ path, content }) },
baseUrl, baseUrl,
); );
}, },
listDirectory(path: string, baseUrl?: string) { listDirectory(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>( return requestJson<FileEntry[]>(
"/fs/list", "/fs/list",
{ method: "POST", body: JSON.stringify({ path }) }, { method: "POST", body: JSON.stringify({ path }) },
baseUrl, baseUrl,
); );
}, },
listDirectoryAbsolute(path: string, baseUrl?: string) { listDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>( return requestJson<FileEntry[]>(
"/io/fs/list/absolute", "/io/fs/list/absolute",
{ method: "POST", body: JSON.stringify({ path }) }, { method: "POST", body: JSON.stringify({ path }) },
baseUrl, baseUrl,
); );
}, },
getHomeDirectory(baseUrl?: string) { createDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<string>("/io/fs/home", {}, baseUrl); return requestJson<boolean>(
}, "/io/fs/create/absolute",
searchFiles(query: string, baseUrl?: string) { { method: "POST", body: JSON.stringify({ path }) },
return requestJson<SearchResult[]>( baseUrl,
"/fs/search", );
{ method: "POST", body: JSON.stringify({ query }) }, },
baseUrl, getHomeDirectory(baseUrl?: string) {
); return requestJson<string>("/io/fs/home", {}, baseUrl);
}, },
execShell(command: string, args: string[], baseUrl?: string) { searchFiles(query: string, baseUrl?: string) {
return requestJson<CommandOutput>( return requestJson<SearchResult[]>(
"/shell/exec", "/fs/search",
{ method: "POST", body: JSON.stringify({ command, args }) }, { method: "POST", body: JSON.stringify({ query }) },
baseUrl, baseUrl,
); );
}, },
cancelChat(baseUrl?: string) { execShell(command: string, args: string[], baseUrl?: string) {
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl); return requestJson<CommandOutput>(
}, "/shell/exec",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
cancelChat(baseUrl?: string) {
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
},
}; };
export class ChatWebSocket { export class ChatWebSocket {
private static sharedSocket: WebSocket | null = null; private static sharedSocket: WebSocket | null = null;
private static refCount = 0; private static refCount = 0;
private socket?: WebSocket; private socket?: WebSocket;
private onToken?: (content: string) => void; private onToken?: (content: string) => void;
private onUpdate?: (messages: Message[]) => void; private onUpdate?: (messages: Message[]) => void;
private onError?: (message: string) => void; private onError?: (message: string) => void;
private connected = false; private connected = false;
private closeTimer?: number; private closeTimer?: number;
connect( connect(
handlers: { handlers: {
onToken?: (content: string) => void; onToken?: (content: string) => void;
onUpdate?: (messages: Message[]) => void; onUpdate?: (messages: Message[]) => void;
onError?: (message: string) => void; onError?: (message: string) => void;
}, },
wsPath = DEFAULT_WS_PATH, wsPath = DEFAULT_WS_PATH,
) { ) {
this.onToken = handlers.onToken; this.onToken = handlers.onToken;
this.onUpdate = handlers.onUpdate; this.onUpdate = handlers.onUpdate;
this.onError = handlers.onError; this.onError = handlers.onError;
if (this.connected) { if (this.connected) {
return; return;
} }
this.connected = true; this.connected = true;
ChatWebSocket.refCount += 1; ChatWebSocket.refCount += 1;
const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const wsHost = import.meta.env.DEV const wsHost = import.meta.env.DEV
? "127.0.0.1:3001" ? "127.0.0.1:3001"
: window.location.host; : window.location.host;
const wsUrl = `${protocol}://${wsHost}${wsPath}`; const wsUrl = `${protocol}://${wsHost}${wsPath}`;
if ( if (
!ChatWebSocket.sharedSocket || !ChatWebSocket.sharedSocket ||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
) { ) {
ChatWebSocket.sharedSocket = new WebSocket(wsUrl); ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
} }
this.socket = ChatWebSocket.sharedSocket; this.socket = ChatWebSocket.sharedSocket;
this.socket.onmessage = (event) => { this.socket.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) as WsResponse; const data = JSON.parse(event.data) as WsResponse;
if (data.type === "token") this.onToken?.(data.content); if (data.type === "token") this.onToken?.(data.content);
if (data.type === "update") this.onUpdate?.(data.messages); if (data.type === "update") this.onUpdate?.(data.messages);
if (data.type === "error") this.onError?.(data.message); if (data.type === "error") this.onError?.(data.message);
} catch (err) { } catch (err) {
this.onError?.(String(err)); this.onError?.(String(err));
} }
}; };
this.socket.onerror = () => { this.socket.onerror = () => {
this.onError?.("WebSocket error"); this.onError?.("WebSocket error");
}; };
} }
sendChat(messages: Message[], config: ProviderConfig) { sendChat(messages: Message[], config: ProviderConfig) {
this.send({ type: "chat", messages, config }); this.send({ type: "chat", messages, config });
} }
cancel() { cancel() {
this.send({ type: "cancel" }); this.send({ type: "cancel" });
} }
close() { close() {
if (!this.connected) return; if (!this.connected) return;
this.connected = false; this.connected = false;
ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
if (this.closeTimer) { if (this.closeTimer) {
window.clearTimeout(this.closeTimer); window.clearTimeout(this.closeTimer);
} }
this.closeTimer = window.setTimeout(() => { this.closeTimer = window.setTimeout(() => {
if (ChatWebSocket.refCount === 0) { if (ChatWebSocket.refCount === 0) {
ChatWebSocket.sharedSocket?.close(); ChatWebSocket.sharedSocket?.close();
ChatWebSocket.sharedSocket = null; ChatWebSocket.sharedSocket = null;
} }
this.socket = ChatWebSocket.sharedSocket ?? undefined; this.socket = ChatWebSocket.sharedSocket ?? undefined;
this.closeTimer = undefined; this.closeTimer = undefined;
}, 250); }, 250);
return; return;
} }
if (ChatWebSocket.refCount === 0) { if (ChatWebSocket.refCount === 0) {
ChatWebSocket.sharedSocket?.close(); ChatWebSocket.sharedSocket?.close();
ChatWebSocket.sharedSocket = null; ChatWebSocket.sharedSocket = null;
} }
this.socket = ChatWebSocket.sharedSocket ?? undefined; this.socket = ChatWebSocket.sharedSocket ?? undefined;
} }
private send(payload: WsRequest) { private send(payload: WsRequest) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
this.onError?.("WebSocket is not connected"); this.onError?.("WebSocket is not connected");
return; return;
} }
this.socket.send(JSON.stringify(payload)); this.socket.send(JSON.stringify(payload));
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,170 +1,170 @@
export interface ProjectPathMatch { export interface ProjectPathMatch {
name: string; name: string;
path: string; path: string;
} }
export interface ProjectPathInputProps { export interface ProjectPathInputProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void; onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
suggestionTail: string; suggestionTail: string;
matchList: ProjectPathMatch[]; matchList: ProjectPathMatch[];
selectedMatch: number; selectedMatch: number;
onSelectMatch: (index: number) => void; onSelectMatch: (index: number) => void;
onAcceptMatch: (path: string) => void; onAcceptMatch: (path: string) => void;
onCloseSuggestions: () => void; onCloseSuggestions: () => void;
currentPartial: string; currentPartial: string;
} }
function renderHighlightedMatch(text: string, query: string) { function renderHighlightedMatch(text: string, query: string) {
if (!query) return text; if (!query) return text;
let qIndex = 0; let qIndex = 0;
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
const counts = new Map<string, number>(); const counts = new Map<string, number>();
return text.split("").map((char) => { return text.split("").map((char) => {
const isMatch = const isMatch =
qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex]; qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex];
if (isMatch) { if (isMatch) {
qIndex += 1; qIndex += 1;
} }
const count = counts.get(char) ?? 0; const count = counts.get(char) ?? 0;
counts.set(char, count + 1); counts.set(char, count + 1);
return ( return (
<span <span
key={`${char}-${count}`} key={`${char}-${count}`}
style={isMatch ? { fontWeight: 600, color: "#222" } : undefined} style={isMatch ? { fontWeight: 600, color: "#222" } : undefined}
> >
{char} {char}
</span> </span>
); );
}); });
} }
export function ProjectPathInput({ export function ProjectPathInput({
value, value,
onChange, onChange,
onKeyDown, onKeyDown,
suggestionTail, suggestionTail,
matchList, matchList,
selectedMatch, selectedMatch,
onSelectMatch, onSelectMatch,
onAcceptMatch, onAcceptMatch,
onCloseSuggestions, onCloseSuggestions,
currentPartial, currentPartial,
}: ProjectPathInputProps) { }: ProjectPathInputProps) {
return ( return (
<div <div
style={{ style={{
position: "relative", position: "relative",
marginTop: "12px", marginTop: "12px",
marginBottom: "170px", marginBottom: "170px",
}} }}
> >
<div <div
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
padding: "10px", padding: "10px",
color: "#aaa", color: "#aaa",
fontFamily: "monospace", fontFamily: "monospace",
whiteSpace: "pre", whiteSpace: "pre",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
{value} {value}
{suggestionTail} {suggestionTail}
</div> </div>
<input <input
type="text" type="text"
value={value} value={value}
placeholder="/path/to/project" placeholder="/path/to/project"
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
style={{ style={{
width: "100%", width: "100%",
padding: "10px", padding: "10px",
fontFamily: "monospace", fontFamily: "monospace",
background: "transparent", background: "transparent",
position: "relative", position: "relative",
zIndex: 1, zIndex: 1,
}} }}
/> />
{matchList.length > 0 && ( {matchList.length > 0 && (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
top: "100%", top: "100%",
left: 0, left: 0,
right: 0, right: 0,
marginTop: "6px", marginTop: "6px",
border: "1px solid #ddd", border: "1px solid #ddd",
borderRadius: "6px", borderRadius: "6px",
overflow: "hidden", overflow: "hidden",
background: "#fff", background: "#fff",
fontFamily: "monospace", fontFamily: "monospace",
height: "160px", height: "160px",
overflowY: "auto", overflowY: "auto",
boxSizing: "border-box", boxSizing: "border-box",
zIndex: 2, zIndex: 2,
}} }}
> >
<div <div
style={{ style={{
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "flex-end",
alignItems: "center", alignItems: "center",
padding: "4px 6px", padding: "4px 6px",
borderBottom: "1px solid #eee", borderBottom: "1px solid #eee",
background: "#fafafa", background: "#fafafa",
}} }}
> >
<button <button
type="button" type="button"
aria-label="Close suggestions" aria-label="Close suggestions"
onClick={onCloseSuggestions} onClick={onCloseSuggestions}
style={{ style={{
width: "24px", width: "24px",
height: "24px", height: "24px",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid #ddd", border: "1px solid #ddd",
background: "#fff", background: "#fff",
cursor: "pointer", cursor: "pointer",
lineHeight: 1, lineHeight: 1,
}} }}
> >
× ×
</button> </button>
</div> </div>
{matchList.map((match, index) => { {matchList.map((match, index) => {
const isSelected = index === selectedMatch; const isSelected = index === selectedMatch;
return ( return (
<button <button
key={match.path} key={match.path}
type="button" type="button"
onMouseEnter={() => onSelectMatch(index)} onMouseEnter={() => onSelectMatch(index)}
onMouseDown={(event) => { onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
onSelectMatch(index); onSelectMatch(index);
onAcceptMatch(match.path); onAcceptMatch(match.path);
}} }}
style={{ style={{
width: "100%", width: "100%",
textAlign: "left", textAlign: "left",
padding: "6px 8px", padding: "6px 8px",
border: "none", border: "none",
background: isSelected ? "#f0f0f0" : "transparent", background: isSelected ? "#f0f0f0" : "transparent",
cursor: "pointer", cursor: "pointer",
fontFamily: "inherit", fontFamily: "inherit",
}} }}
> >
{renderHighlightedMatch(match.name, currentPartial)}/ {renderHighlightedMatch(match.name, currentPartial)}/
</button> </button>
); );
})} })}
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@@ -1,66 +1,66 @@
export interface RecentProjectsListProps { export interface RecentProjectsListProps {
projects: string[]; projects: string[];
onOpenProject: (path: string) => void; onOpenProject: (path: string) => void;
onForgetProject: (path: string) => void; onForgetProject: (path: string) => void;
} }
export function RecentProjectsList({ export function RecentProjectsList({
projects, projects,
onOpenProject, onOpenProject,
onForgetProject, onForgetProject,
}: RecentProjectsListProps) { }: RecentProjectsListProps) {
return ( return (
<div style={{ marginTop: "12px" }}> <div style={{ marginTop: "12px" }}>
<div style={{ fontSize: "0.9em", color: "#666" }}>Recent projects</div> <div style={{ fontSize: "0.9em", color: "#666" }}>Recent projects</div>
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}> <ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
{projects.map((project) => { {projects.map((project) => {
const displayName = const displayName =
project.split("/").filter(Boolean).pop() ?? project; project.split("/").filter(Boolean).pop() ?? project;
return ( return (
<li key={project} style={{ marginBottom: "6px" }}> <li key={project} style={{ marginBottom: "6px" }}>
<div <div
style={{ display: "flex", gap: "6px", alignItems: "center" }} style={{ display: "flex", gap: "6px", alignItems: "center" }}
> >
<button <button
type="button" type="button"
onClick={() => onOpenProject(project)} onClick={() => onOpenProject(project)}
style={{ style={{
flex: 1, flex: 1,
textAlign: "left", textAlign: "left",
padding: "8px 10px", padding: "8px 10px",
borderRadius: "6px", borderRadius: "6px",
border: "1px solid #ddd", border: "1px solid #ddd",
background: "#f7f7f7", background: "#f7f7f7",
cursor: "pointer", cursor: "pointer",
fontFamily: "monospace", fontFamily: "monospace",
fontSize: "0.9em", fontSize: "0.9em",
}} }}
title={project} title={project}
> >
{displayName} {displayName}
</button> </button>
<button <button
type="button" type="button"
aria-label={`Forget ${displayName}`} aria-label={`Forget ${displayName}`}
onClick={() => onForgetProject(project)} onClick={() => onForgetProject(project)}
style={{ style={{
width: "32px", width: "32px",
height: "32px", height: "32px",
borderRadius: "6px", borderRadius: "6px",
border: "1px solid #ddd", border: "1px solid #ddd",
background: "#fff", background: "#fff",
cursor: "pointer", cursor: "pointer",
fontSize: "1.1em", fontSize: "1.1em",
lineHeight: 1, lineHeight: 1,
}} }}
> >
× ×
</button> </button>
</div> </div>
</li> </li>
); );
})} })}
</ul> </ul>
</div> </div>
); );
} }

View File

@@ -3,97 +3,114 @@ import { ProjectPathInput } from "./ProjectPathInput.tsx";
import { RecentProjectsList } from "./RecentProjectsList.tsx"; import { RecentProjectsList } from "./RecentProjectsList.tsx";
export interface RecentProjectMatch { export interface RecentProjectMatch {
name: string; name: string;
path: string; path: string;
} }
export interface SelectionScreenProps { export interface SelectionScreenProps {
knownProjects: string[]; knownProjects: string[];
onOpenProject: (path: string) => void; onOpenProject: (path: string) => void;
onForgetProject: (path: string) => void; onForgetProject: (path: string) => void;
pathInput: string; pathInput: string;
onPathInputChange: (value: string) => void; homeDir?: string | null;
onPathInputKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void; onPathInputChange: (value: string) => void;
isOpening: boolean; onPathInputKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
suggestionTail: string; isOpening: boolean;
matchList: RecentProjectMatch[]; suggestionTail: string;
selectedMatch: number; matchList: RecentProjectMatch[];
onSelectMatch: (index: number) => void; selectedMatch: number;
onAcceptMatch: (path: string) => void; onSelectMatch: (index: number) => void;
onCloseSuggestions: () => void; onAcceptMatch: (path: string) => void;
completionError: string | null; onCloseSuggestions: () => void;
currentPartial: string; completionError: string | null;
currentPartial: string;
} }
export function SelectionScreen({ export function SelectionScreen({
knownProjects, knownProjects,
onOpenProject, onOpenProject,
onForgetProject, onForgetProject,
pathInput, pathInput,
onPathInputChange, homeDir,
onPathInputKeyDown, onPathInputChange,
isOpening, onPathInputKeyDown,
suggestionTail, isOpening,
matchList, suggestionTail,
selectedMatch, matchList,
onSelectMatch, selectedMatch,
onAcceptMatch, onSelectMatch,
onCloseSuggestions, onAcceptMatch,
completionError, onCloseSuggestions,
currentPartial, completionError,
currentPartial,
}: SelectionScreenProps) { }: SelectionScreenProps) {
return ( const resolvedHomeDir = homeDir
<div ? homeDir.endsWith("/")
className="selection-screen" ? homeDir
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }} : `${homeDir}/`
> : "";
<h1>AI Code Assistant</h1> return (
<p>Paste or complete a project path to start.</p> <div
className="selection-screen"
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
>
<h1>StorkIt</h1>
<p>Paste or complete a project path to start.</p>
{knownProjects.length > 0 && ( {knownProjects.length > 0 && (
<RecentProjectsList <RecentProjectsList
projects={knownProjects} projects={knownProjects}
onOpenProject={onOpenProject} onOpenProject={onOpenProject}
onForgetProject={onForgetProject} onForgetProject={onForgetProject}
/> />
)} )}
<ProjectPathInput <ProjectPathInput
value={pathInput} value={pathInput}
onChange={onPathInputChange} onChange={onPathInputChange}
onKeyDown={onPathInputKeyDown} onKeyDown={onPathInputKeyDown}
suggestionTail={suggestionTail} suggestionTail={suggestionTail}
matchList={matchList} matchList={matchList}
selectedMatch={selectedMatch} selectedMatch={selectedMatch}
onSelectMatch={onSelectMatch} onSelectMatch={onSelectMatch}
onAcceptMatch={onAcceptMatch} onAcceptMatch={onAcceptMatch}
onCloseSuggestions={onCloseSuggestions} onCloseSuggestions={onCloseSuggestions}
currentPartial={currentPartial} currentPartial={currentPartial}
/> />
<div <div
style={{ style={{
display: "flex", display: "flex",
gap: "8px", gap: "8px",
marginTop: "8px", marginTop: "8px",
alignItems: "center", alignItems: "center",
}} }}
> >
<button <button
type="button" type="button"
onClick={() => onOpenProject(pathInput)} onClick={() => onOpenProject(pathInput)}
disabled={isOpening} disabled={isOpening}
> >
{isOpening ? "Opening..." : "Open Project"} {isOpening ? "Opening..." : "Open Project"}
</button> </button>
<div style={{ fontSize: "0.85em", color: "#666" }}> <button
Press Tab to complete the next path segment type="button"
</div> onClick={() => {
</div> onPathInputChange(resolvedHomeDir);
onCloseSuggestions();
}}
disabled={isOpening}
>
New Project
</button>
<div style={{ fontSize: "0.85em", color: "#666" }}>
Press Tab to complete the next path segment
</div>
</div>
{completionError && ( {completionError && (
<div style={{ color: "red", marginTop: "8px" }}>{completionError}</div> <div style={{ color: "red", marginTop: "8px" }}>{completionError}</div>
)} )}
</div> </div>
); );
} }

View File

@@ -1,192 +1,192 @@
import * as React from "react"; import * as React from "react";
export interface FileEntry { export interface FileEntry {
name: string; name: string;
kind: "file" | "dir"; kind: "file" | "dir";
} }
export interface ProjectPathMatch { export interface ProjectPathMatch {
name: string; name: string;
path: string; path: string;
} }
export interface UsePathCompletionArgs { export interface UsePathCompletionArgs {
pathInput: string; pathInput: string;
setPathInput: (value: string) => void; setPathInput: (value: string) => void;
homeDir: string | null; homeDir: string | null;
listDirectoryAbsolute: (path: string) => Promise<FileEntry[]>; listDirectoryAbsolute: (path: string) => Promise<FileEntry[]>;
debounceMs?: number; debounceMs?: number;
} }
export interface UsePathCompletionResult { export interface UsePathCompletionResult {
matchList: ProjectPathMatch[]; matchList: ProjectPathMatch[];
selectedMatch: number; selectedMatch: number;
suggestionTail: string; suggestionTail: string;
completionError: string | null; completionError: string | null;
currentPartial: string; currentPartial: string;
setSelectedMatch: (index: number) => void; setSelectedMatch: (index: number) => void;
acceptSelectedMatch: () => void; acceptSelectedMatch: () => void;
acceptMatch: (path: string) => void; acceptMatch: (path: string) => void;
closeSuggestions: () => void; closeSuggestions: () => void;
} }
function isFuzzyMatch(candidate: string, query: string) { function isFuzzyMatch(candidate: string, query: string) {
if (!query) return true; if (!query) return true;
const lowerCandidate = candidate.toLowerCase(); const lowerCandidate = candidate.toLowerCase();
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
let idx = 0; let idx = 0;
for (const char of lowerQuery) { for (const char of lowerQuery) {
idx = lowerCandidate.indexOf(char, idx); idx = lowerCandidate.indexOf(char, idx);
if (idx === -1) return false; if (idx === -1) return false;
idx += 1; idx += 1;
} }
return true; return true;
} }
function getCurrentPartial(input: string) { function getCurrentPartial(input: string) {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return ""; if (!trimmed) return "";
if (trimmed.endsWith("/")) return ""; if (trimmed.endsWith("/")) return "";
const idx = trimmed.lastIndexOf("/"); const idx = trimmed.lastIndexOf("/");
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
} }
export function usePathCompletion({ export function usePathCompletion({
pathInput, pathInput,
setPathInput, setPathInput,
homeDir, homeDir,
listDirectoryAbsolute, listDirectoryAbsolute,
debounceMs = 60, debounceMs = 60,
}: UsePathCompletionArgs): UsePathCompletionResult { }: UsePathCompletionArgs): UsePathCompletionResult {
const [matchList, setMatchList] = React.useState<ProjectPathMatch[]>([]); const [matchList, setMatchList] = React.useState<ProjectPathMatch[]>([]);
const [selectedMatch, setSelectedMatch] = React.useState(0); const [selectedMatch, setSelectedMatch] = React.useState(0);
const [suggestionTail, setSuggestionTail] = React.useState(""); const [suggestionTail, setSuggestionTail] = React.useState("");
const [completionError, setCompletionError] = React.useState<string | null>( const [completionError, setCompletionError] = React.useState<string | null>(
null, null,
); );
React.useEffect(() => { React.useEffect(() => {
let active = true; let active = true;
async function computeSuggestion() { async function computeSuggestion() {
setCompletionError(null); setCompletionError(null);
setSuggestionTail(""); setSuggestionTail("");
setMatchList([]); setMatchList([]);
setSelectedMatch(0); setSelectedMatch(0);
const trimmed = pathInput.trim(); const trimmed = pathInput.trim();
if (!trimmed) { if (!trimmed) {
return; return;
} }
const endsWithSlash = trimmed.endsWith("/"); const endsWithSlash = trimmed.endsWith("/");
let dir = trimmed; let dir = trimmed;
let partial = ""; let partial = "";
if (!endsWithSlash) { if (!endsWithSlash) {
const idx = trimmed.lastIndexOf("/"); const idx = trimmed.lastIndexOf("/");
if (idx >= 0) { if (idx >= 0) {
dir = trimmed.slice(0, idx + 1); dir = trimmed.slice(0, idx + 1);
partial = trimmed.slice(idx + 1); partial = trimmed.slice(idx + 1);
} else { } else {
dir = ""; dir = "";
partial = trimmed; partial = trimmed;
} }
} }
if (!dir) { if (!dir) {
if (homeDir) { if (homeDir) {
dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`; dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`;
} else { } else {
return; return;
} }
} }
const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, ""); const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, "");
const entries = await listDirectoryAbsolute(dirForListing); const entries = await listDirectoryAbsolute(dirForListing);
if (!active) return; if (!active) return;
const matches = entries const matches = entries
.filter((entry) => entry.kind === "dir") .filter((entry) => entry.kind === "dir")
.filter((entry) => isFuzzyMatch(entry.name, partial)) .filter((entry) => isFuzzyMatch(entry.name, partial))
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 8); .slice(0, 8);
if (matches.length === 0) { if (matches.length === 0) {
return; return;
} }
const basePrefix = dir.endsWith("/") ? dir : `${dir}/`; const basePrefix = dir.endsWith("/") ? dir : `${dir}/`;
const list = matches.map((entry) => ({ const list = matches.map((entry) => ({
name: entry.name, name: entry.name,
path: `${basePrefix}${entry.name}/`, path: `${basePrefix}${entry.name}/`,
})); }));
setMatchList(list); setMatchList(list);
} }
const debounceId = window.setTimeout(() => { const debounceId = window.setTimeout(() => {
computeSuggestion().catch((error) => { computeSuggestion().catch((error) => {
console.error(error); console.error(error);
if (!active) return; if (!active) return;
setCompletionError( setCompletionError(
error instanceof Error error instanceof Error
? error.message ? error.message
: "Failed to compute suggestion.", : "Failed to compute suggestion.",
); );
}); });
}, debounceMs); }, debounceMs);
return () => { return () => {
active = false; active = false;
window.clearTimeout(debounceId); window.clearTimeout(debounceId);
}; };
}, [pathInput, homeDir, listDirectoryAbsolute, debounceMs]); }, [pathInput, homeDir, listDirectoryAbsolute, debounceMs]);
React.useEffect(() => { React.useEffect(() => {
if (matchList.length === 0) { if (matchList.length === 0) {
setSuggestionTail(""); setSuggestionTail("");
return; return;
} }
const index = Math.min(selectedMatch, matchList.length - 1); const index = Math.min(selectedMatch, matchList.length - 1);
const next = matchList[index]; const next = matchList[index];
const trimmed = pathInput.trim(); const trimmed = pathInput.trim();
if (next.path.startsWith(trimmed)) { if (next.path.startsWith(trimmed)) {
setSuggestionTail(next.path.slice(trimmed.length)); setSuggestionTail(next.path.slice(trimmed.length));
} else { } else {
setSuggestionTail(""); setSuggestionTail("");
} }
}, [matchList, selectedMatch, pathInput]); }, [matchList, selectedMatch, pathInput]);
const acceptMatch = React.useCallback( const acceptMatch = React.useCallback(
(path: string) => { (path: string) => {
setPathInput(path); setPathInput(path);
}, },
[setPathInput], [setPathInput],
); );
const acceptSelectedMatch = React.useCallback(() => { const acceptSelectedMatch = React.useCallback(() => {
const next = matchList[selectedMatch]?.path; const next = matchList[selectedMatch]?.path;
if (next) { if (next) {
setPathInput(next); setPathInput(next);
} }
}, [matchList, selectedMatch, setPathInput]); }, [matchList, selectedMatch, setPathInput]);
const closeSuggestions = React.useCallback(() => { const closeSuggestions = React.useCallback(() => {
setMatchList([]); setMatchList([]);
setSelectedMatch(0); setSelectedMatch(0);
setSuggestionTail(""); setSuggestionTail("");
setCompletionError(null); setCompletionError(null);
}, []); }, []);
return { return {
matchList, matchList,
selectedMatch, selectedMatch,
suggestionTail, suggestionTail,
completionError, completionError,
currentPartial: getCurrentPartial(pathInput), currentPartial: getCurrentPartial(pathInput),
setSelectedMatch, setSelectedMatch,
acceptSelectedMatch, acceptSelectedMatch,
acceptMatch, acceptMatch,
closeSuggestions, closeSuggestions,
}; };
} }

View File

@@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -25,6 +25,11 @@ struct SearchPayload {
query: String, query: String,
} }
#[derive(Deserialize, Object)]
struct CreateDirectoryPayload {
pub path: String,
}
#[derive(Deserialize, Object)] #[derive(Deserialize, Object)]
struct ExecShellPayload { struct ExecShellPayload {
pub command: String, pub command: String,
@@ -79,6 +84,18 @@ impl IoApi {
Ok(Json(entries)) 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<CreateDirectoryPayload>,
) -> OpenApiResult<Json<bool>> {
io_fs::create_directory_absolute(payload.0.path)
.await
.map_err(bad_request)?;
Ok(Json(true))
}
/// Get the user's home directory. /// Get the user's home directory.
#[oai(path = "/io/fs/home", method = "get")] #[oai(path = "/io/fs/home", method = "get")]
async fn get_home_directory(&self) -> OpenApiResult<Json<String>> { async fn get_home_directory(&self) -> OpenApiResult<Json<String>> {

View File

@@ -3,12 +3,380 @@ use crate::store::StoreOps;
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
const KEY_LAST_PROJECT: &str = "last_project_path"; const KEY_LAST_PROJECT: &str = "last_project_path";
const KEY_SELECTED_MODEL: &str = "selected_model"; const KEY_SELECTED_MODEL: &str = "selected_model";
const KEY_KNOWN_PROJECTS: &str = "known_projects"; 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<T, AppError>`.
* **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<String, String> { pub fn get_home_directory() -> Result<String, String> {
let home = homedir::my_home() let home = homedir::my_home()
.map_err(|e| format!("Failed to resolve home directory: {e}"))? .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))? .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( pub async fn open_project(
path: String, path: String,
state: &SessionState, state: &SessionState,
@@ -55,6 +466,7 @@ pub async fn open_project(
) -> Result<String, String> { ) -> Result<String, String> {
let p = PathBuf::from(&path); let p = PathBuf::from(&path);
ensure_project_root_with_story_kit(p.clone()).await?;
validate_project_path(p.clone()).await?; validate_project_path(p.clone()).await?;
{ {
@@ -236,3 +648,13 @@ pub async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, Str
let full_path = PathBuf::from(path); let full_path = PathBuf::from(path);
list_directory_impl(full_path).await list_directory_impl(full_path).await
} }
pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
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))?
}