From 3aedd7a0a53d76d0a18164d1d4803979850c09b1 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Mon, 15 Jun 2026 21:20:36 +0000 Subject: [PATCH] feat(maestro): live directory autocomplete in spawn form (NAP-329) Adds api.listDir + a reusable dirAutocomplete widget (live dropdown, Tab/Enter completion, /-drill-in, per-(daemon,dir) cache) wired onto the cwd and worktree base-repo fields, with matching dropdown styling. --- web/src/api.ts | 17 +++++++++++++- web/src/dirAutocomplete.ts | Bin 0 -> 8153 bytes web/src/spawn.ts | 17 +++++++++++++- web/src/styles.css | 46 +++++++++++++++++++++++++++++++++++++ web/src/types.ts | 10 ++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 web/src/dirAutocomplete.ts diff --git a/web/src/api.ts b/web/src/api.ts index b3857fd..7ba95b6 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,11 @@ -import type { ApiError, CreateSessionRequest, DaemonInfo, Session, Uuid } from "./types.js"; +import type { + ApiError, + CreateSessionRequest, + DaemonInfo, + DirListing, + Session, + Uuid, +} from "./types.js"; async function jsonOrError(res: Response): Promise { if (res.ok) { @@ -43,6 +50,14 @@ export async function killSession(id: Uuid): Promise { ); } +/** List one directory's immediate subdirectories on `daemonId`'s host (NAP-329). + * `path` may be empty, `~`, relative, or absolute — the daemon resolves it + * against its own home dir. */ +export async function listDir(daemonId: string, path: string): Promise { + const url = `/api/daemons/${encodeURIComponent(daemonId)}/dirs?path=${encodeURIComponent(path)}`; + return jsonOrError(await fetch(url, { credentials: "same-origin" })); +} + export function eventsWsUrl(): string { const proto = location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${location.host}/api/events`; diff --git a/web/src/dirAutocomplete.ts b/web/src/dirAutocomplete.ts new file mode 100644 index 0000000000000000000000000000000000000000..36a03288c21b7af37724b9a33b280802716fc5ff GIT binary patch literal 8153 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 literal 0 HcmV?d00001 diff --git a/web/src/spawn.ts b/web/src/spawn.ts index 6a4d3d5..e625aba 100644 --- a/web/src/spawn.ts +++ b/web/src/spawn.ts @@ -1,4 +1,5 @@ import { createSession } from "./api.js"; +import { attachDirAutocomplete } from "./dirAutocomplete.js"; import type { CreateSessionRequest, DaemonInfo } from "./types.js"; export function openSpawnModal(daemons: Map): void { @@ -97,7 +98,21 @@ export function openSpawnModal(daemons: Map): void { select.addEventListener("change", applyHomeDir); applyHomeDir(); - const close = () => host.remove(); + // Live directory autocomplete (NAP-329) on the path fields, scoped to the + // currently-selected daemon — re-reading `select.value` per query means it + // tracks host changes for free. + const baseRepoInput = host.querySelector( + "input[name=worktree_base_repo]", + ) as HTMLInputElement; + const autocompletes = [ + attachDirAutocomplete(cwdInput, () => select.value), + attachDirAutocomplete(baseRepoInput, () => select.value), + ]; + + const close = () => { + autocompletes.forEach((a) => a.destroy()); + host.remove(); + }; host.querySelector(".close")?.addEventListener("click", close); host.querySelector(".cancel")?.addEventListener("click", close); host.addEventListener("click", (e) => { diff --git a/web/src/styles.css b/web/src/styles.css index 601c230..c8f5dc8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1052,6 +1052,52 @@ textarea { border-radius: var(--r-2); } +/* ---------- directory autocomplete (NAP-329) ---------- */ + +.dir-ac { + position: fixed; + z-index: var(--z-toast); /* above the modal it completes within */ + margin: 0; + padding: var(--sp-1); + list-style: none; + max-height: 280px; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--r-2); + box-shadow: var(--shadow-2); + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +.dir-ac-item { + padding: 6px var(--sp-2); + border-radius: var(--r-1); + color: var(--ink); + font-family: var(--font-mono); + font-size: var(--fs-14); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dir-ac-item:hover { + background: var(--surface-2); +} + +.dir-ac-item.is-active { + background: var(--accent-tint); + color: var(--ink); +} + +.dir-ac-more { + padding: 6px var(--sp-2); + color: var(--faint); + font-size: var(--fs-12); + font-style: italic; +} + /* ---------- not-found ---------- */ .not-found { diff --git a/web/src/types.ts b/web/src/types.ts index 280e440..b14560d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -27,6 +27,16 @@ export interface DaemonInfo { home_dir?: string; } +/** One directory's immediate subdirectories on a daemon's host (NAP-329). */ +export interface DirListing { + /** Resolved absolute directory that was listed (after ~/relative expansion). */ + path: string; + /** Immediate subdirectory names, sorted case-insensitively. */ + entries: string[]; + /** True when the listing was capped server-side. */ + truncated?: boolean; +} + export interface WorktreeSpec { base_repo: string; branch: string;