diff --git a/README.md b/README.md index 02eebe6..7d1f907 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,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 94028d7..b9af5bd 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> { @@ -148,14 +201,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-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 e6dc090..144cec1 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1355,14 +1355,11 @@ pub fn installer(agent: &str) -> Option { } } -/// The folder holding the user's documents: `$XDG_DOCUMENTS_DIR` if -/// absolute, else `Documents` in the home (the same word on every system -/// this program runs on). +/// 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 { - std::env::var_os("XDG_DOCUMENTS_DIR") - .map(PathBuf::from) - .filter(|d| d.is_absolute()) - .or_else(|| home_dir_env().map(|h| h.join("Documents"))) + home_dir_env().map(|h| h.join("Documents")) } /// The books home of `book` (D-27): a visible folder, diff --git a/docs/architecture.md b/docs/architecture.md index 9e0572c..0f84a63 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 0aa3a95..385870f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3987,3 +3987,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/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