Spike native shells and define local-first integrations
CI / rust (push) Successful in 1m40s

This commit is contained in:
2026-08-31 15:42:26 -07:00
parent a703b5a5ee
commit aaa1cb1fa9
28 changed files with 14809 additions and 14 deletions
+2 -1
View File
@@ -1,4 +1,6 @@
/target/ /target/
/spikes/target/
/spikes/*/target/
/.idea/ /.idea/
/.zed/ /.zed/
/.vscode/ /.vscode/
@@ -11,4 +13,3 @@
# Upstream research checkouts live beside this repository, never inside it. # Upstream research checkouts live beside this repository, never inside it.
/Research/ /Research/
/research-checkouts/ /research-checkouts/
Generated
+1438
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,11 +1,17 @@
[workspace] [workspace]
members = ["apps/lumbridge", "crates/lumbridge-core"] members = [
"apps/lumbridge",
"crates/lumbridge-buzz",
"crates/lumbridge-core",
"crates/lumbridge-storage",
]
exclude = ["spikes"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.0.1" version = "0.0.1"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.94"
license = "Apache-2.0" license = "Apache-2.0"
repository = "https://git.karti.ai/lumbridge-public/lumbridge-code" repository = "https://git.karti.ai/lumbridge-public/lumbridge-code"
@@ -15,4 +21,3 @@ unsafe_code = "forbid"
[workspace.lints.clippy] [workspace.lints.clippy]
all = "warn" all = "warn"
pedantic = "warn" pedantic = "warn"
+9
View File
@@ -34,6 +34,8 @@ contracts before committing to a large implementation.
profiles for OpenAI, Anthropic, Gemini, Groq, Cerebras, DeepSeek, and more. profiles for OpenAI, Anthropic, Gemini, Groq, Cerebras, DeepSeek, and more.
- Usage history, burn rate, reset windows, and forecasts with visible data - Usage history, burn rate, reset windows, and forecasts with visible data
provenance instead of invented precision. provenance instead of invented precision.
- Optional first-class Buzz channels, messages, agents, and confirmed redacted
pane sharing without making Buzz a requirement.
Start with [the product spec](docs/PRODUCT_SPEC.md), Start with [the product spec](docs/PRODUCT_SPEC.md),
[architecture](docs/ARCHITECTURE.md), [research map](docs/RESEARCH.md), and [architecture](docs/ARCHITECTURE.md), [research map](docs/RESEARCH.md), and
@@ -51,3 +53,10 @@ cargo run -p lumbridge
See [the testing strategy](docs/TESTING.md) for fake harnesses, ACP replay, See [the testing strategy](docs/TESTING.md) for fake harnesses, ACP replay,
terminal conformance, UI driving, recovery, performance, and packaging tests. terminal conformance, UI driving, recovery, performance, and packaging tests.
The accepted local/remote boundary is recorded in
[decision 0003](docs/decisions/0003-local-data-and-remote-sessions.md). The two
native shell candidates live in [`spikes/`](spikes/), with results tracked in
[the UI scorecard](docs/UI_SPIKE_SCORECARD.md).
The signed-protocol and pane-sharing boundary is in
[the Buzz integration design](docs/BUZZ_INTEGRATION.md).
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "lumbridge-buzz"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
buzz-sdk = { git = "https://github.com/block/buzz.git", rev = "cb3144999bebc4939cb15b2200b373281d493b52" }
nostr = "0.44"
thiserror = "2"
uuid = "1"
[lints]
workspace = true
+209
View File
@@ -0,0 +1,209 @@
//! Credential-free Buzz protocol preparation for Lumbridge.
//!
//! This crate validates the user's pane-sharing decision and delegates event
//! construction to the upstream Buzz SDK. Signing, uploading, and publishing
//! belong to platform adapters that can resolve an OS credential-store handle.
use std::fmt;
use nostr::EventBuilder;
use uuid::Uuid;
/// A locally configured Buzz identity without private key material.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuzzAccount {
/// Lumbridge-local stable identifier.
pub id: String,
/// Human-readable account label.
pub label: String,
/// Buzz relay/server URL.
pub relay_url: String,
/// Public Nostr identity in hexadecimal form.
pub public_key: String,
/// Opaque reference resolved by Keychain or Secret Service.
pub secret_store_handle: String,
}
/// Metadata produced after an approved screenshot has been uploaded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UploadedPaneImage {
/// Publicly retrievable attachment URL returned by Buzz storage.
pub url: String,
/// MIME type, normally `image/png` or `image/webp`.
pub media_type: String,
/// Lower-case SHA-256 digest of the uploaded bytes.
pub sha256: String,
/// Exact byte length of the upload.
pub size_bytes: u64,
/// Optional pixel dimensions.
pub dimensions: Option<(u32, u32)>,
}
impl UploadedPaneImage {
fn imeta(&self) -> Vec<String> {
let mut tag = vec![
"imeta".to_owned(),
format!("url {}", self.url),
format!("m {}", self.media_type),
format!("x {}", self.sha256),
format!("size {}", self.size_bytes),
];
if let Some((width, height)) = self.dimensions {
tag.push(format!("dim {width}x{height}"));
}
tag
}
}
/// The explicit human approval associated with a pane share.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShareApproval {
/// Destination selected in the preview UI.
pub channel_id: Uuid,
/// Final caption visible in the preview UI.
pub caption: String,
/// Whether redaction has run for this exact captured image.
pub redaction_reviewed: bool,
/// Whether the user confirmed the exact image, caption, and destination.
pub user_confirmed: bool,
}
/// Errors that prevent preparation of a pane share.
#[derive(Debug)]
pub enum ShareError {
/// Redaction review is mandatory before sharing a pane.
RedactionNotReviewed,
/// A user must confirm the final preview and destination.
NotConfirmed,
/// The uploaded attachment metadata is malformed.
InvalidAttachment(&'static str),
/// Buzz rejected the message fields.
Buzz(buzz_sdk::SdkError),
}
impl fmt::Display for ShareError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RedactionNotReviewed => formatter.write_str("pane redaction was not reviewed"),
Self::NotConfirmed => formatter.write_str("pane share was not confirmed"),
Self::InvalidAttachment(reason) => {
write!(formatter, "invalid pane attachment: {reason}")
}
Self::Buzz(error) => write!(formatter, "Buzz rejected pane share: {error}"),
}
}
}
impl std::error::Error for ShareError {}
impl From<buzz_sdk::SdkError> for ShareError {
fn from(error: buzz_sdk::SdkError) -> Self {
Self::Buzz(error)
}
}
/// Build an unsigned Buzz message only after the share safety gate passes.
///
/// The returned builder must be signed by an identity obtained from the OS
/// credential store and then published by the Buzz transport adapter.
///
/// # Errors
///
/// Returns an error when review/confirmation is missing, attachment metadata is
/// unsafe, or the Buzz SDK rejects the event.
pub fn prepare_pane_share(
approval: &ShareApproval,
image: &UploadedPaneImage,
) -> Result<EventBuilder, ShareError> {
if !approval.redaction_reviewed {
return Err(ShareError::RedactionNotReviewed);
}
if !approval.user_confirmed {
return Err(ShareError::NotConfirmed);
}
if !matches!(image.media_type.as_str(), "image/png" | "image/webp") {
return Err(ShareError::InvalidAttachment("unsupported image type"));
}
if image.sha256.len() != 64 || !image.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(ShareError::InvalidAttachment(
"SHA-256 must be 64 hexadecimal characters",
));
}
if image.size_bytes == 0 {
return Err(ShareError::InvalidAttachment("image is empty"));
}
let content = if approval.caption.trim().is_empty() {
format!("![Lumbridge pane]({})", image.url)
} else {
format!(
"{}\n\n![Lumbridge pane]({})",
approval.caption.trim(),
image.url
)
};
buzz_sdk::build_message(
approval.channel_id,
&content,
None,
&[],
false,
&[image.imeta()],
)
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use nostr::{Keys, Kind};
use super::{ShareApproval, ShareError, UploadedPaneImage, prepare_pane_share};
fn image() -> UploadedPaneImage {
UploadedPaneImage {
url: "https://buzz.example/upload/pane.png".into(),
media_type: "image/png".into(),
sha256: "a".repeat(64),
size_bytes: 42_000,
dimensions: Some((1_720, 1_400)),
}
}
fn approval() -> ShareApproval {
ShareApproval {
channel_id: uuid::uuid!("5088492f-f83b-41e1-b2cd-95cc741b4521"),
caption: "Lumbridge GPUI spike".into(),
redaction_reviewed: true,
user_confirmed: true,
}
}
#[test]
fn requires_review_and_confirmation() {
let mut decision = approval();
decision.redaction_reviewed = false;
assert!(matches!(
prepare_pane_share(&decision, &image()),
Err(ShareError::RedactionNotReviewed)
));
decision.redaction_reviewed = true;
decision.user_confirmed = false;
assert!(matches!(
prepare_pane_share(&decision, &image()),
Err(ShareError::NotConfirmed)
));
}
#[test]
fn builds_a_signable_buzz_stream_message() {
let event = prepare_pane_share(&approval(), &image())
.expect("approved share should build")
.sign_with_keys(&Keys::generate())
.expect("test identity should sign");
assert_eq!(event.kind, Kind::Custom(9));
assert!(event.content.contains("Lumbridge GPUI spike"));
assert!(event.content.contains("pane.png"));
}
}
+61 -1
View File
@@ -2,6 +2,56 @@
use std::fmt; use std::fmt;
/// The transport Lumbridge uses to reach a user-owned remote machine.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RemoteTransport {
/// The system OpenSSH client, including the user's SSH config, `ProxyJump`,
/// agent, and a Tailscale `MagicDNS` name or tailnet IP when supplied.
OpenSsh,
/// The Tailscale CLI's SSH proxy and host-key verification path.
TailscaleSsh,
}
impl RemoteTransport {
#[must_use]
pub const fn storage_name(self) -> &'static str {
match self {
Self::OpenSsh => "openssh",
Self::TailscaleSsh => "tailscale-ssh",
}
}
#[must_use]
pub fn from_storage_name(value: &str) -> Option<Self> {
match value {
"openssh" => Some(Self::OpenSsh),
"tailscale-ssh" => Some(Self::TailscaleSsh),
_ => None,
}
}
}
/// A connection profile. It contains routing metadata, never credentials.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteHost {
pub id: String,
pub label: String,
pub hostname: String,
pub username: Option<String>,
pub port: Option<u16>,
pub transport: RemoteTransport,
}
/// Where a workspace's processes and PTYs are owned.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ExecutionTarget {
Local,
Remote {
host_id: String,
remote_path: String,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Platform { pub enum Platform {
MacOs, MacOs,
@@ -64,7 +114,7 @@ impl ProductStatus {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{Platform, ProductStatus, UsageProvenance}; use super::{Platform, ProductStatus, RemoteTransport, UsageProvenance};
#[test] #[test]
fn scaffold_status_is_explicit() { fn scaffold_status_is_explicit() {
@@ -76,4 +126,14 @@ mod tests {
fn usage_can_be_explicitly_unavailable() { fn usage_can_be_explicitly_unavailable() {
assert_eq!(UsageProvenance::Unavailable, UsageProvenance::Unavailable); assert_eq!(UsageProvenance::Unavailable, UsageProvenance::Unavailable);
} }
#[test]
fn remote_transport_storage_names_are_stable() {
for transport in [RemoteTransport::OpenSsh, RemoteTransport::TailscaleSsh] {
assert_eq!(
RemoteTransport::from_storage_name(transport.storage_name()),
Some(transport)
);
}
}
} }
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "lumbridge-storage"
description = "Local SQLite persistence for Lumbridge"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
lumbridge-core = { path = "../lumbridge-core" }
rusqlite = { version = "0.40.2", default-features = false, features = ["bundled"] }
[lints]
workspace = true
+284
View File
@@ -0,0 +1,284 @@
//! Local, device-owned persistence for Lumbridge.
//!
//! This database stores workspace metadata and remote routing profiles. API
//! keys, subscription credentials, SSH private keys, and Tailscale credentials
//! are intentionally outside its contract.
use std::path::Path;
use lumbridge_core::{RemoteHost, RemoteTransport};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
) STRICT;
INSERT INTO app_meta (key, value) VALUES ('schema_version', '2')
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
CREATE TABLE IF NOT EXISTS remote_hosts (
id TEXT PRIMARY KEY NOT NULL,
label TEXT NOT NULL,
hostname TEXT NOT NULL,
username TEXT,
port INTEGER CHECK (port IS NULL OR port BETWEEN 1 AND 65535),
transport TEXT NOT NULL CHECK (transport IN ('openssh', 'tailscale-ssh')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
repository_path TEXT,
remote_host_id TEXT REFERENCES remote_hosts(id) ON DELETE RESTRICT,
remote_path TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (
(remote_host_id IS NULL AND remote_path IS NULL)
OR (remote_host_id IS NOT NULL AND remote_path IS NOT NULL)
)
) STRICT;
CREATE TABLE IF NOT EXISTS panes (
id TEXT PRIMARY KEY NOT NULL,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
title TEXT NOT NULL,
position INTEGER NOT NULL,
remote_session_id TEXT,
state_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE INDEX IF NOT EXISTS panes_workspace_position
ON panes(workspace_id, position);
CREATE TABLE IF NOT EXISTS buzz_accounts (
id TEXT PRIMARY KEY NOT NULL,
label TEXT NOT NULL,
relay_url TEXT NOT NULL,
public_key TEXT NOT NULL,
secret_store_handle TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE TABLE IF NOT EXISTS workspace_buzz_channels (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
account_id TEXT NOT NULL REFERENCES buzz_accounts(id) ON DELETE CASCADE,
channel_id TEXT NOT NULL,
channel_name TEXT NOT NULL,
PRIMARY KEY (workspace_id, account_id, channel_id)
) STRICT;
";
#[derive(Debug)]
pub enum StorageError {
Database(rusqlite::Error),
UnknownTransport(String),
}
impl std::fmt::Display for StorageError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Database(error) => write!(formatter, "SQLite error: {error}"),
Self::UnknownTransport(value) => write!(formatter, "unknown remote transport: {value}"),
}
}
}
impl std::error::Error for StorageError {}
impl From<rusqlite::Error> for StorageError {
fn from(error: rusqlite::Error) -> Self {
Self::Database(error)
}
}
pub type Result<T> = std::result::Result<T, StorageError>;
/// The one `SQLite` connection owned by a runtime instance.
pub struct Store {
connection: Connection,
}
impl Store {
/// Open or create a local database and apply forward-compatible migrations.
///
/// # Errors
///
/// Returns an error when the file cannot be opened or its schema cannot be
/// initialized.
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::from_connection(Connection::open(path)?)
}
/// Create an isolated store for tests and short-lived tools.
///
/// # Errors
///
/// Returns an error when `SQLite` cannot initialize the in-memory database.
pub fn open_in_memory() -> Result<Self> {
Self::from_connection(Connection::open_in_memory()?)
}
fn from_connection(connection: Connection) -> Result<Self> {
connection.execute_batch("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;")?;
let mode: String =
connection.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
if mode == "wal" {
connection.execute_batch("PRAGMA synchronous = NORMAL;")?;
}
connection.execute_batch(SCHEMA)?;
Ok(Self { connection })
}
/// Return the currently installed schema version.
///
/// # Errors
///
/// Returns an error if the metadata table cannot be read.
pub fn schema_version(&self) -> Result<u32> {
let raw: String = self.connection.query_row(
"SELECT value FROM app_meta WHERE key = 'schema_version'",
[],
|row| row.get(0),
)?;
raw.parse()
.map_err(|_| StorageError::Database(rusqlite::Error::InvalidQuery))
}
/// Insert or update a credential-free remote host profile.
///
/// # Errors
///
/// Returns an error if `SQLite` rejects the profile.
pub fn save_remote_host(&self, host: &RemoteHost) -> Result<()> {
self.connection.execute(
r"
INSERT INTO remote_hosts (id, label, hostname, username, port, transport)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
label = excluded.label,
hostname = excluded.hostname,
username = excluded.username,
port = excluded.port,
transport = excluded.transport,
updated_at = CURRENT_TIMESTAMP
",
params![
host.id,
host.label,
host.hostname,
host.username,
host.port,
host.transport.storage_name()
],
)?;
Ok(())
}
/// Load one remote host profile by stable identifier.
///
/// # Errors
///
/// Returns an error when the row is malformed or cannot be read.
pub fn remote_host(&self, id: &str) -> Result<Option<RemoteHost>> {
let row = self
.connection
.query_row(
"SELECT id, label, hostname, username, port, transport FROM remote_hosts WHERE id = ?1",
[id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<u16>>(4)?,
row.get::<_, String>(5)?,
))
},
)
.optional()?;
row.map(|(id, label, hostname, username, port, transport)| {
let transport = RemoteTransport::from_storage_name(&transport)
.ok_or_else(|| StorageError::UnknownTransport(transport.clone()))?;
Ok(RemoteHost {
id,
label,
hostname,
username,
port,
transport,
})
})
.transpose()
}
#[cfg(test)]
fn contains_column(&self, table: &str, column: &str) -> Result<bool> {
let mut statement = self
.connection
.prepare(&format!("PRAGMA table_info({table})"))?;
let names = statement.query_map([], |row| row.get::<_, String>(1))?;
for name in names {
if name? == column {
return Ok(true);
}
}
Ok(false)
}
}
#[cfg(test)]
mod tests {
use lumbridge_core::{RemoteHost, RemoteTransport};
use super::Store;
#[test]
fn initializes_schema_and_round_trips_remote_host() {
let store = Store::open_in_memory().expect("store should initialize");
assert_eq!(store.schema_version().expect("schema version"), 2);
let host = RemoteHost {
id: "metal".into(),
label: "metal · Jarvis".into(),
hostname: "desk-1-metal-jarvis.tail.example".into(),
username: Some("engineer".into()),
port: None,
transport: RemoteTransport::TailscaleSsh,
};
store.save_remote_host(&host).expect("host should save");
assert_eq!(
store.remote_host("metal").expect("host should load"),
Some(host)
);
}
#[test]
fn credential_columns_do_not_exist() {
let store = Store::open_in_memory().expect("store should initialize");
for forbidden in ["password", "private_key", "api_key", "auth_token"] {
assert!(
!store
.contains_column("remote_hosts", forbidden)
.expect("schema query")
);
}
for forbidden in ["private_key", "secret_key", "nsec", "auth_token"] {
assert!(
!store
.contains_column("buzz_accounts", forbidden)
.expect("schema query")
);
}
}
}
+57 -6
View File
@@ -8,7 +8,8 @@ Lumbridge should be two cooperating Rust processes:
native desktop UI native desktop UI
| local authenticated IPC | local authenticated IPC
Lumbridge session runtime Lumbridge session runtime
|-- PTYs and process trees |-- local PTYs and process trees
|-- SSH/Tailscale transport to remote Lumbridge runtimes
|-- ACP clients and adapters |-- ACP clients and adapters
|-- repositories and worktrees |-- repositories and worktrees
|-- usage/event ledger |-- usage/event ledger
@@ -30,13 +31,18 @@ messages should be real from the beginning.
- `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters. - `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters.
- `lumbridge-provider`: BYOK providers and provider-neutral usage records. - `lumbridge-provider`: BYOK providers and provider-neutral usage records.
- `lumbridge-storage`: SQLite migrations, event log, snapshots, retention. - `lumbridge-storage`: SQLite migrations, event log, snapshots, retention.
- `lumbridge-remote`: OpenSSH/Tailscale command construction, framed stdio,
handshake, reconnect, heartbeat, and remote-runtime discovery.
- `lumbridge-secrets`: Keychain/libsecret adapters and redaction. - `lumbridge-secrets`: Keychain/libsecret adapters and redaction.
- `lumbridge-git`: repositories, worktrees, diffs, status, conflict state. - `lumbridge-git`: repositories, worktrees, diffs, status, conflict state.
- `lumbridge-buzz`: credential-free Buzz event/broker preparation and pane-share
safety gates; platform adapters own signing and transport.
- `lumbridge-ui`: native desktop state and rendering. - `lumbridge-ui`: native desktop state and rendering.
- `lumbridge`: installable application entry point. - `lumbridge`: installable application entry point.
Only `lumbridge-core` and the entry point exist in the scaffold. New crates are The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
added after their architecture spike passes. `lumbridge-buzz`, and the entry point. Larger runtime crates are added after
their architecture spikes pass.
## Terminal path ## Terminal path
@@ -57,13 +63,46 @@ Do not lock the project to a webview or to Zed's private implementation details
before a measured spike. Compare: before a measured spike. Compare:
1. GPUI for a Zed-like native model and excellent text-heavy interaction. 1. GPUI for a Zed-like native model and excellent text-heavy interaction.
2. Iced/wgpu plus a dedicated terminal renderer for stable Rust portability. 2. Floem for an independent native Rust model with existing editor primitives.
3. Tauri only as a delivery-speed baseline, not the assumed winner.
The winner must render six busy panes smoothly, keep input latency low, support The winner must render six busy panes smoothly, keep input latency low, support
IME/accessibility, package on macOS and both Linux targets, and avoid a license IME/accessibility, package on macOS and both Linux targets, and avoid a license
or upstream-stability trap. or upstream-stability trap.
## Local-first and remote session path
SQLite is device-local. It stores workspace metadata, pane layouts, event and
usage history, remote routing profiles, and small snapshots. It never stores SSH
private keys, Tailscale credentials, provider API keys, or subscription tokens.
Secrets remain in the OS credential store or in the user's existing SSH agent.
A remote pane is not a local PTY wrapped around a long-lived `ssh` process. Its
durable owner is a per-user `lumbridge-runtime` on the destination:
```text
MacBook Lumbridge UI/runtime
|
| ssh host lumbridge remote connect --stdio
| or: tailscale ssh host lumbridge remote connect --stdio
v
remote per-user Lumbridge runtime -- Unix socket -- PTYs, agents, worktrees
|
`-- remote SQLite + chunked scrollback on that machine
```
The SSH child carries a versioned framed protocol over stdio. Normal OpenSSH
remains the default because it honors the user's config, agent, host keys,
ProxyJump, and Tailscale addresses. `tailscale ssh` is an explicit transport for
users who want Tailscale's SSH proxy and host-key path. Lumbridge does not
configure a tailnet, weaken ACLs, copy SSH keys, or require a listening TCP port.
The remote runtime assigns a stable session ID before acknowledging a launch.
On network loss the local pane becomes disconnected, the remote PTY continues,
and reconnect resumes from the last acknowledged output sequence. A second
authorized Lumbridge installation can attach to the same remote session after
the remote runtime arbitrates input ownership. Collaborative simultaneous input
is not part of the first release.
## Harness integration ## Harness integration
Each harness is described by a versioned manifest: executable discovery, launch Each harness is described by a versioned manifest: executable discovery, launch
@@ -88,6 +127,14 @@ usually expose token counts but cost still depends on cached tokens, reasoning,
tool calls, and current pricing. Adapters normalize facts without erasing their tool calls, and current pricing. Adapters normalize facts without erasing their
source or uncertainty. source or uncertainty.
## Buzz collaboration
Buzz channel messages, replies, agents, and attachments use the upstream Rust
SDK's signed Nostr semantics. SQLite stores a public identity and opaque
credential-store handle, never the private identity key. Pane images cross the
network only after local capture, redaction preview, explicit destination, and
confirmation. See `BUZZ_INTEGRATION.md` for the contract and test plan.
## Persistence ## Persistence
SQLite in WAL mode stores metadata, commands/events, normalized usage, and small SQLite in WAL mode stores metadata, commands/events, normalized usage, and small
@@ -95,6 +142,11 @@ snapshots. Large scrollback chunks and binary attachments use content-addressed
files. A write-ahead event is committed before an external mutation is reported files. A write-ahead event is committed before an external mutation is reported
as accepted. Startup replays incomplete operations and reconciles live children. as accepted. Startup replays incomplete operations and reconciles live children.
Default data roots are `~/Library/Application Support/ai.karti.lumbridge/` on
macOS and `${XDG_DATA_HOME:-~/.local/share}/lumbridge/` on Linux. Backups and
exports are explicit; Lumbridge does not synchronize the database through a
hidden hosted account.
## Security ## Security
- macOS secrets: Keychain; Linux secrets: Secret Service/libsecret, with an - macOS secrets: Keychain; Linux secrets: Secret Service/libsecret, with an
@@ -113,4 +165,3 @@ Shared contracts cover PTY, process tree, notifications, secret store, paths,
autostart, updater, and packaging. macOS uses `forkpty`/process groups and native autostart, updater, and packaging. macOS uses `forkpty`/process groups and native
Keychain. Ubuntu and Omarchy use Unix PTYs, cgroups/systemd scopes when available, Keychain. Ubuntu and Omarchy use Unix PTYs, cgroups/systemd scopes when available,
and Secret Service. Omarchy is treated as Arch Linux, not as a separate kernel. and Secret Service. Omarchy is treated as Arch Linux, not as a separate kernel.
+84
View File
@@ -0,0 +1,84 @@
# Buzz integration
## Product boundary
Buzz is Lumbridge's first-class collaboration surface, not its control plane.
Lumbridge remains useful offline, owns its local workspace state, and never
requires a Buzz account to open a terminal or run an agent.
The initial integration provides:
- connect an existing Buzz identity using an OS credential-store reference;
- bind one or more Buzz channels to a Lumbridge workspace;
- read and post channel messages and replies from a collaboration pane;
- surface Buzz agents attached to a bound channel without pretending they are
local processes;
- share an approved pane image to an explicitly selected channel.
## Protocol shape
The pinned upstream is Block's Apache-2.0 Buzz repository at commit
`cb3144999bebc4939cb15b2200b373281d493b52`. `buzz-sdk` constructs typed Nostr
events and broker actions but deliberately owns neither identity keys nor
network connections. Lumbridge follows that separation:
```text
Lumbridge collaboration pane
| typed local command
lumbridge-buzz
| Buzz SDK event/broker action
identity signer + Buzz transport adapter
| signed WebSocket / HTTPS upload
Buzz relay and attachment storage
```
The exact revision stays pinned until an audited upgrade. We use Buzz's signed
protocol and SDK types rather than a parallel webhook format.
## Identity and local data
SQLite stores the account label, relay URL, public key, and an opaque secret
store handle. The Nostr private key stays in macOS Keychain or Linux Secret
Service. It must never enter SQLite, command arguments, crash reports, pane
history, logs, or Git configuration.
An installed Buzz CLI identity may be imported only through an explicit user
flow that transfers it into the OS credential store. Lumbridge must not search
the filesystem for keys. Buzz's current NIP-AB device-pairing tooling is marked
for interoperability testing upstream, so it is not a production login promise.
## Pane-image sharing
Sharing is a user-visible state machine:
1. Capture the selected pane only, excluding application chrome by default.
2. Detect likely secrets and render a local preview with proposed redactions.
3. Let the user adjust redactions, caption, and destination channel.
4. Require confirmation of that exact preview and destination.
5. Encode PNG or WebP, hash it, upload with the authenticated Buzz attachment
flow, and receive a canonical URL.
6. Build a kind-9 Buzz message with Markdown plus an `imeta` tag, sign it using
the OS-held identity, and publish it.
7. Persist only the resulting event ID, attachment metadata, destination, and
local audit outcome. Never persist the unredacted capture.
Any change to the pixels, caption, or destination invalidates confirmation. A
failed upload or publish is retryable but never reported as sent.
## Agent integration
Buzz agents and Lumbridge harnesses are different entities. A Buzz agent may be
shown in the collaboration pane and may exchange messages through Buzz's broker
contract. A local or remote Lumbridge harness remains owned by the Lumbridge
runtime and communicates through ACP or a supervised PTY. Linking the two later
requires an explicit capability grant, visible identity, and revocation path.
## Test plan
- deterministic unit tests for channel IDs, content limits, media metadata, and
the review/confirmation gate;
- signature tests with generated fixture identities only;
- integration tests against a disposable local Buzz relay and attachment store;
- disconnect, duplicate-delivery, cursor-resume, and publish-retry tests;
- rendered tests proving destination and redactions remain visible at confirm;
- a real-account smoke test only in a manually enabled, non-CI test profile.
+29 -2
View File
@@ -21,6 +21,24 @@ The product should feel faster and calmer as concurrency rises.
5. Review file and Git changes by workspace or worktree. 5. Review file and Git changes by workspace or worktree.
6. Understand usage, rate-limit windows, burn rate, and likely exhaustion time. 6. Understand usage, rate-limit windows, burn rate, and likely exhaustion time.
7. Resume the entire workspace after the UI or machine restarts. 7. Resume the entire workspace after the UI or machine restarts.
8. Work from a laptop while selected panes and agents run durably on a
user-owned machine reached through SSH or Tailscale.
9. Follow a project's Buzz channel and intentionally share a redacted pane image
without leaving the workspace.
## Local and remote workspaces
Every Lumbridge installation is useful on its own and owns its local settings,
history, usage observations, and workspace views. A pane can execute locally or
on a saved remote host. Remote hosts may be normal SSH destinations, entries in
the user's SSH config, Tailscale MagicDNS names, or tailnet IPs.
For durable remote work, a per-user Lumbridge runtime on the destination owns
the PTY and process tree. The desktop app connects to that runtime over an SSH
stdio channel and can later reconnect without moving the process to a cloud
service. The same remote workspace may be opened from another authorized
Lumbridge installation, but local UI state is not silently merged between
devices.
## Initial harnesses ## Initial harnesses
@@ -64,13 +82,23 @@ harness-reported, locally measured, estimated, or unavailable. Lumbridge must
not scrape browser cookies or reverse-engineer private account APIs to manufacture not scrape browser cookies or reverse-engineer private account APIs to manufacture
an exact remaining balance. When only local observations exist, the UI says so. an exact remaining balance. When only local observations exist, the UI says so.
## Buzz collaboration
Buzz is optional and deeply integrated: an engineer can connect an existing
identity, bind channels to workspaces, read and send messages, see channel
agents, and share a pane image. Sharing always previews the exact capture,
redactions, caption, and destination before upload. Buzz identity secrets stay
in the operating-system credential store.
## Non-goals for the first release ## Non-goals for the first release
- A new foundation-model training or inference service. - A new foundation-model training or inference service.
- A hosted account or mandatory Lumbridge cloud. - A hosted account or mandatory Lumbridge cloud.
- Replacing every coding harness with one Lumbridge-owned agent loop. - Replacing every coding harness with one Lumbridge-owned agent loop.
- Windows support. - Windows support.
- Mobile control, collaborative cloud sessions, or remote execution. - Mobile control or collaborative hosted sessions.
- Arbitrary third-party remote compute provisioning; the first release connects
only to machines and SSH/Tailscale access the user already controls.
- Bundling third-party subscriptions or reselling model tokens. - Bundling third-party subscriptions or reselling model tokens.
## Experience principles ## Experience principles
@@ -81,4 +109,3 @@ an exact remaining balance. When only local observations exist, the UI says so.
- Approval and security boundaries stay visible. - Approval and security boundaries stay visible.
- Local and offline workflows remain useful. - Local and offline workflows remain useful.
- Estimates are useful only when their uncertainty is honest. - Estimates are useful only when their uncertainty is honest.
+8 -1
View File
@@ -4,9 +4,13 @@
- Capture upstream snapshots, licenses, relevant modules, and architectural notes. - Capture upstream snapshots, licenses, relevant modules, and architectural notes.
- Benchmark UI candidates with six animated terminal panes. - Benchmark UI candidates with six animated terminal panes.
- Prove OpenSSH and `tailscale ssh` stdio transports against a disposable remote
runtime, including disconnect and replay.
- Spike PTY correctness and process-tree cleanup on macOS, Ubuntu, and Omarchy. - Spike PTY correctness and process-tree cleanup on macOS, Ubuntu, and Omarchy.
- Connect the official ACP Rust SDK to two contrasting harnesses. - Connect the official ACP Rust SDK to two contrasting harnesses.
- Audit actual usage/quota surfaces for every launch provider. - Audit actual usage/quota surfaces for every launch provider.
- Prove Buzz SDK message, attachment, cursor, reconnect, and duplicate-delivery
behavior against a disposable local relay.
- Decide terminal core, UI stack, IPC transport, and schema evolution policy. - Decide terminal core, UI stack, IPC transport, and schema evolution policy.
Exit: recorded decisions with working spikes and measured results. Exit: recorded decisions with working spikes and measured results.
@@ -14,8 +18,12 @@ Exit: recorded decisions with working spikes and measured results.
## Phase 1 — terminal workspace alpha ## Phase 1 — terminal workspace alpha
- Durable runtime, local IPC, workspaces, tabs, splits, shells, scrollback. - Durable runtime, local IPC, workspaces, tabs, splits, shells, scrollback.
- Saved SSH/Tailscale hosts and durable remote panes through a per-user remote
runtime; reconnect from a second Lumbridge installation.
- Session restore, crash recovery, command palette, keybindings, notifications. - Session restore, crash recovery, command palette, keybindings, notifications.
- Repository/worktree creation and basic Git status/diff. - Repository/worktree creation and basic Git status/diff.
- Optional Buzz collaboration pane, workspace channel binding, and confirmed
redacted pane-image sharing.
- macOS, Ubuntu, and Omarchy development packages. - macOS, Ubuntu, and Omarchy development packages.
Exit: Lumbridge is worth using as a terminal multiplexer without AI features. Exit: Lumbridge is worth using as a terminal multiplexer without AI features.
@@ -44,4 +52,3 @@ Exit: usage displays are useful, auditable, and honest about uncertainty.
- Signed/notarized macOS releases, `.deb`, AppImage, and Arch package. - Signed/notarized macOS releases, `.deb`, AppImage, and Arch package.
- Delta updater with signed manifests and rollback. - Delta updater with signed manifests and rollback.
- Accessibility, performance, soak, migration, and recovery qualification. - Accessibility, performance, soak, migration, and recovery qualification.
+26
View File
@@ -0,0 +1,26 @@
# Native UI spike scorecard
The GPUI and Floem programs under `spikes/` consume the same six-pane fixture.
No product framework decision is accepted until both rows contain measurements
from macOS and Linux and the hard gates pass.
| Criterion | Hard gate | GPUI | Floem |
|---|---:|---:|---:|
| Builds on macOS Apple Silicon | yes | pending | pending |
| Builds on Ubuntu | yes | pending | pending |
| Builds on Omarchy/Arch | yes | pending | pending |
| Dependency/license closure permits Apache-2.0 distribution | yes | pending | pending |
| Keyboard navigation + AccessKit tree | yes | pending | pending |
| IME and composed Unicode input | yes | pending | pending |
| Isolated system browser child | yes | pending | pending |
| Cold startup, p50/p95 | record | pending | pending |
| Idle RSS / six-stream RSS | record | pending | pending |
| Key-to-present p50/p95 | record | pending | pending |
| Six-stream frame p95/p99 | record | pending | pending |
| Release binary/package size | record | pending | pending |
| Native menu/window/clipboard/drag-and-drop | review | pending | pending |
| API clarity and maintenance burden | review | pending | pending |
The current programs establish dependency, build, launch, and static layout
baselines. The next iteration adds deterministic streaming, input timestamps,
accessibility identifiers, a native Markdown editor, and one Wry browser child.
@@ -0,0 +1,25 @@
# 0003: Data is local; remote sessions live on the user's remote machine
Status: accepted for implementation.
Each Lumbridge installation owns a local SQLite database and content-addressed
data directory. There is no mandatory Lumbridge account, hosted database, or
silent multi-device synchronization. Credentials remain in platform credential
stores, SSH agents, or upstream harnesses rather than SQLite.
Lumbridge supports local work and user-owned remote machines as equal execution
targets. OpenSSH is the default transport and may reach a Tailscale MagicDNS name
or tailnet address. An explicit `tailscale ssh` transport is also supported.
Lumbridge consumes existing connectivity and authorization; it does not manage
tailnet ACLs or private keys.
Durability belongs where the process runs. A remote per-user Lumbridge runtime
owns remote PTYs, agents, worktrees, event history, and scrollback. Desktop apps
connect over a framed SSH stdio stream and reconnect by stable session ID. This
allows a MacBook to close or change networks without terminating work on metal
or amd-server.
Another authorized Lumbridge installation may discover and attach to that remote
workspace. Its device-local window layout and preferences remain local. The
remote runtime arbitrates a single input owner in the first release; real-time
collaborative editing is a separate future capability.
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
repo_path=${LUMBRIDGE_REPO:-/home/metal/Desktop/Lumbridge Code/lumbridge}
window_title='Lumbridge · Bacon'
if [[ ! -d "$repo_path/.git" ]]; then
echo "Lumbridge repository not found at $repo_path" >&2
exit 1
fi
export DISPLAY=${DISPLAY:-:0}
export XAUTHORITY=${XAUTHORITY:-/run/user/1000/gdm/Xauthority}
for command_name in gnome-terminal wmctrl; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "required command not found: $command_name" >&2
exit 1
fi
done
existing_id=$(wmctrl -l | awk -v title="$window_title" 'index($0, title) { print $1; exit }')
if [[ -z "$existing_id" ]]; then
gnome-terminal \
--title="$window_title" \
-- bash -lc "cd '$repo_path' && source /home/metal/.cargo/env && exec bacon" &
for _attempt in $(seq 1 50); do
existing_id=$(wmctrl -l | awk -v title="$window_title" 'index($0, title) { print $1; exit }')
[[ -n "$existing_id" ]] && break
sleep 0.1
done
fi
if [[ -z "$existing_id" ]]; then
echo "Bacon terminal did not become visible" >&2
exit 1
fi
# Gigabyte G34WQC is 3440x1440 at X=0. Leave room for GNOME's top bar.
wmctrl -i -r "$existing_id" -b remove,maximized_vert,maximized_horz
wmctrl -i -r "$existing_id" -e 0,0,0,1720,1400
wmctrl -i -a "$existing_id"
echo "Bacon is running in window $existing_id on the left half of the Gigabyte display."
+53
View File
@@ -0,0 +1,53 @@
---
name: lumbridge-development
description: Build, test, review, benchmark, or document Lumbridge, the native Rust local-first IDE, terminal multiplexer, and agent workspace.
---
# Lumbridge Development
Work from the Lumbridge repository root. Read `AGENTS.md`, then only the product
documents relevant to the task:
- architecture or persistence: `docs/ARCHITECTURE.md` and `docs/decisions/`;
- product behavior: `docs/PRODUCT_SPEC.md`;
- tests: `docs/TESTING.md`;
- native UI work: `docs/UI_OPTIONS.md` and `docs/UI_SPIKE_SCORECARD.md`;
- Buzz work: `docs/BUZZ_INTEGRATION.md`.
Preserve these product boundaries:
- The application shell, terminal, editor, runtime, and state model are native
Rust. Do not introduce Electron, React, TypeScript, or a webview shell.
- Data is local-first. SQLite contains metadata and history, never API keys,
subscription tokens, SSH private keys, Buzz private keys, or Tailscale keys.
- Remote PTYs live in a per-user runtime on the user's remote machine. OpenSSH
and Tailscale are transports over access the user already configured; do not
change tailnet ACLs, copy credentials, or open public listeners.
- macOS, Ubuntu, and Omarchy/Arch are release targets. A Linux-only success is
not cross-platform proof.
- Usage and quota values always retain provenance and uncertainty.
## Development loop
Before editing, inspect Git status and preserve unrelated work. Use:
```bash
bacon
cargo xtest
./scripts/ci.sh
```
Root CI must remain independent from experimental UI dependencies. GPUI and
Floem live in separate workspaces under `spikes/` and consume the same
`ui-shell-model` fixture. Record only observed results in the scorecard; do not
select a framework until both pass the hard gates.
For Buzz integration, use its Apache-2.0 Rust SDK and signed protocol semantics
rather than inventing a webhook dialect. Test against a local relay and fixture
identity. Pane screenshots or transcripts require a visible preview, redaction,
explicit destination, and user confirmation before upload.
Add tests at the lowest deterministic layer first, then platform or rendered
tests where behavior crosses a real boundary. Before handoff, run the relevant
spike build plus `./scripts/ci.sh`, `git diff --check`, and report anything not
validated on all target systems.
@@ -0,0 +1,4 @@
interface:
display_name: "Lumbridge Development"
short_description: "Build and validate the Lumbridge native IDE"
default_prompt: "Use $lumbridge-development to continue building and validating Lumbridge."
+37
View File
@@ -0,0 +1,37 @@
# Native UI spikes
These disposable applications render the same fixture through GPUI and Floem.
They are a decision instrument, not product code. The root workspace excludes
this nested workspace so normal Lumbridge CI does not download or compile both
UI frameworks.
The GPUI spike uses published `gpui 0.2.2`. The Floem spike pins upstream commit
`778bb5f2aa08429e579ee2e6ac97e84fbf18b618`; the crates.io `floem 0.2.0` package
lags the current API substantially enough that comparing it to current GPUI
would not be representative.
Both spikes must preserve the same information architecture:
- workspace/sidebar and remote host state;
- two rows of three busy surfaces;
- local and remote terminal/agent panes;
- native Markdown editor/preview and browser placeholders;
- connection, harness, usage, and burn context in the footer.
Build independently:
```bash
cargo build --release --manifest-path spikes/gpui-shell/Cargo.toml
cargo build --release --manifest-path spikes/floem-shell/Cargo.toml
```
Each candidate is an independent Cargo workspace. GPUI pins `taffy 0.9.0`
while current Floem requires `taffy 0.9.2`; putting them in one comparison
workspace creates an artificial resolver conflict and would let one candidate's
dependency decisions distort the other candidate's build.
The comparison records release build time, binary size, startup, idle RSS,
six-pane streaming frame time, key-to-present latency, accessibility/IME,
window behavior, browser-child integration, packaging, dependency count, and
license closure on macOS, Ubuntu, and Omarchy. A build is not adoption: GPUI's
complete dependency-license closure remains a hard gate.
+4680
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "lumbridge-spike-floem"
description = "Floem implementation of the Lumbridge native shell spike"
version = "0.0.1"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
publish = false
[dependencies]
floem = { git = "https://github.com/lapce/floem", rev = "778bb5f2aa08429e579ee2e6ac97e84fbf18b618", default-features = false, features = ["vger"] }
lumbridge-spike-model = { path = "../ui-shell-model" }
[workspace]
+163
View File
@@ -0,0 +1,163 @@
use floem::{Application, kurbo::Size, peniko::Color, prelude::*, window::WindowConfig};
use lumbridge_spike_model::{
FOOTER_CENTER, FOOTER_LEFT, FOOTER_RIGHT, PANES, PaneFixture, WORKSPACES,
};
const BG: Color = Color::from_rgb8(12, 14, 19);
const PANEL: Color = Color::from_rgb8(18, 23, 34);
const PANEL_ALT: Color = Color::from_rgb8(23, 29, 41);
const BORDER: Color = Color::from_rgb8(41, 50, 68);
const TEXT: Color = Color::from_rgb8(217, 226, 242);
const MUTED: Color = Color::from_rgb8(127, 139, 163);
const ACCENT: Color = Color::from_rgb8(119, 189, 251);
fn pane_card(pane: PaneFixture) -> impl IntoView {
let header = Stack::horizontal((
Label::new(pane.title).style(|s| s.flex_grow(1.0).color(TEXT)),
Label::new(pane.badge).style(|s| s.font_size(11.0).color(ACCENT)),
))
.style(|s| {
s.items_center()
.height(34.0)
.padding_horiz(10.0)
.background(PANEL_ALT)
.border_bottom(1.0)
.border_color(BORDER)
});
let lines =
Stack::vertical(pane.lines.map(|line| {
Label::new(line).style(|s| s.font_size(12.0).color(TEXT).margin_bottom(5.0))
}));
Stack::vertical((
header,
Label::new(pane.target).style(|s| s.font_size(11.0).color(MUTED).padding(10.0)),
lines.style(|s| s.padding_horiz(10.0).padding_bottom(10.0)),
))
.style(|s| {
s.flex_basis(0)
.flex_grow(1.0)
.min_width(0.0)
.min_height(0.0)
.margin(4.0)
.background(PANEL)
.border(1.0)
.border_color(BORDER)
.border_radius(6.0)
})
}
fn app_view() -> impl IntoView {
let sidebar_items = Stack::vertical(WORKSPACES.map(|name| {
Label::new(name).style(move |s| {
s.width_full()
.padding_vert(8.0)
.padding_horiz(12.0)
.margin_bottom(3.0)
.color(if name == "Lumbridge Code" {
TEXT
} else {
MUTED
})
.apply_if(name == "Lumbridge Code", |s| {
s.background(PANEL_ALT).border_radius(6.0)
})
})
}));
let sidebar = Stack::vertical((
Label::new("WORKSPACES").style(|s| s.font_size(11.0).color(MUTED).padding(12.0)),
sidebar_items.style(|s| s.padding_horiz(8.0)),
Label::new("HOSTS").style(|s| {
s.font_size(11.0)
.color(MUTED)
.padding(12.0)
.margin_top(12.0)
}),
Label::new("● metal · connected").style(|s| s.color(ACCENT).padding_horiz(14.0)),
Label::new("● amd-server · connected")
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)),
Label::new("○ spark-1 · sleeping")
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)),
))
.style(|s| {
s.width(220.0)
.height_full()
.flex_shrink(0.0)
.background(PANEL)
.border_right(1.0)
.border_color(BORDER)
});
let top_row = Stack::horizontal((
pane_card(PANES[0]),
pane_card(PANES[1]),
pane_card(PANES[2]),
))
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
let bottom_row = Stack::horizontal((
pane_card(PANES[3]),
pane_card(PANES[4]),
pane_card(PANES[5]),
))
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
let panes = Stack::vertical((top_row, bottom_row)).style(|s| {
s.flex_basis(0)
.flex_grow(1.0)
.min_width(0.0)
.height_full()
.padding(4.0)
});
let header = Stack::horizontal((
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT).flex_grow(1.0)),
Label::new("UI spike · Floem").style(|s| s.color(MUTED).flex_grow(1.0)),
Label::new("⌘K Command Palette").style(|s| s.color(ACCENT)),
))
.style(|s| {
s.height(46.0)
.padding_horiz(14.0)
.items_center()
.background(PANEL_ALT)
.border_bottom(1.0)
.border_color(BORDER)
});
let body = Stack::horizontal((sidebar, panes))
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0));
let footer = Stack::horizontal((
Label::new(FOOTER_LEFT).style(|s| s.flex_grow(1.0)),
Label::new(FOOTER_CENTER).style(|s| s.flex_grow(1.0)),
Label::new(FOOTER_RIGHT),
))
.style(|s| {
s.height(30.0)
.padding_horiz(10.0)
.items_center()
.font_size(11.0)
.color(MUTED)
.background(PANEL_ALT)
.border_top(1.0)
.border_color(BORDER)
});
Stack::vertical((header, body, footer))
.style(|s| s.width_full().height_full().background(BG))
.window_title(|| "Lumbridge · Floem spike".to_owned())
}
fn main() {
Application::new()
.window(
|_| app_view(),
Some(
WindowConfig::default()
.size(Size::new(1280.0, 800.0))
.min_size(Size::new(900.0, 600.0))
.title("Lumbridge · Floem spike"),
),
)
.run();
}
+7195
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "lumbridge-spike-gpui"
description = "GPUI implementation of the Lumbridge native shell spike"
version = "0.0.1"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
publish = false
[dependencies]
gpui = "0.2.2"
lumbridge-spike-model = { path = "../ui-shell-model" }
[workspace]
+192
View File
@@ -0,0 +1,192 @@
use gpui::{
App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
rgb, size,
};
use lumbridge_spike_model::{
FOOTER_CENTER, FOOTER_LEFT, FOOTER_RIGHT, PANES, PaneFixture, WORKSPACES,
};
const BG: u32 = 0x0c0e13;
const PANEL: u32 = 0x121722;
const PANEL_ALT: u32 = 0x171d29;
const BORDER: u32 = 0x293244;
const TEXT: u32 = 0xd9e2f2;
const MUTED: u32 = 0x7f8ba3;
const ACCENT: u32 = 0x77bdfb;
struct LumbridgeShell;
fn pane_card(pane: &PaneFixture) -> impl IntoElement {
div()
.flex()
.flex_col()
.min_w_0()
.min_h_0()
.overflow_hidden()
.bg(rgb(PANEL))
.border_1()
.border_color(rgb(BORDER))
.rounded_md()
.child(
div()
.flex()
.items_center()
.justify_between()
.px_3()
.h(px(34.0))
.bg(rgb(PANEL_ALT))
.border_b_1()
.border_color(rgb(BORDER))
.text_sm()
.text_color(rgb(TEXT))
.child(pane.title)
.child(div().text_xs().text_color(rgb(ACCENT)).child(pane.badge)),
)
.child(
div()
.px_3()
.py_2()
.text_xs()
.text_color(rgb(MUTED))
.child(pane.target),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.px_3()
.pb_3()
.text_sm()
.text_color(rgb(TEXT))
.children(pane.lines.into_iter()),
)
}
impl Render for LumbridgeShell {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let sidebar = div()
.flex()
.flex_col()
.w(px(220.0))
.flex_none()
.bg(rgb(PANEL))
.border_r_1()
.border_color(rgb(BORDER))
.child(
div()
.px_4()
.py_3()
.text_sm()
.text_color(rgb(MUTED))
.child("WORKSPACES"),
)
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
div()
.mx_2()
.mb_1()
.px_3()
.py_2()
.rounded_md()
.when(index == 0, |view| {
view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))
})
.when(index != 0, |view| view.text_color(rgb(MUTED)))
.child(name)
}))
.child(
div()
.mt_4()
.px_4()
.text_xs()
.text_color(rgb(MUTED))
.child("HOSTS")
.child(
div()
.mt_2()
.text_color(rgb(ACCENT))
.child("● metal · connected"),
)
.child(div().mt_2().child("● amd-server · connected"))
.child(div().mt_2().child("○ spark-1 · sleeping")),
);
let grid = div()
.grid()
.grid_cols(3)
.grid_rows(2)
.gap_2()
.p_2()
.size_full()
.children(PANES.iter().map(pane_card));
div()
.flex()
.flex_col()
.size_full()
.bg(rgb(BG))
.text_color(rgb(TEXT))
.child(
div()
.flex()
.items_center()
.justify_between()
.h(px(46.0))
.flex_none()
.px_4()
.bg(rgb(PANEL_ALT))
.border_b_1()
.border_color(rgb(BORDER))
.child(div().text_lg().child("Lumbridge"))
.child(
div()
.text_sm()
.text_color(rgb(MUTED))
.child("UI spike · GPUI"),
)
.child(
div()
.text_sm()
.text_color(rgb(ACCENT))
.child("⌘K Command Palette"),
),
)
.child(div().flex().flex_1().min_h_0().child(sidebar).child(grid))
.child(
div()
.flex()
.items_center()
.justify_between()
.h(px(30.0))
.flex_none()
.px_3()
.bg(rgb(PANEL_ALT))
.border_t_1()
.border_color(rgb(BORDER))
.text_xs()
.text_color(rgb(MUTED))
.child(FOOTER_LEFT)
.child(FOOTER_CENTER)
.child(FOOTER_RIGHT),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(gpui::TitlebarOptions {
title: Some("Lumbridge · GPUI spike".into()),
..Default::default()
}),
..Default::default()
},
|_, cx| cx.new(|_| LumbridgeShell),
)
.expect("GPUI spike window should open");
cx.activate(true);
});
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "lumbridge-spike-model"
version = "0.0.1"
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "lumbridge-spike-model"
description = "Identical fixture data for Lumbridge native UI comparisons"
version = "0.0.1"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
publish = false
[lib]
path = "src/lib.rs"
[workspace]
+122
View File
@@ -0,0 +1,122 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SurfaceKind {
Terminal,
Markdown,
Browser,
Review,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PaneFixture {
pub title: &'static str,
pub badge: &'static str,
pub target: &'static str,
pub kind: SurfaceKind,
pub lines: [&'static str; 4],
}
pub const PANES: [PaneFixture; 6] = [
PaneFixture {
title: "Codex · runtime",
badge: "WORKING",
target: "metal · Tailscale SSH",
kind: SurfaceKind::Terminal,
lines: [
"$ cargo nextest run -p lumbridge-runtime",
"PASS remote::reconnect_replays_output",
"PASS pty::resize_preserves_cursor",
"agent is editing 3 files…",
],
},
PaneFixture {
title: "Claude Code · UI",
badge: "NEEDS INPUT",
target: "MacBook Air · local",
kind: SurfaceKind::Terminal,
lines: [
"$ bacon clippy",
"finished in 0.42s",
"Which split should receive focus?",
"[Approve] [Steer] [Cancel]",
],
},
PaneFixture {
title: "Pi · docs",
badge: "STREAMING",
target: "amd-server · OpenSSH",
kind: SurfaceKind::Terminal,
lines: [
"$ cargo watch --why",
"ACP session resumed at event 1842",
"writing remote-session.md",
"",
],
},
PaneFixture {
title: "Architecture.md",
badge: "MARKDOWN",
target: "lumbridge-code · worktree",
kind: SurfaceKind::Markdown,
lines: [
"## Remote session path",
"The destination runtime owns the PTY.",
"The laptop may disconnect and reattach.",
"SQLite remains local to each machine.",
],
},
PaneFixture {
title: "Preview · ACP docs",
badge: "BROWSER",
target: "isolated system web engine",
kind: SurfaceKind::Browser,
lines: [
"https://agentclientprotocol.com",
"Content process: sandboxed",
"Runtime bridge: no privileged access",
"Open in external browser ↗",
],
},
PaneFixture {
title: "Changes · lumbridge-runtime",
badge: "REVIEW",
target: "metal · worktree remote-runtime",
kind: SurfaceKind::Review,
lines: [
"+ 184 remote transport",
"+ 96 SQLite migrations",
" 12 obsolete scaffold",
"6 files · tests passing",
],
},
];
pub const WORKSPACES: [&str; 5] = [
"Lumbridge Code",
"Runtime / metal",
"UI spikes",
"ACP adapters",
"Usage telemetry",
];
pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input";
pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining";
pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m";
#[cfg(test)]
mod tests {
use super::{PANES, SurfaceKind};
#[test]
fn comparison_fixture_has_six_mixed_surfaces() {
assert_eq!(PANES.len(), 6);
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Markdown));
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Browser));
assert_eq!(
PANES
.iter()
.filter(|pane| pane.kind == SurfaceKind::Terminal)
.count(),
3
);
}
}