Text-completion picker to select a project

This commit is contained in:
Dave
2026-02-16 19:44:29 +00:00
parent ffab287d16
commit 45bce740b6
7 changed files with 439 additions and 28 deletions

134
Cargo.lock generated
View File

@@ -155,7 +155,7 @@ dependencies = [
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -643,6 +643,18 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "homedir"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527"
dependencies = [
"cfg-if",
"nix",
"widestring",
"windows",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -764,7 +776,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core", "windows-core 0.62.2",
] ]
[[package]] [[package]]
@@ -1156,7 +1168,7 @@ dependencies = [
"libc", "libc",
"redox_syscall", "redox_syscall",
"smallvec", "smallvec",
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -1873,6 +1885,7 @@ dependencies = [
"chrono", "chrono",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"homedir",
"ignore", "ignore",
"mime_guess", "mime_guess",
"poem", "poem",
@@ -2501,6 +2514,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]] [[package]]
name = "wildmatch" name = "wildmatch"
version = "2.6.1" version = "2.6.1"
@@ -2516,6 +2535,41 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-core 0.61.2",
"windows-future",
"windows-link 0.1.3",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -2524,9 +2578,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link", "windows-link 0.2.1",
"windows-result", "windows-result 0.4.1",
"windows-strings", "windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
] ]
[[package]] [[package]]
@@ -2551,21 +2616,46 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
]
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.6.1" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
"windows-result", "windows-result 0.4.1",
"windows-strings", "windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
] ]
[[package]] [[package]]
@@ -2574,7 +2664,16 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
] ]
[[package]] [[package]]
@@ -2583,7 +2682,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -2619,7 +2718,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -2659,7 +2758,7 @@ version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1", "windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1", "windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1", "windows_i686_gnu 0.53.1",
@@ -2670,6 +2769,15 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "windows_x86_64_msvc 0.53.1",
] ]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link 0.1.3",
]
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.42.2" version = "0.42.2"

View File

@@ -18,3 +18,4 @@ walkdir = "2.5.0"
eventsource-stream = "0.2.3" eventsource-stream = "0.2.3"
rust-embed = "8" rust-embed = "8"
mime_guess = "2" mime_guess = "2"
homedir = "0.3.6"

View File

@@ -3,12 +3,36 @@ import { api } from "./api/client";
import { Chat } from "./components/Chat"; import { Chat } from "./components/Chat";
import "./App.css"; import "./App.css";
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;
}
function App() { function App() {
const [projectPath, setProjectPath] = React.useState<string | null>(null); const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null); const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [pathInput, setPathInput] = React.useState(""); const [pathInput, setPathInput] = React.useState("");
const [isOpening, setIsOpening] = React.useState(false); const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]); const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
const [homeDir, setHomeDir] = React.useState<string | null>(null);
const [suggestion, setSuggestion] = React.useState<string | null>(null);
const [suggestionTail, setSuggestionTail] = React.useState("");
const [completionError, setCompletionError] = React.useState<string | null>(
null,
);
const [matchList, setMatchList] = React.useState<
{ name: string; path: string }[]
>([]);
const [selectedMatch, setSelectedMatch] = React.useState(0);
React.useEffect(() => { React.useEffect(() => {
api api
@@ -17,6 +41,122 @@ function App() {
.catch((error) => console.error(error)); .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;
};
}, []);
React.useEffect(() => {
let active = true;
async function computeSuggestion() {
setCompletionError(null);
setSuggestion(null);
setSuggestionTail("");
setMatchList([]);
setSelectedMatch(0);
const trimmed = pathInput.trim();
if (!trimmed) {
return;
}
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 (!dir) {
if (homeDir) {
dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`;
} else {
return;
}
}
const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, "");
const entries = await api.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);
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);
}
computeSuggestion().catch((error) => {
console.error(error);
if (!active) return;
setCompletionError(
error instanceof Error
? error.message
: "Failed to compute suggestion.",
);
});
return () => {
active = false;
};
}, [pathInput, homeDir]);
React.useEffect(() => {
if (matchList.length === 0) {
setSuggestion(null);
setSuggestionTail("");
return;
}
const index = Math.min(selectedMatch, matchList.length - 1);
const next = matchList[index];
setSuggestion(next.path);
const trimmed = pathInput.trim();
if (next.path.startsWith(trimmed)) {
setSuggestionTail(next.path.slice(trimmed.length));
} else {
setSuggestionTail("");
}
}, [matchList, selectedMatch, pathInput]);
async function openProject(path: string) { async function openProject(path: string) {
if (!path.trim()) { if (!path.trim()) {
setErrorMsg("Please enter a project path."); setErrorMsg("Please enter a project path.");
@@ -66,7 +206,7 @@ function App() {
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }} style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
> >
<h1>AI Code Assistant</h1> <h1>AI Code Assistant</h1>
<p>Paste a project path to start the Story-Driven Spec Workflow.</p> <p>Paste or complete a project path to start.</p>
{knownProjects.length > 0 && ( {knownProjects.length > 0 && (
<div style={{ marginTop: "12px" }}> <div style={{ marginTop: "12px" }}>
<div style={{ fontSize: "0.9em", color: "#666" }}> <div style={{ fontSize: "0.9em", color: "#666" }}>
@@ -97,21 +237,140 @@ function App() {
</ul> </ul>
</div> </div>
)} )}
<input
type="text" <div
value={pathInput} style={{
placeholder="/path/to/project" position: "relative",
onChange={(event) => setPathInput(event.target.value)} marginTop: "12px",
onKeyDown={(event) => { marginBottom: "170px",
if (event.key === "Enter") {
handleOpen();
}
}} }}
style={{ width: "100%", padding: "10px", marginTop: "12px" }} >
/> <div
<button type="button" onClick={handleOpen} disabled={isOpening}> style={{
{isOpening ? "Opening..." : "Open Project"} position: "absolute",
</button> inset: 0,
padding: "10px",
color: "#aaa",
fontFamily: "monospace",
whiteSpace: "pre",
overflow: "hidden",
textOverflow: "ellipsis",
pointerEvents: "none",
}}
>
{pathInput}
{suggestionTail}
</div>
<input
type="text"
value={pathInput}
placeholder="/path/to/project"
onChange={(event) => setPathInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
if (matchList.length > 0) {
event.preventDefault();
setSelectedMatch((prev) => (prev + 1) % matchList.length);
}
} else if (event.key === "ArrowUp") {
if (matchList.length > 0) {
event.preventDefault();
setSelectedMatch(
(prev) =>
(prev - 1 + matchList.length) % matchList.length,
);
}
} else if (event.key === "Tab") {
if (matchList.length > 0) {
event.preventDefault();
const next = matchList[selectedMatch]?.path;
if (next) {
setPathInput(next);
}
}
} else if (event.key === "Enter") {
handleOpen();
}
}}
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,
}}
>
{matchList.map((match, index) => {
const isSelected = index === selectedMatch;
return (
<button
key={match.path}
type="button"
onMouseEnter={() => setSelectedMatch(index)}
onMouseDown={(event) => {
event.preventDefault();
setSelectedMatch(index);
setPathInput(match.path);
}}
style={{
width: "100%",
textAlign: "left",
padding: "6px 8px",
border: "none",
background: isSelected ? "#f0f0f0" : "transparent",
cursor: "pointer",
fontFamily: "inherit",
}}
>
{match.name}/
</button>
);
})}
</div>
)}
</div>
<div
style={{
display: "flex",
gap: "8px",
marginTop: "8px",
alignItems: "center",
}}
>
<button type="button" onClick={handleOpen} disabled={isOpening}>
{isOpening ? "Opening..." : "Open 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> </div>
) : ( ) : (
<div className="workspace" style={{ height: "100%" }}> <div className="workspace" style={{ height: "100%" }}>

View File

@@ -153,6 +153,16 @@ export const api = {
baseUrl, 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) { searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>( return requestJson<SearchResult[]>(
"/fs/search", "/fs/search",

View File

@@ -20,6 +20,8 @@ walkdir = { workspace = true }
eventsource-stream = { workspace = true } eventsource-stream = { workspace = true }
rust-embed = { workspace = true } rust-embed = { workspace = true }
mime_guess = { workspace = true } mime_guess = { workspace = true }
homedir = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View File

@@ -67,6 +67,25 @@ impl IoApi {
Ok(Json(entries)) Ok(Json(entries))
} }
/// List files and folders at an absolute path (not scoped to the project root).
#[oai(path = "/io/fs/list/absolute", method = "post")]
async fn list_directory_absolute(
&self,
payload: Json<FilePathPayload>,
) -> OpenApiResult<Json<Vec<io_fs::FileEntry>>> {
let entries = io_fs::list_directory_absolute(payload.0.path)
.await
.map_err(bad_request)?;
Ok(Json(entries))
}
/// Get the user's home directory.
#[oai(path = "/io/fs/home", method = "get")]
async fn get_home_directory(&self) -> OpenApiResult<Json<String>> {
let home = io_fs::get_home_directory().map_err(bad_request)?;
Ok(Json(home))
}
/// Search the currently open project for files containing the provided query string. /// Search the currently open project for files containing the provided query string.
#[oai(path = "/io/search", method = "post")] #[oai(path = "/io/search", method = "post")]
async fn search_files( async fn search_files(

View File

@@ -9,6 +9,13 @@ const KEY_LAST_PROJECT: &str = "last_project_path";
const KEY_SELECTED_MODEL: &str = "selected_model"; const KEY_SELECTED_MODEL: &str = "selected_model";
const KEY_KNOWN_PROJECTS: &str = "known_projects"; const KEY_KNOWN_PROJECTS: &str = "known_projects";
pub fn get_home_directory() -> Result<String, String> {
let home = homedir::my_home()
.map_err(|e| format!("Failed to resolve home directory: {e}"))?
.ok_or_else(|| "Home directory not found".to_string())?;
Ok(home.to_string_lossy().to_string())
}
/// Resolves a relative path against the active project root (pure function for testing). /// Resolves a relative path against the active project root (pure function for testing).
/// Returns error if path attempts traversal (..). /// Returns error if path attempts traversal (..).
fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, String> { fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, String> {
@@ -209,3 +216,8 @@ pub async fn list_directory(path: String, state: &SessionState) -> Result<Vec<Fi
let full_path = resolve_path(state, &path)?; let full_path = resolve_path(state, &path)?;
list_directory_impl(full_path).await list_directory_impl(full_path).await
} }
pub async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, String> {
let full_path = PathBuf::from(path);
list_directory_impl(full_path).await
}