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