diff --git a/core/src/ai.rs b/core/src/ai.rs index 7891e7c..34233c2 100644 --- a/core/src/ai.rs +++ b/core/src/ai.rs @@ -42,6 +42,36 @@ pub fn home_volume(agent: &str) -> String { /// recognisable from the host. pub const GUEST_HOME: &str = "/root"; +/// The host's git identity, for the sandbox to commit with. +/// +/// Read from the host's git config rather than guessed: an agent that commits +/// as `root@bsdkrun` produces history someone has to rewrite later. +pub fn git_identity() -> (Option, Option) { + let get = |key: &str| { + std::process::Command::new("git") + .args(["config", "--get", key]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|v| !v.is_empty()) + }; + (get("user.name"), get("user.email")) +} + +/// The host's `~/.ssh`, when it has one. +/// +/// Mounted **read-only** into a sandbox so `git push` over SSH works with the +/// keys you already use. Read-only stops an agent rewriting your config or +/// authorized_keys; it does *not* stop it reading a private key, so this is a +/// deliberate trade — `--no-ssh` opts out, and a sandbox without it can still +/// clone over HTTPS. +pub fn host_ssh_dir() -> Option { + let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?; + let dir = PathBuf::from(home).join(".ssh"); + dir.is_dir().then_some(dir) +} + /// The host directory holding skills shared by every agent, and the guest path /// it is mounted at. /// @@ -377,12 +407,16 @@ pub fn project_of(vdir: &Path) -> Option { } /// The project a session belongs to: what was asked for, else the shared -/// folder's name. +/// folder's name, else the cloned repository's. /// -/// Defaulting to the workspace is what makes grouping useful without anyone -/// having to think about it — two sessions on `~/code/api` are two views of -/// the same work, whatever they are called. -pub fn resolve_project(explicit: Option<&str>, workspace: Option<&Path>) -> Option { +/// Defaulting this way is what makes grouping useful without anyone having to +/// think about it — two sessions on `~/code/api`, or two clones of the same +/// repo, are two views of the same work whatever they are called. +pub fn resolve_project( + explicit: Option<&str>, + workspace: Option<&Path>, + repo: Option<&str>, +) -> Option { explicit .map(str::trim) .filter(|p| !p.is_empty()) @@ -392,6 +426,18 @@ pub fn resolve_project(explicit: Option<&str>, workspace: Option<&Path>) -> Opti .and_then(|w| w.file_name()) .map(|n| n.to_string_lossy().into_owned()) }) + .or_else(|| repo.map(repo_project_name).filter(|n| !n.is_empty())) +} + +/// `https://github.com/owner/repo.git` → `repo`. +fn repo_project_name(url: &str) -> String { + url.trim() + .trim_end_matches('/') + .rsplit(['/', ':']) + .next() + .unwrap_or("") + .trim_end_matches(".git") + .to_string() } fn read_trimmed(path: PathBuf) -> Option { @@ -455,11 +501,17 @@ pub fn home_dir(agent: &str) -> Result { /// Order matters: the home mount has to come before the skills mount nested /// inside it, or virtio-fs mounts the parent over the child and the shared /// store disappears. -pub fn mounts(agent: &Agent, workspace: Option<&Path>) -> Result> { +pub fn mounts(agent: &Agent, workspace: Option<&Path>, ssh: bool) -> Result> { let mut specs = vec![format!("{}:{GUEST_HOME}", home_dir(agent.id)?.display())]; if let Some(skills) = host_skills_dir() { specs.push(format!("{}:{GUEST_HOME}/{SKILLS_DIR}", skills.display())); } + if ssh { + if let Some(ssh_dir) = host_ssh_dir() { + // Read-only: see `host_ssh_dir` for what that does and does not buy. + specs.push(format!("{}:{GUEST_HOME}/.ssh:ro", ssh_dir.display())); + } + } if let Some(w) = workspace { // Sharing $HOME itself would defeat the sandbox *and* collide with the // home mount above; the agent's own home is already persistent. @@ -509,6 +561,35 @@ pub fn docker_start_script() -> String { .to_string() } +/// Write the host's git identity into the sandbox, idempotently. +/// +/// In the wrapper rather than at provisioning time because the home volume is +/// shared across an agent's sessions and the host's identity can change — +/// re-stating it each start costs nothing and cannot drift. +pub fn git_identity_script() -> String { + let (name, email) = git_identity(); + let mut parts = Vec::new(); + if let Some(name) = name { + parts.push(format!( + "git config --global user.name {} 2>/dev/null || true", + shell_quote(&name) + )); + } + if let Some(email) = email { + parts.push(format!( + "git config --global user.email {} 2>/dev/null || true", + shell_quote(&email) + )); + } + if parts.is_empty() { + return "true".to_string(); + } + // Trust the shared workspace: git refuses to operate in a directory owned + // by another uid, which is exactly what a host mount looks like in here. + parts.push("git config --global --add safe.directory '*' 2>/dev/null || true".to_string()); + parts.join("; ") +} + /// The argv that opens an agent's TUI in its sandbox. /// /// Wrapped in a shell so the session can `cd` into the shared workspace and @@ -516,9 +597,13 @@ pub fn docker_start_script() -> String { /// so quitting the agent ends the session rather than dropping to a shell the /// user did not ask for. pub fn tui_argv(agent: &Agent, workspace: Option<&str>) -> Vec { + // Prefer the shared folder; otherwise fall back to whatever `--repo` + // cloned, which the boot path records in /etc/bsdkrun-cwd. Without the + // fallback an agent given a repository would start in `$HOME` and have to + // be told where its own checkout is. let cd = match workspace { Some(w) => format!("cd {} 2>/dev/null || true; ", shell_quote(w)), - None => String::new(), + None => "cd \"$(cat /etc/bsdkrun-cwd 2>/dev/null)\" 2>/dev/null || true; ".to_string(), }; let cmd = agent .command @@ -532,11 +617,14 @@ pub fn tui_argv(agent: &Agent, workspace: Option<&str>) -> Vec { format!( "export HOME={home}; export TERM=${{TERM:-xterm-256color}}; \ export PATH=\"$HOME/.local/bin:/usr/local/bin:$PATH\"; \ - {skills}; {docker}; {cd}\ + [ -d /nix/var/nix/profiles/default/bin ] && \ + export PATH=\"$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH\"; \ + {skills}; {git}; {docker}; {cd}\ if command -v {probe} >/dev/null 2>&1; then exec {cmd}; else \ echo \"[bsdkrun] {label} is not installed in this sandbox\"; exec bash; fi", home = GUEST_HOME, skills = skills_link_script(agent), + git = git_identity_script(), docker = docker_start_script(), probe = shell_quote(agent.command[0]), label = agent.label, @@ -609,6 +697,27 @@ mod tests { assert!(find("kiro").is_none()); } + #[test] + fn project_falls_back_to_the_repo_name() { + assert_eq!( + resolve_project(None, None, Some("https://github.com/owner/api.git")).as_deref(), + Some("api") + ); + assert_eq!( + resolve_project(None, None, Some("git@github.com:owner/api")).as_deref(), + Some("api") + ); + // An explicit project and a shared folder both win over the repo. + assert_eq!( + resolve_project(Some("mine"), None, Some("https://x/y.git")).as_deref(), + Some("mine") + ); + assert_eq!( + resolve_project(None, Some(Path::new("/code/web")), Some("https://x/y.git")).as_deref(), + Some("web") + ); + } + #[test] fn tui_argv_cds_into_the_workspace_and_links_skills() { let claude = find("claude").unwrap(); @@ -627,7 +736,7 @@ mod tests { #[test] fn mounts_share_home_skills_and_the_workspace_in_that_order() { let claude = find("claude").unwrap(); - let m = mounts(claude, Some(Path::new("/tmp/project"))).unwrap(); + let m = mounts(claude, Some(Path::new("/tmp/project")), false).unwrap(); // The home mount must precede the skills mount nested inside it. let home = m.iter().position(|s| s.ends_with(GUEST_HOME)).unwrap(); let skills = m.iter().position(|s| s.contains(SKILLS_DIR)).unwrap(); @@ -639,7 +748,7 @@ mod tests { fn sharing_the_whole_home_directory_is_refused() { let claude = find("claude").unwrap(); let home = std::env::var("HOME").unwrap(); - assert!(mounts(claude, Some(Path::new(&home))).is_err()); + assert!(mounts(claude, Some(Path::new(&home)), false).is_err()); } #[test] diff --git a/core/src/cli.rs b/core/src/cli.rs index 04f1acf..4b5e328 100644 --- a/core/src/cli.rs +++ b/core/src/cli.rs @@ -569,6 +569,22 @@ pub struct AiRunArgs { #[arg(long, value_name = "PROJECT")] pub project: Option, + /// Do not share the host's `~/.ssh`. + /// + /// It is shared read-only by default so `git push` works with the keys you + /// already use. An agent can *read* a private key through it, so a sandbox + /// you do not fully trust should opt out and clone over HTTPS. + #[arg(long)] + pub no_ssh: bool, + + /// Clone a git repository into the sandbox and start the agent in it. + /// + /// The clone happens *inside* the sandbox, so it needs no access to your + /// filesystem — which makes it the natural way to give an agent a codebase + /// when the engine is remote. + #[arg(long, value_name = "URL")] + pub repo: Option, + /// Start it in the background and print its id, instead of attaching. #[arg(short = 'd', long)] pub detach: bool, @@ -619,6 +635,22 @@ pub struct AiStartArgs { #[arg(long, value_name = "PROJECT")] pub project: Option, + /// Do not share the host's `~/.ssh`. + /// + /// It is shared read-only by default so `git push` works with the keys you + /// already use. An agent can *read* a private key through it, so a sandbox + /// you do not fully trust should opt out and clone over HTTPS. + #[arg(long)] + pub no_ssh: bool, + + /// Clone a git repository into the sandbox and start the agent in it. + /// + /// The clone happens *inside* the sandbox, so it needs no access to your + /// filesystem — which makes it the natural way to give an agent a codebase + /// when the engine is remote. + #[arg(long, value_name = "URL")] + pub repo: Option, + /// Start it in the background and print its id, instead of attaching. #[arg(short = 'd', long)] pub detach: bool, @@ -637,6 +669,8 @@ impl AiRunArgs { new: self.new, name: self.name, project: self.project, + repo: self.repo, + no_ssh: self.no_ssh, detach: self.detach, } } diff --git a/core/src/commands/boot.rs b/core/src/commands/boot.rs index 8f75de8..ebb4423 100644 --- a/core/src/commands/boot.rs +++ b/core/src/commands/boot.rs @@ -2661,7 +2661,7 @@ fn boot_ai_sandbox( ai::record_label(&vdir, args.name.as_deref()); ai::record_project( &vdir, - ai::resolve_project(args.project.as_deref(), workspace).as_deref(), + ai::resolve_project(args.project.as_deref(), workspace, args.repo.as_deref()).as_deref(), ); info!(agent = agent.id, sandbox = %name, "booting the agent sandbox"); @@ -2675,7 +2675,7 @@ fn boot_ai_sandbox( // what makes a second session cheap. What persists is the home volume // mounted below, holding the agent's login. volume: None, - mounts: ai::mounts(agent, workspace)?, + mounts: ai::mounts(agent, workspace, !args.no_ssh)?, attach_disk: vec![], entrypoint: None, env: vec![format!("HOME={}", ai::GUEST_HOME)], @@ -2691,10 +2691,18 @@ fn boot_ai_sandbox( cpus: args.vm.cpus, mem: args.vm.mem, }, - repo: None, + repo: args.repo.clone(), command: vec![], }; - boot_linux_from(largs, Some(built), &[])?; + // The clone runs as the post-boot command, not from `largs.repo` — that + // field only records the request; `boot_linux` is what turns it into argv, + // and this path calls `boot_linux_from` directly. + let clone = args + .repo + .as_deref() + .and_then(repo_clone_argv) + .unwrap_or_default(); + boot_linux_from(largs, Some(built), &clone)?; Ok(machine_id) } diff --git a/core/src/flavors.rs b/core/src/flavors.rs index 5e49578..6773f63 100644 --- a/core/src/flavors.rs +++ b/core/src/flavors.rs @@ -73,6 +73,20 @@ macro_rules! docker_engine { }; } +/// Determinate Nix, for an agent that needs a toolchain the sandbox lacks. +/// +/// `--init none` because the guest has no systemd for the daemon to hook +/// into; single-user mode is what works here and is what an agent running as +/// root wants anyway. `|| true` keeps a sandbox usable if the installer +/// cannot run — the agent itself is already installed by then. +macro_rules! nix_engine { + () => { + "command -v nix >/dev/null 2>&1 || \ + (curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix \ + | sh -s -- install linux --no-confirm --init none) || true" + }; +} + /// `pip!["uv"]` → cache-less pip install. macro_rules! pip { ($($pkg:literal),+ $(,)?) => { @@ -297,6 +311,7 @@ pub fn catalog() -> &'static [CatalogFlavor] { apt!["git", "ripgrep"], npm!["@anthropic-ai/claude-code"], docker_engine!(), + nix_engine!(), ], }, CatalogFlavor { @@ -307,7 +322,12 @@ pub fn catalog() -> &'static [CatalogFlavor] { ports: NONE, env: NONE, nix: NONE, - provision: provision![apt!["git"], npm!["@openai/codex"], docker_engine!(),], + provision: provision![ + apt!["git"], + npm!["@openai/codex"], + docker_engine!(), + nix_engine!(), + ], }, CatalogFlavor { name: "gemini", @@ -321,6 +341,7 @@ pub fn catalog() -> &'static [CatalogFlavor] { apt!["git", "ripgrep"], npm!["@google/gemini-cli"], docker_engine!(), + nix_engine!(), ], }, CatalogFlavor { @@ -331,7 +352,12 @@ pub fn catalog() -> &'static [CatalogFlavor] { ports: NONE, env: NONE, nix: NONE, - provision: provision![apt!["git"], npm!["@kilocode/cli"], docker_engine!(),], + provision: provision![ + apt!["git"], + npm!["@kilocode/cli"], + docker_engine!(), + nix_engine!(), + ], }, CatalogFlavor { name: "qwen", @@ -341,7 +367,12 @@ pub fn catalog() -> &'static [CatalogFlavor] { ports: NONE, env: NONE, nix: NONE, - provision: provision![apt!["git"], npm!["@qwen-code/qwen-code"], docker_engine!(),], + provision: provision![ + apt!["git"], + npm!["@qwen-code/qwen-code"], + docker_engine!(), + nix_engine!(), + ], }, CatalogFlavor { name: "opencode", @@ -358,6 +389,7 @@ pub fn catalog() -> &'static [CatalogFlavor] { " || curl -fsSL https://opencode.ai/install | bash" ), docker_engine!(), + nix_engine!(), ], }, CatalogFlavor { @@ -372,6 +404,7 @@ pub fn catalog() -> &'static [CatalogFlavor] { apt!["git", "curl"], npm!["@charmland/crush"], docker_engine!(), + nix_engine!(), ], }, CatalogFlavor { @@ -382,7 +415,12 @@ pub fn catalog() -> &'static [CatalogFlavor] { ports: NONE, env: NONE, nix: NONE, - provision: provision![apt!["git"], npm!["@github/copilot"], docker_engine!(),], + provision: provision![ + apt!["git"], + npm!["@github/copilot"], + docker_engine!(), + nix_engine!(), + ], }, // These two are *assistants*, not TUI coding agents: they run as // services and talk over messaging channels, so they are flavors to diff --git a/daemon/proto/bsdkrun.proto b/daemon/proto/bsdkrun.proto index d896ae2..18a1460 100644 --- a/daemon/proto/bsdkrun.proto +++ b/daemon/proto/bsdkrun.proto @@ -487,6 +487,8 @@ message AiStartRequest { optional string name = 6; // The project to group it under; defaults to the shared folder's name. optional string project = 7; + // Clone this git repository into the sandbox and start the agent in it. + optional string repo = 8; } message AiStartResponse { diff --git a/daemon/src/graphql.rs b/daemon/src/graphql.rs index eade288..f470467 100644 --- a/daemon/src/graphql.rs +++ b/daemon/src/graphql.rs @@ -671,6 +671,8 @@ pub struct AiStartInput { pub name: Option, /// The project to group it under. Defaults to the shared folder's name. pub project: Option, + /// Clone this git repository into the sandbox and start the agent in it. + pub repo: Option, } /// Starting the Docker engine VM. Every field is optional: the zero value is @@ -1143,6 +1145,7 @@ impl Mutation { new: input.new, name: input.name, project: input.project, + repo: input.repo, }; api(ctx)?.ops.ai_start(&opts).await.map_err(gql_err) } diff --git a/daemon/src/ops.rs b/daemon/src/ops.rs index 58a06be..9c3dbdd 100644 --- a/daemon/src/ops.rs +++ b/daemon/src/ops.rs @@ -240,6 +240,10 @@ pub struct AiStartOpts { pub name: Option, /// The project to group it under. Defaults to the shared folder's name. pub project: Option, + /// Clone this git repository into the sandbox and start the agent in it. + /// Needs no access to the caller's filesystem, which makes it the natural + /// way to hand a remote engine a codebase. + pub repo: Option, } impl AiStartOpts { @@ -256,6 +260,10 @@ impl AiStartOpts { new: self.new, name: self.name.clone(), project: self.project.clone(), + repo: self.repo.clone(), + // A daemon-started sandbox gets the *engine host's* keys, and + // only because they are the ones its git would use anyway. + no_ssh: false, detach: true, }), }) diff --git a/daemon/src/service.rs b/daemon/src/service.rs index bd71b38..376545a 100644 --- a/daemon/src/service.rs +++ b/daemon/src/service.rs @@ -641,6 +641,7 @@ impl Bsdkrun for BsdkrunService { new: r.new, name: r.name, project: r.project, + repo: r.repo, }; let machine_id = self.ops.ai_start(&opts).await?; Ok(Response::new(AiStartResponse { machine_id })) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4894dcb..e8ba75b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -259,6 +259,7 @@ async fn ai_start( workspace: Option, new_session: bool, name: Option, + repo: Option, ) -> Result { let bin = state.binary()?; let mut args: Vec = vec!["ai".into(), "start".into(), agent, "-d".into()]; @@ -266,6 +267,10 @@ async fn ai_start( args.push("--name".into()); args.push(n); } + if let Some(r) = repo.filter(|r| !r.trim().is_empty()) { + args.push("--repo".into()); + args.push(r); + } match workspace.filter(|w| !w.is_empty()) { Some(w) => { args.push("--workspace".into()); @@ -302,6 +307,7 @@ async fn launch_agent( workspace: Option, new_session: bool, name: Option, + repo: Option, ) -> Result<(), String> { let bin = state.binary().map_err(|e| e.to_string())?; let mut args: Vec = vec!["ai".into(), "start".into(), agent, "-d".into()]; @@ -309,6 +315,10 @@ async fn launch_agent( args.push("--name".into()); args.push(n); } + if let Some(r) = repo.filter(|r| !r.trim().is_empty()) { + args.push("--repo".into()); + args.push(r); + } match workspace.filter(|w| !w.is_empty()) { Some(w) => { args.push("--workspace".into()); diff --git a/desktop/src/components/AgentPanel.tsx b/desktop/src/components/AgentPanel.tsx index 1ab6b09..61e500c 100644 --- a/desktop/src/components/AgentPanel.tsx +++ b/desktop/src/components/AgentPanel.tsx @@ -13,6 +13,7 @@ import { IconLayoutList, IconArrowsMinimize, IconChevronDown, + IconBrandGit, IconFolder, IconFolderOff, IconPlus, @@ -29,9 +30,10 @@ import { } from "../state/atoms"; import { useAiAgents, useAttachAgentSession, useStartAgent } from "../lib/queries"; import { useToast } from "../state/toast"; -import { pickWorkspace } from "../lib/api"; +import { HAS_NATIVE_FOLDER_PICKER, pickWorkspace } from "../lib/api"; import TerminalPane from "./TerminalPane"; import AgentSessionPicker from "./AgentSessionPicker"; +import AgentPromptModal from "./AgentPromptModal"; const MIN_W = 340; @@ -58,6 +60,9 @@ export default function AgentPanel() { const startAgent = useStartAgent(); const attachSession = useAttachAgentSession(); const [pickerOpen, setPickerOpen] = useState(false); + const [prompt, setPrompt] = useState<"repo" | "name" | "workspace" | null>( + null, + ); const toast = useToast(); const agent = agents.find((a) => a.id === agentId); @@ -66,10 +71,16 @@ export default function AgentPanel() { const [starting, setStarting] = useState(false); const start = useCallback( - async (opts?: { newSession?: boolean; name?: string }) => { + async (opts?: { newSession?: boolean; name?: string; repo?: string }) => { setStarting(true); try { - await startAgent(agentId, workspace, opts?.newSession ?? false, opts?.name); + await startAgent( + agentId, + workspace, + opts?.newSession ?? false, + opts?.name, + opts?.repo, + ); } catch (e) { toast("error", "Could not start the agent", String(e)); } finally { @@ -79,13 +90,24 @@ export default function AgentPanel() { [agentId, workspace, startAgent, toast], ); + /** Apply a chosen folder: a different folder is a different sandbox. */ + const useFolder = async (dir: string) => { + setWorkspace(dir); + // Restart so the agent actually sees it, rather than leaving a stale mount + // behind the same prompt. + if (session) await startAgent(agentId, dir, true); + }; + const chooseFolder = async () => { + // Native dialog where there is one; otherwise the same modal the rest of + // the panel uses, since a browser cannot hand back a filesystem path. + if (!HAS_NATIVE_FOLDER_PICKER) { + setPrompt("workspace"); + return; + } const dir = await pickWorkspace(); if (dir === null) return; // cancelled - setWorkspace(dir); - // A different folder is a different sandbox: restart so the agent actually - // sees it, rather than leaving a stale mount behind the same prompt. - if (session) await startAgent(agentId, dir, true); + await useFolder(dir); }; // Drag the left edge to resize. @@ -201,6 +223,17 @@ export default function AgentPanel() {
+ + + @@ -281,6 +307,44 @@ export default function AgentPanel() { )}
+ setPrompt(null)} + onSubmit={(dir) => { + useFolder(dir).catch((e) => + toast("error", "Could not share that folder", String(e)), + ); + }} + /> + + setPrompt(null)} + onSubmit={(repo) => start({ newSession: true, repo })} + /> + + setPrompt(null)} + onSubmit={(name) => start({ newSession: true, name: name || undefined })} + /> + setPickerOpen(false)} diff --git a/desktop/src/components/AgentPromptModal.tsx b/desktop/src/components/AgentPromptModal.tsx new file mode 100644 index 0000000..b66d358 --- /dev/null +++ b/desktop/src/components/AgentPromptModal.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { Modal, ModalContent, Kbd } from "@heroui/react"; +import type { Icon } from "@tabler/icons-react"; + +/** + * A one-line prompt in the same chrome as the command palette: an icon, a + * borderless input, ↵ to accept and esc to cancel. + * + * Exists because `window.prompt` blocks the whole webview, cannot be styled, + * and looks like a different application — three reasons that all matter when + * the thing being asked for is part of a flow (clone this repo, name this + * session) rather than an interruption. + */ +export default function AgentPromptModal({ + open, + title, + placeholder, + icon: IconComponent, + hint, + initialValue = "", + submitLabel = "↵ confirm", + allowEmpty = false, + onSubmit, + onClose, +}: { + open: boolean; + title: string; + placeholder: string; + icon: Icon; + /** A line under the input — what the value will do. */ + hint?: string; + initialValue?: string; + submitLabel?: string; + /** Accept an empty value (naming is optional; a repo URL is not). */ + allowEmpty?: boolean; + onSubmit: (value: string) => void; + onClose: () => void; +}) { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + if (open) setValue(initialValue); + }, [open, initialValue]); + + const submit = () => { + const trimmed = value.trim(); + if (!trimmed && !allowEmpty) return; + onSubmit(trimmed); + onClose(); + }; + + return ( + + +
{ + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } + }} + > +
+ + setValue(e.target.value)} + placeholder={placeholder} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-foreground-500" + /> + esc +
+
+ {hint ?? title} + {submitLabel} +
+
+
+
+ ); +} diff --git a/desktop/src/components/AgentSessionPicker.tsx b/desktop/src/components/AgentSessionPicker.tsx index 4c49e96..899c062 100644 --- a/desktop/src/components/AgentSessionPicker.tsx +++ b/desktop/src/components/AgentSessionPicker.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Modal, ModalContent, Input, Tooltip } from "@heroui/react"; +import { Modal, ModalContent, Kbd, Tooltip } from "@heroui/react"; import { useAtomValue } from "jotai"; import { IconFolder, @@ -141,32 +141,36 @@ export default function AgentSessionPicker({
-
- + + - } - classNames={{ inputWrapper: "border-white/10" }} + onChange={(e) => setQuery(e.target.value)} + placeholder="Search sessions and projects…" + className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-foreground-500" /> + esc
-
+
{pickable.length === 0 ? ( -

+

{sessions.length === 0 ? "No sessions yet. Start one from the panel." : "Nothing matches that."} @@ -179,7 +183,7 @@ export default function AgentSessionPicker({ return (

{row.project}
diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts index 0510b0e..8285fc7 100644 --- a/desktop/src/lib/api.ts +++ b/desktop/src/lib/api.ts @@ -66,7 +66,15 @@ export const api = { workspace: string | null, newSession: boolean, name?: string, - ) => invoke("ai_start", { agent, workspace, newSession, name: name ?? null }), + repo?: string, + ) => + invoke("ai_start", { + agent, + workspace, + newSession, + name: name ?? null, + repo: repo ?? null, + }), /** Same, but streams the first-run flavor build into the progress modal. */ launchAgent: ( launchId: string, @@ -74,6 +82,7 @@ export const api = { workspace: string | null, newSession: boolean, name?: string, + repo?: string, ) => invoke("launch_agent", { launchId, @@ -81,6 +90,7 @@ export const api = { workspace, newSession, name: name ?? null, + repo: repo ?? null, }), /** The argv that starts the agent's TUI in a sandbox. */ aiShellCommand: (agent: string, machineId: string) => @@ -200,6 +210,9 @@ export const onFlavorLog = (cb: (p: FlavorLog) => void): Promise => listen("flavor://log", (e) => cb(e.payload)); export const onFlavorDone = (cb: (p: FlavorDone) => void): Promise => listen("flavor://done", (e) => cb(e.payload)); +/** This build can open a native directory picker (see `pickWorkspace`). */ +export const HAS_NATIVE_FOLDER_PICKER = true; + /** * Ask the user for a folder to share with an agent. `null` when cancelled — * distinct from `""`, which would mean "share nothing". diff --git a/desktop/src/lib/queries.ts b/desktop/src/lib/queries.ts index 4c45110..ff74cfa 100644 --- a/desktop/src/lib/queries.ts +++ b/desktop/src/lib/queries.ts @@ -329,6 +329,7 @@ export function useStartAgent() { workspace: string | null, newSession: boolean, name?: string, + repo?: string, ) => { setSelected(agent); setOpen(true); @@ -341,8 +342,16 @@ export function useStartAgent() { const label = info?.label ?? agent; const machineId = info?.installed - ? await api.aiStart(agent, workspace, newSession, name) - : await streamInstall(setLaunch, label, agent, workspace, newSession, name); + ? await api.aiStart(agent, workspace, newSession, name, repo) + : await streamInstall( + setLaunch, + label, + agent, + workspace, + newSession, + name, + repo, + ); const command = await api.aiShellCommand(agent, machineId); setSession({ @@ -371,6 +380,7 @@ function streamInstall( workspace: string | null, newSession: boolean, name?: string, + repo?: string, ): Promise { const launchId = `agent-${agent}-${Date.now()}`; setLaunch({ @@ -393,7 +403,7 @@ function streamInstall( }).then((u) => { unlisten = u; }); - api.launchAgent(launchId, agent, workspace, newSession, name).catch((e) => { + api.launchAgent(launchId, agent, workspace, newSession, name, repo).catch((e) => { unlisten?.(); reject(e); }); diff --git a/web/src/components/AgentPanel.tsx b/web/src/components/AgentPanel.tsx index 1ab6b09..61e500c 100644 --- a/web/src/components/AgentPanel.tsx +++ b/web/src/components/AgentPanel.tsx @@ -13,6 +13,7 @@ import { IconLayoutList, IconArrowsMinimize, IconChevronDown, + IconBrandGit, IconFolder, IconFolderOff, IconPlus, @@ -29,9 +30,10 @@ import { } from "../state/atoms"; import { useAiAgents, useAttachAgentSession, useStartAgent } from "../lib/queries"; import { useToast } from "../state/toast"; -import { pickWorkspace } from "../lib/api"; +import { HAS_NATIVE_FOLDER_PICKER, pickWorkspace } from "../lib/api"; import TerminalPane from "./TerminalPane"; import AgentSessionPicker from "./AgentSessionPicker"; +import AgentPromptModal from "./AgentPromptModal"; const MIN_W = 340; @@ -58,6 +60,9 @@ export default function AgentPanel() { const startAgent = useStartAgent(); const attachSession = useAttachAgentSession(); const [pickerOpen, setPickerOpen] = useState(false); + const [prompt, setPrompt] = useState<"repo" | "name" | "workspace" | null>( + null, + ); const toast = useToast(); const agent = agents.find((a) => a.id === agentId); @@ -66,10 +71,16 @@ export default function AgentPanel() { const [starting, setStarting] = useState(false); const start = useCallback( - async (opts?: { newSession?: boolean; name?: string }) => { + async (opts?: { newSession?: boolean; name?: string; repo?: string }) => { setStarting(true); try { - await startAgent(agentId, workspace, opts?.newSession ?? false, opts?.name); + await startAgent( + agentId, + workspace, + opts?.newSession ?? false, + opts?.name, + opts?.repo, + ); } catch (e) { toast("error", "Could not start the agent", String(e)); } finally { @@ -79,13 +90,24 @@ export default function AgentPanel() { [agentId, workspace, startAgent, toast], ); + /** Apply a chosen folder: a different folder is a different sandbox. */ + const useFolder = async (dir: string) => { + setWorkspace(dir); + // Restart so the agent actually sees it, rather than leaving a stale mount + // behind the same prompt. + if (session) await startAgent(agentId, dir, true); + }; + const chooseFolder = async () => { + // Native dialog where there is one; otherwise the same modal the rest of + // the panel uses, since a browser cannot hand back a filesystem path. + if (!HAS_NATIVE_FOLDER_PICKER) { + setPrompt("workspace"); + return; + } const dir = await pickWorkspace(); if (dir === null) return; // cancelled - setWorkspace(dir); - // A different folder is a different sandbox: restart so the agent actually - // sees it, rather than leaving a stale mount behind the same prompt. - if (session) await startAgent(agentId, dir, true); + await useFolder(dir); }; // Drag the left edge to resize. @@ -201,6 +223,17 @@ export default function AgentPanel() {
+ + + @@ -281,6 +307,44 @@ export default function AgentPanel() { )}
+ setPrompt(null)} + onSubmit={(dir) => { + useFolder(dir).catch((e) => + toast("error", "Could not share that folder", String(e)), + ); + }} + /> + + setPrompt(null)} + onSubmit={(repo) => start({ newSession: true, repo })} + /> + + setPrompt(null)} + onSubmit={(name) => start({ newSession: true, name: name || undefined })} + /> + setPickerOpen(false)} diff --git a/web/src/components/AgentPromptModal.tsx b/web/src/components/AgentPromptModal.tsx new file mode 100644 index 0000000..b66d358 --- /dev/null +++ b/web/src/components/AgentPromptModal.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { Modal, ModalContent, Kbd } from "@heroui/react"; +import type { Icon } from "@tabler/icons-react"; + +/** + * A one-line prompt in the same chrome as the command palette: an icon, a + * borderless input, ↵ to accept and esc to cancel. + * + * Exists because `window.prompt` blocks the whole webview, cannot be styled, + * and looks like a different application — three reasons that all matter when + * the thing being asked for is part of a flow (clone this repo, name this + * session) rather than an interruption. + */ +export default function AgentPromptModal({ + open, + title, + placeholder, + icon: IconComponent, + hint, + initialValue = "", + submitLabel = "↵ confirm", + allowEmpty = false, + onSubmit, + onClose, +}: { + open: boolean; + title: string; + placeholder: string; + icon: Icon; + /** A line under the input — what the value will do. */ + hint?: string; + initialValue?: string; + submitLabel?: string; + /** Accept an empty value (naming is optional; a repo URL is not). */ + allowEmpty?: boolean; + onSubmit: (value: string) => void; + onClose: () => void; +}) { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + if (open) setValue(initialValue); + }, [open, initialValue]); + + const submit = () => { + const trimmed = value.trim(); + if (!trimmed && !allowEmpty) return; + onSubmit(trimmed); + onClose(); + }; + + return ( + + +
{ + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } + }} + > +
+ + setValue(e.target.value)} + placeholder={placeholder} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-foreground-500" + /> + esc +
+
+ {hint ?? title} + {submitLabel} +
+
+
+
+ ); +} diff --git a/web/src/components/AgentSessionPicker.tsx b/web/src/components/AgentSessionPicker.tsx index 4c49e96..899c062 100644 --- a/web/src/components/AgentSessionPicker.tsx +++ b/web/src/components/AgentSessionPicker.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Modal, ModalContent, Input, Tooltip } from "@heroui/react"; +import { Modal, ModalContent, Kbd, Tooltip } from "@heroui/react"; import { useAtomValue } from "jotai"; import { IconFolder, @@ -141,32 +141,36 @@ export default function AgentSessionPicker({
-
- + + - } - classNames={{ inputWrapper: "border-white/10" }} + onChange={(e) => setQuery(e.target.value)} + placeholder="Search sessions and projects…" + className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-foreground-500" /> + esc
-
+
{pickable.length === 0 ? ( -

+

{sessions.length === 0 ? "No sessions yet. Start one from the panel." : "Nothing matches that."} @@ -179,7 +183,7 @@ export default function AgentSessionPicker({ return (

{row.project}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 975a56a..4fffee2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -534,6 +534,7 @@ export const api = { workspace: string | null, newSession: boolean, name?: string, + repo?: string, ): Promise => { const d = await gql<{ aiStart: string }>( `mutation($i:AiStartInput!){ aiStart(input:$i) }`, @@ -543,6 +544,7 @@ export const api = { workspace: orNull(workspace ?? undefined), new: newSession, name: orNull(name), + repo: orNull(repo), }, }, ); @@ -560,8 +562,9 @@ export const api = { workspace: string | null, newSession: boolean, name?: string, + repo?: string, ): Promise => { - await api.aiStart(agent, workspace, newSession, name); + await api.aiStart(agent, workspace, newSession, name, repo); }, /** The argv that starts the agent's TUI in a sandbox. */ @@ -1053,20 +1056,15 @@ export const onFlavorDone = (cb: (p: FlavorDone) => void) => listen("flavor://done", cb); /** Native menus are a desktop-shell feature; nothing emits this on the web. */ /** - * Ask for a folder to share with an agent. - * - * A browser cannot open a native directory picker that yields a *path*, and - * the path has to exist on the daemon's host anyway — which is not this - * machine when the daemon is remote. So this asks for the path directly, and - * says whose filesystem it means. + * A browser cannot open a directory picker that yields a *path*, and the path + * has to exist on the daemon's host anyway — which is not this machine when + * the daemon is remote. The panel asks for it in a modal instead. */ +export const HAS_NATIVE_FOLDER_PICKER = false; + +/** Never called in this build; see `HAS_NATIVE_FOLDER_PICKER`. */ export async function pickWorkspace(): Promise { - const answer = window.prompt( - "Folder to share with the agent.\n\nThis path is on the machine running bsdkrund, not on this computer.", - "", - ); - const trimmed = answer?.trim(); - return trimmed ? trimmed : null; + return null; } export const onMenuAction = (_cb: (action: string) => void): Promise => diff --git a/web/src/lib/queries.ts b/web/src/lib/queries.ts index 700137d..936b66b 100644 --- a/web/src/lib/queries.ts +++ b/web/src/lib/queries.ts @@ -329,6 +329,7 @@ export function useStartAgent() { workspace: string | null, newSession: boolean, name?: string, + repo?: string, ) => { setSelected(agent); setOpen(true); @@ -341,8 +342,16 @@ export function useStartAgent() { const label = info?.label ?? agent; const machineId = info?.installed - ? await api.aiStart(agent, workspace, newSession, name) - : await streamInstall(setLaunch, label, agent, workspace, newSession, name); + ? await api.aiStart(agent, workspace, newSession, name, repo) + : await streamInstall( + setLaunch, + label, + agent, + workspace, + newSession, + name, + repo, + ); const command = await api.aiShellCommand(agent, machineId); setSession({ @@ -371,6 +380,7 @@ function streamInstall( workspace: string | null, newSession: boolean, name?: string, + repo?: string, ): Promise { const launchId = `agent-${agent}-${Date.now()}`; setLaunch({ @@ -393,7 +403,7 @@ function streamInstall( }).then((u) => { unlisten = u; }); - api.launchAgent(launchId, agent, workspace, newSession, name).catch((e) => { + api.launchAgent(launchId, agent, workspace, newSession, name, repo).catch((e) => { unlisten?.(); reject(e); });