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

@@ -6,172 +6,175 @@ import { usePathCompletion } from "./components/selection/usePathCompletion";
import "./App.css";
function App() {
const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [pathInput, setPathInput] = React.useState("");
const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
const [homeDir, setHomeDir] = React.useState<string | null>(null);
const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [pathInput, setPathInput] = React.useState("");
const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
const [homeDir, setHomeDir] = React.useState<string | null>(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<HTMLInputElement>,
) {
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 (
<main
className="container"
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
>
{!projectPath ? (
<SelectionScreen
knownProjects={knownProjects}
onOpenProject={openProject}
onForgetProject={handleForgetProject}
pathInput={pathInput}
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>
)}
function handlePathInputKeyDown(
event: React.KeyboardEvent<HTMLInputElement>,
) {
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 && (
<div className="error-message" style={{ marginTop: "20px" }}>
<p style={{ color: "red" }}>Error: {errorMsg}</p>
</div>
)}
</main>
);
return (
<main
className="container"
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
>
{!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;

View File

@@ -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<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
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<T>;
return res.json() as Promise<T>;
}
export const api = {
getCurrentProject(baseUrl?: string) {
return requestJson<string | null>("/project", {}, baseUrl);
},
getKnownProjects(baseUrl?: string) {
return requestJson<string[]>("/projects", {}, baseUrl);
},
forgetKnownProject(path: string, baseUrl?: string) {
return requestJson<boolean>(
"/projects/forget",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
openProject(path: string, baseUrl?: string) {
return requestJson<string>(
"/project",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
closeProject(baseUrl?: string) {
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
},
getModelPreference(baseUrl?: string) {
return requestJson<string | null>("/model", {}, baseUrl);
},
setModelPreference(model: string, baseUrl?: string) {
return requestJson<boolean>(
"/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<string[]>(url.pathname + url.search, {}, "");
},
getAnthropicApiKeyExists(baseUrl?: string) {
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
},
getAnthropicModels(baseUrl?: string) {
return requestJson<string[]>("/anthropic/models", {}, baseUrl);
},
setAnthropicApiKey(api_key: string, baseUrl?: string) {
return requestJson<boolean>(
"/anthropic/key",
{ method: "POST", body: JSON.stringify({ api_key }) },
baseUrl,
);
},
readFile(path: string, baseUrl?: string) {
return requestJson<string>(
"/fs/read",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
writeFile(path: string, content: string, baseUrl?: string) {
return requestJson<boolean>(
"/fs/write",
{ method: "POST", body: JSON.stringify({ path, content }) },
baseUrl,
);
},
listDirectory(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/fs/list",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
listDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/io/fs/list/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
getHomeDirectory(baseUrl?: string) {
return requestJson<string>("/io/fs/home", {}, baseUrl);
},
searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>(
"/fs/search",
{ method: "POST", body: JSON.stringify({ query }) },
baseUrl,
);
},
execShell(command: string, args: string[], baseUrl?: string) {
return requestJson<CommandOutput>(
"/shell/exec",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
cancelChat(baseUrl?: string) {
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
},
getCurrentProject(baseUrl?: string) {
return requestJson<string | null>("/project", {}, baseUrl);
},
getKnownProjects(baseUrl?: string) {
return requestJson<string[]>("/projects", {}, baseUrl);
},
forgetKnownProject(path: string, baseUrl?: string) {
return requestJson<boolean>(
"/projects/forget",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
openProject(path: string, baseUrl?: string) {
return requestJson<string>(
"/project",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
closeProject(baseUrl?: string) {
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
},
getModelPreference(baseUrl?: string) {
return requestJson<string | null>("/model", {}, baseUrl);
},
setModelPreference(model: string, baseUrl?: string) {
return requestJson<boolean>(
"/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<string[]>(url.pathname + url.search, {}, "");
},
getAnthropicApiKeyExists(baseUrl?: string) {
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
},
getAnthropicModels(baseUrl?: string) {
return requestJson<string[]>("/anthropic/models", {}, baseUrl);
},
setAnthropicApiKey(api_key: string, baseUrl?: string) {
return requestJson<boolean>(
"/anthropic/key",
{ method: "POST", body: JSON.stringify({ api_key }) },
baseUrl,
);
},
readFile(path: string, baseUrl?: string) {
return requestJson<string>(
"/fs/read",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
writeFile(path: string, content: string, baseUrl?: string) {
return requestJson<boolean>(
"/fs/write",
{ method: "POST", body: JSON.stringify({ path, content }) },
baseUrl,
);
},
listDirectory(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/fs/list",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
listDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/io/fs/list/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
createDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<boolean>(
"/io/fs/create/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
getHomeDirectory(baseUrl?: string) {
return requestJson<string>("/io/fs/home", {}, baseUrl);
},
searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>(
"/fs/search",
{ method: "POST", body: JSON.stringify({ query }) },
baseUrl,
);
},
execShell(command: string, args: string[], baseUrl?: string) {
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 {
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));
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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<HTMLInputElement>) => 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<HTMLInputElement>) => 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 (
<div
className="selection-screen"
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
>
<h1>AI Code Assistant</h1>
<p>Paste or complete a project path to start.</p>
const resolvedHomeDir = homeDir
? homeDir.endsWith("/")
? homeDir
: `${homeDir}/`
: "";
return (
<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 && (
<RecentProjectsList
projects={knownProjects}
onOpenProject={onOpenProject}
onForgetProject={onForgetProject}
/>
)}
{knownProjects.length > 0 && (
<RecentProjectsList
projects={knownProjects}
onOpenProject={onOpenProject}
onForgetProject={onForgetProject}
/>
)}
<ProjectPathInput
value={pathInput}
onChange={onPathInputChange}
onKeyDown={onPathInputKeyDown}
suggestionTail={suggestionTail}
matchList={matchList}
selectedMatch={selectedMatch}
onSelectMatch={onSelectMatch}
onAcceptMatch={onAcceptMatch}
onCloseSuggestions={onCloseSuggestions}
currentPartial={currentPartial}
/>
<ProjectPathInput
value={pathInput}
onChange={onPathInputChange}
onKeyDown={onPathInputKeyDown}
suggestionTail={suggestionTail}
matchList={matchList}
selectedMatch={selectedMatch}
onSelectMatch={onSelectMatch}
onAcceptMatch={onAcceptMatch}
onCloseSuggestions={onCloseSuggestions}
currentPartial={currentPartial}
/>
<div
style={{
display: "flex",
gap: "8px",
marginTop: "8px",
alignItems: "center",
}}
>
<button
type="button"
onClick={() => onOpenProject(pathInput)}
disabled={isOpening}
>
{isOpening ? "Opening..." : "Open Project"}
</button>
<div style={{ fontSize: "0.85em", color: "#666" }}>
Press Tab to complete the next path segment
</div>
</div>
<div
style={{
display: "flex",
gap: "8px",
marginTop: "8px",
alignItems: "center",
}}
>
<button
type="button"
onClick={() => onOpenProject(pathInput)}
disabled={isOpening}
>
{isOpening ? "Opening..." : "Open Project"}
</button>
<button
type="button"
onClick={() => {
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 && (
<div style={{ color: "red", marginTop: "8px" }}>{completionError}</div>
)}
</div>
);
{completionError && (
<div style={{ color: "red", marginTop: "8px" }}>{completionError}</div>
)}
</div>
);
}

View File

@@ -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<FileEntry[]>;
debounceMs?: number;
pathInput: string;
setPathInput: (value: string) => void;
homeDir: string | null;
listDirectoryAbsolute: (path: string) => Promise<FileEntry[]>;
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<ProjectPathMatch[]>([]);
const [selectedMatch, setSelectedMatch] = React.useState(0);
const [suggestionTail, setSuggestionTail] = React.useState("");
const [completionError, setCompletionError] = React.useState<string | null>(
null,
);
const [matchList, setMatchList] = React.useState<ProjectPathMatch[]>([]);
const [selectedMatch, setSelectedMatch] = React.useState(0);
const [suggestionTail, setSuggestionTail] = React.useState("");
const [completionError, setCompletionError] = React.useState<string | null>(
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,
};
}

View File

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