feat(viewer): the agent dock and the first-run page (P11b)

D-27: a terminal panel along the bottom of the viewer (alacritty_terminal over
a PTY) runs Claude Code (default) or Codex in the book's books home, resizable,
collapsible, the choice remembered. The agent is an ordinary child process the
owner describes (agent_start); the viewer stays read-only: the agent writes
through the server like any other. A missing agent is offered through its
vendor's installer (a command to copy), never bundled; no folder is marked
trusted (Q-7, recommended answer: let Codex ask). Windows is not offered until
its ConPTY checks are recorded.

P11a found that onboarding with no terminal was false: the viewer opened only an
existing book. With no books and an owner, wanderbooks gui now shows a first-run
page: the private folder, the owner passphrase (typed in the viewer, optional),
the first books, an agent, then the books open. connect and New books... make
the books home. Tests: dock.rs, welcome.rs, books_home.rs (host and cli).
Docs: testing.md record and For P12, architecture, parity, gui.md, README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 17:06:14 -06:00
1 parent 0442ab804e
commit 8f2e3378e8
15 files changed
+2620 -33

No files matched your search

+7 -1
View File
@@ -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/<id>` (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
+63 -6
View File
@@ -71,7 +71,7 @@ impl ledger_gui::owner::Owner for ViewerOwner {
}
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
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<Vec<String>, 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<Vec<String>, 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<ledger_gui::owner::AgentStart, String> {
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<Vec<String>, String> {
@@ -148,14 +201,18 @@ fn run_gui(args: Vec<String>) -> 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) {
+152
View File
@@ -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<String> = 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__*"])
);
}
+98 -3
View File
@@ -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<crate::welcome::Welcome>,
/// What a first run opens its books with: the data root named, and
/// "today".
first_run_source: (Option<std::path::PathBuf>, Option<chrono::NaiveDate>),
}
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<std::path::PathBuf>,
today: Option<chrono::NaiveDate>,
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));
}
}
File diff suppressed because it is too large. Load diff
+28 -10
View File
@@ -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<Item = String>,
@@ -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()),
}))
}),
)?)
}
+88 -1
View File
@@ -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> {
[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<String>,
/// 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<String>,
/// 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<Vec<String>, 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<Vec<String>, 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<AgentStart, String> {
Err("not here".into())
}
}
static OWNER: OnceLock<Arc<dyn Owner>> = OnceLock::new();
@@ -104,6 +186,11 @@ pub fn set_owner(owner: Arc<dyn Owner>) {
let _ = OWNER.set(owner);
}
/// The viewer's owner, if the program gave it one.
pub fn current() -> Option<Arc<dyn Owner>> {
owner()
}
fn owner() -> Option<Arc<dyn Owner>> {
OWNER.get().cloned()
}
+460
View File
@@ -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<Vec<String>, 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<Vec<String>, 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<Agent>, Receiver<Done>)>,
/// What the last step said or why it was refused.
said: Option<Result<Vec<String>, String>>,
/// The books to open, once the person says.
open: Option<String>,
}
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<String> {
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<Agent>,
owner: &Arc<dyn Owner>,
work: impl FnOnce(&dyn Owner) -> Result<Vec<String>, 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<dyn Owner>) {
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<dyn Owner>, 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)),
};
}
}
}
+260
View File
@@ -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<Option<Result<AgentStart, String>>>,
asked: Mutex<Vec<String>>,
}
impl Owner for Fake {
fn close_books(&self) -> Result<String, String> {
Err("not here".into())
}
fn new_books(&self, _: &str, _: &str) -> Result<String, String> {
Err("not here".into())
}
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
Err("not here".into())
}
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
Err("not here".into())
}
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
Err("not here".into())
}
fn connect(&self, _: Agent, _: &str) -> Result<Vec<String>, String> {
Err("not here".into())
}
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
Err("not here".into())
}
fn agent_start(&self, agent: Agent, book: &str) -> Result<AgentStart, String> {
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<Fake> {
static FAKE: OnceLock<Arc<Fake>> = 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);
}
+323
View File
@@ -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<Option<(PathBuf, PathBuf)>>,
asked: Mutex<Vec<String>>,
}
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<String, String> {
Err("not here".into())
}
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
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<Vec<String>, String> {
Err("not here".into())
}
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
Err("not here".into())
}
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
Err("not here".into())
}
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Fake> {
static FAKE: OnceLock<Arc<Fake>> = 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);
}
+4 -7
View File
@@ -1355,14 +1355,11 @@ pub fn installer(agent: &str) -> Option<Installer> {
}
}
/// 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<PathBuf> {
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,
+27 -2
View File
@@ -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,
`<Documents>/Wanderbooks/<book id>`, 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,
+8
View File
@@ -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, `<Documents>/Wanderbooks/<id>`, 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 |
+80
View File
@@ -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
`<exe> 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.
+12 -3
View File
@@ -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