Compare commits

...
4 Commits
Author SHA1 Message Date
swaitsandClaude Opus 5.5 2a00f4ef33 fix(gui): recent activity rows clip before the timestamp column; SHOT_HEIGHT for tall screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:37:18 -06:00
swaitsandClaude Opus 5.5 54d2775a33 fix(gui): clip long row text instead of drawing over amounts; drafts aren't payments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:32:50 -07:00
swaitsandClaude Opus 5.5 01d41c0daf fix: tell the agent the receipt policy the books actually use
The server's instructions hardcoded '$25.00'; they now state the
configured threshold, and a zero threshold reads 'every entry needs at
least one receipt'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:30:35 -07:00
swaitsandClaude Opus 5.5 005a96055a site: wanderbooks.ai — one-liner, canonical and social tags, Netlify config and deploy recipes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:24:10 -07:00
16 changed files with 237 additions and 69 deletions

No files matched your search

+1
View File
@@ -2,3 +2,4 @@
*.orig
.DS_Store
site/dist/
.netlify/
+1 -1
View File
@@ -64,7 +64,7 @@ New books, one command (installs Rust and wanderbooks if needed, makes the
books dir with the MCP server registered for it, and starts the agent):
```bash
curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
```
Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just`
+10 -6
View File
@@ -270,20 +270,24 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
theme::regular(12.5),
p.muted,
);
painter.text(
// Leave room for the debit, credit, and balance columns.
let text_w = r.width() - 90.0 - amt * 3.0 - 12.0;
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 90.0, y),
egui::Align2::LEFT_CENTER,
desc,
&desc,
font.clone(),
p.text,
text_w,
);
if !sub.is_empty() {
painter.text(
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
egui::Align2::LEFT_CENTER,
sub,
&sub,
theme::regular(11.5),
p.faint,
text_w,
);
}
for (i, v) in [d, c].iter().enumerate() {
+11 -5
View File
@@ -368,12 +368,13 @@ fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
theme::regular(12.5),
if late { p.negative } else { p.muted },
);
ui.painter().text(
widgets::clipped_text(
ui.painter(),
r.left_center() + vec2(70.0, 0.0),
egui::Align2::LEFT_CENTER,
&it.label,
theme::regular(13.0),
p.text,
r.width() - 70.0 - 130.0,
);
let (txt, c) = if it.amount > Decimal::ZERO {
(format!("+{}", fmt::money(it.amount)), p.positive)
@@ -405,7 +406,11 @@ fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let mut out: Vec<&crate::books::EntryInfo> = b
.entries
.iter()
.filter(|e| e.date > since && e.cash_effect < Decimal::ZERO)
.filter(|e| {
e.status == ledger_core::EntryStatus::Posted
&& e.date > since
&& e.cash_effect < Decimal::ZERO
})
.collect();
out.sort_by_key(|e| e.cash_effect);
out.truncate(8);
@@ -431,12 +436,13 @@ fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
theme::regular(12.5),
p.muted,
);
ui.painter().text(
widgets::clipped_text(
ui.painter(),
r.left_center() + vec2(70.0, 0.0),
egui::Align2::LEFT_CENTER,
&e.description,
theme::regular(13.0),
p.text,
r.width() - 70.0 - 130.0,
);
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
+22 -9
View File
@@ -267,20 +267,33 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
for &i in &e.events {
let line = &b.journal[i];
let ev = super::journal::describe_any(b, line);
ui.horizontal(|ui| {
ui.horizontal_top(|ui| {
ui.label(RichText::new(ev.icon).color(p.muted));
let r = widgets::link(ui, RichText::new(&ev.text).font(theme::regular(12.5)));
if r.clicked() {
shell.go(Route::JournalEvent(line.seq));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let r = ui.add(
egui::Label::new(RichText::new(&ev.text).font(theme::regular(12.5)))
.wrap()
.sense(egui::Sense::click()),
);
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked() {
shell.go(Route::JournalEvent(line.seq));
}
ui.label(
RichText::new(format!("#{} · {}", line.seq, fmt::timestamp(&line.ts)))
.font(theme::regular(11.5))
.color(p.faint),
RichText::new(format!(
"event {} · {}",
line.seq,
fmt::timestamp(&line.ts)
))
.font(theme::regular(11.0))
.color(p.faint),
);
});
});
ui.add_space(4.0);
}
});
}
+3 -2
View File
@@ -401,12 +401,13 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
egui::FontId::proportional(15.0),
p.muted,
);
painter.text(
widgets::clipped_text(
&painter,
egui::pos2(rect.left() + 80.0, y),
egui::Align2::LEFT_CENTER,
&ev.text,
theme::regular(13.0),
p.text,
rect.width() - 80.0 - 200.0,
);
painter.text(
egui::pos2(rect.right() - 8.0, y),
+45 -33
View File
@@ -433,39 +433,51 @@ fn recent(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
}
shown += 1;
let fresh = line.seq > b.fresh_from_seq && since_update < 8.0;
let row = egui::Frame::new()
.fill(if fresh {
p.new_highlight
} else {
egui::Color32::TRANSPARENT
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(ev.icon).size(15.0).color(p.muted));
let r = widgets::link(
ui,
RichText::new(&ev.text).font(theme::regular(13.0)),
);
if r.clicked() {
go =
Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::timestamp(&line.ts))
.font(theme::regular(11.5))
.color(p.faint),
);
},
);
});
});
let _ = (row, i);
// One painted row: icon, the event (cut with an ellipsis
// before the timestamp column), the timestamp.
let (r, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), 30.0),
egui::Sense::click(),
);
if fresh {
ui.painter()
.rect_filled(r, egui::CornerRadius::same(6), p.new_highlight);
} else if resp.hovered() {
ui.painter()
.rect_filled(r, egui::CornerRadius::same(6), p.hover);
}
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let painter = ui.painter_at(r);
let y = r.center().y;
painter.text(
egui::pos2(r.left() + 8.0, y),
egui::Align2::LEFT_CENTER,
ev.icon,
egui::FontId::proportional(15.0),
p.muted,
);
let ts =
painter.layout_no_wrap(fmt::timestamp(&line.ts), theme::regular(11.5), p.faint);
let ts_w = ts.size().x;
painter.galley(
egui::pos2(r.right() - 8.0 - ts_w, y - ts.size().y / 2.0),
ts,
p.faint,
);
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 32.0, y),
&ev.text,
theme::regular(13.0),
p.text,
r.width() - 32.0 - ts_w - 28.0,
);
if resp.clicked() {
go = Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
}
let _ = i;
}
if shown == 0 {
widgets::empty(ui, icon::CLOCK, "Nothing yet", "");
+24
View File
@@ -289,3 +289,27 @@ pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T,
});
changed
}
/// Left-aligned single-line text, vertically centered at `pos`, cut with
/// an ellipsis at `max_width` so it never runs into whatever is to its
/// right (an amount, a date).
pub fn clipped_text(
painter: &egui::Painter,
pos: egui::Pos2,
text: &str,
font: egui::FontId,
color: Color32,
max_width: f32,
) {
use egui::text::{LayoutJob, TextFormat, TextWrapping};
let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font, color));
job.wrap = TextWrapping {
max_width: max_width.max(10.0),
max_rows: 1,
break_anywhere: true,
overflow_character: Some('…'),
};
let galley = painter.layout_job(job);
let y = pos.y - galley.size().y / 2.0;
painter.galley(egui::pos2(pos.x, y), galley, color);
}
+7 -1
View File
@@ -76,7 +76,13 @@ fn screenshots() {
}
let books = data::load_once(&source).unwrap();
let mut harness = Harness::builder()
.with_size(egui::vec2(1440.0, 920.0))
.with_size(egui::vec2(
1440.0,
std::env::var("SHOT_HEIGHT")
.ok()
.and_then(|h| h.parse().ok())
.unwrap_or(920.0),
))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, dark, route.clone()));
harness.run_steps(4);
let ctx = harness.ctx.clone();
+20 -4
View File
@@ -101,10 +101,26 @@ impl ReceiptPolicy {
/// Human-readable policy summary for previews and errors.
pub fn describe(&self) -> String {
format!(
"receipts required when entry debits are strictly greater than {}",
self.threshold()
)
if self.threshold().is_zero() {
"receipts required on every entry".to_string()
} else {
format!(
"receipts required when entry debits are strictly greater than {}",
self.threshold()
)
}
}
/// The receipt rule, phrased for the agent's operating instructions.
pub fn rule(&self) -> String {
if self.threshold().is_zero() {
"every entry needs at least one receipt".to_string()
} else {
format!(
"any entry moving more than ${} in debits needs at least one receipt",
self.threshold().round_dp(2)
)
}
}
/// Normalize a description for split detection.
+5 -1
View File
@@ -4051,7 +4051,11 @@ impl ServerHandler for BooksServer {
.enable_resources()
.build(),
)
.with_instructions(INSTRUCTIONS)
// Tell the agent the policy these books actually use.
.with_instructions(INSTRUCTIONS.replace(
"any entry moving more than $25.00 in debits needs at least one receipt",
&self.policy.rule(),
))
.with_server_info(Implementation::new(
"wanderbooks",
env!("CARGO_PKG_VERSION"),
+51
View File
@@ -1586,3 +1586,54 @@ fn mcp_vendor_1099_over_stdio() {
assert_eq!(v["blobs_verified"], 1);
assert!(v["events"].as_u64().unwrap() > 10);
}
#[test]
fn mcp_receipts_always_required_when_threshold_is_zero() {
let dir = test_dir("always-receipts");
let toml = dir.join("wanderbooks.toml");
std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap();
let mut mcp = Mcp::spawn_with(
&dir.join("books-data"),
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
);
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
let instructions = init["result"]["instructions"].as_str().unwrap();
assert!(
instructions.contains("every entry needs at least one receipt"),
"the agent is told the real policy: {instructions}"
);
assert!(!instructions.contains("$25.00"));
mcp.notify("notifications/initialized");
for (code, name, typ) in [("1000", "Bank", "asset"), ("6000", "Fees", "expense")] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-10-01", "description": "Bank fee"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": "5.00"}),
);
}
let err = mcp.call_err(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert!(
err.contains("receipt"),
"a $5 entry needs a receipt too: {err}"
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
# wanderbooks installer. One line on a new machine:
#
# curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
# curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
#
# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the GUI opens as
# a normal window on Windows 11.
+10 -3
View File
@@ -2,6 +2,9 @@
set shell := ["bash", "-cu"]
# The Netlify CLI, fetched on demand.
netlify := "npx --yes netlify-cli@27"
# List recipes
default:
@just --list
@@ -41,6 +44,10 @@ site:
cp site/index.html install.sh site/dist/
cp -r site/assets site/dist/
# Publish the website: just publish user@host:/var/www/wanderbooks
publish dest: site
rsync -av --delete site/dist/ "{{dest}}"
# Deploy the website to wanderbooks.ai (Netlify; `netlify login` once first)
deploy: site
{{netlify}} deploy --prod --dir site/dist --no-build
# Deploy a preview of the website and print its URL
deploy-preview: site
{{netlify}} deploy --dir site/dist --no-build
+17
View File
@@ -0,0 +1,17 @@
# wanderbooks.ai: the static site in site/, plus install.sh at the root.
# `just site` builds the same thing locally; `just deploy` publishes it.
[build]
command = "rm -rf site/dist && mkdir -p site/dist && cp site/index.html install.sh site/dist/ && cp -r site/assets site/dist/"
publish = "site/dist"
# curl | sh reads plain text; never let a CDN cache an old installer for long.
[[headers]]
for = "/install.sh"
[headers.values]
Content-Type = "text/plain; charset=utf-8"
Cache-Control = "public, max-age=300"
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=86400"
+9 -3
View File
@@ -5,6 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>wanderbooks — own your books, hire an AI bookkeeper</title>
<meta name="description" content="Real double-entry bookkeeping in a folder you own, kept by an AI bookkeeper you talk to. No subscription, no lock-in, every number traceable to its receipt. An open-source alternative to QuickBooks Online.">
<link rel="canonical" href="https://wanderbooks.ai/">
<meta property="og:title" content="wanderbooks — own your books, hire an AI bookkeeper">
<meta property="og:description" content="Real double-entry bookkeeping in a folder you own, kept by an AI bookkeeper you talk to. No subscription, no lock-in.">
<meta property="og:url" content="https://wanderbooks.ai/">
<meta property="og:image" content="https://wanderbooks.ai/assets/overview.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -117,7 +123,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
<p class="lede">wanderbooks is real double-entry accounting that lives in <b>a folder you own</b>, kept by <b>an AI bookkeeper you talk to</b>. It finds your receipts, records and categorizes everything, fixes its mistakes the way an accountant would, and hands your CPA a clean package. No subscription. No lock-in. Every number traceable to its receipt.</p>
<div class="install">
<span class="prompt">$</span>
<code id="cmd">curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/YourCompany</code>
<code id="cmd">curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/YourCompany</code>
<button class="copy" id="copy">Copy</button>
</div>
<p class="note">One line installs everything, makes your books, and starts your bookkeeper. Linux and macOS; Windows via WSL2. Free and open source; you bring <a href="https://claude.com/claude-code">Claude</a>.</p>
@@ -250,10 +256,10 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
</footer>
</div>
<script>
// When served next to install.sh, point the one-liner at this site.
// On a preview deploy, point the one-liner at the preview's own install.sh.
(function () {
var cmd = document.getElementById("cmd");
if (location.protocol === "https:") {
if (location.protocol === "https:" && location.hostname !== "wanderbooks.ai") {
cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- ~/Books/YourCompany";
}
document.getElementById("copy").addEventListener("click", function () {