Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 36 additions & 24 deletions crates/deckard-app/src/activity_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
//! had no human and renders neutral. An executed shield reads the *result* ("moved … ETH to your
//! private balance"), the demo's payoff.
//!
//! Each row carries the two-actor chain (the cyan agent squircle for Atlas, neutral for a
//! Each row carries the two-actor chain (the cyan `agent_mark` for the agent, neutral for a
//! foreground app action), the lifecycle glyph, the real broadcast `tx_hash`, a relative
//! timestamp, and — for an over-cap/over-scope proposal — the ACTUAL breached fence (per-tx vs
//! daily, never a hardcoded cite). A header STOP control is the always-reachable panic brake.
Expand Down Expand Up @@ -54,14 +54,14 @@ use deckard_contract::{

use crate::money::money;
use crate::shell::Shell;
use crate::shell_chrome::agent_squircle;
use crate::theme;
use crate::widgets::agent_mark;

/// The displayed subject for an action's origin: the agent's name when an agent acted, "You"
/// when the foreground app did. One agent in the demo scope (Atlas).
fn origin_subject(origin: ProposalOrigin) -> &'static str {
/// The displayed subject for an action's origin: the agent's handle when an agent acted, "You"
/// when the foreground app did (E2, #182 — one agent in demo scope, named via `Shell::agent_handle`).
fn origin_subject(origin: ProposalOrigin, agent_handle: &str) -> &str {
match origin {
ProposalOrigin::Agent => "Atlas",
ProposalOrigin::Agent => agent_handle,
ProposalOrigin::App => "You",
}
}
Expand Down Expand Up @@ -434,12 +434,10 @@ impl Shell {
.text_color(fg)
.child("Activity"),
)
.child(
div()
.text_sm()
.text_color(muted)
.child("What Atlas and you did this session. Watch it, and stop it."),
),
.child(div().text_sm().text_color(muted).child(format!(
"What {} and you did this session. Watch it, and stop it.",
self.agent_handle()
))),
)
.child(self.activity_stop_control(cx))
}
Expand Down Expand Up @@ -549,11 +547,11 @@ impl Shell {

/// One dense feed row — the two-actor chain + the outcome cluster.
///
/// - **Agent, auto-allowed within cap** (no human in the loop): `[A] Atlas · shield 0.05 ETH
/// - **Agent, auto-allowed within cap** (no human in the loop): `[K] Kyoto · shield 0.05 ETH
/// · auto-approved within cap ✓ 0x6ea…9f3c · 4m ago` (muted label, green glyph).
/// - **Agent, executed shield** (the demo payoff): `[A] Atlas · shield 0.05 ETH · moved
/// - **Agent, executed shield** (the demo payoff): `[K] Kyoto · shield 0.05 ETH · moved
/// 0.05 ETH to your private balance ✓ 0x6ea…9f3c · 4m ago`.
/// - **Agent, proposed (over cap / mainnet)** — lives in NEEDS YOU: `[A] Atlas · … → You
/// - **Agent, proposed (over cap / mainnet)** — lives in NEEDS YOU: `[K] Kyoto · … → You
/// waiting … over per-tx cap`, plus the `⌘⏎ · x` hint on the SELECTED row and a hover-only
/// "Review →" for the mouse.
/// - **Agent, human-approved/denied** (`!auto_allowed`, settled): the outcome label tints
Expand Down Expand Up @@ -583,6 +581,7 @@ impl Shell {
let amber = theme::amber(is_dark);

let is_agent = record.origin == ProposalOrigin::Agent;
let agent_handle = self.agent_handle();
let proposed = is_proposed(record);
let selected = proposed && selected_id == Some(record.request_id);
// A human is in the chain for anything that was NOT auto-allowed hands-free — an over-cap
Expand All @@ -594,13 +593,19 @@ impl Shell {
let needed_human = human_acted(record);
let summary = payload_summary(&record.payload, self.mask);

// The lead glyph: the cyan agent squircle for an agent, a neutral identity square for an
// app action — both static.
// The lead glyph: the cyan agent mark (handle-seeded) for an agent, a neutral identity
// square for an app action — both static.
let lead = if is_agent {
agent_squircle(px(20.0), px(6.0), agent, agent_tint)
agent_mark(
&agent_handle,
crate::tokens::MARK_MD,
crate::tokens::RADIUS_ROW,
agent,
agent_tint,
)
} else {
div()
.size(px(20.0))
.size(crate::tokens::MARK_MD)
.rounded(crate::tokens::RADIUS_ROW)
.bg(theme::identity_square(is_dark))
.into_any_element()
Expand All @@ -618,7 +623,7 @@ impl Shell {
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(fg)
.child(origin_subject(record.origin).to_string()),
.child(origin_subject(record.origin, &agent_handle).to_string()),
)
.child(div().flex_shrink_0().text_sm().text_color(muted).child("·"))
// The verb + object is the part that grows and clamps: it gets the flex space and
Expand Down Expand Up @@ -843,16 +848,23 @@ impl Shell {
let agent_tint = theme::agent_tint(is_dark);

let is_agent = record.origin == ProposalOrigin::Agent;
let subject = origin_subject(record.origin);
let agent_handle = self.agent_handle();
let subject = origin_subject(record.origin, &agent_handle);
let cite = cite_phrase(record.reason).unwrap_or("held for your approval");

let band = {
let lead = if is_agent {
agent_squircle(px(24.0), px(7.0), agent, agent_tint)
agent_mark(
&agent_handle,
crate::tokens::MARK_MD,
crate::tokens::RADIUS_ROW,
agent,
agent_tint,
)
} else {
div()
.size(px(24.0))
.rounded(px(7.0))
.size(crate::tokens::MARK_MD)
.rounded(crate::tokens::RADIUS_ROW)
.bg(theme::identity_square(is_dark))
.into_any_element()
};
Expand Down
20 changes: 11 additions & 9 deletions crates/deckard-app/src/agent_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ use gpui_component::{
use crate::shell::Shell;
use crate::theme;
use crate::welcome::{agent_policy_rows, fraction};
use crate::widgets::{budget_gauge, section_label};
use crate::widgets::{agent_mark, budget_gauge, section_label};

impl Shell {
/// The agent surface for the selected agent (currently the one agent, Atlas):
/// identity + a plain-language autonomy statement + the limits/scope + a budget
/// The agent surface for the selected agent (currently the one agent, handle `Kyoto` by
/// default): identity + a plain-language autonomy statement + the limits/scope + a budget
/// gauge + controls (Pause / Rotate / Adjust / Revoke and STOP) + what this
/// agent did. Built from `self.agent_policy` (the live daemon fence).
pub fn render_agent_surface(&self, cx: &mut Context<Self>) -> impl IntoElement {
Expand All @@ -38,6 +38,7 @@ impl Shell {
let agent = theme::agent(is_dark);
let agent_tint = theme::agent_tint(is_dark);
let amber = theme::amber(is_dark);
let agent_handle = self.agent_handle();

// Derive the per-tx cap (ETH) for the plain-language autonomy line — never
// invented; the same number the fence shows. `None` until the first fetch, or
Expand Down Expand Up @@ -68,9 +69,10 @@ impl Shell {
.w_full()
.items_center()
.gap_3()
.child(crate::shell_chrome::agent_squircle(
px(34.0),
px(9.0),
.child(agent_mark(
&agent_handle,
crate::tokens::MARK_LG,
crate::tokens::RADIUS_ROW,
agent,
agent_tint,
))
Expand All @@ -79,7 +81,7 @@ impl Shell {
.text_xl()
.font_weight(FontWeight::SEMIBOLD)
.text_color(fg)
.child("Atlas"),
.child(agent_handle.clone()),
)
// A small "acting" status — cyan, the agent actor signal.
.child(
Expand Down Expand Up @@ -132,8 +134,8 @@ impl Shell {
let spent_eth = deckard_core::format_amount(p.spent_today_wei, 18, 6);
let mono_rows = mono.clone();
let autonomy = format!(
"Atlas acts on its own under {cap} ETH per move and asks you above \
that. It can shield ETH only. It never holds your key, and it \
"{agent_handle} acts on its own under {cap} ETH per move and asks you \
above that. It can shield ETH only. It never holds your key, and it \
cannot send to a new address."
);

Expand Down
1 change: 1 addition & 0 deletions crates/deckard-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod commit_flow;
mod commit_view;
mod errors;
mod money;
mod names;
mod onboarding;
mod palette;
mod palette_commands;
Expand Down
150 changes: 150 additions & 0 deletions crates/deckard-app/src/names.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Identity naming — the human-readable name a wallet or the agent shows before (and unless) the
//! operator renames it (E2, #182). DESIGN.md §"Identity is named": wallets/agents carry a real
//! name/handle; the literal word Wallet is never an entity label, and the breadcrumb names the
//! entity, never a project prefix.
//!
//! A fresh vault reads like `Meridian`, not `Wallet` or a raw `0x…` address. The wallet name is
//! derived deterministically from the account address (stable across launches for the same key, so
//! it never flickers), and the agent's handle is assigned from a rotating city list (retiring the
//! old fixed placeholder). Both are overridable: the override is a persisted `Settings` field read
//! by `Shell::wallet_name` / `Shell::agent_handle`; this module only supplies the default when no
//! override is set.

/// Curated wallet codenames — calm place/landmark words, never crypto jargon (DESIGN.md §Language).
/// The default name is one of these, chosen deterministically from the account address so the same
/// key always reads the same, yet two different wallets read differently. `Meridian` leads so the
/// golden-ref demo account reads like the reference.
const WALLET_NAMES: &[&str] = &[
"Meridian", "Harbor", "Vantage", "Beacon", "Cascade", "Summit", "Anchor", "Haven", "Compass",
"Vista", "Keystone", "Bastion",
];

/// Auto-assigned agent handles — a rotating city list (DESIGN.md §request-origin model: "non-human
/// sessions get an auto-assigned handle"). Index 0 is the first agent's default (`Kyoto`), which
/// retires the old fixed placeholder handle.
const AGENT_HANDLES: &[&str] = &[
"Kyoto", "Osaka", "Lisbon", "Oslo", "Nairobi", "Quito", "Bergen", "Cairo",
];

/// A small, dependency-free FNV-1a hash of `seed` — deterministic (never `rand`), so the same
/// address always maps to the same codename across launches.
fn fnv1a(seed: &str) -> u64 {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET;
for b in seed.as_bytes() {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(PRIME);
}
hash
}

/// The deterministic default name for the wallet at `address` (its EIP-55 string in practice). An
/// empty seed falls to the first name, so a not-yet-unlocked shell still reads sensibly.
pub(crate) fn default_wallet_name(address: &str) -> &'static str {
let idx = (fnv1a(address) as usize) % WALLET_NAMES.len();
WALLET_NAMES[idx]
}

/// The default handle for the agent at `index` (one agent in v1 scope → index 0 = `Kyoto`); wraps
/// if there are ever more agents than curated handles.
pub(crate) fn default_agent_handle(index: usize) -> &'static str {
AGENT_HANDLES[index % AGENT_HANDLES.len()]
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn wallet_name_is_deterministic_and_curated() {
let a = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
// Same address → same name, every call (no rng, no per-launch drift).
assert_eq!(default_wallet_name(a), default_wallet_name(a));
assert!(WALLET_NAMES.contains(&default_wallet_name(a)));
// Never the forbidden literal, never a raw address.
assert_ne!(default_wallet_name(a), "Wallet");
assert!(!default_wallet_name(a).starts_with("0x"));
// The empty (locked) seed still yields a real codename, not a blank.
assert!(!default_wallet_name("").is_empty());
}

#[test]
fn agent_handle_rotates_and_retires_placeholder() {
assert_eq!(default_agent_handle(0), "Kyoto");
// Distinct handles for distinct indices; wraps past the list end.
assert_ne!(default_agent_handle(0), default_agent_handle(1));
assert_eq!(
default_agent_handle(0),
default_agent_handle(AGENT_HANDLES.len())
);
}

/// Reflective source-scan (mirrors `tokens::lint::no_raw_text_size_px_in_views`): the retired
/// *entity* labels — the bare word Wallet standing in for the wallet's name, and the old fixed
/// agent placeholder — must appear NOWHERE as a rendered string literal (E2 acceptance, #182).
/// Identity is named: the breadcrumb/masthead/sidebar name the entity (`Meridian` / `Kyoto`),
/// never a mode word.
///
/// The needles are the EXACT quoted tokens (`"Wallet"` / `"Atlas"`), not a substring/word match,
/// and that precision is deliberate: the DoD bans the word only as an *entity* label ("the
/// breadcrumb names the entity ... never 'Wallet'"), while DESIGN + the golden ref use the word
/// descriptively everywhere — the `Wallets` sidebar group, the `This wallet` rail header, the
/// Settings `Wallet name` field. A substring match would false-positive on all of those; the
/// standalone `"Wallet"` literal is unambiguously the retired entity label. Comments/docs are
/// exempt (they explain the rule); this module is exempt (it names the needles).
#[test]
fn retired_identity_labels_are_never_rendered() {
use std::fs;
use std::path::Path;

// Build the forbidden needles from chars so THIS file never contains the sequence it bans
// (`"{q}Wallet{q}"` is not `"Wallet"`), and pair each with a fix hint.
let q = '"';
let forbidden = [
(
format!("{q}Wallet{q}"),
"name the entity (e.g. Meridian) via Shell::wallet_name",
),
(
format!("{q}Atlas{q}"),
"use the generated handle via Shell::agent_handle (e.g. Kyoto)",
),
];

let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
for entry in fs::read_dir(&src).expect("read src dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
// This module names the forbidden needles (in the check + messages); skip it.
if name == "names.rs" {
continue;
}
let text = fs::read_to_string(&path).expect("read source");
for (i, raw) in text.lines().enumerate() {
let line = raw.trim_start();
if line.starts_with("//") {
continue;
}
for (needle, hint) in &forbidden {
if line.contains(needle.as_str()) {
offenders.push(format!("{name}:{} — {hint}", i + 1));
}
}
}
}
assert!(
offenders.is_empty(),
"a retired identity label is rendered as a literal (DESIGN.md \"Identity is named\"):\n {}",
offenders.join("\n ")
);
}
}
Loading
Loading