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

+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));
}
}