Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5028dd7ca1
|
||
|
|
1e2fa39193
|
No files matched your search
@@ -6,6 +6,26 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] — 2026-10-14
|
||||||
|
|
||||||
|
Carries LLeMbas CLI 1.2.0, whose agent stays linked before any folder is allowed and follows
|
||||||
|
changes to its folders without a restart.
|
||||||
|
|
||||||
|
### 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
|
## [0.3.0] — 2026-10-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
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
|
(`~/.config/lembas`). The app's copy of the CLI lives in its own directory
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.0
|
1.2.0
|
||||||
Generated
+1
-1
@@ -2064,7 +2064,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llembas-desktop"
|
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"
|
description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer"
|
||||||
authors = ["Jaroslav Beneš"]
|
authors = ["Jaroslav Beneš"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -108,6 +108,25 @@ fn running_in(state: &Path) -> Option<u32> {
|
|||||||
(pid != 0 && alive(pid)).then_some(pid)
|
(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<String> {
|
||||||
|
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<String> {
|
||||||
|
std::env::var("COMPUTERNAME").ok().filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub fn alive(pid: u32) -> bool {
|
pub fn alive(pid: u32) -> bool {
|
||||||
// Signal 0 checks without sending; EPERM means it exists but is someone else's.
|
// Signal 0 checks without sending; EPERM means it exists but is someone else's.
|
||||||
|
|||||||
+159
-7
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
mod cli;
|
mod cli;
|
||||||
|
mod remote;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
@@ -34,6 +35,7 @@ use tauri_plugin_opener::OpenerExt;
|
|||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
use agent::{Agent, State as AgentState};
|
use agent::{Agent, State as AgentState};
|
||||||
|
use remote::Remote;
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
|
|
||||||
const MAIN: &str = "main";
|
const MAIN: &str = "main";
|
||||||
@@ -53,6 +55,8 @@ struct App {
|
|||||||
linking: Mutex<bool>,
|
linking: Mutex<bool>,
|
||||||
/// The newer release the last check found, if any.
|
/// The newer release the last check found, if any.
|
||||||
update: Mutex<Option<String>>,
|
update: Mutex<Option<String>>,
|
||||||
|
/// The agent's folders, as `lembas remote` last answered.
|
||||||
|
remote: Mutex<Option<Remote>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TrayItems {
|
struct TrayItems {
|
||||||
@@ -84,6 +88,8 @@ struct Status {
|
|||||||
agent_wanted: bool,
|
agent_wanted: bool,
|
||||||
/// A newer release of the app, when the last check found one.
|
/// A newer release of the app, when the last check found one.
|
||||||
update: Option<String>,
|
update: Option<String>,
|
||||||
|
/// Which folders the agent may open sessions in (None: not known yet).
|
||||||
|
remote: Option<Remote>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status(app: &App) -> Status {
|
fn status(app: &App) -> Status {
|
||||||
@@ -95,6 +101,7 @@ fn status(app: &App) -> Status {
|
|||||||
agent: app.agent.state(),
|
agent: app.agent.state(),
|
||||||
agent_wanted: app.agent.wanted(),
|
agent_wanted: app.agent.wanted(),
|
||||||
update: app.update.lock().unwrap().clone(),
|
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::<App>();
|
let app = handle.state::<App>();
|
||||||
let s = app.update(|s| s.agent = on)?;
|
let s = app.update(|s| s.agent = on)?;
|
||||||
match (on, s.server) {
|
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(),
|
_ => app.agent.stop(),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the CLI which folders the agent may use; remember and announce it.
|
||||||
|
fn refresh_remote(handle: &AppHandle) -> Option<Remote> {
|
||||||
|
let r = remote::run(&["list"]).ok();
|
||||||
|
*handle.state::<App>().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>();
|
||||||
|
*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<String>) {
|
||||||
|
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.
|
/// The app's own first-run page, as the webview addresses it on this platform.
|
||||||
fn local_page() -> Url {
|
fn local_page() -> Url {
|
||||||
let base = if cfg!(windows) {
|
let base = if cfg!(windows) {
|
||||||
@@ -227,7 +356,9 @@ fn build_main(handle: &AppHandle, target: Option<Url>) -> tauri::Result<()> {
|
|||||||
}
|
}
|
||||||
let nav = handle.clone();
|
let nav = handle.clone();
|
||||||
window.add_child(
|
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),
|
LogicalPosition::new(FRAME, FRAME + TITLE_H),
|
||||||
LogicalSize::new(
|
LogicalSize::new(
|
||||||
size.width - 2.0 * FRAME,
|
size.width - 2.0 * FRAME,
|
||||||
@@ -439,7 +570,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
|
|||||||
)?;
|
)?;
|
||||||
let toggle = MenuItem::with_id(handle, "toggle", "Start the agent", true, None::<&str>)?;
|
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 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 quit = MenuItem::with_id(handle, "quit", "Quit LLeMbas", true, None::<&str>)?;
|
||||||
let menu = Menu::with_items(
|
let menu = Menu::with_items(
|
||||||
handle,
|
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 —
|
/// 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).
|
/// 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]
|
#[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 h = window.app_handle();
|
||||||
let app = h.state::<App>();
|
let app = h.state::<App>();
|
||||||
let m = |id: &str, text: &str, on: bool| MenuItem::with_id(h, id, text, on, None::<&str>);
|
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)?,
|
None => m("update", "Check for updates…", true)?,
|
||||||
};
|
};
|
||||||
let reload = m("reload", "Reload", 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)?;
|
let quit = m("quit", "Quit LLeMbas", true)?;
|
||||||
Menu::with_items(
|
Menu::with_items(
|
||||||
h,
|
h,
|
||||||
@@ -650,7 +784,13 @@ fn titlebar_menu(window: Window) -> Result<(), String> {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
let menu = build().map_err(|e| e.to_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) {
|
fn on_agent_state(handle: &AppHandle, state: &AgentState) {
|
||||||
@@ -678,7 +818,12 @@ pub fn run() {
|
|||||||
set_agent,
|
set_agent,
|
||||||
link_computer,
|
link_computer,
|
||||||
titlebar_menu,
|
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()))
|
.on_menu_event(|h, ev| on_menu(h, ev.id().as_ref()))
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -703,11 +848,18 @@ pub fn run() {
|
|||||||
tray: Mutex::new(None),
|
tray: Mutex::new(None),
|
||||||
linking: Mutex::new(false),
|
linking: Mutex::new(false),
|
||||||
update: Mutex::new(None),
|
update: Mutex::new(None),
|
||||||
|
remote: Mutex::new(None),
|
||||||
});
|
});
|
||||||
let items = build_tray(&handle)?;
|
let items = build_tray(&handle)?;
|
||||||
*handle.state::<App>().tray.lock().unwrap() = Some(items);
|
*handle.state::<App>().tray.lock().unwrap() = Some(items);
|
||||||
open_main(&handle, loaded.server.as_deref())?;
|
open_main(&handle, loaded.server.as_deref())?;
|
||||||
watch_updates(handle.clone());
|
watch_updates(handle.clone());
|
||||||
|
{
|
||||||
|
let h = handle.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
refresh_remote(&h);
|
||||||
|
});
|
||||||
|
}
|
||||||
if let (true, Some(server)) = (loaded.agent, loaded.server) {
|
if let (true, Some(server)) = (loaded.agent, loaded.server) {
|
||||||
handle.state::<App>().agent.start(server);
|
handle.state::<App>().agent.start(server);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
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 <args…> --json`: the new state, or what the CLI said went wrong.
|
||||||
|
pub fn run(args: &[&str]) -> Result<Remote, String> {
|
||||||
|
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<Remote, String> {
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find_map(|l| serde_json::from_str::<Remote>(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-1
@@ -33,7 +33,7 @@ p { margin: 0; }
|
|||||||
|
|
||||||
/* A field is three stacked parts on fixed rows: label, control row, hint. */
|
/* 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); }
|
.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 { font-size: var(--text-sm); color: var(--ink-muted); min-height: 1.4em; }
|
||||||
.hint.is-error, .status.is-error { color: var(--warning); }
|
.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); }
|
.row { grid-template-columns: 1fr; grid-template-rows: var(--control-h) var(--control-h); }
|
||||||
.field { grid-template-rows: auto auto auto; }
|
.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); }
|
||||||
@@ -20,13 +20,53 @@ function render(s) {
|
|||||||
$("version").textContent = s.version
|
$("version").textContent = s.version
|
||||||
if (s.server && !$("address").value) $("address").value = s.server
|
if (s.server && !$("address").value) $("address").value = s.server
|
||||||
$("agent-section").hidden = !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")
|
const status = $("agent-status")
|
||||||
status.textContent = text
|
status.textContent = text
|
||||||
status.className = `status ${cls}`
|
status.className = `status ${cls}`
|
||||||
$("link").textContent = s.linked ? "Link again" : "Link this computer"
|
$("link").textContent = s.linked ? "Link again" : "Link this computer"
|
||||||
$("toggle").textContent = s.agent_wanted ? "Stop the agent" : "Start the agent"
|
$("toggle").textContent = s.agent_wanted ? "Stop the agent" : "Start the agent"
|
||||||
$("toggle").disabled = !s.linked && !s.agent_wanted
|
$("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() {
|
async function refresh() {
|
||||||
@@ -70,5 +110,14 @@ $("open").addEventListener("click", async () => {
|
|||||||
if (s.server) await invoke("set_server", { address: s.server })
|
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))
|
listen("status", (e) => render(e.payload))
|
||||||
refresh()
|
refresh()
|
||||||
|
invoke("remote_refresh")
|
||||||
@@ -43,6 +43,35 @@
|
|||||||
<button type="button" class="btn btn--primary" id="open">Open the server</button>
|
<button type="button" class="btn btn--primary" id="open">Open the server</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="folders-h" id="folders-section" hidden>
|
||||||
|
<h2 id="folders-h">[ PROJECT FOLDERS ]</h2>
|
||||||
|
<p class="muted">Agent chats from your server can work in these folders and anything inside
|
||||||
|
them — nowhere else on this computer. Start one in the server's Agents tab, on
|
||||||
|
<strong>Local</strong>.</p>
|
||||||
|
<ul class="folders" id="folders"></ul>
|
||||||
|
<p class="status" id="folders-status" aria-live="polite"></p>
|
||||||
|
<div class="row row--buttons">
|
||||||
|
<button type="button" class="btn btn--primary" id="add-folder">Add folder…</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="max-mode">The most an agent chat may do here</label>
|
||||||
|
<select id="max-mode">
|
||||||
|
<option value="manual">Manual — asks first</option>
|
||||||
|
<option value="edit">Edit — asks before commands</option>
|
||||||
|
<option value="auto">Auto — asks nothing</option>
|
||||||
|
<option value="plan">Plan — changes nothing</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">A chat asking for more is lowered to this.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Terminal</span>
|
||||||
|
<label class="check"><input type="checkbox" id="terminal"> Let the server open a terminal here</label>
|
||||||
|
<p class="hint">A shell as you, in one of these folders.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+2
-1
@@ -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="elsewhere"] .dot { background: var(--leaf); }
|
||||||
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
|
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
|
||||||
.bar__agent[data-state="failed"] .dot,
|
.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 {
|
.bar__update {
|
||||||
padding: 0 var(--sp-3);
|
padding: 0 var(--sp-3);
|
||||||
|
|||||||
+11
-3
@@ -16,6 +16,7 @@ const WORDS = {
|
|||||||
running: "agent running",
|
running: "agent running",
|
||||||
elsewhere: "agent running (service)",
|
elsewhere: "agent running (service)",
|
||||||
failed: "agent stopped",
|
failed: "agent stopped",
|
||||||
|
no_folders: "agent: no folders",
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(s) {
|
function render(s) {
|
||||||
@@ -25,7 +26,9 @@ function render(s) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
$("host").textContent = host
|
$("host").textContent = host
|
||||||
$("host").title = s.server || ""
|
$("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").dataset.state = state
|
||||||
$("agent-text").textContent = WORDS[state] ?? state
|
$("agent-text").textContent = WORDS[state] ?? state
|
||||||
$("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent"
|
$("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent"
|
||||||
@@ -45,8 +48,13 @@ $("minimize").addEventListener("click", () => win.minimize())
|
|||||||
$("maximize").addEventListener("click", () => win.toggleMaximize())
|
$("maximize").addEventListener("click", () => win.toggleMaximize())
|
||||||
$("close").addEventListener("click", () => win.close())
|
$("close").addEventListener("click", () => win.close())
|
||||||
$("update").addEventListener("click", () => invoke("update_app"))
|
$("update").addEventListener("click", () => invoke("update_app"))
|
||||||
$("menu").addEventListener("click", () => invoke("titlebar_menu"))
|
// The menu opens under the button that was clicked: its bottom-left corner, in page pixels.
|
||||||
$("agent").addEventListener("click", () => invoke("titlebar_menu"))
|
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("status", (e) => render(e.payload))
|
||||||
listen("maximized", (e) => maximized(!!e.payload))
|
listen("maximized", (e) => maximized(!!e.payload))
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
--radius: 2px;
|
--radius: 2px;
|
||||||
--press: 2px;
|
--press: 2px;
|
||||||
--control-h: 2.5rem;
|
--control-h: 2.5rem;
|
||||||
--card-max: 34rem;
|
--card-max: 40rem;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #0B0D10;
|
--bg: #0B0D10;
|
||||||
|
|||||||
Reference in new issue
Block a user