Add layered settings, and fix a migration mechanism that silently lied

Two things, because the second could not be built on the first.

The schema stamp was part of the same execute_batch as the CREATE TABLE IF NOT
EXISTS statements, and it wrote unconditionally. Opening an older file therefore
added no columns but flipped the version forward anyway; opening a *newer* file
stamped it back down and then wrote rows the newer build could not read. Both
produced a database whose recorded version was a lie, and every future schema
change would have inherited it.

Now the version is read before anything is applied, migrations are ordered and
forward-only inside one transaction, a newer file is refused with SchemaTooNew
rather than downgraded, and a supported version raised without a step to reach
it fails at the first open instead of claiming success. Tested by stamping a
file at version 99 and asserting both the refusal and that the stamp is left
untouched.

lumbridge-settings resolves compiled default -> settings.toml -> environment.
The environment sits above the file deliberately: decision 0016 calls
LUMBRIDGE_CLAUDE_OAUTH=0 "one switch off", and a switch a config file can
silently re-enable is not a switch. A pinned value renders disabled and names
the variable, rather than accepting an edit that would do nothing.

Every field carries a WriteAuthority. Routing all writes through Configure is
the obvious design and would hand a layout-only agent the program every future
pane launches — the guarantee decision 0006 exists to make. Anything naming a
program, path or destination is Human-only, asserted by a test that reads the
path rather than trusting the author.

Four paths are permanently not settings, with the reason recorded beside each
and a test asserting their absence: the usage endpoint URL, the credentials
path, the client identity, and the shell program. A configuration file that can
redirect where an access token is sent is a credential exfiltration path with a
friendly name.

Environment access is a trait rather than std::env, because the workspace forbids
unsafe, set_var is unsafe in Rust 2024, and the layering rule has to be testable
without mutating the process running the test.

Verified live with LUMBRIDGE_CLAUDE_OAUTH=0: the account-endpoint row reads off,
greyed, "pinned by LUMBRIDGE_CLAUDE_OAUTH". The Advanced page names every file,
endpoint and child process Lumbridge touches and states that nothing is sent
anywhere else — as a fact, not as a toggle nobody can flip.

File loading, comment-preserving writes and editable controls are not in this
pass; 0022 records why that order is the honest one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Metal Agent
2026-09-01 00:06:06 -07:00
co-authored by Claude Opus 5
parent 72887cb4ab
commit e5d7a3efd5
14 changed files with 1431 additions and 14 deletions
+5 -3
View File
@@ -30,9 +30,9 @@ use gpui::KeyBinding;
use crate::{
AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusSidebar, FocusUp,
JumpToAttention, OpenPalette, PasteIntoPane, RestartPane, SelectPane1, SelectPane2,
SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower, TerminalShorter,
TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar,
JumpToAttention, OpenPalette, OpenSettings, PasteIntoPane, RestartPane, SelectPane1,
SelectPane2, SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower,
TerminalShorter, TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar,
};
/// The context every binding is scoped to.
@@ -68,6 +68,7 @@ pub(crate) const BINDINGS: &[&str] = &[
"secondary-alt-b",
"secondary-alt-s",
"secondary-alt-a",
"secondary-alt-,",
];
/// Builds the bindings in the same order as [`BINDINGS`].
@@ -100,6 +101,7 @@ pub(crate) fn bindings() -> Vec<KeyBinding> {
KeyBinding::new(BINDINGS[20], ToggleSidebar, Some(CONTEXT)),
KeyBinding::new(BINDINGS[21], FocusSidebar, Some(CONTEXT)),
KeyBinding::new(BINDINGS[22], JumpToAttention, Some(CONTEXT)),
KeyBinding::new(BINDINGS[23], OpenSettings, Some(CONTEXT)),
]
}
+110 -1
View File
@@ -1,6 +1,7 @@
mod attention;
mod keymap;
mod panel_registry;
mod settings_view;
mod sidebar;
mod theme;
mod usage_feed;
@@ -31,6 +32,7 @@ use lumbridge_ui_fixture::{
use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource};
use lumbridge_harness::MonotonicWallClock;
use lumbridge_settings::{Page, ProcessEnv, Settings, SettingsContent};
use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane};
use sidebar::model::{
AttentionEntry, HostEntry, Indicator, PaneActivity, PaneEntry, QuotaEntry, SidebarInput,
@@ -69,6 +71,7 @@ actions!(
TerminatePane,
PasteIntoPane,
ToggleSidebar,
OpenSettings,
FocusSidebar,
JumpToAttention,
SelectPane1,
@@ -112,6 +115,9 @@ struct LumbridgeShell {
sidebar: SidebarState,
sidebar_has_focus: bool,
sidebar_dragging: bool,
settings: Settings,
/// The settings page on screen, if the pane is open.
settings_page: Option<Page>,
/// Stamps attention signals. Monotonic, so a signal cannot appear to have
/// arrived before one recorded earlier.
clock: MonotonicWallClock,
@@ -749,6 +755,12 @@ impl LumbridgeShell {
sidebar,
sidebar_has_focus: false,
sidebar_dragging: false,
// No file is read yet: this resolves the compiled defaults against
// the real environment, so the pane already tells the truth about
// which switches are pinned. Loading and watching the file is the
// next step.
settings: Settings::resolve(&SettingsContent::default(), Vec::new(), &ProcessEnv),
settings_page: None,
clock: MonotonicWallClock::start(),
root_focus,
}
@@ -1181,6 +1193,101 @@ impl LumbridgeShell {
cx.notify();
}
/// Opens the settings pane, or closes it.
fn open_settings(&mut self, _: &OpenSettings, window: &mut Window, cx: &mut Context<Self>) {
self.settings_page = match self.settings_page {
Some(_) => None,
None => Some(Page::UsageAndQuota),
};
window.focus(&self.root_focus);
cx.notify();
}
/// The settings pane: a page list beside the page.
fn settings_pane(&self, page: Page, cx: &mut Context<Self>) -> gpui::AnyElement {
let theme = self.theme.colors;
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.bg(theme.scrim_wash)
.child(
div()
.flex()
.w(px(880.0))
.h(px(560.0))
.rounded(px(8.0))
.overflow_hidden()
.bg(theme.surface_overlay)
.border_1()
.border_color(theme.border)
.child(
div()
.flex()
.flex_col()
.w(px(200.0))
.flex_none()
.bg(theme.chrome)
.border_r_1()
.border_color(theme.border_quiet)
.child(
div()
.px_3()
.py_3()
.text_sm()
.text_color(theme.text)
.child("Settings"),
)
.children(Page::ALL.into_iter().map(|candidate| {
div()
.id(("settings-page", u64::from(candidate as u32)))
.cursor_pointer()
.px_3()
.py_2()
.text_sm()
.when(candidate == page, |view| {
view.bg(theme.surface_active).text_color(theme.text)
})
.when(candidate != page, |view| view.text_color(theme.muted))
.hover(|view| view.bg(theme.surface_raised))
.on_click(cx.listener(move |shell, _, _, cx| {
shell.settings_page = Some(candidate);
cx.notify();
}))
.child(candidate.title())
}))
.child(div().flex_1())
.child(
div()
.px_3()
.py_2()
.text_xs()
.text_color(theme.muted)
.child("⌘⌥, closes"),
),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.px_3()
.py_3()
.text_sm()
.text_color(theme.text)
.child(page.title()),
)
.child(settings_view::page(page, &self.settings, theme)),
),
)
.into_any_element()
}
fn toggle_sidebar(&mut self, _: &ToggleSidebar, window: &mut Window, cx: &mut Context<Self>) {
self.sidebar.visible = !self.sidebar.visible;
if !self.sidebar.visible {
@@ -2707,6 +2814,7 @@ impl LumbridgeShell {
.on_action(cx.listener(Self::toggle_sidebar))
.on_action(cx.listener(Self::focus_sidebar))
.on_action(cx.listener(Self::jump_to_attention))
.on_action(cx.listener(Self::open_settings))
.on_action(cx.listener(Self::terminal_taller))
.on_action(cx.listener(Self::terminal_shorter))
.on_action(cx.listener(Self::terminal_wider))
@@ -2822,6 +2930,7 @@ impl LumbridgeShell {
.when(self.add_panel_chooser_open, |view| {
view.child(self.add_panel_chooser(cx))
})
.children(self.settings_page.map(|page| self.settings_pane(page, cx)))
.into_any_element()
}
@@ -2978,7 +3087,7 @@ impl LumbridgeShell {
}))
.child(sidebar::view::row(
&row,
sidebar::view::RowStyle {
&sidebar::view::RowStyle {
theme,
cursored,
focused,
+236
View File
@@ -0,0 +1,236 @@
//! The settings pane.
//!
//! Four pages that describe code which actually runs. Every row says three
//! things a settings interface usually leaves out: where the value came from,
//! when a change takes effect, and who is allowed to make it.
//!
//! A control whose value was pinned by the environment is drawn disabled with
//! the variable named, rather than accepting an edit that would silently do
//! nothing — decision 0016 calls that switch "one switch off", and an interface
//! that lets a file or a click override it has quietly broken the promise.
use gpui::prelude::*;
use gpui::{div, px};
use lumbridge_settings::{Page, SETTINGS, SettingItem, Settings, WriteAuthority};
use crate::theme::ThemeColors;
/// Everything one row needs to say.
struct Row<'a> {
item: &'a SettingItem,
value: String,
origin: String,
editable: bool,
}
/// One row: what it is, where it came from, when it applies.
fn setting_row(row: &Row<'_>, theme: ThemeColors) -> gpui::AnyElement {
let Row {
item,
value,
origin,
editable,
} = row;
let (label, detail, takes_effect, authority) = (
item.label,
item.detail,
item.takes_effect.label(),
item.authority,
);
let editable = *editable;
div()
.flex()
.flex_col()
.gap(px(2.0))
.px_3()
.py_2()
.border_b_1()
.border_color(theme.border_quiet)
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.text_color(if editable { theme.text } else { theme.muted })
.child(label.to_owned()),
)
.child(
div()
.flex_none()
.px(px(5.0))
.rounded(px(3.0))
.text_xs()
.bg(theme.surface_raised)
.text_color(if editable { theme.accent } else { theme.muted })
.child(value.to_owned()),
),
)
.child(
div()
.text_xs()
.text_color(theme.muted)
.child(detail.to_owned()),
)
.child(
div()
.flex()
.gap_2()
.text_xs()
.text_color(theme.muted)
.child(origin.to_owned())
.child("·")
.child(takes_effect.to_owned())
.when(authority == WriteAuthority::Human, |view| {
view.child("·").child(
div()
.text_color(theme.attention)
.child("a person must set this"),
)
}),
)
.into_any_element()
}
/// Renders one page.
pub(crate) fn page(page: Page, settings: &Settings, theme: ThemeColors) -> gpui::AnyElement {
let value_for = |path: &str| -> (String, String, bool) {
match path {
"appearance.theme" => (
settings.theme.value.clone(),
settings.theme.origin.label(),
settings.theme.origin.is_editable(),
),
"appearance.accent" => (
settings.accent.value.clone(),
settings.accent.origin.label(),
settings.accent.origin.is_editable(),
),
"usage.claude_account_endpoint" => (
on_off(settings.claude_account_endpoint.value),
settings.claude_account_endpoint.origin.label(),
settings.claude_account_endpoint.origin.is_editable(),
),
"usage.claude_transcripts" => (
on_off(settings.claude_transcripts.value),
settings.claude_transcripts.origin.label(),
settings.claude_transcripts.origin.is_editable(),
),
"usage.codex_app_server" => (
on_off(settings.codex_app_server.value),
settings.codex_app_server.origin.label(),
settings.codex_app_server.origin.is_editable(),
),
"usage.claude_rate_limit_feed" => (
settings
.claude_rate_limit_feed
.value
.clone()
.unwrap_or_else(|| "wherever the bridge was installed".to_owned()),
settings.claude_rate_limit_feed.origin.label(),
settings.claude_rate_limit_feed.origin.is_editable(),
),
"terminal.allow_osc52_clipboard" => (
on_off(settings.allow_osc52_clipboard.value),
settings.allow_osc52_clipboard.origin.label(),
settings.allow_osc52_clipboard.origin.is_editable(),
),
_ => ("".to_owned(), "unknown".to_owned(), false),
}
};
let rows: Vec<gpui::AnyElement> = SETTINGS
.iter()
.filter(|item| item.page == page)
.map(|item| {
let (value, origin, editable) = value_for(item.path);
setting_row(
&Row {
item,
value,
origin,
editable,
},
theme,
)
})
.collect();
div()
.flex()
.flex_col()
.size_full()
.overflow_hidden()
.children(rows)
.when(page == Page::Advanced, |view| {
view.child(what_lumbridge_reads(settings, theme))
})
.into_any_element()
}
fn on_off(value: bool) -> String {
if value { "on" } else { "off" }.to_owned()
}
/// The Advanced page's disclosure.
///
/// Stated as fact rather than offered as a toggle. "No telemetry" is not a
/// setting anyone can turn on, so presenting it as one would be theatre.
fn what_lumbridge_reads(settings: &Settings, theme: ThemeColors) -> gpui::AnyElement {
let reads = [
"~/.claude/projects — session transcripts, four token counters per record",
"the status-line feed — the two subscription windows, written by the installed bridge",
"~/.claude/.credentials.json — the access token, only when the account endpoint is on",
"GET api.anthropic.com/api/oauth/usage — your own account, identified as lumbridge",
"codex app-server — launched as a child, asked only for its quota windows",
];
div()
.flex()
.flex_col()
.gap(px(2.0))
.px_3()
.py_2()
.child(
div()
.text_xs()
.text_color(theme.muted)
.child("WHAT LUMBRIDGE READS"),
)
.children(reads.into_iter().map(|line| {
div()
.text_xs()
.text_color(theme.muted)
.child(line.to_owned())
}))
.child(
div()
.mt_2()
.text_xs()
.text_color(theme.success)
.child("Nothing is sent anywhere else. There is no telemetry to turn off."),
)
.when(!settings.unknown.is_empty(), |view| {
view.child(
div()
.mt_2()
.text_xs()
.text_color(theme.attention)
.child(format!(
"{} key(s) in settings.toml were not understood by this build and were left alone: {}",
settings.unknown.len(),
settings
.unknown
.iter()
.map(|key| key.path.clone())
.collect::<Vec<_>>()
.join(", ")
)),
)
})
.into_any_element()
}
+1 -1
View File
@@ -94,7 +94,7 @@ fn quota_chip(headline: Option<&str>, critical: bool, theme: ThemeColors) -> gpu
clippy::too_many_lines,
reason = "one declarative element tree per row kind; splitting hides the shared anatomy"
)]
pub(crate) fn row(row: &SidebarRow, style: RowStyle) -> gpui::AnyElement {
pub(crate) fn row(row: &SidebarRow, style: &RowStyle) -> gpui::AnyElement {
let theme = style.theme;
let selected = matches!(&row.body, RowBody::Pane(pane) if pane.selected);
let indent = f32::from(row.depth) * 10.0;
+10 -4
View File
@@ -25,10 +25,9 @@ pub(crate) struct ThemeColors {
pub(crate) surface: Rgba,
pub(crate) surface_raised: Rgba,
pub(crate) surface_active: Rgba,
/// Not yet painted anywhere. The command palette and the panel chooser
/// still sit on `surface_raised`; moving them is a visible change, and the
/// commit that introduced this engine is deliberately not one.
#[expect(dead_code, reason = "the overlay surfaces move onto it separately")]
/// The settings pane sits on this. The command palette and the panel
/// chooser still use `surface_raised`; moving them is a visible change and
/// belongs in a commit that says so.
pub(crate) surface_overlay: Rgba,
pub(crate) border: Rgba,
pub(crate) border_quiet: Rgba,
@@ -45,6 +44,9 @@ pub(crate) struct ThemeColors {
/// The needs-input row tint, which arrives with the sidebar rework.
#[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")]
pub(crate) attention_wash: Rgba,
/// The dimming behind a modal. Translucent, so what it covers stays legible
/// as context rather than disappearing.
pub(crate) scrim_wash: Rgba,
}
/// Widens an 8-bit channel into the 0..=1 float GPUI wants.
@@ -79,6 +81,10 @@ impl From<&Palette> for ThemeColors {
danger_container: rgba(palette.danger_container),
attention: rgba(palette.attention),
attention_wash: rgba(palette.attention_wash),
scrim_wash: Rgba {
a: 0.55,
..rgba(palette.scrim)
},
}
}
}