Project creation is workign
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user