diff --git a/Cargo.lock b/Cargo.lock index 5cd2d97..2fb4810 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.2", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.5", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -3049,6 +3074,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.5.0" @@ -3956,6 +3990,7 @@ dependencies = [ name = "ledger-gui" version = "0.1.0" dependencies = [ + "alacritty_terminal", "chrono", "eframe", "egui", @@ -4371,6 +4406,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -6227,6 +6271,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.5", +] + [[package]] name = "rustls" version = "0.23.45" @@ -6613,6 +6668,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -8167,6 +8232,20 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "cursor-icon", + "log", + "memchr", + "serde", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/README.md b/README.md index 84d20f4..0a25b2b 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,13 @@ curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme Already installed, three commands: `wanderbooks setup`, `wanderbooks books create acme`, `wanderbooks connect claude --book acme` (or `connect codex`). Then talk to the agent; `wanderbooks gui acme` shows the books, and its -Books menu backs them up, restores, starts new books and closes them. +Books menu backs them up, restores, starts new books and closes them. With +no terminal (the AppImage, a menu entry), opening the app with no books +shows a first-run page that does the same three steps (the private folder, +an owner passphrase, the first books) and connects an agent; a panel along +the bottom of the window then runs Claude Code or Codex in the books' own +folder, `~/Documents/Wanderbooks/` (optional: any agent can use the +books). Updating: an AppImage, or a `wanderbooks` you put somewhere yourself, brings itself to the newest release with `wanderbooks update` (it checks the diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index af35684..3685c05 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -71,7 +71,7 @@ impl ledger_gui::owner::Owner for ViewerOwner { } fn new_books(&self, id: &str, currency: &str) -> Result { - ledger_host::commands::new_books(&self.0, id, currency) + ledger_host::commands::new_books_and_home(&self.0, id, currency) } fn back_up_now(&self, book: &str) -> Result, String> { @@ -113,7 +113,60 @@ impl ledger_gui::owner::Owner for ViewerOwner { ledger_gui::owner::Agent::Claude => "claude", ledger_gui::owner::Agent::Codex => "codex", }; - ledger_host::commands::connect_agent(&self.0, agent, book) + ledger_host::commands::connect_and_home(&self.0, agent, book) + } + + fn first_run(&self) -> ledger_gui::owner::FirstRun { + let s = ledger_host::commands::first_run_state(&self.0); + ledger_gui::owner::FirstRun { + root: s.root, + books: s.books, + passphrase_set: s.passphrase_set, + } + } + + fn set_up(&self) -> Result, String> { + ledger_host::commands::set_up_root(&self.0) + } + + fn set_passphrase(&self, new: &str) -> Result<(), String> { + ledger_host::commands::set_first_passphrase(&self.0, new) + } + + fn hold(&self, book: &str) { + ledger_host::commands::hold_for_viewer(&self.0, Some(book.to_string())); + } + + fn agent_start( + &self, + agent: ledger_gui::owner::Agent, + book: &str, + ) -> Result { + use ledger_gui::owner::{AgentStart, Installer}; + let agent = match agent { + ledger_gui::owner::Agent::Claude => "claude", + ledger_gui::owner::Agent::Codex => "codex", + }; + Ok(match ledger_host::commands::start_agent(agent, book)? { + ledger_host::commands::AgentStart::Ready { + label, + program, + args, + dir, + env, + } => AgentStart::Ready { + label: label.to_string(), + program, + args, + dir, + env, + }, + ledger_host::commands::AgentStart::Missing(i) => AgentStart::Missing(Installer { + label: i.label.to_string(), + command: i.command.to_string(), + page: i.page.to_string(), + }), + }) } fn upgrade_format(&self, book: &str, passphrase: &str) -> Result, String> { @@ -147,14 +200,18 @@ fn run_gui(args: Vec) -> i32 { } let root = value("--root").map(PathBuf::from); let book = value("--book"); - if known && ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() { - // The viewer asks about a format upgrade in its own window. + if known { + // The viewer asks about a format upgrade in its own window, makes + // the first books when there are none, and starts the agent in + // its dock. let common = ledger_host::commands::Common { - root, + root: root.clone(), ..Default::default() }; ledger_gui::owner::set_owner(std::sync::Arc::new(ViewerOwner(common.clone()))); - ledger_host::commands::hold_for_viewer(&common, book); + if ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() { + ledger_host::commands::hold_for_viewer(&common, book); + } } } match ledger_gui::run("wanderbooks gui", args) { diff --git a/crates/ledger-cli/tests/books_home.rs b/crates/ledger-cli/tests/books_home.rs new file mode 100644 index 0000000..6292286 --- /dev/null +++ b/crates/ledger-cli/tests/books_home.rs @@ -0,0 +1,152 @@ +//! `wanderbooks connect` makes the book's books home (P11b, D-27): a folder in +//! the account's Documents holding the agent's pointer and nothing of the +//! books. Run with a home of the test's own: nothing here touches the account's +//! real Documents or data root. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const KNOBS: &str = + "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; + +struct Home { + dir: tempfile::TempDir, +} + +impl Home { + fn new() -> Self { + let dir = tempfile::Builder::new() + .prefix("wb-books-home-") + .tempdir() + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["setup", "--home"]) + .arg(dir.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + Self { dir } + } + + fn root(&self) -> PathBuf { + self.dir.path().join(".local/share/wanderbooks") + } + + fn cmd(&self, mode: &str, path: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + c.arg(mode) + .arg("--root") + .arg(self.root()) + .args(["--test-knobs", KNOBS]) + .env_clear() + .env("PATH", format!("{}:/usr/bin:/bin", path.display())) + .env("HOME", self.dir.path()) + .stdin(Stdio::null()); + c + } +} + +/// A stand-in `claude` that accepts whatever it is told. +fn fake_agent(home: &Home) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin = home.dir.path().join("agent-bin"); + std::fs::create_dir_all(&bin).unwrap(); + let script = bin.join("claude"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin +} + +#[test] +fn connect_makes_the_books_home_and_it_holds_no_books_data() { + let home = Home::new(); + let out = home + .cmd("books", Path::new("/nonexistent")) + .args(["create", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + let docs = home.dir.path().join("Documents/Wanderbooks/beta"); + + // An agent that isn't installed: said, and no folder made for nothing. + let out = home + .cmd("connect", Path::new("/nonexistent")) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(!docs.exists(), "a home for an agent that isn't there"); + + // Connected: the user-scope entry as ever, and the books home beside it. + let bin = fake_agent(&home); + let out = home + .cmd("connect", &bin) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + let said = String::from_utf8_lossy(&out.stdout); + assert!( + said.contains("Claude Code reaches the books beta"), + "{said}" + ); + assert!( + said.contains(&format!("The books home is {}", docs.display())), + "{said}" + ); + let mut names: Vec = Vec::new(); + for dir in [docs.clone(), docs.join(".claude")] { + for e in std::fs::read_dir(&dir).unwrap() { + let p = e.unwrap().path(); + if p.is_file() { + names.push( + p.strip_prefix(&docs) + .unwrap() + .to_string_lossy() + .into_owned(), + ); + } + } + } + names.sort(); + assert_eq!( + names, + [ + ".claude/settings.json", + ".mcp.json", + "AGENTS.md", + "CLAUDE.md" + ] + ); + // Nothing in it names the data root, a token or a key. + let root = home.root(); + for n in &names { + let text = std::fs::read_to_string(docs.join(n)).unwrap(); + assert!(!text.contains(root.to_str().unwrap()), "{n}: {text}"); + assert!(!text.contains("wb_"), "{n} holds a token: {text}"); + assert!(!text.contains("--root"), "{n}: {text}"); + } + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(docs.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!( + mcp["mcpServers"]["wanderbooks-beta"]["args"], + serde_json::json!(["mcp", "--book", "beta"]) + ); + // Run again: still four files, the rule once. + let out = home + .cmd("connect", &bin) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(docs.join(".claude/settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["permissions"]["allow"], + serde_json::json!(["mcp__wanderbooks-beta__*"]) + ); +} diff --git a/crates/ledger-cli/tests/common/mod.rs b/crates/ledger-cli/tests/common/mod.rs index a98a68c..e3990eb 100644 --- a/crates/ledger-cli/tests/common/mod.rs +++ b/crates/ledger-cli/tests/common/mod.rs @@ -74,6 +74,10 @@ impl Home { } } + /// The root `setup --home` made: the platform's data folder under the + /// home (`.local/share` on Linux, `Library/Application Support` on + /// macOS), never a Linux path spelled out (the portable macOS job + /// found the server refusing a root that was never set up). pub fn root(&self) -> PathBuf { ledger_store::root::default_root(self.dir.path()) } diff --git a/crates/ledger-cli/tests/oracle_mcp.rs b/crates/ledger-cli/tests/oracle_mcp.rs index 564d5fa..061c7bc 100644 --- a/crates/ledger-cli/tests/oracle_mcp.rs +++ b/crates/ledger-cli/tests/oracle_mcp.rs @@ -3497,11 +3497,14 @@ fn cpa_package_holds_the_year_and_its_evidence() { // and the em dashes of the depreciation memos: written differently, so // said, the memos grouped (four lines for one name and four memos), in // warnings and the README alike. + // (The audit notice P8 adds while no counted audit stands behind the + // package is a warning too; it is not about the IIF.) let warnings: Vec = out["warnings"] .as_array() .unwrap() .iter() .map(|w| w.as_str().unwrap().to_string()) + .filter(|w| !w.starts_with("No counted audit")) .collect(); assert_eq!(warnings.len(), 4, "{out}"); let readme = std::str::from_utf8(&files["README.txt"]).unwrap(); diff --git a/crates/ledger-gui/Cargo.toml b/crates/ledger-gui/Cargo.toml index 6229a84..5557ba5 100644 --- a/crates/ledger-gui/Cargo.toml +++ b/crates/ledger-gui/Cargo.toml @@ -32,6 +32,8 @@ rust_decimal = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" zeroize = "1" +# The dock: a terminal emulator over a PTY (ConPTY on Windows), P11b +alacritty_terminal = "0.26" [dev-dependencies] chrono = "0.4" diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 8aa63b2..8ea0ef3 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -151,24 +151,75 @@ pub struct App { menu: crate::menu::Menu, /// The owner's Books menu (owner.rs). owner_menu: crate::owner::OwnerMenu, + /// The agent dock along the bottom (dock.rs). + dock: crate::dock::Dock, + /// The first-run page, while there are no books to show (welcome.rs). + welcome: Option, + /// What a first run opens its books with: the data root named, and + /// "today". + first_run_source: (Option, Option), } impl App { /// The live app over a books directory. pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self { - // The scheme kept from last time; before schemes, a light/dark - // choice; else the system's mode (schemes INVARIANT 4). + let mut app = Self::new_without_books(cc); + app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone())); + app + } + + /// The app on a first run: no books yet, so the page that sets them up + /// (welcome.rs), which opens them once they are made. `root` is the data + /// root named on the command line, if any. + pub fn first_run( + cc: &eframe::CreationContext<'_>, + root: Option, + today: Option, + state: crate::owner::FirstRun, + ) -> Self { + let mut app = Self::new_without_books(cc); + app.welcome = Some(crate::welcome::Welcome::new(state)); + app.first_run_source = (root, today); + app + } + + /// An app with no books and no loader yet: the scheme kept from last + /// time (before schemes, a light/dark choice; else the system's mode: + /// schemes INVARIANT 4) and the dock as it was left. + pub fn new_without_books(cc: &eframe::CreationContext<'_>) -> Self { let (scheme, filter) = restored( |key| cc.storage.and_then(|s| s.get_string(key)), cc.egui_ctx.global_style().visuals.dark_mode, ); let mut app = Self::bare(&cc.egui_ctx, scheme); app.filter = filter; + app.dock = crate::dock::Dock::restored(|key| cc.storage.and_then(|s| s.get_string(key))); app.receipts.thumb_dir = crate::receipts::thumb_dir(); - app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone())); app } + /// The first-run page, if it is up (tests). + pub fn welcome(&self) -> Option<&crate::welcome::Welcome> { + self.welcome.as_ref() + } + + /// Open the books `id` of the data root named, as the first-run page + /// was asked to: the books come in as the loader reads them. + fn open_first_books(&mut self, ctx: &egui::Context, id: &str) { + let (root, today) = self.first_run_source.clone(); + match crate::book_in_root(root.as_deref(), Some(id)) { + Ok(data_dir) => { + self.loader = Some(Loader::spawn(Source { data_dir, today }, ctx.clone())); + self.welcome = None; + } + Err(why) => { + if let Some(w) = self.welcome.as_mut() { + w.refused(why); + } + } + } + } + /// An app showing fixed books (tests and screenshots). pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self { let mut app = Self::bare(ctx, schemes::own(dark)); @@ -198,6 +249,28 @@ impl App { live_label: false, menu: Default::default(), owner_menu: Default::default(), + dock: Default::default(), + welcome: None, + first_run_source: (None, None), + } + } + + /// The agent dock (tests). + pub fn dock_mut(&mut self) -> &mut crate::dock::Dock { + &mut self.dock + } + + /// The dock along the bottom, for books of a data root with an owner + /// to start the agent (dock.rs). + fn dock(&mut self, ui: &mut Ui) { + if !crate::dock::offered() { + return; + } + let (Some(owner), Some(books)) = (crate::owner::current(), self.books.clone()) else { + return; + }; + if let Some(book) = crate::owner::book_id(&books) { + self.dock.show(ui, &owner, &book); } } @@ -556,6 +629,10 @@ impl App { } fn handle_keys(&mut self, ui: &Ui) { + // The agent in the dock has the keyboard while it is clicked on. + if self.dock.has_keyboard() { + return; + } let (back, fwd, palette) = ui.input(|i| { ( (i.modifiers.alt && i.key_pressed(Key::ArrowLeft)) @@ -1636,6 +1713,22 @@ fn checks(b: &Books) -> Vec<(bool, String, String)> { impl eframe::App for App { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + // A first run: the page that sets the books up, until there are some. + if self.welcome.is_some() && self.books.is_none() { + let p = pal(ui.ctx()); + let owner = crate::owner::current(); + let mut open = None; + if let (Some(w), Some(owner)) = (self.welcome.as_mut(), owner) { + egui::CentralPanel::no_frame() + .frame(Frame::new().fill(p.bg)) + .show(ui, |ui| w.show(ui, &owner)); + open = w.take_open(); + } + if let Some(id) = open { + self.open_first_books(&ui.ctx().clone(), &id); + } + return; + } let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default(); for u in updates { match u { @@ -1656,6 +1749,7 @@ impl eframe::App for App { let compact = ui.available_width() < COMPACT_BELOW; self.sidebar(ui, compact); self.topbar(ui); + self.dock(ui); let p = pal(ui.ctx()); let since_update = ui.input(|i| i.time) - self.updated_at; if since_update < 6.0 { @@ -1706,6 +1800,7 @@ impl eframe::App for App { storage.set_string(SCHEME_KEY, self.scheme.key()); storage.set_string(FILTER_KEY, self.filter.name().to_string()); storage.set_string("dark", self.scheme.dark.to_string()); + self.dock.keep(&mut |k, v| storage.set_string(k, v)); } } diff --git a/crates/ledger-gui/src/dock.rs b/crates/ledger-gui/src/dock.rs new file mode 100644 index 0000000..68591d2 --- /dev/null +++ b/crates/ledger-gui/src/dock.rs @@ -0,0 +1,1010 @@ +//! The agent dock (plan P11b, D-27): a terminal along the bottom of the +//! viewer's window that runs Claude Code (the default) or Codex in the +//! book's books home. +//! +//! ```text +//! GOAL: the owner talks to the agent that keeps the books without leaving +//! the app, and never has to: any other agent, anywhere, reaches the +//! same books through the MCP. +//! SOURCE: D-27 (the dock, the books home, "never required"); D-32 (no +//! helper, no confinement: the agent is an ordinary child process +//! of the viewer's program, started through the [`Owner`]); D-54; +//! Q-7 (Codex asks about trusting the folder itself). +//! INVARIANTS: +//! 1. The dock writes nothing of the books. The agent it runs writes +//! through the books server like any other agent; the viewer stays +//! read-only (tests/read_only.rs). +//! 2. The agent runs in the book's books home, a folder holding only its +//! pointer and settings; the dock never runs anything in the data +//! root and never marks a folder trusted. +//! 3. An agent that isn't installed is offered through its vendor's +//! installer (a command to copy and a page); nothing is installed by +//! the app and no agent is bundled. +//! 4. Keystrokes go to the agent only while the dock has the keyboard +//! (a click gives it, a click elsewhere takes it away): the viewer's +//! own shortcuts work everywhere else. Ctrl-C is the agent's. +//! 5. The terminal is as large as the panel: resizing the panel resizes +//! the agent's terminal, which redraws. +//! 6. The agent chosen and whether the dock is open are the viewer's +//! preferences, remembered; they are not the books'. +//! 7. Offered only where it has been checked to work (Linux and macOS): +//! a ConPTY agent on Windows must pass the four checks (it starts, +//! `/login` works, resize redraws, Ctrl-C reaches it) before it is. +//! ``` + +use crate::owner::{Agent, AgentStart, Installer, Owner}; +use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize}; +use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg}; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::index::{Column, Line, Point, Side}; +use alacritty_terminal::selection::{Selection, SelectionType}; +use alacritty_terminal::sync::FairMutex; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::{Config, Term, TermMode, point_to_viewport, viewport_to_point}; +use alacritty_terminal::tty; +use alacritty_terminal::vte::ansi::{Color as TermColor, NamedColor, Rgb}; +use egui::{Color32, Event, FontId, Key, Modifiers, Pos2, Rect, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use std::borrow::Cow; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, channel}; +use std::sync::{Arc, Mutex}; + +/// Whether the dock is offered on this system (INVARIANT 7). +pub fn offered() -> bool { + cfg!(unix) +} + +/// A size in character cells. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cells { + /// Columns. + pub cols: u16, + /// Rows. + pub rows: u16, +} + +impl Dimensions for Cells { + fn total_lines(&self) -> usize { + self.rows as usize + } + fn screen_lines(&self) -> usize { + self.rows as usize + } + fn columns(&self) -> usize { + self.cols as usize + } +} + +/// What the terminal tells the dock: it listens for the emulator's events. +#[derive(Clone)] +struct Proxy { + wake: Arc, + to_pty: Arc>>, + size: Arc>, + exited: Arc, + title: Arc>, +} + +impl Proxy { + fn write(&self, text: String) { + if let Some(tx) = self + .to_pty + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + { + let _ = tx.send(Msg::Input(Cow::Owned(text.into_bytes()))); + } + } +} + +impl EventListener for Proxy { + fn send_event(&self, event: TermEvent) { + match event { + TermEvent::Wakeup | TermEvent::Bell => (self.wake)(), + // The terminal's own answers to the program's questions. + TermEvent::PtyWrite(text) => self.write(text), + TermEvent::TextAreaSizeRequest(format) => { + let size = *self.size.lock().unwrap_or_else(|e| e.into_inner()); + self.write(format(size)); + } + TermEvent::Title(t) => { + *self.title.lock().unwrap_or_else(|e| e.into_inner()) = t; + (self.wake)(); + } + TermEvent::ResetTitle => { + self.title.lock().unwrap_or_else(|e| e.into_inner()).clear(); + } + TermEvent::ChildExit(_) | TermEvent::Exit => { + self.exited.store(true, Ordering::SeqCst); + (self.wake)(); + } + _ => {} + } + } +} + +/// An agent running on a terminal of its own. +pub struct Session { + term: Arc>>, + to_pty: EventLoopSender, + size: Arc>, + cells: Cells, + exited: Arc, + title: Arc>, +} + +impl Session { + /// Run `program args` in `dir` with `env` added, on a terminal of `cells`; + /// `wake` is called when there is something new to draw. + pub fn spawn( + program: &std::path::Path, + args: &[String], + dir: &std::path::Path, + env: &[(String, String)], + cells: Cells, + wake: Arc, + ) -> Result { + let window = WindowSize { + num_lines: cells.rows, + num_cols: cells.cols, + cell_width: 1, + cell_height: 1, + }; + let options = tty::Options { + shell: Some(tty::Shell::new( + program.to_string_lossy().into_owned(), + args.to_vec(), + )), + working_directory: Some(dir.to_path_buf()), + drain_on_exit: true, + env: env.iter().cloned().collect(), + #[cfg(windows)] + escape_args: true, + }; + let pty = tty::new(&options, window, 0).map_err(|e| e.to_string())?; + let exited = Arc::new(AtomicBool::new(false)); + let to_pty = Arc::new(Mutex::new(None)); + let size = Arc::new(Mutex::new(window)); + let title = Arc::new(Mutex::new(String::new())); + let proxy = Proxy { + wake, + to_pty: to_pty.clone(), + size: size.clone(), + exited: exited.clone(), + title: title.clone(), + }; + let term = Arc::new(FairMutex::new(Term::new( + Config::default(), + &cells, + proxy.clone(), + ))); + let event_loop = + EventLoop::new(term.clone(), proxy, pty, true, false).map_err(|e| e.to_string())?; + let sender = event_loop.channel(); + *to_pty.lock().unwrap_or_else(|e| e.into_inner()) = Some(sender.clone()); + // Its thread ends on Shutdown, and its terminal's child with it. + drop(event_loop.spawn()); + Ok(Session { + term, + to_pty: sender, + size, + cells, + exited, + title, + }) + } + + /// Send keystrokes (or anything else the program is to read). + pub fn write(&self, bytes: impl Into>) { + let bytes = bytes.into(); + if !bytes.is_empty() { + let _ = self.to_pty.send(Msg::Input(bytes)); + } + } + + /// Make the terminal `cells` large (the program is told, and redraws). + pub fn resize(&mut self, cells: Cells) { + if cells == self.cells || cells.cols == 0 || cells.rows == 0 { + return; + } + self.cells = cells; + let window = WindowSize { + num_lines: cells.rows, + num_cols: cells.cols, + cell_width: 1, + cell_height: 1, + }; + *self.size.lock().unwrap_or_else(|e| e.into_inner()) = window; + self.term.lock().resize(cells); + let _ = self.to_pty.send(Msg::Resize(window)); + } + + /// The terminal's size now. + pub fn cells(&self) -> Cells { + self.cells + } + + /// Whether the program has ended. + pub fn exited(&self) -> bool { + self.exited.load(Ordering::SeqCst) + } + + /// The window title the program set, if any. + pub fn title(&self) -> String { + self.title.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + + /// The screen as text, a line each, trailing blanks cut (tests and + /// accessibility). + pub fn text(&self) -> String { + let term = self.term.lock(); + let (rows, cols) = (term.screen_lines(), term.columns()); + let mut out = Vec::new(); + for line in 0..rows { + let start = Point::new(Line(line as i32), Column(0)); + let end = Point::new(Line(line as i32), Column(cols - 1)); + out.push(term.bounds_to_string(start, end).trim_end().to_string()); + } + out.join("\n") + } +} + +impl Drop for Session { + fn drop(&mut self) { + let _ = self.to_pty.send(Msg::Shutdown); + } +} + +/// The bytes a key sends to a program on a terminal (None: nothing, or the +/// key's text comes separately as typed text). `app_cursor` is the +/// program's application-cursor mode. +pub fn key_bytes(key: Key, mods: Modifiers, app_cursor: bool) -> Option> { + // xterm's modifier parameter: 1 + shift 1 + alt 2 + ctrl 4. + let param = 1 + u8::from(mods.shift) + 2 * u8::from(mods.alt) + 4 * u8::from(mods.ctrl); + let csi = |final_byte: char| -> Vec { + if param == 1 { + format!("\x1b[{final_byte}").into_bytes() + } else { + format!("\x1b[1;{param}{final_byte}").into_bytes() + } + }; + let arrow = |final_byte: char| -> Vec { + if param == 1 && app_cursor { + format!("\x1bO{final_byte}").into_bytes() + } else { + csi(final_byte) + } + }; + let tilde = |n: u8| -> Vec { + if param == 1 { + format!("\x1b[{n}~").into_bytes() + } else { + format!("\x1b[{n};{param}~").into_bytes() + } + }; + let with_alt = |mut b: Vec| { + if mods.alt { + b.insert(0, 0x1b); + } + b + }; + Some(match key { + Key::Enter if mods.shift => b"\n".to_vec(), + Key::Enter => with_alt(b"\r".to_vec()), + Key::Backspace if mods.ctrl => vec![0x08], + Key::Backspace => with_alt(vec![0x7f]), + Key::Tab if mods.shift => b"\x1b[Z".to_vec(), + Key::Tab => b"\t".to_vec(), + Key::Escape => vec![0x1b], + Key::ArrowUp => arrow('A'), + Key::ArrowDown => arrow('B'), + Key::ArrowRight => arrow('C'), + Key::ArrowLeft => arrow('D'), + Key::Home => arrow('H'), + Key::End => arrow('F'), + Key::PageUp => tilde(5), + Key::PageDown => tilde(6), + Key::Insert => tilde(2), + Key::Delete => tilde(3), + Key::F1 => b"\x1bOP".to_vec(), + Key::F2 => b"\x1bOQ".to_vec(), + Key::F3 => b"\x1bOR".to_vec(), + Key::F4 => b"\x1bOS".to_vec(), + Key::F5 => tilde(15), + Key::F6 => tilde(17), + Key::F7 => tilde(18), + Key::F8 => tilde(19), + Key::F9 => tilde(20), + Key::F10 => tilde(21), + Key::F11 => tilde(23), + Key::F12 => tilde(24), + Key::Space if mods.ctrl => vec![0], + // Ctrl-A to Ctrl-Z (Ctrl-C included: it is the agent's, INVARIANT 4), + // Alt adding the escape; text typed with Alt is the key's too. + k if mods.ctrl || mods.alt => { + let name = k.name(); + let c = name.chars().next().filter(|_| name.chars().count() == 1)?; + if mods.ctrl && c.is_ascii_alphabetic() { + with_alt(vec![c.to_ascii_lowercase() as u8 - b'a' + 1]) + } else if mods.ctrl { + match c { + '[' => vec![0x1b], + '\\' => vec![0x1c], + ']' => vec![0x1d], + '/' => vec![0x1f], + _ => return None, + } + } else { + let c = if mods.shift { + c.to_ascii_uppercase() + } else { + c.to_ascii_lowercase() + }; + with_alt(c.to_string().into_bytes()) + } + } + _ => return None, + }) +} + +/// The 16 colors of the terminal's palette, dark or light. +fn ansi16(dark: bool) -> [Color32; 16] { + let c = |r, g, b| Color32::from_rgb(r, g, b); + if dark { + [ + c(0x1c, 0x1c, 0x22), + c(0xe0, 0x6c, 0x75), + c(0x7f, 0xc0, 0x6e), + c(0xe5, 0xc0, 0x7b), + c(0x61, 0xaf, 0xef), + c(0xc6, 0x78, 0xdd), + c(0x56, 0xb6, 0xc2), + c(0xc8, 0xcc, 0xd4), + c(0x5c, 0x63, 0x70), + c(0xf0, 0x8b, 0x93), + c(0x98, 0xd3, 0x87), + c(0xf0, 0xd0, 0x8f), + c(0x7b, 0xc0, 0xf8), + c(0xd8, 0x92, 0xea), + c(0x73, 0xcb, 0xd6), + c(0xf2, 0xf4, 0xf8), + ] + } else { + [ + c(0x1b, 0x1b, 0x1f), + c(0xb9, 0x1c, 0x1c), + c(0x15, 0x80, 0x3d), + c(0x8a, 0x5a, 0x00), + c(0x1d, 0x4e, 0xd8), + c(0x86, 0x25, 0xa8), + c(0x0e, 0x74, 0x90), + c(0x6b, 0x6b, 0x74), + c(0x4a, 0x4a, 0x52), + c(0xdc, 0x26, 0x26), + c(0x16, 0xa3, 0x4a), + c(0xa1, 0x62, 0x07), + c(0x25, 0x63, 0xeb), + c(0xa8, 0x55, 0xf7), + c(0x08, 0x91, 0xb2), + c(0x9c, 0x9c, 0xa4), + ] + } +} + +/// A color the terminal asks for, as the dock draws it. +fn color(c: TermColor, dark: bool, fg: Color32, bg: Color32, bold: bool) -> Color32 { + let table = ansi16(dark); + match c { + TermColor::Spec(Rgb { r, g, b }) => Color32::from_rgb(r, g, b), + TermColor::Indexed(i) => match i { + 0..=7 if bold => table[i as usize + 8], + 0..=15 => table[i as usize], + 16..=231 => { + let n = i - 16; + let level = |v: u8| if v == 0 { 0 } else { 55 + 40 * v }; + Color32::from_rgb(level(n / 36), level(n % 36 / 6), level(n % 6)) + } + _ => { + let v = 8 + 10 * (i - 232); + Color32::from_rgb(v, v, v) + } + }, + TermColor::Named(n) => match n { + NamedColor::Foreground | NamedColor::BrightForeground | NamedColor::Cursor => fg, + NamedColor::Background => bg, + NamedColor::DimForeground => fg.gamma_multiply(0.6), + n if (n as usize) < 8 && bold => table[n as usize + 8], + n if (n as usize) < 16 => table[n as usize], + n => { + // Dim black .. dim white follow the cursor color. + let i = (n as usize).saturating_sub(NamedColor::DimBlack as usize); + table[i.min(7)].gamma_multiply(0.7) + } + }, + } +} + +/// What the dock is doing. +enum State { + /// Closed or not started: nothing runs. + Idle, + /// The owner is finding the agent and its books home. + Starting(Receiver>), + /// The agent runs. + Running(Box), + /// The agent isn't installed. + Missing(Installer), + /// It couldn't be started. + Failed(String), +} + +/// The dock's state and look: one per window. +pub struct Dock { + /// Whether the panel is open (INVARIANT 6). + pub open: bool, + /// The agent chosen (INVARIANT 6). + pub agent: Agent, + state: State, + /// The book it was started for. + book: Option, + /// Has the keyboard (INVARIANT 4). + focused: bool, +} + +impl Default for Dock { + fn default() -> Self { + Self { + open: false, + agent: Agent::Claude, + state: State::Idle, + book: None, + focused: false, + } + } +} + +impl Dock { + /// The dock as kept: the `agent` key and the `open` flag. + pub fn restored(get: impl Fn(&str) -> Option) -> Dock { + Dock { + open: get("dock_open").is_some_and(|v| v == "true"), + agent: get("dock_agent") + .and_then(|k| Agent::from_key(&k)) + .unwrap_or(Agent::Claude), + ..Default::default() + } + } + + /// Keep the preferences. + pub fn keep(&self, set: &mut dyn FnMut(&str, String)) { + set("dock_open", self.open.to_string()); + set("dock_agent", self.agent.key().to_string()); + } + + /// Whether the dock has the keyboard: the viewer's own shortcuts wait. + pub fn has_keyboard(&self) -> bool { + self.focused && self.open + } + + /// The screen of the running agent, as text (tests). + pub fn screen(&self) -> Option { + match &self.state { + State::Running(s) => Some(s.text()), + _ => None, + } + } + + /// The running agent's terminal size (tests). + pub fn cells(&self) -> Option { + match &self.state { + State::Running(s) => Some(s.cells()), + _ => None, + } + } + + /// Send keystrokes to the running agent (tests). + pub fn send(&self, bytes: &'static [u8]) { + if let State::Running(s) = &self.state { + s.write(bytes); + } + } + + /// Start (or restart) the agent for `book`: found and its books home + /// made off the UI thread, then run. + fn start(&mut self, ctx: &egui::Context, owner: &Arc, book: &str) { + let (tx, rx) = channel(); + let (o, agent, b, ctx) = (owner.clone(), self.agent, book.to_string(), ctx.clone()); + std::thread::spawn(move || { + let _ = tx.send(o.agent_start(agent, &b)); + ctx.request_repaint(); + }); + self.state = State::Starting(rx); + self.book = Some(book.to_string()); + } + + /// The panel along the bottom of the window, for `book`'s agent. + pub fn show(&mut self, ui: &mut Ui, owner: &Arc, book: &str) { + let p = crate::theme::pal(ui.ctx()); + // Another book is shown: its own agent, not the last one's. + if self.book.as_deref().is_some_and(|b| b != book) { + self.state = State::Idle; + self.book = None; + } + // Starting finished? + if let State::Starting(rx) = &self.state + && let Ok(done) = rx.try_recv() + { + let ctx = ui.ctx().clone(); + self.state = match done { + Ok(AgentStart::Ready { + program, + args, + dir, + env, + .. + }) => { + let wake: Arc = Arc::new(move || ctx.request_repaint()); + match Session::spawn( + &program, + &args, + &dir, + &env, + Cells { + cols: 100, + rows: 24, + }, + wake, + ) { + Ok(s) => { + self.focused = true; + State::Running(Box::new(s)) + } + Err(why) => State::Failed(why), + } + } + Ok(AgentStart::Missing(i)) => State::Missing(i), + Err(why) => State::Failed(why), + }; + } + if self.open && matches!(self.state, State::Idle) { + self.start(ui.ctx(), owner, book); + } + let header = 34.0; + let panel = // Its own id open and closed: a size is remembered for each. + egui::Panel::bottom(if self.open { "dock-open" } else { "dock-closed" }).frame( + egui::Frame::new() + .fill(p.surface) + .stroke(egui::Stroke::new(1.0, p.border)), + ); + let panel = if self.open { + panel + .resizable(true) + .default_size(280.0) + .size_range(120.0..=2000.0) + } else { + panel.resizable(false).exact_size(header) + }; + panel.show(ui, |ui| { + // The panel keeps the size it has, whatever it holds just now. + if self.open { + ui.set_min_height(ui.available_height()); + } + self.header(ui, owner, book); + if !self.open { + return; + } + ui.add_space(0.0); + self.body(ui, &p); + }); + } + + fn header(&mut self, ui: &mut Ui, owner: &Arc, book: &str) { + let p = crate::theme::pal(ui.ctx()); + let mut restart = false; + ui.horizontal(|ui| { + ui.set_height(30.0); + ui.add_space(8.0); + let chevron = if self.open { + icon::CARET_DOWN + } else { + icon::CARET_UP + }; + if ui + .add(egui::Button::new(RichText::new(chevron).size(14.0)).frame(false)) + .on_hover_text(if self.open { + "Collapse the agent dock" + } else { + "Open the agent dock" + }) + .clicked() + { + self.open = !self.open; + } + ui.label( + RichText::new(format!("{} Agent", icon::TERMINAL_WINDOW)) + .font(crate::theme::semibold(12.5)) + .color(p.text), + ); + for agent in [Agent::Claude, Agent::Codex] { + if ui + .selectable_label(self.agent == agent, agent.label()) + .on_hover_text(format!("Run {} in this book's books home", agent.label())) + .clicked() + && self.agent != agent + { + self.agent = agent; + self.open = true; + restart = true; + } + } + if self.open { + let running = matches!(self.state, State::Running(_)); + let ended = matches!(&self.state, State::Running(s) if s.exited()); + if (ended || !running) + && !matches!(self.state, State::Starting(_)) + && ui.button("Start").clicked() + { + restart = true; + } + if running + && !ended + && ui + .button(icon::ARROW_CLOCKWISE) + .on_hover_text("Restart the agent") + .clicked() + { + restart = true; + } + if let State::Running(s) = &self.state { + let title = s.title(); + if !title.is_empty() { + ui.label(RichText::new(title).size(12.0).color(p.faint)); + } + } + } else { + ui.label( + RichText::new("Optional: any agent can use these books") + .size(12.0) + .color(p.faint), + ); + } + }); + if restart { + self.state = State::Idle; + self.start(ui.ctx(), owner, book); + } + } + + fn body(&mut self, ui: &mut Ui, p: &crate::theme::Palette) { + match &mut self.state { + State::Idle => {} + State::Starting(_) => { + ui.label(RichText::new("Starting the agent…").color(p.muted)); + } + State::Failed(why) => { + ui.add_space(6.0); + ui.colored_label(p.warning, format!("{} {why}", icon::WARNING)); + } + State::Missing(i) => { + ui.add_space(6.0); + ui.label( + RichText::new(format!("{} isn't installed here.", i.label)) + .font(crate::theme::semibold(13.0)), + ); + ui.label( + "Install it with its vendor's installer, in a terminal, then press Start:", + ); + ui.horizontal(|ui| { + ui.code(&i.command); + if ui.button(format!("{} Copy", icon::COPY)).clicked() { + ui.ctx().copy_text(i.command.clone()); + } + }); + ui.hyperlink_to(format!("{} instructions", i.label), &i.page); + ui.label( + RichText::new( + "You never need this: any agent you run yourself reaches the same books.", + ) + .color(p.faint), + ); + } + State::Running(session) => { + let (focused, _) = terminal(ui, session, p, self.focused); + self.focused = focused; + } + } + } +} + +/// The terminal's cell, in points. +fn cell_size(ui: &Ui, font: &FontId) -> egui::Vec2 { + let w = ui.ctx().fonts_mut(|f| f.glyph_width(font, 'M')); + let h = ui.ctx().fonts_mut(|f| f.row_height(font)); + vec2(w.max(1.0), h.max(1.0)) +} + +/// Draw the terminal in the space left, feed it the keyboard while it has +/// it, and keep its size the panel's (INVARIANT 5). Whether it has the +/// keyboard now. +fn terminal( + ui: &mut Ui, + session: &mut Session, + p: &crate::theme::Palette, + had: bool, +) -> (bool, Rect) { + let font = FontId::monospace(13.0); + let cell = cell_size(ui, &font); + let avail = ui.available_size(); + let (rect, response) = ui.allocate_exact_size(avail, Sense::click_and_drag()); + let id = response.id; + let dark = ui.visuals().dark_mode; + // The keyboard: given by a click in the terminal, kept until a click + // outside it (INVARIANT 4). + let mut focused = had; + if response.clicked() || response.drag_started() { + focused = true; + } else if ui.input(|i| i.pointer.any_pressed()) + && !ui + .input(|i| i.pointer.interact_pos()) + .is_some_and(|pos| rect.contains(pos)) + { + focused = false; + } + if focused { + ui.memory_mut(|m| { + m.request_focus(id); + m.set_focus_lock_filter( + id, + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: true, + }, + ); + }); + } + // Its size: the panel's, in cells. + let cols = ((rect.width() / cell.x).floor() as i64).clamp(2, 1000) as u16; + let rows = ((rect.height() / cell.y).floor() as i64).clamp(1, 500) as u16; + session.resize(Cells { cols, rows }); + + // The mouse: select with a drag, scroll with the wheel. + let point_at = |pos: Pos2, offset: usize| -> Point { + let col = + (((pos.x - rect.min.x) / cell.x).floor().max(0.0) as usize).min(cols as usize - 1); + let line = + (((pos.y - rect.min.y) / cell.y).floor().max(0.0) as usize).min(rows as usize - 1); + viewport_to_point(offset, Point::new(line, Column(col))) + }; + { + let mut term = session.term.lock(); + let offset = term.grid().display_offset(); + if let Some(pos) = response.interact_pointer_pos() { + if response.drag_started() { + term.selection = Some(Selection::new( + SelectionType::Simple, + point_at(pos, offset), + Side::Left, + )); + } else if response.dragged() + && let Some(sel) = term.selection.as_mut() + { + sel.update(point_at(pos, offset), Side::Right); + } else if response.clicked() { + term.selection = None; + } + } + if response.hovered() { + let wheel = ui.input(|i| i.smooth_scroll_delta.y); + let lines = (wheel / cell.y).round() as i32; + if lines != 0 { + term.scroll_display(Scroll::Delta(lines)); + } + } + } + + // The keyboard. + if focused { + let app_cursor = session.term.lock().mode().contains(TermMode::APP_CURSOR); + let bracketed = session + .term + .lock() + .mode() + .contains(TermMode::BRACKETED_PASTE); + let events = ui.input(|i| i.events.clone()); + let mods = ui.input(|i| i.modifiers); + for ev in events { + match ev { + Event::Text(t) if !mods.alt && !mods.ctrl => { + session.write(t.into_bytes()); + } + Event::Paste(t) => { + let t = t.replace("\r\n", "\r").replace('\n', "\r"); + if bracketed { + session.write(format!("\x1b[200~{t}\x1b[201~").into_bytes()); + } else { + session.write(t.into_bytes()); + } + } + Event::Key { + key, + pressed: true, + modifiers, + .. + } => { + // Ctrl-Shift-C copies the selection; Ctrl-V is a paste (its + // own event), anything else a program's. + if modifiers.ctrl && modifiers.shift && key == Key::C { + if let Some(text) = session.term.lock().selection_to_string() { + ui.ctx().copy_text(text); + } + } else if modifiers.ctrl && !modifiers.shift && key == Key::V { + } else if let Some(bytes) = key_bytes(key, modifiers, app_cursor) { + // Typing back to the bottom. + session.term.lock().scroll_display(Scroll::Bottom); + session.write(bytes); + } + } + _ => {} + } + } + } + + // The picture. + let painter = ui.painter_at(rect); + let (fg, bg) = (p.text, p.surface); + painter.rect_filled(rect, 0.0, bg); + let term = session.term.lock(); + let content = term.renderable_content(); + let offset = content.display_offset; + for cell_at in content.display_iter { + let Some(vp) = point_to_viewport(offset, cell_at.point) else { + continue; + }; + let flags = cell_at.cell.flags; + if flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + let bold = flags.contains(Flags::BOLD); + let mut cfg = color(cell_at.cell.fg, dark, fg, bg, bold); + let mut cbg = color(cell_at.cell.bg, dark, fg, bg, false); + if flags.contains(Flags::INVERSE) { + std::mem::swap(&mut cfg, &mut cbg); + } + if flags.contains(Flags::DIM) { + cfg = cfg.gamma_multiply(0.7); + } + let selected = content + .selection + .is_some_and(|sel| sel.contains(cell_at.point)); + if selected { + cbg = p.selected; + cfg = p.text; + } + let width = if flags.contains(Flags::WIDE_CHAR) { + 2.0 + } else { + 1.0 + }; + let top_left = rect.min + vec2(vp.column.0 as f32 * cell.x, vp.line as f32 * cell.y); + let r = Rect::from_min_size(top_left, vec2(cell.x * width, cell.y)); + if cbg != bg { + painter.rect_filled(r, 0.0, cbg); + } + let c = cell_at.cell.c; + if c != ' ' && c != '\0' && !flags.contains(Flags::HIDDEN) { + painter.text(top_left, egui::Align2::LEFT_TOP, c, font.clone(), cfg); + } + if flags.intersects(Flags::ALL_UNDERLINES) { + let y = r.bottom() - 1.0; + painter.line_segment( + [Pos2::new(r.left(), y), Pos2::new(r.right(), y)], + (1.0, cfg), + ); + } + } + // The cursor, a block that is hollow when the keyboard is elsewhere. + if !content.mode.contains(TermMode::SHOW_CURSOR) { + return (focused, rect); + } + if let Some(vp) = point_to_viewport(offset, content.cursor.point) { + let top_left = rect.min + vec2(vp.column.0 as f32 * cell.x, vp.line as f32 * cell.y); + let r = Rect::from_min_size(top_left, cell); + if focused { + painter.rect_filled(r, 0.0, p.accent.gamma_multiply(0.8)); + } else { + painter.rect_stroke(r, 0.0, (1.0, p.accent), egui::StrokeKind::Inside); + } + } + (focused, rect) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_become_what_a_terminal_sends() { + let none = Modifiers::NONE; + let ctrl = Modifiers::CTRL; + assert_eq!(key_bytes(Key::C, ctrl, false), Some(vec![3])); + assert_eq!(key_bytes(Key::D, ctrl, false), Some(vec![4])); + assert_eq!(key_bytes(Key::A, ctrl, false), Some(vec![1])); + assert_eq!(key_bytes(Key::Enter, none, false), Some(b"\r".to_vec())); + assert_eq!(key_bytes(Key::Backspace, none, false), Some(vec![0x7f])); + assert_eq!(key_bytes(Key::Escape, none, false), Some(vec![0x1b])); + assert_eq!( + key_bytes(Key::ArrowUp, none, false), + Some(b"\x1b[A".to_vec()) + ); + assert_eq!( + key_bytes(Key::ArrowUp, none, true), + Some(b"\x1bOA".to_vec()) + ); + assert_eq!( + key_bytes(Key::ArrowLeft, ctrl, true), + Some(b"\x1b[1;5D".to_vec()), + "a modifier takes the arrow out of application mode" + ); + assert_eq!( + key_bytes(Key::Delete, none, false), + Some(b"\x1b[3~".to_vec()) + ); + assert_eq!( + key_bytes(Key::Tab, Modifiers::SHIFT, false), + Some(b"\x1b[Z".to_vec()) + ); + assert_eq!( + key_bytes(Key::B, Modifiers::ALT, false), + Some(b"\x1bb".to_vec()) + ); + // Plain letters are typed text, not keys. + assert_eq!(key_bytes(Key::A, none, false), None); + assert_eq!(key_bytes(Key::Space, none, false), None); + } + + #[test] + fn colors_follow_the_terminals_palette() { + let (fg, bg) = (Color32::WHITE, Color32::BLACK); + let red = color(TermColor::Named(NamedColor::Red), true, fg, bg, false); + assert_eq!(red, ansi16(true)[1]); + let bold_red = color(TermColor::Named(NamedColor::Red), true, fg, bg, true); + assert_eq!(bold_red, ansi16(true)[9]); + assert_eq!( + color( + TermColor::Named(NamedColor::Foreground), + true, + fg, + bg, + false + ), + fg + ); + assert_eq!( + color( + TermColor::Spec(Rgb { r: 1, g: 2, b: 3 }), + true, + fg, + bg, + false + ), + Color32::from_rgb(1, 2, 3) + ); + // The 6x6x6 cube and the grays. + assert_eq!( + color(TermColor::Indexed(16), true, fg, bg, false), + Color32::from_rgb(0, 0, 0) + ); + assert_eq!( + color(TermColor::Indexed(231), true, fg, bg, false), + Color32::from_rgb(255, 255, 255) + ); + assert_eq!( + color(TermColor::Indexed(232), true, fg, bg, false), + Color32::from_rgb(8, 8, 8) + ); + } +} diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index 31c5d86..3f40ffa 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -9,6 +9,7 @@ pub mod books; pub mod charts; pub mod data; pub mod demo; +pub mod dock; pub mod export; pub mod fmt; pub mod menu; @@ -22,6 +23,7 @@ mod schemes; #[cfg(test)] mod test_support; mod theme; +pub mod welcome; mod widgets; pub use app::App; @@ -53,8 +55,10 @@ impl std::error::Error for Exit {} /// a book of the data root (plan 3.6), the only books it opens (D-44): /// `--root`, else the root `wanderbooks setup` made, never the current /// directory or a settings file; `--book` names one, else the root's only -/// one (or the first, by id). `--demo` builds a demo company in a temp -/// directory and opens that. +/// one (or the first, by id). With no books yet, and an owner to set them +/// up (`wanderbooks gui`), it opens the first-run page (welcome.rs) instead +/// of ending. `--demo` builds a demo company in a temp directory and opens +/// that. pub fn run( prog: &str, args: impl IntoIterator, @@ -94,17 +98,26 @@ pub fn run( eprintln!("demo books: {}", dir.display()); data_dir = Some(dir.join("books-data")); } + // The books to show, or a first run (no books yet, and the program gave + // the viewer an owner to set them up: plan P11b), or the reason not. + let mut first_run = None; let data_dir = match data_dir { - Some(d) => d, + Some(d) => Some(d), None => match book_in_root(root.as_deref(), book.as_deref()) { - Ok(d) => d, - Err(why) => { - eprintln!("{why}"); - return Err(Box::new(Exit(2))); - } + Ok(d) => Some(d), + Err(why) => match owner::current() { + // Named books that aren't there are a mistake, not a first run. + Some(owner) if book.is_none() => { + first_run = Some(owner.first_run()); + None + } + _ => { + eprintln!("{why}"); + return Err(Box::new(Exit(2))); + } + }, }, }; - let source = Source { data_dir, today }; let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("wanderbooks") @@ -121,7 +134,12 @@ pub fn run( Ok(eframe::run_native( "wanderbooks", options, - Box::new(move |cc| Ok(Box::new(App::new(cc, source)))), + Box::new(move |cc| { + Ok(Box::new(match (data_dir, first_run) { + (Some(data_dir), _) => App::new(cc, Source { data_dir, today }), + (None, state) => App::first_run(cc, root, today, state.unwrap_or_default()), + })) + }), )?) } diff --git a/crates/ledger-gui/src/owner.rs b/crates/ledger-gui/src/owner.rs index bee5031..53c20d8 100644 --- a/crates/ledger-gui/src/owner.rs +++ b/crates/ledger-gui/src/owner.rs @@ -47,7 +47,24 @@ pub enum Agent { } impl Agent { - fn label(self) -> &'static str { + /// The key it is kept under (the viewer's preference) and the word + /// the owner's commands know it by. + pub fn key(self) -> &'static str { + match self { + Agent::Claude => "claude", + Agent::Codex => "codex", + } + } + + /// The agent kept under `key`. + pub fn from_key(key: &str) -> Option { + [Agent::Claude, Agent::Codex] + .into_iter() + .find(|a| a.key() == key) + } + + /// Its name. + pub fn label(self) -> &'static str { match self { Agent::Claude => "Claude Code", Agent::Codex => "Codex", @@ -55,6 +72,51 @@ impl Agent { } } +/// Where a first run stands: what exists already (the viewer's welcome +/// page starts from the first missing step). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FirstRun { + /// The books' private folder is there. + pub root: bool, + /// The ids of the books in it. + pub books: Vec, + /// The owner passphrase is set. + pub passphrase_set: bool, +} + +/// How a person gets an agent's program: the vendor's own installer, never +/// bundled (D-27). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Installer { + /// The agent's name. + pub label: String, + /// The vendor's install command, for a terminal. + pub command: String, + /// The vendor's page about it. + pub page: String, +} + +/// What it takes to run an agent in a books home (the dock runs it on a +/// terminal of its own; nothing here starts a process). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentStart { + /// Run `program args` in `dir` with `env` added. + Ready { + /// The agent's name. + label: String, + /// The program. + program: std::path::PathBuf, + /// Its arguments. + args: Vec, + /// The books home it starts in. + dir: std::path::PathBuf, + /// Environment to add. + env: Vec<(String, String)>, + }, + /// The agent isn't installed: the vendor's installer. + Missing(Installer), +} + /// A restore previewed: what it would do, and what it is of (INVARIANT 3). #[derive(Clone, Debug, PartialEq, Eq)] pub struct RestorePlan { @@ -95,6 +157,26 @@ pub trait Owner: Send + Sync { /// The owner's go-ahead for the books `book`'s format upgrade, with the /// owner passphrase they typed (the server checks it). fn upgrade_format(&self, book: &str, passphrase: &str) -> Result, String>; + /// Where a first run stands (P11b: onboarding with no terminal). + fn first_run(&self) -> FirstRun { + FirstRun::default() + } + /// Make the books' private folder (what `wanderbooks setup` does). + fn set_up(&self) -> Result, String> { + Err("not here".into()) + } + /// Set the owner passphrase for the first time, as typed (twice) in the + /// viewer. + fn set_passphrase(&self, _new: &str) -> Result<(), String> { + Err("not here".into()) + } + /// Hold the books `book` open while the viewer runs (the viewer's lease). + fn hold(&self, _book: &str) {} + /// How to run `agent` in the books home of `book` (made if need be): the + /// dock starts it on a terminal of its own (D-27). + fn agent_start(&self, _agent: Agent, _book: &str) -> Result { + Err("not here".into()) + } } static OWNER: OnceLock> = OnceLock::new(); @@ -104,6 +186,11 @@ pub fn set_owner(owner: Arc) { let _ = OWNER.set(owner); } +/// The viewer's owner, if the program gave it one. +pub fn current() -> Option> { + owner() +} + fn owner() -> Option> { OWNER.get().cloned() } diff --git a/crates/ledger-gui/src/welcome.rs b/crates/ledger-gui/src/welcome.rs new file mode 100644 index 0000000..9f69667 --- /dev/null +++ b/crates/ledger-gui/src/welcome.rs @@ -0,0 +1,460 @@ +//! The first run (plan P11b, found by P11a: "onboards and posts an entry +//! with no terminal" was false while the viewer opened only an existing +//! book and `setup` and `books create` needed a terminal): the page the +//! viewer shows when there are no books yet. It sets up the books' private +//! folder, asks for the owner passphrase, makes the first books and connects +//! an agent, each through the [`Owner`], then opens the books. +//! +//! ```text +//! GOAL: a person who installed the app and opened it, with no terminal +//! and no admin password, has books, an agent that can keep them and +//! the view of them, in minutes. +//! SOURCE: plan/served.md P11a As built (Done-when 1: the AppImage "onboards +//! and posts an entry with no terminal"), P11b; D-19 (the viewer's +//! "New books…" and "Connect" menu items; one app); D-27 (the books +//! home, the dock, never required); D-29 (no passphrase handling +//! beyond the owner's own: the owner passphrase is typed here and +//! handed to the owner, as the upgrade dialog does). +//! INVARIANTS: +//! 1. The page only asks the [`Owner`]; nothing here writes a file of the +//! books or of the data root. +//! 2. Each step is shown from what exists now ([`FirstRun`]): a step done +//! (by the command line, say) is not asked again, and the page opens at +//! the first step missing. +//! 3. The owner passphrase may be left for later (the books work without +//! it); an agent may be left for later, or never: any agent reaches the +//! books through the MCP. +//! 4. A passphrase typed is wiped from memory once handed over, or when the +//! page goes. +//! 5. Each step runs off the UI thread and says what came of it, in +//! words; a refusal stays on the page, with the step. +//! 6. The books are opened only once they exist. +//! ``` + +use crate::owner::{Agent, AgentStart, FirstRun, Owner}; +use egui::{RichText, Ui}; +use egui_phosphor::regular as icon; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, channel}; + +/// The least an owner passphrase may be (the store's rule, said here). +pub const MIN_PASSPHRASE: usize = ledger_store::passphrase::MIN_CHARS; + +/// What a step's worker sends back: what it said (or why it was refused), +/// and where the first run stands now. +type Done = (Result, String>, FirstRun); + +/// The first-run page's state. +pub struct Welcome { + state: FirstRun, + /// The id and currency typed for the first books. + id: String, + currency: String, + /// The passphrase typed, twice. + pass: String, + again: String, + /// The passphrase step was left for later. + pass_later: bool, + /// The agents asked for, and what came of each. + agents: Vec<(Agent, Result, String>)>, + /// The agents may be left for later. + agent_later: bool, + /// A step under way: what it is, the agent it connects (if it does), + /// and its answer. + job: Option<(String, Option, Receiver)>, + /// What the last step said or why it was refused. + said: Option, String>>, + /// The books to open, once the person says. + open: Option, +} + +impl Drop for Welcome { + fn drop(&mut self) { + zeroize::Zeroize::zeroize(&mut self.pass); + zeroize::Zeroize::zeroize(&mut self.again); + } +} + +impl Welcome { + /// The page for a first run that stands at `state`. + pub fn new(state: FirstRun) -> Self { + Self { + state, + id: String::new(), + currency: "USD".into(), + pass: String::new(), + again: String::new(), + pass_later: false, + agents: Vec::new(), + agent_later: false, + job: None, + said: None, + open: None, + } + } + + /// Where the first run stands. + pub fn state(&self) -> &FirstRun { + &self.state + } + + /// The books the person chose to open, once. + pub fn take_open(&mut self) -> Option { + self.open.take() + } + + /// Show why the books could not be opened. + pub fn refused(&mut self, why: String) { + self.said = Some(Err(why)); + } + + fn run( + &mut self, + ctx: &egui::Context, + what: &str, + agent: Option, + owner: &Arc, + work: impl FnOnce(&dyn Owner) -> Result, String> + Send + 'static, + ) { + let (tx, rx) = channel(); + let (o, ctx) = (owner.clone(), ctx.clone()); + std::thread::spawn(move || { + let done = work(o.as_ref()); + let _ = tx.send((done, o.first_run())); + ctx.request_repaint(); + }); + self.job = Some((what.to_string(), agent, rx)); + self.said = None; + } + + /// The page, in the window's central area. + pub fn show(&mut self, ui: &mut Ui, owner: &Arc) { + let p = crate::theme::pal(ui.ctx()); + if let Some((_, agent, rx)) = &self.job { + let agent = *agent; + match rx.try_recv() { + Ok((done, state)) => { + self.job = None; + self.state = state; + if let Some(agent) = agent { + self.agents.retain(|(a, _)| *a != agent); + self.agents.push((agent, done.clone())); + } + self.said = Some(done); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.job = None; + self.said = Some(Err( + "it ended without an answer: look again at what exists, and try again" + .into(), + )); + self.state = owner.first_run(); + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + } + let busy = self.job.is_some(); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.set_max_width(640.0); + ui.add_space(36.0); + ui.label(RichText::new("Welcome to wanderbooks").font(crate::theme::bold(28.0))); + ui.label( + RichText::new( + "Your company's books, kept by an agent and checked by you. A few short steps; nothing here needs a terminal.", + ) + .color(p.muted), + ); + ui.add_space(18.0); + ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { + self.steps(ui, owner, busy); + }); + }); + }); + } + + /// A step's frame: its number, title and whether it is done. + fn card( + ui: &mut Ui, + n: usize, + title: &str, + done: bool, + active: bool, + body: impl FnOnce(&mut Ui), + ) { + let p = crate::theme::pal(ui.ctx()); + egui::Frame::new() + .fill(p.surface) + .stroke(egui::Stroke::new( + 1.0, + if active { p.accent } else { p.border }, + )) + .corner_radius(10) + .inner_margin(egui::Margin::symmetric(18, 14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (mark, color) = if done { + (icon::CHECK_CIRCLE.to_string(), p.positive) + } else { + (format!("{n}."), if active { p.accent } else { p.faint }) + }; + ui.label( + RichText::new(mark) + .font(crate::theme::semibold(16.0)) + .color(color), + ); + ui.label(RichText::new(title).font(crate::theme::semibold(16.0))); + }); + if active || done { + body(ui); + } + }); + ui.add_space(10.0); + } + + fn steps(&mut self, ui: &mut Ui, owner: &Arc, busy: bool) { + let p = crate::theme::pal(ui.ctx()); + let st = self.state.clone(); + let have_books = !st.books.is_empty(); + // The first step missing is the one asked for (INVARIANT 2). + let now = if !st.root { + 1 + } else if !st.passphrase_set && !self.pass_later { + 2 + } else if !have_books { + 3 + } else if self.agents.iter().all(|(_, r)| r.is_err()) && !self.agent_later { + 4 + } else { + 5 + }; + + // 1. The private folder. + Self::card( + ui, + 1, + "A private place for your books", + st.root, + now == 1, + |ui| { + if st.root { + ui.label(RichText::new("Made. Only your account can open it.").color(p.muted)); + } else { + ui.label("Wanderbooks keeps your books in a folder that only your account can open. Your agent reaches them through the app, never by looking in the folder."); + if ui + .add_enabled(!busy, egui::Button::new("Set it up")) + .clicked() + { + let ctx = ui.ctx().clone(); + self.run(&ctx, "Setting up", None, owner, |o| o.set_up()); + } + } + }, + ); + + // 2. The owner passphrase. + Self::card( + ui, + 2, + "Your owner passphrase", + st.passphrase_set, + now == 2, + |ui| { + if st.passphrase_set { + ui.label(RichText::new("Set.").color(p.muted)); + return; + } + ui.label(format!( + "The books ask for it before anything only you may do, like moving them to a newer format. Pick one you will remember: {MIN_PASSPHRASE} characters or more. It is not kept anywhere you can read." + )); + ui.add_enabled( + !busy, + egui::TextEdit::singleline(&mut self.pass) + .password(true) + .hint_text("passphrase") + .desired_width(f32::INFINITY), + ); + ui.add_enabled( + !busy, + egui::TextEdit::singleline(&mut self.again) + .password(true) + .hint_text("the same again") + .desired_width(f32::INFINITY), + ); + let long = self.pass.chars().count() >= MIN_PASSPHRASE; + let same = self.pass == self.again; + if !self.pass.is_empty() && !long { + ui.colored_label( + p.warning, + format!("{MIN_PASSPHRASE} characters or more, please."), + ); + } else if !self.again.is_empty() && !same { + ui.colored_label(p.warning, "The two don't match."); + } + ui.horizontal(|ui| { + if ui + .add_enabled(!busy && long && same, egui::Button::new("Set passphrase")) + .clicked() + { + // Handed over, and gone from the page (INVARIANT 4). + let typed = zeroize::Zeroizing::new(std::mem::take(&mut self.pass)); + zeroize::Zeroize::zeroize(&mut self.again); + let ctx = ui.ctx().clone(); + self.run(&ctx, "Setting the passphrase", None, owner, move |o| { + o.set_passphrase(&typed) + .map(|()| vec!["The owner passphrase is set.".to_string()]) + }); + } + if ui + .add_enabled(!busy, egui::Button::new("Not now")) + .on_hover_text( + "The books work without it; `wanderbooks passphrase` sets it later", + ) + .clicked() + { + self.pass_later = true; + } + }); + }, + ); + + // 3. The first books. + let ready = st.root; + Self::card( + ui, + 3, + "Your first books", + have_books, + ready && now == 3, + |ui| { + if have_books { + ui.label( + RichText::new(format!("Made: {}.", st.books.join(", "))).color(p.muted), + ); + return; + } + ui.label("A short name for the books: lowercase letters, digits and hyphens (your company's name, say). You can name the company itself afterwards, with your agent."); + ui.add_enabled( + !busy, + egui::TextEdit::singleline(&mut self.id) + .hint_text("my-company") + .desired_width(f32::INFINITY), + ); + ui.label("Their base currency (fixed for good):"); + ui.add_enabled( + !busy, + egui::TextEdit::singleline(&mut self.currency).desired_width(120.0), + ); + let ok = !self.id.trim().is_empty() && !self.currency.trim().is_empty(); + if ui + .add_enabled(!busy && ok, egui::Button::new("Make the books")) + .clicked() + { + let (id, cur) = (self.id.trim().to_string(), self.currency.trim().to_string()); + let ctx = ui.ctx().clone(); + self.run(&ctx, "Making the books", None, owner, move |o| { + let made = o.new_books(&id, &cur)?; + // Held open while the viewer runs. + o.hold(&id); + Ok(vec![made]) + }); + } + }, + ); + + // 4. An agent. + let book = st.books.first().cloned(); + let step4_active = have_books && now == 4; + let agents = self.agents.clone(); + Self::card( + ui, + 4, + "An agent to keep them", + agents.iter().any(|(_, r)| r.is_ok()), + step4_active, + |ui| { + ui.label("An agent (Claude Code or Codex) records your transactions, checks receipts and answers questions, through the books' own tools. This connects it; the app also has a panel along the bottom of its window that runs it for you. You never need either: any agent you run yourself can reach the same books."); + for (agent, result) in &agents { + match result { + Ok(lines) => { + ui.label(RichText::new(lines.join(" ")).color(p.positive)); + } + Err(why) => { + ui.colored_label(p.warning, format!("{}: {why}", agent.label())); + } + } + } + let Some(book) = book.clone() else { + return; + }; + ui.horizontal(|ui| { + for agent in [Agent::Claude, Agent::Codex] { + if ui + .add_enabled( + !busy, + egui::Button::new(format!("Connect {}", agent.label())), + ) + .clicked() + { + let b = book.clone(); + let ctx = ui.ctx().clone(); + self.run(&ctx, "Connecting", Some(agent), owner, move |o| { + match o.connect(agent, &b) { + Ok(lines) => Ok(lines), + // Not installed: how to get it. + Err(why) => match o.agent_start(agent, &b) { + Ok(AgentStart::Missing(i)) => Err(format!( + "{why}. Install it with `{}` in a terminal ({}), then connect it here.", + i.command, i.page + )), + _ => Err(why), + }, + } + }); + } + } + if ui + .add_enabled(!busy, egui::Button::new("I'll use my own agent")) + .clicked() + { + self.agent_later = true; + } + }); + }, + ); + + // 5. Open. + if have_books { + let id = st.books[0].clone(); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui + .add_enabled( + !busy, + egui::Button::new( + RichText::new(format!("{} Open the books", icon::BOOK_OPEN)) + .font(crate::theme::semibold(14.0)), + ) + .min_size(egui::vec2(180.0, 36.0)), + ) + .clicked() + { + self.open = Some(id); + } + }); + } + + // What the last step said. + if let Some((what, _, _)) = &self.job { + ui.add_space(8.0); + ui.label(RichText::new(format!("{what}…")).color(p.muted)); + } else if let Some(said) = &self.said { + ui.add_space(8.0); + match said { + Ok(lines) => ui.label(RichText::new(lines.join(" ")).color(p.muted)), + Err(why) => ui.colored_label(p.warning, format!("{} {why}", icon::WARNING)), + }; + } + } +} diff --git a/crates/ledger-gui/tests/dock.rs b/crates/ledger-gui/tests/dock.rs new file mode 100644 index 0000000..12738d0 --- /dev/null +++ b/crates/ledger-gui/tests/dock.rs @@ -0,0 +1,260 @@ +//! The agent dock (dock.rs, plan P11b, D-27): it runs the chosen agent in +//! the book's books home, the agent's output reaches the panel, keys and +//! Ctrl-C reach the agent, a resize redraws; an agent that isn't installed +//! is offered through its vendor's installer; the dock writes nothing of +//! the books. Linux and macOS (a PTY); Windows is not offered. + +#![cfg(unix)] + +use chrono::NaiveDate; +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::Currency; +use ledger_gui::App; +use ledger_gui::data::{self, Source}; +use ledger_gui::nav::Route; +use ledger_gui::owner::{Agent, AgentStart, Installer, Owner, RestorePlan}; +use ledger_store::Store; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// An owner that only answers `agent_start`, and remembers what it was asked. +#[derive(Default)] +struct Fake { + answer: Mutex>>, + asked: Mutex>, +} + +impl Owner for Fake { + fn close_books(&self) -> Result { + Err("not here".into()) + } + fn new_books(&self, _: &str, _: &str) -> Result { + Err("not here".into()) + } + fn back_up_now(&self, _: &str) -> Result, String> { + Err("not here".into()) + } + fn plan_restore(&self, _: &str, _: Option, _: bool) -> Result { + Err("not here".into()) + } + fn restore(&self, _: &RestorePlan) -> Result, String> { + Err("not here".into()) + } + fn connect(&self, _: Agent, _: &str) -> Result, String> { + Err("not here".into()) + } + fn upgrade_format(&self, _: &str, _: &str) -> Result, String> { + Err("not here".into()) + } + fn agent_start(&self, agent: Agent, book: &str) -> Result { + self.asked.lock().unwrap().push(format!("{agent:?} {book}")); + self.answer + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| Err("no answer set".into())) + } +} + +/// One test at a time: they share the one owner. +fn alone() -> std::sync::MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn fake() -> Arc { + static FAKE: OnceLock> = OnceLock::new(); + FAKE.get_or_init(|| { + let f = Arc::new(Fake::default()); + ledger_gui::owner::set_owner(f.clone()); + f + }) + .clone() +} + +/// Books in a data root, named `acme`. +fn books() -> (tempfile::TempDir, PathBuf, ledger_gui::books::Books) { + let dir = tempfile::Builder::new() + .prefix("wb-gui-dock-") + .tempdir() + .unwrap(); + std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap(); + let book = dir.path().join("books").join("acme"); + std::fs::create_dir_all(&book).unwrap(); + drop(Store::create(&book, Currency::USD).unwrap()); + let b = data::load_once(&Source { + data_dir: book.clone(), + today: NaiveDate::from_ymd_opt(2026, 10, 15), + }) + .unwrap(); + (dir, book, b) +} + +/// A stand-in `claude`: says where it is, answers a typed line, and reports +/// Ctrl-C and a resize (`stty size`) without ending. +fn fake_claude(dir: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("claude"); + std::fs::write( + &path, + r#"#!/bin/bash +echo "fake-agent in $(basename "$(pwd)")" +trap 'echo got-interrupt' INT +trap 'echo "size $(stty size)"' WINCH +echo ready +while true; do + if read -r -t 0.1 line; then echo "you-said: $line"; fi +done +"#, + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +fn harness(b: ledger_gui::books::Books) -> Harness<'static, App> { + let mut h = Harness::builder() + .with_size(egui::vec2(1200.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview)); + h.run_steps(3); + h +} + +/// SHOT_DIR set: the window as drawn, to look at. +fn shot(h: &mut Harness<'static, App>, name: &str) { + if let Ok(out) = std::env::var("SHOT_DIR") { + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + h.render() + .unwrap() + .save(out.join(format!("{name}.png"))) + .unwrap(); + } +} + +/// Run until the dock's screen holds `want`. +fn until(h: &mut Harness<'static, App>, want: &str) -> String { + let end = Instant::now() + Duration::from_secs(20); + loop { + h.run_steps(2); + let screen = h.state_mut().dock_mut().screen().unwrap_or_default(); + if screen.contains(want) { + return screen; + } + assert!( + Instant::now() < end, + "the dock never showed {want:?}; it shows:\n{screen}" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn dock_runs_the_chosen_agent() { + let _alone = alone(); + let f = fake(); + let tools = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let home = home.path().join("acme"); + std::fs::create_dir_all(&home).unwrap(); + *f.answer.lock().unwrap() = Some(Ok(AgentStart::Ready { + label: "Claude Code".into(), + program: fake_claude(tools.path()), + args: vec![], + dir: home.clone(), + env: vec![("TERM".into(), "xterm-256color".into())], + })); + let (_dir, book, b) = books(); + let before = std::fs::read(book.join("journal.jsonl")).ok(); + let mut h = harness(b); + assert!( + h.state_mut().dock_mut().screen().is_none(), + "nothing runs until the dock is opened" + ); + + // Opened: the chosen agent (Claude Code, the default) is started for + // this book, in its books home; its output reaches the panel. + h.state_mut().dock_mut().open = true; + let screen = until(&mut h, "ready"); + assert!(f.asked.lock().unwrap().contains(&"Claude acme".to_string())); + assert!(screen.contains("fake-agent in acme"), "{screen}"); + + // Typed text reaches it (and the terminal shows it, as a terminal does). + h.event(egui::Event::Text("hello".into())); + h.key_press(Key::Enter); + until(&mut h, "you-said: hello"); + + // Ctrl-C reaches the agent as an interrupt, and it goes on. + h.key_press_modifiers(Modifiers::CTRL, Key::C); + until(&mut h, "got-interrupt"); + + // A resize redraws: the program is told the new size. + let before_size = h.state_mut().dock_mut().cells().unwrap(); + h.set_size(egui::vec2(800.0, 900.0)); + h.run_steps(4); + let after = h.state_mut().dock_mut().cells().unwrap(); + assert!( + after.cols < before_size.cols, + "{before_size:?} -> {after:?}" + ); + until(&mut h, &format!("size {} {}", after.rows, after.cols)); + + shot(&mut h, "dock-running"); + + // Closing the window ends the agent with it (the session is dropped). + drop(h); + // The dock wrote nothing of the books. + assert_eq!(std::fs::read(book.join("journal.jsonl")).ok(), before); +} + +#[test] +fn agent_missing_offers_the_installer() { + let _alone = alone(); + let f = fake(); + *f.answer.lock().unwrap() = Some(Ok(AgentStart::Missing(Installer { + label: "Claude Code".into(), + command: "curl -fsSL https://claude.ai/install.sh | bash".into(), + page: "https://code.claude.com/docs/en/setup".into(), + }))); + let (_dir, _book, b) = books(); + let mut h = harness(b); + h.state_mut().dock_mut().open = true; + for _ in 0..100 { + h.run_steps(2); + if h.query_by_label_contains("isn't installed here").is_some() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + h.get_by_label_contains("Claude Code isn't installed here"); + h.get_by_label_contains("curl -fsSL https://claude.ai/install.sh | bash"); + h.get_by_label_contains("Copy"); + shot(&mut h, "dock-missing"); + // Nothing runs; nothing was installed by the app. + assert!(h.state_mut().dock_mut().screen().is_none()); +} + +#[test] +fn a_chosen_agent_and_an_open_dock_are_kept() { + let _alone = alone(); + fake(); + // Preferences: the agent and whether it is open are the viewer's. + let kept = |k: &str| match k { + "dock_agent" => Some("codex".to_string()), + "dock_open" => Some("true".to_string()), + _ => None, + }; + let dock = ledger_gui::dock::Dock::restored(kept); + assert_eq!(dock.agent, Agent::Codex); + assert!(dock.open); + let mut seen = Vec::new(); + dock.keep(&mut |k, v| seen.push((k.to_string(), v))); + assert!(seen.contains(&("dock_agent".to_string(), "codex".to_string()))); + assert!(seen.contains(&("dock_open".to_string(), "true".to_string()))); + let none = ledger_gui::dock::Dock::restored(|_| None); + assert_eq!(none.agent, Agent::Claude, "Claude Code is the default"); + assert!(!none.open); +} diff --git a/crates/ledger-gui/tests/welcome.rs b/crates/ledger-gui/tests/welcome.rs new file mode 100644 index 0000000..05fdb0c --- /dev/null +++ b/crates/ledger-gui/tests/welcome.rs @@ -0,0 +1,323 @@ +//! The first-run page (welcome.rs, plan P11b): with no books yet, the viewer +//! sets up the private folder, asks for the owner passphrase, makes the first +//! books, connects an agent and opens the books, each through the owner and +//! none needing a terminal. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::Currency; +use ledger_gui::App; +use ledger_gui::owner::{Agent, FirstRun, Owner, RestorePlan}; +use ledger_store::root::{BookId, Root}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +/// An owner over a real data root in a scratch folder, that records what it +/// was asked (the first run's steps), and refuses what it should. +#[derive(Default)] +struct Fake { + place: Mutex>, + asked: Mutex>, +} + +impl Fake { + fn reset(&self, home: PathBuf, root: PathBuf) { + *self.place.lock().unwrap() = Some((home, root)); + self.asked.lock().unwrap().clear(); + } + fn said(&self, what: String) { + self.asked.lock().unwrap().push(what); + } + fn root_path(&self) -> (PathBuf, PathBuf) { + self.place.lock().unwrap().clone().unwrap() + } +} + +impl Owner for Fake { + fn close_books(&self) -> Result { + Err("not here".into()) + } + fn new_books(&self, id: &str, currency: &str) -> Result { + self.said(format!("new {id} {currency}")); + let (_, root) = self.root_path(); + let root = Root::find(Some(&root)).map_err(|e| e.to_string())?; + let id = BookId::parse(id).map_err(|e| e.to_string())?; + root.create_book(&id, Currency::new(currency).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + Ok(format!("Made new books \"{id}\" ({currency}).")) + } + fn back_up_now(&self, _: &str) -> Result, String> { + Err("not here".into()) + } + fn plan_restore(&self, _: &str, _: Option, _: bool) -> Result { + Err("not here".into()) + } + fn restore(&self, _: &RestorePlan) -> Result, String> { + Err("not here".into()) + } + fn connect(&self, agent: Agent, book: &str) -> Result, String> { + self.said(format!("connect {agent:?} {book}")); + match agent { + Agent::Claude => Ok(vec!["Claude Code reaches the books.".into()]), + Agent::Codex => Err("Codex isn't installed here".into()), + } + } + fn upgrade_format(&self, _: &str, _: &str) -> Result, String> { + Err("not here".into()) + } + fn first_run(&self) -> FirstRun { + let (_, root) = self.root_path(); + let Ok(found) = Root::find(Some(&root)) else { + return FirstRun::default(); + }; + FirstRun { + root: true, + books: found + .list_books() + .unwrap() + .iter() + .map(|b| b.as_str().to_string()) + .collect(), + passphrase_set: ledger_store::passphrase::is_set(&found.auth_dir().unwrap()).unwrap(), + } + } + fn set_up(&self) -> Result, String> { + self.said("set up".into()); + let (home, root) = self.root_path(); + Root::setup(&home, Some(&root), None).map_err(|e| e.to_string())?; + Ok(vec!["Made the books' private folder.".into()]) + } + fn set_passphrase(&self, new: &str) -> Result<(), String> { + self.said(format!("passphrase of {} characters", new.chars().count())); + let (_, root) = self.root_path(); + let found = Root::find(Some(&root)).map_err(|e| e.to_string())?; + ledger_store::passphrase::set(&found.auth_dir().unwrap(), new, None) + .map_err(|e| e.to_string()) + } + fn hold(&self, book: &str) { + self.said(format!("hold {book}")); + } +} + +/// One test at a time: they share the one owner. +fn alone() -> std::sync::MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn fake() -> Arc { + static FAKE: OnceLock> = OnceLock::new(); + FAKE.get_or_init(|| { + let f = Arc::new(Fake::default()); + ledger_gui::owner::set_owner(f.clone()); + f + }) + .clone() +} + +fn harness(root: PathBuf, state: FirstRun) -> Harness<'static, App> { + let mut h = Harness::builder() + .with_size(egui::vec2(1000.0, 1100.0)) + .build_eframe(move |cc| { + App::first_run( + cc, + Some(root.clone()), + chrono::NaiveDate::from_ymd_opt(2026, 10, 15), + state.clone(), + ) + }); + h.run_steps(3); + h +} + +/// Run until `f` holds (a step's worker answered). +fn until(h: &mut Harness<'static, App>, what: &str, f: impl Fn(&Harness<'static, App>) -> bool) { + for _ in 0..300 { + h.run_steps(2); + if f(h) { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + shot(h, "welcome-stuck"); + panic!("never: {what}"); +} + +fn shot(h: &mut Harness<'static, App>, name: &str) { + if let Ok(out) = std::env::var("SHOT_DIR") { + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + h.render() + .unwrap() + .save(out.join(format!("{name}.png"))) + .unwrap(); + } +} + +fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::Builder::new() + .prefix("wb-gui-welcome-") + .tempdir() + .unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let root = dir.path().join("data"); + (dir, home, root) +} + +#[test] +fn first_run_onboards_with_no_terminal() { + let _alone = alone(); + let f = fake(); + let (_dir, home, root) = scratch(); + f.reset(home, root.clone()); + let mut h = harness(root.clone(), FirstRun::default()); + shot(&mut h, "welcome-1"); + + // 1. The private folder: set up from the page. + h.get_by_label("Set it up").click(); + until(&mut h, "the folder is made", |h| { + h.query_by_label_contains("Made. Only your account") + .is_some() + }); + assert!(h.state().welcome().unwrap().state().root); + + // 2. The passphrase: typed twice, handed to the owner, gone from the page. + assert_eq!( + h.query_all_by_role(egui::accesskit::Role::PasswordInput) + .count(), + 2, + "two password fields" + ); + for i in 0..2 { + h.get_all_by_role(egui::accesskit::Role::PasswordInput) + .nth(i) + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::PasswordInput) + .nth(i) + .unwrap() + .type_text("a long enough phrase"); + h.run_steps(2); + } + shot(&mut h, "welcome-2"); + h.get_by_label("Set passphrase").click(); + until(&mut h, "the passphrase is set", |h| { + h.state().welcome().unwrap().state().passphrase_set + }); + + // 3. The first books. + h.get_all_by_role(egui::accesskit::Role::TextInput) + .next() + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .next() + .unwrap() + .type_text("acme"); + h.run_steps(2); + h.get_by_label("Make the books").click(); + until(&mut h, "the books are made", |h| { + h.state().welcome().unwrap().state().books == ["acme"] + }); + shot(&mut h, "welcome-3"); + + // 4. An agent: Claude Code connects; Codex isn't installed, and says so. + h.get_by_label("Connect Codex").click(); + until(&mut h, "Codex is refused", |h| { + h.query_all_by_label_contains("Codex isn't installed here") + .next() + .is_some() + }); + h.get_by_label("Connect Claude Code").click(); + until(&mut h, "Claude Code is connected", |h| { + h.query_all_by_label_contains("Claude Code reaches the books") + .next() + .is_some() + }); + shot(&mut h, "welcome-4"); + + // 5. Open the books: the viewer shows them. + h.get_by_label_contains("Open the books").click(); + until(&mut h, "the books are shown", |h| { + h.query_by_label_contains("Needs attention").is_some() + }); + assert!(h.state().welcome().is_none(), "the page is gone"); + shot(&mut h, "welcome-5"); + + // What the owner was asked, in order, and nothing else. + let asked = f.asked.lock().unwrap().clone(); + assert_eq!( + asked, + [ + "set up", + "passphrase of 20 characters", + "new acme USD", + "hold acme", + "connect Codex acme", + "connect Claude acme", + ] + ); +} + +#[test] +fn first_run_starts_at_the_first_step_missing_and_asks_nothing_done_again() { + let _alone = alone(); + let f = fake(); + let (_dir, home, root) = scratch(); + f.reset(home.clone(), root.clone()); + // Done at a terminal already: the folder and the passphrase. + f.set_up().unwrap(); + f.set_passphrase("a long enough phrase").unwrap(); + f.asked.lock().unwrap().clear(); + let state = f.first_run(); + assert_eq!( + state, + FirstRun { + root: true, + books: vec![], + passphrase_set: true + } + ); + let h = harness(root, state); + // No folder or passphrase to ask for; the books are. + assert!(h.query_by_label("Set it up").is_none()); + assert!(h.query_by_label("Set passphrase").is_none()); + h.get_by_label("Make the books"); + assert!(f.asked.lock().unwrap().is_empty()); +} + +#[test] +fn a_passphrase_too_short_or_not_repeated_is_not_sent_and_can_wait() { + let _alone = alone(); + let f = fake(); + let (_dir, home, root) = scratch(); + f.reset(home, root.clone()); + f.set_up().unwrap(); + f.asked.lock().unwrap().clear(); + let mut h = harness(root, f.first_run()); + h.get_all_by_role(egui::accesskit::Role::PasswordInput) + .next() + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::PasswordInput) + .next() + .unwrap() + .type_text("short"); + h.run_steps(3); + h.get_by_label_contains("characters or more, please"); + // Too short: the button does nothing. + h.get_by_label("Set passphrase").click(); + h.run_steps(3); + assert!(f.asked.lock().unwrap().is_empty()); + // Not now: the books are asked for next, and nothing was sent. + h.get_by_label("Not now").click(); + h.run_steps(3); + h.get_by_label("Make the books"); + assert!(f.asked.lock().unwrap().is_empty()); + assert!(!f.first_run().passphrase_set); +} diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index dfba42e..0646efb 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1146,7 +1146,7 @@ pub fn connect( }; // The owner's command (INVARIANT 6). common.ask_upgrades = true; - match connect_agent(&common, agent, &book) { + match connect_and_home(&common, agent, &book) { Ok(lines) => { for line in lines { println!("{line}"); @@ -1160,20 +1160,6 @@ pub fn connect( } } -/// Whether `p` is a file someone may run (Windows: a file; what it can -/// run as is told by its extension, which the caller chose). -fn is_runnable(p: &Path) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) - } - #[cfg(not(unix))] - { - std::fs::metadata(p).is_ok_and(|m| m.is_file()) - } -} - /// Let the agent `agent` ("claude" or "codex") reach the books `book`: /// their token kept in its token file (made if none is), and `wanderbooks /// mcp --book ` added to the agent's own list as @@ -1187,50 +1173,11 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result>()) - .unwrap_or_default() - .into_iter() - .chain(usual) - .filter(|d| d.is_absolute()) - .flat_map(|d| { - // Windows runs a program by its extension. - let names: &[&str] = if cfg!(windows) { - &[".exe", ".cmd", ".bat"] - } else { - &[""] - }; - names - .iter() - .map(|ext| d.join(format!("{cli}{ext}"))) - .collect::>() - }) - .find(|p| is_runnable(p)) - .ok_or_else(|| { - format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)") - })?; + let program = find_agent_program(cli).ok_or_else(|| { + format!( + "{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)" + ) + })?; let mut lines = Vec::new(); // The token: kept in its file, or made now. let found = @@ -1313,6 +1260,417 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result, + home: Option<&Path>, +) -> Option { + let usual = [ + ".local/bin", + ".npm-global/bin", + ".bun/bin", + ".cargo/bin", + ".volta/bin", + // Where npm puts global programs on Windows. + "AppData/Roaming/npm", + ] + .into_iter() + .filter_map(|d| home.map(|h| h.join(d))) + .chain( + ["/usr/local/bin", "/opt/homebrew/bin"] + .into_iter() + .map(PathBuf::from), + ); + path.map(|p| std::env::split_paths(p).collect::>()) + .unwrap_or_default() + .into_iter() + .chain(usual) + .filter(|d| d.is_absolute()) + .flat_map(|d| { + // Windows runs a program by its extension. + let names: &[&str] = if cfg!(windows) { + &[".exe", ".cmd", ".bat"] + } else { + &[""] + }; + names + .iter() + .map(|ext| d.join(format!("{cli}{ext}"))) + .collect::>() + }) + .find(|p| is_program(p)) +} + +/// Whether `p` is a file this account can run. +fn is_program(p: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + p.is_file() + } +} + +/// This account's home, from the environment. +fn home_dir_env() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|h| h.is_absolute()) +} + +/// The agent program `cli` ("claude" or "codex") for this account. +fn find_agent_program(cli: &str) -> Option { + find_agent_in( + cli, + std::env::var_os("PATH").as_deref(), + home_dir_env().as_deref(), + ) +} + +/// How a person gets an agent's program: the vendor's own installer, +/// never bundled (D-27). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Installer { + /// The agent's name. + pub label: &'static str, + /// The vendor's install command, to run in a terminal. + pub command: &'static str, + /// The vendor's page about it. + pub page: &'static str, +} + +/// The vendor's installer for `agent` ("claude" or "codex"). +pub fn installer(agent: &str) -> Option { + match agent { + "claude" => Some(Installer { + label: "Claude Code", + command: if cfg!(windows) { + "irm https://claude.ai/install.ps1 | iex" + } else { + "curl -fsSL https://claude.ai/install.sh | bash" + }, + page: "https://code.claude.com/docs/en/setup", + }), + "codex" => Some(Installer { + label: "Codex", + command: "npm install -g @openai/codex", + page: "https://developers.openai.com/codex/cli", + }), + _ => None, + } +} + +/// The folder holding the user's documents: `Documents` in the home (the +/// word the spec uses on every system this program runs on; nothing in the +/// environment moves it, so a test's own `HOME` is all a test needs). +pub fn documents_dir() -> Option { + home_dir_env().map(|h| h.join("Documents")) +} + +/// The books home of `book` (D-27): a visible folder, +/// `/Wanderbooks/`, where an agent works on that book. It +/// holds the agent's pointer and settings for the book and nothing of the +/// books themselves (see [`make_books_home_in`]). Named by the book's id, +/// which never changes, not by the company's name, which is set after the +/// books are made and can change. +pub fn books_home(book: &str) -> PathBuf { + books_home_in( + &documents_dir().unwrap_or_else(|| PathBuf::from("Documents")), + book, + ) +} + +/// [`books_home`] under the given documents folder. +pub fn books_home_in(documents: &Path, book: &str) -> PathBuf { + documents.join("Wanderbooks").join(book) +} + +/// The program an agent's pointer file names: the AppImage's own file when +/// this runs from one (its mount goes away with the process), else this +/// executable as it is on disk. +fn pointer_program() -> Result { + if let Some(image) = std::env::var_os("APPIMAGE") + .map(PathBuf::from) + .filter(|p| p.is_absolute() && p.is_file()) + { + return Ok(image); + } + crate::start::this_executable() +} + +/// Make (or refresh) the books home of `book` under `documents`, for the +/// program `exe` (D-27). It holds exactly four files: +/// +/// ```text +/// AGENTS.md, CLAUDE.md three lines saying whose books these are and +/// how to reach them (written when absent: a +/// person's edits stay) +/// .mcp.json the project-level MCP entry: ` mcp --book ` +/// .claude/settings.json allows `mcp__wanderbooks-__*`, so the daily +/// flow doesn't prompt +/// ``` +/// +/// No books data, no key, no token, no path into the data root: the entry +/// finds the root the way every `wanderbooks` command does. The two JSON +/// files are merged into, so anything else a person keeps in them stays. +/// Trust in the folder is the person's to give: nothing here marks it +/// trusted for any agent (Q-7). +pub fn make_books_home_in( + documents: &Path, + book: &str, + company: Option<&str>, + exe: &Path, +) -> Result { + let book = ledger_store::root::BookId::parse(book) + .map_err(|e| e.to_string())? + .to_string(); + if !documents.is_absolute() { + return Err("there is no documents folder to make the books home in".into()); + } + let home = books_home_in(documents, &book); + let write_err = + |what: &Path, e: std::io::Error| format!("couldn't write {}: {}", what.display(), e.kind()); + std::fs::create_dir_all(home.join(".claude")).map_err(|e| write_err(&home, e))?; + let name = format!("wanderbooks-{book}"); + // The three lines, for both agents' file names. + let whose = company + .map(str::trim) + .filter(|c| !c.is_empty()) + .unwrap_or(&book); + let text = format!( + "These are {whose}'s books.\nConnect the wanderbooks MCP and call `books.start`.\nThe books live in the app's private folder, not here.\n" + ); + for file in ["AGENTS.md", "CLAUDE.md"] { + let path = home.join(file); + if !path.exists() { + std::fs::write(&path, &text).map_err(|e| write_err(&path, e))?; + } + } + let merge = |path: PathBuf, + edit: &dyn Fn(&mut serde_json::Map)| + -> Result<(), String> { + let mut doc = std::fs::read_to_string(&path) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::json!({})); + if let Some(map) = doc.as_object_mut() { + edit(map); + } + let mut out = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?; + out.push('\n'); + std::fs::write(&path, out).map_err(|e| write_err(&path, e)) + }; + let entry = serde_json::json!({ + "command": exe.to_string_lossy(), + "args": ["mcp", "--book", book], + }); + merge(home.join(".mcp.json"), &|doc| { + let servers = doc + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})); + if let Some(servers) = servers.as_object_mut() { + servers.insert(name.clone(), entry.clone()); + } + })?; + let rule = format!("mcp__{name}__*"); + merge(home.join(".claude").join("settings.json"), &|doc| { + let perms = doc + .entry("permissions") + .or_insert_with(|| serde_json::json!({})); + if let Some(perms) = perms.as_object_mut() { + let allow = perms + .entry("allow") + .or_insert_with(|| serde_json::json!([])); + if let Some(allow) = allow.as_array_mut() + && !allow.iter().any(|r| r.as_str() == Some(rule.as_str())) + { + allow.push(serde_json::Value::String(rule.clone())); + } + } + })?; + Ok(home) +} + +/// [`make_books_home_in`] for this account: its documents folder and this +/// program. +pub fn make_books_home(book: &str, company: Option<&str>) -> Result { + let documents = documents_dir().ok_or("this account has no documents folder")?; + make_books_home_in(&documents, book, company, &pointer_program()?) +} + +/// [`connect_agent`], and the books home made beside it (D-27: the agent's +/// own list gets the user-scope entry as ever; the home adds the +/// project-level one). What was done, a line each. +pub fn connect_and_home(common: &Common, agent: &str, book: &str) -> Result, String> { + let mut lines = connect_agent(common, agent, book)?; + lines.push(home_line(book)); + Ok(lines) +} + +/// [`new_books`], and the books home made beside them (D-27): the viewer's +/// "New books…". The home is a convenience: its trouble is said, and the +/// books stay made. +pub fn new_books_and_home(common: &Common, id: &str, currency: &str) -> Result { + let made = new_books(common, id, currency)?; + Ok(format!("{made} {}", home_line(id.trim()))) +} + +/// The books home made, said in a line (its trouble, too: the home is a +/// convenience, never a reason to fail what was asked). +fn home_line(book: &str) -> String { + match make_books_home(book, None) { + Ok(home) => format!( + "The books home is {}: start your agent there.", + home.display() + ), + Err(why) => format!("The books home wasn't made: {why}."), + } +} + +/// What starting an agent in a books home needs (the dock runs it on a +/// terminal of its own). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentStart { + /// The program, its arguments, the folder it starts in and the + /// environment it is given. + Ready { + /// The agent's name. + label: &'static str, + /// The program. + program: PathBuf, + /// Its arguments. + args: Vec, + /// The books home. + dir: PathBuf, + /// Environment to add. + env: Vec<(String, String)>, + }, + /// The agent isn't installed: how to get it. + Missing(Installer), +} + +/// Start `agent` ("claude" or "codex") on `book` (D-27): make sure the +/// books home is there, and say how to run the agent in it. Claude Code +/// and Codex ask about trusting the folder themselves the first time (Q-7: +/// the app marks nothing trusted). +pub fn start_agent(agent: &str, book: &str) -> Result { + let documents = documents_dir().ok_or("this account has no documents folder")?; + start_agent_in( + &documents, + &pointer_program()?, + std::env::var_os("PATH").as_deref(), + home_dir_env().as_deref(), + agent, + book, + ) +} + +/// [`start_agent`] with its surroundings given: the documents folder, the +/// program the books home points at, the `PATH` and the home that find the +/// agent. +pub fn start_agent_in( + documents: &Path, + exe: &Path, + path: Option<&std::ffi::OsStr>, + home: Option<&Path>, + agent: &str, + book: &str, +) -> Result { + let how = installer(agent).ok_or_else(|| format!("{agent:?}: claude or codex"))?; + let cli = if agent == "claude" { "claude" } else { "codex" }; + let dir = make_books_home_in(documents, book, None, exe)?; + let Some(program) = find_agent_in(cli, path, home) else { + return Ok(AgentStart::Missing(how)); + }; + Ok(AgentStart::Ready { + label: how.label, + program, + args: Vec::new(), + dir, + env: vec![ + ("TERM".into(), "xterm-256color".into()), + ("COLORTERM".into(), "truecolor".into()), + ], + }) +} + +/// Where a first run stands (P11b: onboarding with no terminal): whether +/// the books' private folder is there, which books it holds, and whether +/// the owner passphrase is set. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FirstRun { + /// The private folder (data root) exists and can be opened. + pub root: bool, + /// The ids of the books it holds. + pub books: Vec, + /// The owner passphrase is set. + pub passphrase_set: bool, +} + +/// [`FirstRun`] for the root `common` names (else the one setup recorded). +pub fn first_run_state(common: &Common) -> FirstRun { + let Ok(found) = ledger_store::root::Root::find(common.root.as_deref()) else { + return FirstRun::default(); + }; + FirstRun { + root: true, + books: found + .list_books() + .map(|b| b.iter().map(|id| id.as_str().to_string()).collect()) + .unwrap_or_default(), + passphrase_set: found + .auth_dir() + .ok() + .and_then(|a| ledger_store::passphrase::is_set(&a).ok()) + .unwrap_or(false), + } +} + +/// `wanderbooks setup` for a viewer with no terminal: make the books' +/// private folder (`common.root`, else this account's default; plan 3.4). +/// What was done, a line each. The folder's place is not said. +pub fn set_up_root(common: &Common) -> Result, String> { + let home = ledger_store::root::account_home().map_err(|e| e.to_string())?; + let xdg = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); + let (_, report) = + ledger_store::root::Root::setup(&home, common.root.as_deref(), xdg.as_deref()) + .map_err(|e| e.to_string())?; + Ok(vec![ + if report.created { + "Made the books' private folder. Only your account can open it." + } else { + "The books' private folder is there. Only your account can open it." + } + .to_string(), + ]) +} + +/// Set the owner passphrase for the first time, from a viewer (a person +/// typed it there, twice). Changing one in force is `wanderbooks +/// passphrase`'s: it asks for the one in force. +pub fn set_first_passphrase(common: &Common, new: &str) -> Result<(), String> { + use ledger_store::passphrase; + let found = + ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?; + let auth = found.auth_dir().map_err(|e| e.to_string())?; + if passphrase::is_set(&auth).map_err(|e| e.to_string())? { + return Err( + "an owner passphrase is set already: `wanderbooks passphrase` changes it".into(), + ); + } + passphrase::check_length(new).map_err(|e| e.to_string())?; + passphrase::set(&auth, new, None).map_err(|e| e.to_string()) +} + /// Make the token `name` for `book` (the server does) and keep it in its /// token file (INVARIANT 4: kept first, or taken back). fn make_token( diff --git a/crates/ledger-host/tests/books_home.rs b/crates/ledger-host/tests/books_home.rs new file mode 100644 index 0000000..8556d01 --- /dev/null +++ b/crates/ledger-host/tests/books_home.rs @@ -0,0 +1,253 @@ +//! Books homes (P11b, D-27): the folder an agent works in for a book. It +//! holds the agent's pointer and settings and nothing of the books. + +#[cfg_attr(not(unix), allow(unused_imports))] +use ledger_host::commands::{ + AgentStart, books_home_in, find_agent_in, installer, make_books_home_in, start_agent_in, +}; +use std::path::{Path, PathBuf}; + +/// Every file under `dir`, relative, sorted. +fn names(dir: &Path) -> Vec { + fn walk(base: &Path, dir: &Path, out: &mut Vec) { + for e in std::fs::read_dir(dir).unwrap() { + let p = e.unwrap().path(); + if p.is_dir() { + walk(base, &p, out); + } else { + out.push(p.strip_prefix(base).unwrap().to_string_lossy().into_owned()); + } + } + } + let mut out = Vec::new(); + walk(dir, dir, &mut out); + out.sort(); + out +} + +#[test] +fn books_home_holds_no_books_data() { + let docs = tempfile::tempdir().unwrap(); + // A data root with a journal, a key and a token file, elsewhere. + let root = tempfile::tempdir().unwrap(); + let book = root.path().join("books").join("acme"); + std::fs::create_dir_all(&book).unwrap(); + std::fs::write(book.join("journal.jsonl"), "{}").unwrap(); + let exe = PathBuf::from("/usr/bin/wanderbooks"); + + let home = make_books_home_in(docs.path(), "acme", Some("Acme Inc."), &exe).unwrap(); + assert_eq!(home, books_home_in(docs.path(), "acme")); + assert_eq!(home, docs.path().join("Wanderbooks").join("acme")); + // Exactly these four files. + assert_eq!( + names(&home), + [ + ".claude/settings.json", + ".mcp.json", + "AGENTS.md", + "CLAUDE.md" + ] + ); + // None names the data root, a token, a key or a journal. + for f in names(&home) { + let text = std::fs::read_to_string(home.join(&f)).unwrap(); + for bad in [ + root.path().to_str().unwrap(), + "token", + "journal", + "vault", + "key", + ] { + assert!(!text.contains(bad), "{f} mentions {bad:?}: {text}"); + } + } + let agents = std::fs::read_to_string(home.join("AGENTS.md")).unwrap(); + assert_eq!(agents.lines().count(), 3); + assert!(agents.contains("Acme Inc.'s books"), "{agents}"); + assert!(agents.contains("books.start")); + assert_eq!( + agents, + std::fs::read_to_string(home.join("CLAUDE.md")).unwrap() + ); + // The pointer and the allowance. + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!( + mcp["mcpServers"]["wanderbooks-acme"], + serde_json::json!({"command": "/usr/bin/wanderbooks", "args": ["mcp", "--book", "acme"]}) + ); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".claude/settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["permissions"]["allow"], + serde_json::json!(["mcp__wanderbooks-acme__*"]) + ); +} + +#[test] +fn a_books_home_made_again_keeps_what_a_person_kept() { + let docs = tempfile::tempdir().unwrap(); + let exe = PathBuf::from("/usr/bin/wanderbooks"); + let home = make_books_home_in(docs.path(), "acme", None, &exe).unwrap(); + // Their words in AGENTS.md; their own server and rule in the JSON. + std::fs::write(home.join("AGENTS.md"), "Mine.\n").unwrap(); + std::fs::write( + home.join(".mcp.json"), + r#"{"mcpServers":{"other":{"command":"x"}}}"#, + ) + .unwrap(); + std::fs::write( + home.join(".claude/settings.json"), + r#"{"permissions":{"allow":["Bash(ls)"]},"model":"opus"}"#, + ) + .unwrap(); + let exe2 = PathBuf::from("/opt/wanderbooks"); + make_books_home_in(docs.path(), "acme", None, &exe2).unwrap(); + make_books_home_in(docs.path(), "acme", None, &exe2).unwrap(); + assert_eq!( + std::fs::read_to_string(home.join("AGENTS.md")).unwrap(), + "Mine.\n" + ); + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!(mcp["mcpServers"]["other"]["command"], "x"); + assert_eq!( + mcp["mcpServers"]["wanderbooks-acme"]["command"], + "/opt/wanderbooks" + ); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".claude/settings.json")).unwrap()) + .unwrap(); + assert_eq!(settings["model"], "opus"); + assert_eq!( + settings["permissions"]["allow"], + serde_json::json!(["Bash(ls)", "mcp__wanderbooks-acme__*"]), + "once, however often it is made" + ); +} + +#[test] +fn a_books_home_is_never_made_for_a_name_that_is_not_an_id() { + let docs = tempfile::tempdir().unwrap(); + let exe = PathBuf::from("/usr/bin/wanderbooks"); + for bad in ["../x", "a/b", "", "A B"] { + assert!( + make_books_home_in(docs.path(), bad, None, &exe).is_err(), + "{bad:?}" + ); + } + assert!(std::fs::read_dir(docs.path()).unwrap().next().is_none()); + // And not into a relative place. + assert!(make_books_home_in(Path::new("Documents"), "acme", None, &exe).is_err()); +} + +#[cfg(unix)] +#[test] +fn an_agent_is_found_on_the_path_or_where_its_installer_puts_it() { + use std::os::unix::fs::PermissionsExt; + let bin = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let docs = tempfile::tempdir().unwrap(); + let exe = PathBuf::from("/usr/bin/wanderbooks"); + let path = std::ffi::OsString::from(bin.path()); + // Neither: missing, with the vendor's installer to offer. + let nowhere = std::ffi::OsString::from("/nonexistent"); + match start_agent_in( + docs.path(), + &exe, + Some(&nowhere), + Some(home.path()), + "claude", + "acme", + ) + .unwrap() + { + AgentStart::Missing(i) => { + assert_eq!(i, installer("claude").unwrap()); + assert!(i.command.contains("claude.ai/install"), "{}", i.command); + } + other => panic!("{other:?}"), + } + // On the PATH. + let claude = bin.path().join("claude"); + std::fs::write(&claude, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + find_agent_in("claude", Some(&path), Some(home.path())), + Some(claude.clone()) + ); + // A file that cannot be run is not the agent. + let codex = bin.path().join("codex"); + std::fs::write(&codex, "x").unwrap(); + assert_eq!(find_agent_in("codex", Some(&path), Some(home.path())), None); + // Where its installer puts it. + let local = home.path().join(".local/bin"); + std::fs::create_dir_all(&local).unwrap(); + let codex = local.join("codex"); + std::fs::write(&codex, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + find_agent_in("codex", Some(&nowhere), Some(home.path())), + Some(codex) + ); + // Starting: in the books home, nothing marked trusted. + match start_agent_in( + docs.path(), + &exe, + Some(&path), + Some(home.path()), + "claude", + "acme", + ) + .unwrap() + { + AgentStart::Ready { + program, + dir, + args, + env, + label, + } => { + assert_eq!(program, claude); + assert_eq!(dir, books_home_in(docs.path(), "acme")); + assert_eq!(label, "Claude Code"); + assert!(args.is_empty()); + assert!( + env.iter() + .any(|(k, v)| k == "TERM" && v == "xterm-256color") + ); + } + other => panic!("{other:?}"), + } + assert!(start_agent_in(docs.path(), &exe, None, None, "gemini", "acme").is_err()); +} + +#[test] +fn a_first_run_reads_where_it_stands_and_sets_the_first_passphrase_once() { + use ledger_host::commands::{Common, FirstRun, first_run_state, set_first_passphrase}; + let home = tempfile::tempdir().unwrap(); + let place = home.path().join("data"); + let common = Common { + root: Some(place.clone()), + ..Default::default() + }; + // Nothing there yet. + assert_eq!(first_run_state(&common), FirstRun::default()); + // Set up (what `wanderbooks setup --root` does), without a terminal. + ledger_store::root::Root::setup(home.path(), Some(&place), None).unwrap(); + assert_eq!( + first_run_state(&common), + FirstRun { + root: true, + books: vec![], + passphrase_set: false + } + ); + // Too short is refused; a good one is set; a second first one is refused. + assert!(set_first_passphrase(&common, "short").is_err()); + set_first_passphrase(&common, "a long enough passphrase").unwrap(); + assert!(first_run_state(&common).passphrase_set); + let again = set_first_passphrase(&common, "another long passphrase").unwrap_err(); + assert!(again.contains("wanderbooks passphrase"), "{again}"); +} diff --git a/docs/architecture.md b/docs/architecture.md index d22bee2..40f59e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,8 +36,8 @@ the specification in `bookspec/` (which never depends on the engine). | `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels, the audit workpaper) out | `ledger-core`, `ledger-reader` | | `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` | | `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above | -| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads, `update` and `rollback` (P11a) | `ledger-store`, `ledger-mcp`, `ledger-config` | -| `ledger-gui` | the viewer: read-only, live, egui; the Books menu hands work to the server | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` | +| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads, `update` and `rollback` (P11a), books homes and how to start an agent in one (P11b) | `ledger-store`, `ledger-mcp`, `ledger-config` | +| `ledger-gui` | the viewer: read-only, live, egui; the Books menu hands work to the server; the agent dock and the first-run page (P11b) | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` | | `ledger-cli` | `wanderbooks`, the one executable: every mode (`serve`, `server`, `host`, `mcp`, `gui`, `setup`, `books`, `token`, `connect`, `doctor`, …) and the over-the-wire test suites | everything | | `bookspec/checker`, `bookspec/runner` | the example schema and parser, the rule catalogue, the `Engine` trait; the per-rule runner | nothing of ours (the adapter in `ledger-core`'s tests depends on them) | @@ -116,6 +116,31 @@ appended journal lines; the UI renders an immutable view model rebuilt per change. Statements and series come from `ledger-core` (`reports`, `analysis`); the GUI only arranges them. See `plan/gui.md`. +Two things sit beside the read-only view, both through the `Owner` the +program gives the viewer (never a write of the books by the viewer): + +- **The agent dock** (`dock.rs`, P11b, D-27): a terminal panel along the + bottom of the window (`alacritty_terminal` over a PTY; ConPTY on Windows, + not offered until its four checks are recorded) that runs Claude Code or + Codex in the book's **books home**. The agent is an ordinary child process + of the viewer's program (D-32: no helper, no confinement) and writes the + books through the server like any other agent. The CLIs are found or + offered through their vendors' installers, never bundled; the app marks + no folder trusted (Q-7). Never required: any agent, anywhere, reaches the + same books through the MCP. +- **The first-run page** (`welcome.rs`): with no books and an owner to ask, + `wanderbooks gui` shows a page that sets up the private folder, the owner + passphrase, the first books and an agent, then opens the books, so an + installed app onboards with no terminal. + +A **books home** (`ledger_host::commands::books_home`) is a visible folder, +`/Wanderbooks/`, made by the viewer's "New books…" and by +`wanderbooks connect` (and again whenever the dock starts an agent). It holds +exactly `AGENTS.md`, `CLAUDE.md` (three lines), `.mcp.json` (the +project-level entry, `wanderbooks mcp --book ID`) and `.claude/settings.json` +(allowing `mcp__wanderbooks-ID__*`): no books data, key, token or path into +the data root (I-1 stays: the books are hidden, not here). + ## Shared read-only views (`ledger-package`) `ledger-package` is the shared read-only view: packages, the invoice PDF, diff --git a/docs/parity.md b/docs/parity.md index 5a3f598..eeaf8c8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -247,3 +247,11 @@ Three new rows, none above changed status, and the floor stands. | Audit run: one programme of checks over the statements, the control accounts, closes, schedules, assets, evidence, drafts, tax categories, 1099 decisions, settings and the store's integrity (`books.audit`, `books.workpaper`) | An auditor's workpaper; QBO "Audit log"/"Review" and Xero's "Reports review" are a cross-check only | partial | rules.md B-45; `ledger_core::audit`, `ledger_store::audit`; tests `audit_detects_each_seeded_defect` (every code seeded), `audit_is_deterministic`, `workpaper_pdf_is_deterministic`; the fixture `p8_before` was written by the version before; `partial` until a hand-verified golden covers a workpaper | | Attestations of receipts, entries and periods, weighed against `policy.attestation_bar` when asked (`books.attest_receipt`, `books.review_entry`, `books.attest_period`, `books.attestations`) | An accountant's sign-off on a month | partial | `below_bar_not_counted`, `amount_mismatch_never_counts`, `stale_head_period_attestation_refused`; the method is the client's own claim, and the books say so | | The CPA package waits for a counted audit (`policy.cpa_package_requires_audit`) | A close checklist before handoff | partial | `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`; behind the feature `audits` | + +## P11b: the agent dock, books homes, first run (2026-10-29) + +| Area | Counterpart (a cross-check, not the spec) | Status | Notes | +|---|---|---|---| +| Books home: a visible folder per book, `/Wanderbooks/`, holding `AGENTS.md`, `CLAUDE.md`, `.mcp.json` and `.claude/settings.json` and nothing of the books | A QBO/Xero company has no counterpart; the point is the agent's workplace (D-27) | partial | `books_home_holds_no_books_data`, `a_books_home_made_again_keeps_what_a_person_kept` (`crates/ledger-host/tests/books_home.rs`); `partial` until the macOS home is tried | +| Agent dock: Claude Code or Codex in a terminal along the bottom of the viewer, started in the books home; never required | QBO's assistant panel is a cross-check only | partial | `dock_runs_the_chosen_agent`, `agent_missing_offers_the_installer` (`crates/ledger-gui/tests/dock.rs`); Linux checked; macOS by the CI run; Windows not offered (ConPTY unchecked) | +| First run with no terminal: the viewer sets up the private folder, the owner passphrase, the first books and an agent | QBO's "set up your company" flow | partial | `first_run_onboards_with_no_terminal` (`crates/ledger-gui/tests/welcome.rs`); the AppImage's whole path is the EM's to try on a clean machine | diff --git a/docs/testing.md b/docs/testing.md index 27e3284..403f142 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4045,3 +4045,83 @@ this repository's files live in `tests/bookspec.rs`: `every_vendored_pin_is_verb `rules_md_carries_every_id`. The examples named `bookspec/examples/...` in the older sections above are files of that repository. The checker's, the runner's and the derivation tests run in its own CI. + +## P11b: books homes, the agent dock, the first run (2026-10-29) + +Three layers, none touching the owner's books or data root: a home is made +under a scratch "documents" folder the test gives (`make_books_home_in` +takes it as an argument, so no test writes in an account's real +`~/Documents`); the dock runs a stand-in `claude` (a bash script) on a real +PTY under the viewer's harness; the first run is driven page by page +against an owner over a real scratch data root. + +`crates/ledger-host/tests/books_home.rs`: + +- `books_home_holds_no_books_data`: exactly four files + (`.claude/settings.json`, `.mcp.json`, `AGENTS.md`, `CLAUDE.md`); none + names the data root (a root with a journal sits beside the home), a token, + a key, a vault or a journal; `AGENTS.md` is three lines and names the + company and `books.start`; `.mcp.json` holds `wanderbooks-acme` running + ` mcp --book acme` and no `--root`; the settings allow + `mcp__wanderbooks-acme__*`. +- `a_books_home_made_again_keeps_what_a_person_kept`: a person's + `AGENTS.md`, their other MCP server and their other settings stay; the + entry and the rule are refreshed and appear once. +- `a_books_home_is_never_made_for_a_name_that_is_not_an_id`: `../x`, + `a/b`, the empty id and `A B` make nothing; a relative documents folder is + refused. +- `an_agent_is_found_on_the_path_or_where_its_installer_puts_it` + (`agent_missing_offers_the_installer`'s host half): not found gives the + vendor's installer; found on the PATH, a file that cannot be run is not + the agent, `~/.local/bin` is searched; `start_agent_in` returns the books + home as the directory and marks nothing trusted (Q-7). +- `a_first_run_reads_where_it_stands_and_sets_the_first_passphrase_once`. + +`crates/ledger-gui/tests/dock.rs` (Linux and macOS; a PTY): + +- `dock_runs_the_chosen_agent`: closed, nothing runs; opened, the owner is + asked for Claude Code on book `acme`, the program starts in the books + home (its own `pwd`), its output reaches the panel, typed text and Enter + reach it, a Ctrl-C key reaches it as an interrupt (`got-interrupt`; the + agent goes on), narrowing the window resizes the terminal and the program + is told the new size (`stty size` on its `WINCH` matches `Dock::cells`); + the journal is byte for byte as it was. +- `agent_missing_offers_the_installer`: the page names the agent, shows the + vendor's install command with a Copy button and starts nothing. +- `a_chosen_agent_and_an_open_dock_are_kept`: the preferences round trip, + Claude Code the default. +- Unit tests in `dock.rs`: `keys_become_what_a_terminal_sends`, + `colors_follow_the_terminals_palette`. + +`crates/ledger-gui/tests/welcome.rs`: + +- `first_run_onboards_with_no_terminal`: from nothing, "Set it up" makes the + private folder, two matching passphrase fields set the passphrase (the + owner is handed 20 characters, once), "Make the books" makes `acme` and + holds it, Codex is refused with its reason and Claude Code connects, + "Open the books" shows the books (Needs attention) and the page is gone; + the owner was asked exactly: set up, passphrase, new, hold, connect + Codex, connect Claude. +- `first_run_starts_at_the_first_step_missing_and_asks_nothing_done_again`. +- `a_passphrase_too_short_or_not_repeated_is_not_sent_and_can_wait`. + +Existing tests that cover the unchanged parts: `owner_menu.rs` (the menu +still hands each action to the owner), `read_only.rs`, `sidenav.rs` (the +sidebar's shape golden is unchanged: the dock sits right of the sidebar). + +**Not tested here:** macOS and Windows (no machine); the real `claude` and +`codex` (not installed on CI); the real `wanderbooks gui` first run +through the AppImage on a clean machine (the EM's). + +**For P12 (mutation targets):** `commands::make_books_home_in` (the +`!path.exists()` test that keeps a person's `AGENTS.md`; the merge that +dedupes the allow rule; `BookId::parse` before any path is joined); +`find_agent_in` (the order PATH then the installer folders; `is_program`'s +mode test); `start_agent_in` (the `Missing` arm); `first_run_state`'s three +fields; `set_first_passphrase`'s already-set refusal; `dock::key_bytes` (the +modifier parameter's arithmetic, the Ctrl-letter byte, the app-cursor +switch, the Alt escape); `dock::color`'s cube and gray ramps and its bold +brightening; `Session::resize`'s equality early return; the panel's +open/closed ids (a remembered size must not leak between them); +`Welcome::steps`' choice of `now` (each arm), the passphrase length and +equality gates, the `agent_later` flag. diff --git a/plan/HANDOFF.md b/plan/HANDOFF.md index 4a238b2..30eede2 100644 --- a/plan/HANDOFF.md +++ b/plan/HANDOFF.md @@ -18,7 +18,7 @@ You are the engineering manager (EM) of a team implementing the remaining work o - `main` is `bfd453b6`. Landed since the hand-off: U0a, U0b, U0c, P7, P9a, P13 steps 1-3, P4b S4.1, S4.2, S4.3 (2026-10-26 to 2026-10-27), then S4.4, S5, S6, S7 and P6 (2026-10-29, merged from the paused branches by the planning session). Live on the owner's books: P0 to P3, Projects, P4b S1 (`document_standing`); everything behind `documents_v2` waits for the owner's upgrade (Q-5). - Nothing is built and unmerged. Branch `ci-fixes` (`76285434`, a fuzz target's renamed path and a regression test) is the one branch left to merge. -- Landed 2026-10-29 by wave 1 of this session's subagents: U0d, S4.5, P13 steps 4-5, P11a step 0 (the portable CI job; its keyring stores are being removed). Building: P4, U0e. Not started: P5, P8, P10, P11a steps 1-7, P11b, P11c, P12, P13 step 6 (the owner's call). +- Landed 2026-10-29 by this session's subagents in three waves: U0d, U0e, S4.5, P4, P5, P8, P10 (the runbook), P11a steps 0-4 plus the unsigned macOS bundle job, P11b, P13 steps 4-6 (the spec lives at `git.swaits.com/swaits/bookspec`). Building: P11c (branch `P11c`, proven by the portable CI jobs). Left: P11a steps 5-7 (signing deferred, D-64), P12, the owner's re-audit. - The plan's section 8 was rewritten on 2026-10-26 for this hand-off and revised on 2026-10-28 (section 0, "What changed on 2026-10-28"; `plan/2026-10-28_DRIFT_REPORT.md`). Sections 1 to 7 are the older design and carry strike-throughs (`~~text~~ → D-n`) and "As built" notes where the build or a decision departed; where section 8 and an earlier section disagree, section 8 is right. - Mutation testing is paused until unit P12 (decision D-33). Workers never run `cargo mutants`; each unit writes a "For P12" paragraph in `docs/testing.md` instead (recipe R-G). - The owner's live books are never touched by anyone on this team. The only books a worker uses are test fixtures and throwaway books made by `wanderbooks setup` in a scratch root. A unit's new writes are refused on the owner's books until the owner's format upgrade; merging never waits for that. diff --git a/plan/STATUS.md b/plan/STATUS.md index d83c4ee..288e925 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -1,6 +1,24 @@ # STATUS — 2026-10-28: nine units landed, five built on paused branches; P11a starts -## Now (2026-10-29, evening): wave 1 of the rest of the plan is landing +## Now (2026-10-29, night): waves 1 to 3 landed; P11c and P12 left + +**Landed today**, each merged with a compile and the unit's own tests +(D-60) and gated by GitHub's `check` on the push: U0d, U0e, S4.5, P4, +P5, P6 (from the paused wave), P8, P10's runbook, P11a steps 0-4 and +the unsigned macOS bundle job, P11b, P13 steps 4-6. The plan's units +still open: **P11c** (the native Windows port; its branch compiles and +runs the suite on windows-latest, two lock-semantics failures fixed, +macOS still refuses to start the server in CI and the next run prints +why), **P11a** steps 5-7 (signing deferred by D-64; the in-app accept +and the viewer's update notice need names; the Mac trial is the +owner's), **P12** (max testing, after P11c), and P10's re-audit itself +(the owner's run). Decisions today: D-60 to D-65. + +**The CI state** is the measure of the day: `main` must be green on +`check`; the portable macOS and Windows jobs are the two ports' gates +and red until they land. Every push is watched; a docs-only push is +never made alone (it cancels the running gate). + **Who builds.** The owner chose (the question tool) that this planning session is the EM and launches Sonnet subagents in parallel worktrees, diff --git a/plan/gui.md b/plan/gui.md index e304177..9929e94 100644 --- a/plan/gui.md +++ b/plan/gui.md @@ -144,11 +144,20 @@ column and the tie-out badge (S6), write-offs on the timeline (S7), documents grouped by category and subject (P6). S4.5 adds the number, terms, bill-to and the issued copy to Save. +Landed 2026-10-29 (P11b): **the agent dock** along the bottom of the window +(Claude Code or Codex in the book's books home, over a PTY with +`alacritty_terminal`; collapsible and resizable; the choice remembered; +Linux checked, macOS by CI, Windows not offered until its ConPTY checks are +recorded; `dock.rs`, `tests/dock.rs`) and **the first-run page** (no books yet: +the private folder, the owner passphrase, the first books, an agent, then +the books open; `welcome.rs`, `tests/welcome.rs`), which is what lets an +installed app onboard with no terminal. Both go through the `Owner`; the +viewer still writes nothing of the books. + Next, in the plan's order: S4.5's page changes; P4's draft states in Needs attention; P11a (the installed app: the viewer is its face, with -"Check for updates" in the menu) and P11b (the agent dock along the -bottom of the window, Claude Code or Codex in it, started in the book's -books home; D-27). Not planned: a web build, which belongs to the hosted +"Check for updates" in the menu; P11b, the dock and the first run, is +landed, above). Not planned: a web build, which belongs to the hosted service (`plan/served.md` section 11, a browser viewer); the remote viewer replica (D-51: a backup clone opened read-only serves instead). Still open from the first version's list: a cash forecast with a diff --git a/plan/served.md b/plan/served.md index 8e5e7ee..6abd15a 100644 --- a/plan/served.md +++ b/plan/served.md @@ -835,10 +835,10 @@ One row per unit of work. A unit is what one engineer takes into one git worktre | P9c | Remote viewer replica, invitations | not planned (D-51) | | | | | | P10 | The re-audit runbook and its CI mirror; the owner runs the re-audit | not started | P5, P6, P8 | P11a, P13 | none | 1 (+ owner days) | | P11a | Packaging and updates: step 0 the platform groundwork, the trial, `wanderbooks update`/`rollback`, Linux packages, signing, macOS and Windows bundles | in progress: steps 0-4 and the macOS half of step 6 landed 2026-10-29 (update/rollback, Linux packages, AppImage with restart, the daily staged update, the unsigned `.dmg` job); step 5 (signing) deferred by D-64; left: the in-app accept and the viewer's update notice (names needed), the release workflow's first run (owner's click), the trials on a Mac | none (code); certificates and keys from the owner for the signing steps; Q-4 before the manifest step | everything | none | 20-25 | -| P11b | The agent dock and books homes | not started | P11a (an installed app to dock into) | P12 | none | 8-10 | +| P11b | The agent dock and books homes | landed 2026-10-29 (`f625848a`): the dock (a real PTY, Claude Code or Codex in a books home), books homes under `~/Documents/`, the first-run page with no terminal; macOS unverified until the portable job runs it; Windows not offered (P11c) | P11a (an installed app to dock into) | P12 | none | 8-10 | | P11c | The native Windows port: named pipes with the SID check, owner-only DACLs, file locking, the `%LOCALAPPDATA%` data root, the Windows CI job made gating (D-62) | in progress (wave 2, branch `P11c`) | U0e; P11a step 0 (the portable job) | P11b, P12 | none | 5-8 | | P12 | Max testing, again | not started | everything else merged | | none | 2-3 (+ machine time) | -| P13 | bookspec: checker crate, `Engine` trait, rule ids, the spec text, the runner | in progress: steps 1-5 landed (`579d0eed`, `d839e709`); step 6 (the spec's own public repository) decided 2026-10-29 (D-65): split out now | none for steps 1-4; the engine stable for step 5 | everything | none | 15-20 | +| P13 | bookspec: checker crate, `Engine` trait, rule ids, the spec text, the runner | steps 1-6 done: steps 1-5 landed (`579d0eed`, `d839e709`); step 6 landed 2026-10-29 (`d738c445`): the specification lives at `git.swaits.com/swaits/bookspec`, pinned by rev (D-65); ten stated rules still lack an example (the schema cannot show them yet), listed in the As-built block | none for steps 1-4; the engine stable for step 5 | everything | none | 15-20 | #### 8.0.2 Dependency graph and merge order @@ -1672,7 +1672,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-gui/src/dock.rs`, `commands::books_home`; touches `owner.rs`, `app.rs`, `commands.rs`. 6. **Tests and proofs:** named above. 7. **Done when:** the three tests pass on Linux; the dock runs Claude Code in a books home on Linux and macOS; Windows recorded as offered or not. -8. **As built:** none yet. +8. **As built:** (2026-10-29, branch `P11b`; the EM fills the commits.) **Books homes:** `ledger_host::commands::{books_home, books_home_in, make_books_home, make_books_home_in}` as specified, four files, the JSON two merged into and the two text files written only when absent (a person's edits stay). ~~``~~ → the folder is named by the book **id** (`~/Documents/Wanderbooks/`): the company name is set after the books are made and can change, the id cannot; `AGENTS.md` says the id in the company's place (a later unit may pass the name). `Documents` is `$HOME/Documents` only (no `XDG_DOCUMENTS_DIR`: an inherited variable would aim a test at the real folder; Windows `%USERPROFILE%\\Documents`, unbuilt). `.mcp.json` names `wanderbooks mcp --book ID` with no `--root` (a path into the data root is exactly what the home must not hold); the program is `$APPIMAGE` when set, else this executable. `connect_agent` is unchanged; `connect_and_home` (the `connect` command and the viewer's Connect) and `new_books_and_home` (the viewer's New books...) make the home; the plain `new_books`/`books create` do not (the in-process tests call them with the account's own home). **Q-7, built as the recommended answer:** nothing marks any folder trusted for Claude Code or Codex; each asks the person the first time. **Dock:** `crates/ledger-gui/src/dock.rs`, `alacritty_terminal` 0.26 over a PTY, collapsible and resizable, agent and open state kept in the viewer's preferences. ~~`Owner::start_agent(Agent, book) -> Child`~~ → `Owner::agent_start(Agent, book) -> Result`: a PTY is created by the terminal library, which spawns the child itself, so the owner (over `ledger_host::commands::start_agent`) says *what* to run (program, arguments, books home, `TERM`) or hands back the vendor's installer (`AgentStart::Missing`: a command to copy and a page; nothing installed by the app). Mouse selection (Ctrl-Shift-C copies), wheel scroll, bracketed paste and the usual keys work; not built: mouse reporting to the agent, a search, hyperlinks. **Windows is not offered** (`dock::offered()` is `cfg!(unix)`): the ConPTY checks need a Windows machine and the Windows host is P11c's; the code compiles for it in principle but is unchecked. macOS is by the CI run only. **First run (added by P11a's finding):** `crates/ledger-gui/src/welcome.rs`: with no books and an owner (the `wanderbooks` program gives it one for every `gui` run with its own flags), `ledger_gui::run` opens the page instead of exiting 2 (a named `--book` that is missing is still an error): the private folder (`set_up_root`), the owner passphrase typed twice in the viewer and handed to `set_first_passphrase` (optional; refuses when one is set), the first books (`new_books_and_home`, then the viewer's lease through `hold`), an agent (Connect Claude Code / Codex, with the installer said when it is missing; optional), then Open. It starts at the first step missing. New `Owner` methods (`first_run`, `set_up`, `set_passphrase`, `hold`, `agent_start`) have default bodies, so no other fake owner changed. **Tests:** `crates/ledger-host/tests/books_home.rs` (5), `crates/ledger-gui/tests/dock.rs` (3: `dock_runs_the_chosen_agent` on a real PTY with a stand-in `claude`, `agent_missing_offers_the_installer`, `a_chosen_agent_and_an_open_dock_are_kept`) plus `dock.rs` unit tests, `crates/ledger-gui/tests/welcome.rs` (3), `crates/ledger-cli/tests/books_home.rs` (connect makes the home). The sidebar's shape golden did not change (the dock sits right of the sidebar). **Done when:** (1) the three named tests pass on Linux; (2) Linux yes; macOS by CI only; (3) Windows recorded: not offered. **Not checked here:** the AppImage's whole first run on a clean machine (P11a's Done-when 1 still wants it); the real `claude` and `codex` (a stand-in was used). #### P11c: the native Windows port (D-62) @@ -2184,7 +2184,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-62 | Native Windows (owner, 2026-10-29, the question tool, after P11a step 0 found the Windows side is a port, not cfg fixes: "macOS first, Windows port as its own unit") | **The first bundled app ships for macOS and Linux; the native Windows port is unit P11c**, built after U0e beside P11b; Windows stays WSL2-only meanwhile and the `portable (windows-latest)` job is allowed to fail until P11c lands. D-56's destination is unchanged, only its order. | P11a, P11b, P11c | | D-63 | Where agents' tokens live (owner, 2026-10-29, the question tool, on seeing P11a step 0 add Keychain and Credential Manager stores: "why are you talking about the OS keyring? I thought we made a decision to not use that garbage"; the plan had no such decision recorded, so this one records it) | **A 0600 file under the 0700 data root, on every OS; no OS keyring.** The data root is the trust boundary already (I-1: hidden, not locked, same user), so the keyring added no wall and cost headless servers a D-Bus session, CI a knob, and every OS its own store. Unit U0e removes the Secret Service, Keychain and Credential Manager stores and the `file_keyring` knob; 3.3's sentence is struck. | U0e, P9a's recipe, P11a, P11c | | D-64 | Q-4 answered (owner, 2026-10-29, the question tool: "Defer: ship unsigned builds to yourself first") | **P11a step 5 (the signed release list, the key ceremony, Apple and Microsoft code signing) is deferred until the first public release is near.** CI builds unsigned artifacts for the owner's own machines; `wanderbooks update` works from a local feed meanwhile (no compiled-in key). The two-key design of D-25 is unchanged, only its date. | P11a step 5, `release.yml` | -| D-65 | P13 step 6 (owner, 2026-10-29, the question tool: "Split it out now") | **The bookspec specification gets a public repository of its own now**, with this repository depending on it; the ten stated rules without examples and the three reserved numbers are published as such. | P13 step 6 | +| D-65 | P13 step 6 (owner, 2026-10-29, the question tool: "Split it out now"; then, when this session's token could not create a GitHub repository: "Gitea only, skip GitHub") | **The bookspec specification lives in a public repository of its own, `https://git.swaits.com/swaits/bookspec`** (history preserved; CC BY 4.0 for the text and examples, MIT OR Apache-2.0 for the code), and wanderbooks depends on it pinned by commit, so `just check` still runs every example. No GitHub copy. The ten stated rules without examples and the reserved numbers are published as such. | P13 step 6; `crates/ledger-core/Cargo.toml` | ### 10.2 Deferred