diff --git a/CHANGELOG.md b/CHANGELOG.md index ef203eb..c44f0b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ All notable changes to LLeMbas Desktop. The format follows ## [Unreleased] +### Added + +- **Project folders**: Settings… (in the title bar's and the tray's menu) lists the folders the + agent may work in, with *Add folder…* (the system's folder dialog), *Remove*, the most an agent + chat may do there, and whether the server may open a terminal. Turning the agent on with no + folder chosen opens this page. Changes go through the bundled CLI (`lembas remote`). +- The server is told it is shown in LLeMbas Desktop and on which computer, so it lists this + computer's device as **Local**, first. + +### Fixed + +- The title bar's menu opens under the button, not in the middle of the window. +- The agent no longer shows a green light while it may open no folder: the title bar says + *agent: no folders*. + ## [0.3.0] — 2026-10-14 ### Added diff --git a/README.md b/README.md index dd1624c..345dc9b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ built into the app before anything is installed. 1. Start LLeMbas and enter your server's address. Sign in there as you would in a browser. 2. From the tray, **Link this computer**. The approval page opens in the window; approve it. -3. The agent starts. In the server's new-chat screen, choose this computer for an agent chat. +3. The agent starts. Choose the folders it may work in (Settings…, *Add folder…*); a new + computer allows none until you do. +4. In the server's Agents tab, start an agent chat on **Local** and pick one of those folders. The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration (`~/.config/lembas`). The app's copy of the CLI lives in its own directory diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4b73f03..c3ec135 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2064,7 +2064,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "llembas-desktop" -version = "0.3.0" +version = "0.4.0" dependencies = [ "gtk", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc3a5e5..833b85d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "llembas-desktop" -version = "0.3.0" +version = "0.4.0" description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer" authors = ["Jaroslav Beneš"] license = "MIT" diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index c446dc0..95956ab 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -108,6 +108,25 @@ fn running_in(state: &Path) -> Option { (pid != 0 && alive(pid)).then_some(pid) } +/// This computer's host name: what the CLI sent the server as `device_host` at login +/// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one. +#[cfg(unix)] +pub fn hostname() -> Option { + let mut buf = [0u8; 256]; + let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; + if r != 0 { + return None; + } + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let name = String::from_utf8_lossy(&buf[..end]).trim().to_string(); + (!name.is_empty()).then_some(name) +} + +#[cfg(not(unix))] +pub fn hostname() -> Option { + std::env::var("COMPUTERNAME").ok().filter(|s| !s.is_empty()) +} + #[cfg(unix)] pub fn alive(pid: u32) -> bool { // Signal 0 checks without sending; EPERM means it exists but is someone else's. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 96aa080..77a7e14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod agent; mod cli; +mod remote; mod settings; use std::io::{BufRead, BufReader}; @@ -34,6 +35,7 @@ use tauri_plugin_opener::OpenerExt; use tauri_plugin_updater::UpdaterExt; use agent::{Agent, State as AgentState}; +use remote::Remote; use settings::Settings; const MAIN: &str = "main"; @@ -53,6 +55,8 @@ struct App { linking: Mutex, /// The newer release the last check found, if any. update: Mutex>, + /// The agent's folders, as `lembas remote` last answered. + remote: Mutex>, } struct TrayItems { @@ -84,6 +88,8 @@ struct Status { agent_wanted: bool, /// A newer release of the app, when the last check found one. update: Option, + /// Which folders the agent may open sessions in (None: not known yet). + remote: Option, } fn status(app: &App) -> Status { @@ -95,6 +101,7 @@ fn status(app: &App) -> Status { agent: app.agent.state(), agent_wanted: app.agent.wanted(), update: app.update.lock().unwrap().clone(), + remote: app.remote.lock().unwrap().clone(), } } @@ -159,12 +166,134 @@ fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> { let app = handle.state::(); let s = app.update(|s| s.agent = on)?; match (on, s.server) { - (true, Some(server)) => app.agent.start(server), + (true, Some(server)) => { + app.agent.start(server); + let h = handle.clone(); + std::thread::spawn(move || { + // Connected but allowed into no folder is the state a new computer starts in: + // say so where the folders are chosen, rather than showing a green light. + if let Some(r) = refresh_remote(&h) { + if !r.usable() { + let _ = h.run_on_main_thread({ + let h = h.clone(); + move || { + let _ = open_main(&h, None); + } + }); + } + } + }); + } _ => app.agent.stop(), } Ok(()) } +/// Ask the CLI which folders the agent may use; remember and announce it. +fn refresh_remote(handle: &AppHandle) -> Option { + let r = remote::run(&["list"]).ok(); + *handle.state::().remote.lock().unwrap() = r.clone(); + let _ = handle.emit("status", status_of(handle)); + r +} + +/// After a change: remember the new state, and start the app's own agent again when the CLI +/// says the running service cannot take the change live. +fn remote_changed(handle: &AppHandle, r: Remote) { + let app = handle.state::(); + *app.remote.lock().unwrap() = Some(r.clone()); + if r.restart_needed && app.agent.wanted() { + if let Some(server) = app.settings().server { + app.agent.start(server); + } + } + let _ = handle.emit("status", status_of(handle)); +} + +/// Run a `lembas remote` change off the main thread; the page hears the outcome as `status`, +/// or as `remote-error`. +fn change_remote(handle: &AppHandle, args: Vec) { + let h = handle.clone(); + std::thread::spawn(move || { + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + match remote::run(&refs) { + Ok(r) => remote_changed(&h, r), + Err(e) => { + let _ = h.emit("remote-error", e); + } + } + }); +} + +#[tauri::command] +fn remote_refresh(handle: AppHandle) { + std::thread::spawn(move || { + refresh_remote(&handle); + }); +} + +/// Choose a folder with the system's dialog and let the agent work in it (this turns remote +/// control on, if it was off). +#[tauri::command] +fn remote_add(handle: AppHandle) { + let h = handle.clone(); + handle + .dialog() + .file() + .set_title("A folder the agent may work in") + .pick_folder(move |picked| { + if let Some(path) = picked.and_then(|p| p.into_path().ok()) { + change_remote(&h, vec!["add".into(), path.to_string_lossy().into_owned()]); + } + }); +} + +#[tauri::command] +fn remote_remove(handle: AppHandle, dir: String) { + change_remote(&handle, vec!["remove".into(), dir]); +} + +#[tauri::command] +fn remote_mode(handle: AppHandle, mode: String) -> Result<(), String> { + if !["manual", "edit", "auto", "plan"].contains(&mode.as_str()) { + return Err(format!("no such mode: {mode}")); + } + change_remote(&handle, vec!["mode".into(), mode]); + Ok(()) +} + +#[tauri::command] +fn remote_terminal(handle: AppHandle, on: bool) { + change_remote( + &handle, + vec!["terminal".into(), if on { "on" } else { "off" }.into()], + ); +} + +/// The server's pages are told they are in LLeMbas Desktop, and on which computer: it shows +/// the device that is this computer as "Local". A label only — it grants nothing. +fn user_agent() -> String { + let platform = if cfg!(target_os = "linux") { + "X11; Linux x86_64" + } else if cfg!(target_os = "macos") { + "Macintosh; Intel Mac OS X 10_15_7" + } else { + "Windows NT 10.0; Win64; x64" + }; + let host = cli::hostname() + .filter(|h| { + h.len() <= 253 + && h.chars() + .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c)) + }) + .map(|h| format!(" (host={h})")) + .unwrap_or_default(); + format!( + "Mozilla/5.0 ({platform}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 LLeMbasDesktop/{}{host}", + env!("CARGO_PKG_VERSION") + ) +} + /// The app's own first-run page, as the webview addresses it on this platform. fn local_page() -> Url { let base = if cfg!(windows) { @@ -227,7 +356,9 @@ fn build_main(handle: &AppHandle, target: Option) -> tauri::Result<()> { } let nav = handle.clone(); window.add_child( - WebviewBuilder::new(CONTENT, url).on_navigation(move |url| allowed(&nav, url)), + WebviewBuilder::new(CONTENT, url) + .user_agent(&user_agent()) + .on_navigation(move |url| allowed(&nav, url)), LogicalPosition::new(FRAME, FRAME + TITLE_H), LogicalSize::new( size.width - 2.0 * FRAME, @@ -439,7 +570,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result { )?; let toggle = MenuItem::with_id(handle, "toggle", "Start the agent", true, None::<&str>)?; let link = MenuItem::with_id(handle, "link", "Link this computer…", true, None::<&str>)?; - let server = MenuItem::with_id(handle, "server", "Change server…", true, None::<&str>)?; + let server = MenuItem::with_id(handle, "server", "Settings…", true, None::<&str>)?; let quit = MenuItem::with_id(handle, "quit", "Quit LLeMbas", true, None::<&str>)?; let menu = Menu::with_items( handle, @@ -610,8 +741,11 @@ fn on_menu(h: &AppHandle, id: &str) { /// The title bar's menu: the tray's items, where the window is (a desktop without a tray — /// GNOME without the AppIndicator extension — has no other way to reach them). +/// The title bar's menu, opened under the button that asked for it: `x`, `y` are the button's +/// bottom-left corner in the title bar page. (A plain popup opens at the pointer, which a Wayland +/// compositor does not tell the app — it landed in the middle of the window.) #[tauri::command] -fn titlebar_menu(window: Window) -> Result<(), String> { +fn titlebar_menu(window: Window, x: f64, y: f64) -> Result<(), String> { let h = window.app_handle(); let app = h.state::(); let m = |id: &str, text: &str, on: bool| MenuItem::with_id(h, id, text, on, None::<&str>); @@ -632,7 +766,7 @@ fn titlebar_menu(window: Window) -> Result<(), String> { None => m("update", "Check for updates…", true)?, }; let reload = m("reload", "Reload", true)?; - let server = m("server", "Change server…", true)?; + let server = m("server", "Settings…", true)?; let quit = m("quit", "Quit LLeMbas", true)?; Menu::with_items( h, @@ -650,7 +784,13 @@ fn titlebar_menu(window: Window) -> Result<(), String> { ) }; let menu = build().map_err(|e| e.to_string())?; - window.popup_menu(&menu).map_err(|e| e.to_string()) + // The title bar sits inside the frame while the window is not maximized. + let framed = + !(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false)); + let inset = if framed { FRAME } else { 0.0 }; + window + .popup_menu_at(&menu, LogicalPosition::new(x + inset, y + inset)) + .map_err(|e| e.to_string()) } fn on_agent_state(handle: &AppHandle, state: &AgentState) { @@ -678,7 +818,12 @@ pub fn run() { set_agent, link_computer, titlebar_menu, - update_app + update_app, + remote_refresh, + remote_add, + remote_remove, + remote_mode, + remote_terminal ]) .on_menu_event(|h, ev| on_menu(h, ev.id().as_ref())) .setup(|app| { @@ -703,11 +848,18 @@ pub fn run() { tray: Mutex::new(None), linking: Mutex::new(false), update: Mutex::new(None), + remote: Mutex::new(None), }); let items = build_tray(&handle)?; *handle.state::().tray.lock().unwrap() = Some(items); open_main(&handle, loaded.server.as_deref())?; watch_updates(handle.clone()); + { + let h = handle.clone(); + std::thread::spawn(move || { + refresh_remote(&h); + }); + } if let (true, Some(server)) = (loaded.agent, loaded.server) { handle.state::().agent.start(server); } diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs new file mode 100644 index 0000000..0a1cc12 --- /dev/null +++ b/src-tauri/src/remote.rs @@ -0,0 +1,77 @@ +//! Which folders the agent may work in: the CLI's `remote` settings, read and changed through +//! `lembas remote … --json` so the app never edits the CLI's files itself. A freshly linked +//! computer allows no folder at all (remote control is off and `roots` is empty), so the agent +//! is connected but every agent chat on it is refused until at least one folder is added. + +use std::process::{Command, Stdio}; + +use serde::{Deserialize, Serialize}; + +use crate::cli; + +/// `lembas remote list --json`, and what every changing command answers with. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Remote { + pub enabled: bool, + pub roots: Vec, + pub max_mode: String, + pub terminal: bool, + /// The running service must start again for the change to apply. + pub restart_needed: bool, +} + +impl Remote { + /// Agent chats can be opened here at all. + pub fn usable(&self) -> bool { + self.enabled && !self.roots.is_empty() + } +} + +/// Run `lembas remote --json`: the new state, or what the CLI said went wrong. +pub fn run(args: &[&str]) -> Result { + let out = Command::new(cli::binary()) + .arg("remote") + .args(args) + .arg("--json") + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("Could not start {}: {e}", cli::binary().display()))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(if err.is_empty() { + format!("lembas remote {} failed ({})", args.join(" "), out.status) + } else { + err.trim_start_matches("lembas: ").to_string() + }); + } + parse(&String::from_utf8_lossy(&out.stdout)) +} + +/// The last JSON object the CLI printed (it may say a word before it). +fn parse(stdout: &str) -> Result { + stdout + .lines() + .rev() + .find_map(|l| serde_json::from_str::(l.trim()).ok()) + .ok_or_else(|| "lembas remote gave no answer this version of the app understands — is the bundled LLeMbas CLI too old?".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_state_and_defaults_what_is_missing() { + let r = parse( + "added /home/a/project\n{\"enabled\":true,\"roots\":[\"/home/a/project\"],\"max_mode\":\"edit\",\"terminal\":false}\n", + ) + .unwrap(); + assert!(r.usable()); + assert_eq!(r.roots, vec!["/home/a/project"]); + assert!(!r.restart_needed); + let off = parse("{\"enabled\":false,\"roots\":[]}").unwrap(); + assert!(!off.usable()); + assert!(parse("lembas: unknown command remote").is_err()); + } +} diff --git a/ui/app.css b/ui/app.css index 28ccd05..5f2aa8c 100644 --- a/ui/app.css +++ b/ui/app.css @@ -33,7 +33,7 @@ p { margin: 0; } /* A field is three stacked parts on fixed rows: label, control row, hint. */ .field { display: grid; grid-template-rows: auto var(--control-h) auto; gap: var(--sp-2); } -label { font: var(--text-sm) / 1.4 var(--font-ui); color: var(--ink-muted); } +label, .label { font: var(--text-sm) / 1.4 var(--font-ui); color: var(--ink-muted); } .hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: 1.4em; } .hint.is-error, .status.is-error { color: var(--warning); } @@ -77,3 +77,53 @@ input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outl .row { grid-template-columns: 1fr; grid-template-rows: var(--control-h) var(--control-h); } .field { grid-template-rows: auto auto auto; } } + +/* The folder list: every row the same two tracks, the path cut from the left (its end is the + part that tells folders apart), the full path in the title. */ +.folders { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); } +.folders li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--sp-2); + align-items: center; + min-height: var(--control-h); + padding: 0 0 0 var(--sp-3); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.folders .path { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + direction: rtl; + text-align: left; + font: var(--text-sm) var(--font-ui); +} +.folders .path bdi { direction: ltr; unicode-bidi: isolate; } +.folders .btn { height: calc(var(--control-h) - 2px); box-shadow: none; border-width: 0 0 0 1px; } +.folders .empty { padding: var(--sp-3); color: var(--warning); font-size: var(--text-sm); } + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + grid-auto-rows: auto var(--control-h) auto; + column-gap: var(--sp-4); + row-gap: var(--sp-2); + margin-top: var(--sp-4); +} +/* Each field spans three shared rows (label, control, hint), so the controls of side-by-side + fields sit on one line whatever their labels and hints do. */ +.settings-grid .field { grid-row: span 3; grid-template-rows: subgrid; } +select { + height: var(--control-h); + min-width: 0; + padding: 0 var(--sp-2); + font: var(--text-sm) var(--font-ui); + color: var(--ink); + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius); +} +.check { display: flex; align-items: center; gap: var(--sp-2); color: var(--ink); } +.check input { width: 1.1rem; height: 1.1rem; margin: 0; accent-color: var(--accent); } diff --git a/ui/app.js b/ui/app.js index 454e9ad..d3cc397 100644 --- a/ui/app.js +++ b/ui/app.js @@ -20,13 +20,53 @@ function render(s) { $("version").textContent = s.version if (s.server && !$("address").value) $("address").value = s.server $("agent-section").hidden = !s.server - const [text, cls] = (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail) + const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length) + const [text, cls] = + s.agent.state === "running" && noFolders + ? ["The agent is connected, but may open no folder yet: add one below.", "is-error"] + : (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail) const status = $("agent-status") status.textContent = text status.className = `status ${cls}` $("link").textContent = s.linked ? "Link again" : "Link this computer" $("toggle").textContent = s.agent_wanted ? "Stop the agent" : "Start the agent" $("toggle").disabled = !s.linked && !s.agent_wanted + renderFolders(s) +} + +function renderFolders(s) { + const r = s.remote + $("folders-section").hidden = !s.server || !s.linked || !r + if (!r) return + const list = $("folders") + list.replaceChildren() + if (!r.enabled || !r.roots.length) { + const li = document.createElement("li") + li.className = "empty" + li.textContent = "No folder yet: the agent can open nothing. Add the folder of a project." + list.append(li) + } + for (const dir of r.enabled ? r.roots : []) { + const li = document.createElement("li") + const path = document.createElement("span") + path.className = "path" + path.title = dir + const inner = document.createElement("bdi") + inner.textContent = dir + path.append(inner) + const remove = document.createElement("button") + remove.type = "button" + remove.className = "btn" + remove.textContent = "Remove" + remove.setAttribute("aria-label", `Remove ${dir}`) + remove.addEventListener("click", () => invoke("remote_remove", { dir })) + li.append(path, remove) + list.append(li) + } + if (r.max_mode) $("max-mode").value = r.max_mode + $("terminal").checked = !!r.terminal + $("folders-status").className = "status" + $("folders-status").textContent = "" } async function refresh() { @@ -70,5 +110,14 @@ $("open").addEventListener("click", async () => { if (s.server) await invoke("set_server", { address: s.server }) }) +$("add-folder").addEventListener("click", () => invoke("remote_add")) +$("max-mode").addEventListener("change", (e) => invoke("remote_mode", { mode: e.target.value })) +$("terminal").addEventListener("change", (e) => invoke("remote_terminal", { on: e.target.checked })) + +listen("remote-error", (e) => { + $("folders-status").className = "status is-error" + $("folders-status").textContent = String(e.payload) +}) listen("status", (e) => render(e.payload)) refresh() +invoke("remote_refresh") diff --git a/ui/index.html b/ui/index.html index cb23648..235b87b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -43,6 +43,35 @@ + + diff --git a/ui/titlebar.css b/ui/titlebar.css index 2f5bf64..f78b39e 100644 --- a/ui/titlebar.css +++ b/ui/titlebar.css @@ -64,7 +64,8 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); } .bar__agent[data-state="starting"] .dot { background: var(--accent); } .bar__agent[data-state="failed"] .dot, -.bar__agent[data-state="not_linked"] .dot { background: var(--warning); } +.bar__agent[data-state="not_linked"] .dot, +.bar__agent[data-state="no_folders"] .dot { background: var(--warning); } .bar__update { padding: 0 var(--sp-3); diff --git a/ui/titlebar.js b/ui/titlebar.js index 46e07e3..e6de386 100644 --- a/ui/titlebar.js +++ b/ui/titlebar.js @@ -16,6 +16,7 @@ const WORDS = { running: "agent running", elsewhere: "agent running (service)", failed: "agent stopped", + no_folders: "agent: no folders", } function render(s) { @@ -25,7 +26,9 @@ function render(s) { } catch {} $("host").textContent = host $("host").title = s.server || "" - const state = s.agent.state + // Connected but allowed into no folder: a green light would say it works when it cannot. + const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length) + const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state $("agent").dataset.state = state $("agent-text").textContent = WORDS[state] ?? state $("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent" @@ -45,8 +48,13 @@ $("minimize").addEventListener("click", () => win.minimize()) $("maximize").addEventListener("click", () => win.toggleMaximize()) $("close").addEventListener("click", () => win.close()) $("update").addEventListener("click", () => invoke("update_app")) -$("menu").addEventListener("click", () => invoke("titlebar_menu")) -$("agent").addEventListener("click", () => invoke("titlebar_menu")) +// The menu opens under the button that was clicked: its bottom-left corner, in page pixels. +function menuAt(button) { + const r = button.getBoundingClientRect() + return invoke("titlebar_menu", { x: r.left, y: r.bottom }) +} +$("menu").addEventListener("click", (e) => menuAt(e.currentTarget)) +$("agent").addEventListener("click", (e) => menuAt(e.currentTarget)) listen("status", (e) => render(e.payload)) listen("maximized", (e) => maximized(!!e.payload)) diff --git a/ui/tokens.css b/ui/tokens.css index 9c21d2c..bf49be2 100644 --- a/ui/tokens.css +++ b/ui/tokens.css @@ -13,7 +13,7 @@ --radius: 2px; --press: 2px; --control-h: 2.5rem; - --card-max: 34rem; + --card-max: 40rem; color-scheme: dark; --bg: #0B0D10;