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

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