swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/fidget-spinner-cli/src
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-19 17:41:40 -0400
committermain <main@swarm.moe>2026-03-19 17:41:40 -0400
commit352fb5f089e74bf47b60c6221594b9c22defe251 (patch)
tree2ad1620fcf9e0f138ae950888c925b9f53a19997 /crates/fidget-spinner-cli/src
parent958c7bf261a404a7df99e394997ab10e724cfca7 (diff)
downloadfidget_spinner-352fb5f089e74bf47b60c6221594b9c22defe251.zip
Prepare fidget spinner for public sharing
Diffstat (limited to 'crates/fidget-spinner-cli/src')
-rw-r--r--crates/fidget-spinner-cli/src/main.rs273
-rw-r--r--crates/fidget-spinner-cli/src/ui.rs600
2 files changed, 866 insertions, 7 deletions
diff --git a/crates/fidget-spinner-cli/src/main.rs b/crates/fidget-spinner-cli/src/main.rs
index 9b2b8ae..fe4cb5f 100644
--- a/crates/fidget-spinner-cli/src/main.rs
+++ b/crates/fidget-spinner-cli/src/main.rs
@@ -1,8 +1,10 @@
mod bundled_skill;
mod mcp;
+mod ui;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
+use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use camino::{Utf8Path, Utf8PathBuf};
@@ -10,7 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use fidget_spinner_core::{
AnnotationVisibility, CodeSnapshotRef, CommandRecipe, ExecutionBackend, FrontierContract,
FrontierNote, FrontierVerdict, GitCommitHash, MetricObservation, MetricSpec, MetricUnit,
- NodeAnnotation, NodeClass, NodePayload, NonEmptyText, OptimizationObjective,
+ NodeAnnotation, NodeClass, NodePayload, NonEmptyText, OptimizationObjective, TagName,
};
use fidget_spinner_store_sqlite::{
CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, EdgeAttachment,
@@ -21,7 +23,11 @@ use serde_json::{Map, Value, json};
use uuid::Uuid;
#[derive(Parser)]
-#[command(author, version, about = "Fidget Spinner local project CLI")]
+#[command(
+ author,
+ version,
+ about = "Fidget Spinner CLI, MCP server, and local navigator"
+)]
struct Cli {
#[command(subcommand)]
command: Command,
@@ -29,29 +35,48 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
+ /// Initialize a project-local `.fidget_spinner/` store.
Init(InitArgs),
+ /// Read the local project payload schema.
Schema {
#[command(subcommand)]
command: SchemaCommand,
},
+ /// Create and inspect frontiers.
Frontier {
#[command(subcommand)]
command: FrontierCommand,
},
+ /// Create, inspect, and mutate DAG nodes.
Node {
#[command(subcommand)]
command: NodeCommand,
},
+ /// Record terse off-path notes.
Note(NoteCommand),
+ /// Manage the repo-local tag registry.
+ Tag {
+ #[command(subcommand)]
+ command: TagCommand,
+ },
+ /// Record off-path research and enabling work.
Research(ResearchCommand),
+ /// Close a core-path experiment atomically.
Experiment {
#[command(subcommand)]
command: ExperimentCommand,
},
+ /// Serve the hardened stdio MCP endpoint.
Mcp {
#[command(subcommand)]
command: McpCommand,
},
+ /// Serve the minimal local web navigator.
+ Ui {
+ #[command(subcommand)]
+ command: UiCommand,
+ },
+ /// Inspect or install bundled Codex skills.
Skill {
#[command(subcommand)]
command: SkillCommand,
@@ -60,22 +85,28 @@ enum Command {
#[derive(Args)]
struct InitArgs {
+ /// Project root to initialize.
#[arg(long, default_value = ".")]
project: PathBuf,
+ /// Human-facing project name. Defaults to the directory name.
#[arg(long)]
name: Option<String>,
+ /// Payload schema namespace written into `.fidget_spinner/schema.json`.
#[arg(long, default_value = "local.project")]
namespace: String,
}
#[derive(Subcommand)]
enum SchemaCommand {
+ /// Show the current project schema as JSON.
Show(ProjectArg),
}
#[derive(Subcommand)]
enum FrontierCommand {
+ /// Create a frontier and root contract node.
Init(FrontierInitArgs),
+ /// Show one frontier projection or list frontiers when omitted.
Status(FrontierStatusArgs),
}
@@ -115,10 +146,15 @@ struct FrontierStatusArgs {
#[derive(Subcommand)]
enum NodeCommand {
+ /// Create a generic DAG node.
Add(NodeAddArgs),
+ /// List recent nodes.
List(NodeListArgs),
+ /// Show one node in full.
Show(NodeShowArgs),
+ /// Attach an annotation to a node.
Annotate(NodeAnnotateArgs),
+ /// Archive a node without deleting it.
Archive(NodeArchiveArgs),
}
@@ -138,6 +174,8 @@ struct NodeAddArgs {
payload_json: Option<String>,
#[arg(long = "payload-file")]
payload_file: Option<PathBuf>,
+ #[command(flatten)]
+ tag_selection: ExplicitTagSelectionArgs,
#[arg(long = "field")]
fields: Vec<String>,
#[arg(long = "annotation")]
@@ -154,12 +192,22 @@ struct NodeListArgs {
frontier: Option<String>,
#[arg(long, value_enum)]
class: Option<CliNodeClass>,
+ #[arg(long = "tag")]
+ tags: Vec<String>,
#[arg(long)]
include_archived: bool,
#[arg(long, default_value_t = 20)]
limit: u32,
}
+#[derive(Args, Default)]
+struct ExplicitTagSelectionArgs {
+ #[arg(long = "tag")]
+ tags: Vec<String>,
+ #[arg(long, conflicts_with = "tags")]
+ no_tags: bool,
+}
+
#[derive(Args)]
struct NodeShowArgs {
#[command(flatten)]
@@ -198,9 +246,18 @@ struct NoteCommand {
#[derive(Subcommand)]
enum NoteSubcommand {
+ /// Record a quick off-path note.
Quick(QuickNoteArgs),
}
+#[derive(Subcommand)]
+enum TagCommand {
+ /// Register a new repo-local tag.
+ Add(TagAddArgs),
+ /// List registered repo-local tags.
+ List(ProjectArg),
+}
+
#[derive(Args)]
struct ResearchCommand {
#[command(subcommand)]
@@ -209,6 +266,7 @@ struct ResearchCommand {
#[derive(Subcommand)]
enum ResearchSubcommand {
+ /// Record off-path research or enabling work.
Add(QuickResearchArgs),
}
@@ -222,11 +280,23 @@ struct QuickNoteArgs {
title: String,
#[arg(long)]
body: String,
+ #[command(flatten)]
+ tag_selection: ExplicitTagSelectionArgs,
#[arg(long = "parent")]
parents: Vec<String>,
}
#[derive(Args)]
+struct TagAddArgs {
+ #[command(flatten)]
+ project: ProjectArg,
+ #[arg(long)]
+ name: String,
+ #[arg(long)]
+ description: String,
+}
+
+#[derive(Args)]
struct QuickResearchArgs {
#[command(flatten)]
project: ProjectArg,
@@ -244,16 +314,24 @@ struct QuickResearchArgs {
#[derive(Subcommand)]
enum ExperimentCommand {
+ /// Close a core-path experiment with checkpoint, run, note, and verdict.
Close(ExperimentCloseArgs),
}
#[derive(Subcommand)]
enum McpCommand {
+ /// Serve the public stdio MCP host. If `--project` is omitted, the host starts unbound.
Serve(McpServeArgs),
#[command(hide = true)]
Worker(McpWorkerArgs),
}
+#[derive(Subcommand)]
+enum UiCommand {
+ /// Serve the local read-only navigator.
+ Serve(UiServeArgs),
+}
+
#[derive(Args)]
struct ExperimentCloseArgs {
#[command(flatten)]
@@ -304,33 +382,41 @@ struct ExperimentCloseArgs {
#[derive(Subcommand)]
enum SkillCommand {
+ /// List bundled skills.
List,
+ /// Install bundled skills into a Codex skill directory.
Install(SkillInstallArgs),
+ /// Print one bundled skill body.
Show(SkillShowArgs),
}
#[derive(Args)]
struct SkillInstallArgs {
+ /// Bundled skill name. Defaults to all bundled skills.
#[arg(long)]
name: Option<String>,
+ /// Destination root. Defaults to `~/.codex/skills`.
#[arg(long)]
destination: Option<PathBuf>,
}
#[derive(Args)]
struct SkillShowArgs {
+ /// Bundled skill name. Defaults to `fidget-spinner`.
#[arg(long)]
name: Option<String>,
}
#[derive(Args)]
struct ProjectArg {
+ /// Project root or any nested path inside a project containing `.fidget_spinner/`.
#[arg(long, default_value = ".")]
project: PathBuf,
}
#[derive(Args)]
struct McpServeArgs {
+ /// Optional initial project binding. When omitted, the MCP starts unbound.
#[arg(long)]
project: Option<PathBuf>,
}
@@ -341,6 +427,18 @@ struct McpWorkerArgs {
project: PathBuf,
}
+#[derive(Args)]
+struct UiServeArgs {
+ #[command(flatten)]
+ project: ProjectArg,
+ /// Bind address for the local navigator.
+ #[arg(long, default_value = "127.0.0.1:8913")]
+ bind: SocketAddr,
+ /// Maximum rows rendered in list views.
+ #[arg(long, default_value_t = 200)]
+ limit: u32,
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum CliNodeClass {
Contract,
@@ -416,6 +514,10 @@ fn run() -> Result<(), StoreError> {
Command::Note(command) => match command.command {
NoteSubcommand::Quick(args) => run_quick_note(args),
},
+ Command::Tag { command } => match command {
+ TagCommand::Add(args) => run_tag_add(args),
+ TagCommand::List(project) => run_tag_list(project),
+ },
Command::Research(command) => match command.command {
ResearchSubcommand::Add(args) => run_quick_research(args),
},
@@ -426,6 +528,9 @@ fn run() -> Result<(), StoreError> {
McpCommand::Serve(args) => mcp::serve(args.project),
McpCommand::Worker(args) => mcp::serve_worker(args.project),
},
+ Command::Ui { command } => match command {
+ UiCommand::Serve(args) => run_ui_serve(args),
+ },
Command::Skill { command } => match command {
SkillCommand::List => print_json(&bundled_skill::bundled_skill_summaries()),
SkillCommand::Install(args) => run_skill_install(args),
@@ -439,16 +544,17 @@ fn run() -> Result<(), StoreError> {
fn run_init(args: InitArgs) -> Result<(), StoreError> {
let project_root = utf8_path(args.project);
- let display_name = NonEmptyText::new(args.name.unwrap_or_else(|| {
- project_root
- .file_name()
- .map_or_else(|| "fidget-spinner-project".to_owned(), ToOwned::to_owned)
- }))?;
+ let display_name = args
+ .name
+ .map(NonEmptyText::new)
+ .transpose()?
+ .unwrap_or(default_display_name_for_root(&project_root)?);
let namespace = NonEmptyText::new(args.namespace)?;
let store = ProjectStore::init(&project_root, display_name, namespace)?;
println!("initialized {}", store.state_root());
println!("project: {}", store.config().display_name);
println!("schema: {}", store.state_root().join("schema.json"));
+ maybe_print_gitignore_hint(&project_root)?;
Ok(())
}
@@ -498,6 +604,7 @@ fn run_node_add(args: NodeAddArgs) -> Result<(), StoreError> {
.as_deref()
.map(parse_frontier_id)
.transpose()?;
+ let tags = optional_cli_tags(args.tag_selection, args.class == CliNodeClass::Note)?;
let payload = load_payload(
store.schema().schema_ref(),
args.payload_json,
@@ -514,6 +621,7 @@ fn run_node_add(args: NodeAddArgs) -> Result<(), StoreError> {
frontier_id,
title: NonEmptyText::new(args.title)?,
summary: args.summary.map(NonEmptyText::new).transpose()?,
+ tags,
payload,
annotations,
attachments: lineage_attachments(args.parents)?,
@@ -530,6 +638,7 @@ fn run_node_list(args: NodeListArgs) -> Result<(), StoreError> {
.map(parse_frontier_id)
.transpose()?,
class: args.class.map(Into::into),
+ tags: parse_tag_set(args.tags)?,
include_archived: args.include_archived,
limit: args.limit,
})?;
@@ -585,6 +694,7 @@ fn run_quick_note(args: QuickNoteArgs) -> Result<(), StoreError> {
.transpose()?,
title: NonEmptyText::new(args.title)?,
summary: None,
+ tags: Some(explicit_cli_tags(args.tag_selection)?),
payload,
annotations: Vec::new(),
attachments: lineage_attachments(args.parents)?,
@@ -592,6 +702,20 @@ fn run_quick_note(args: QuickNoteArgs) -> Result<(), StoreError> {
print_json(&node)
}
+fn run_tag_add(args: TagAddArgs) -> Result<(), StoreError> {
+ let mut store = open_store(&args.project.project)?;
+ let tag = store.add_tag(
+ TagName::new(args.name)?,
+ NonEmptyText::new(args.description)?,
+ )?;
+ print_json(&tag)
+}
+
+fn run_tag_list(args: ProjectArg) -> Result<(), StoreError> {
+ let store = open_store(&args.project)?;
+ print_json(&store.list_tags()?)
+}
+
fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> {
let mut store = open_store(&args.project.project)?;
let payload = NodePayload::with_schema(
@@ -607,6 +731,7 @@ fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> {
.transpose()?,
title: NonEmptyText::new(args.title)?,
summary: args.summary.map(NonEmptyText::new).transpose()?,
+ tags: None,
payload,
annotations: Vec::new(),
attachments: lineage_attachments(args.parents)?,
@@ -684,6 +809,10 @@ fn run_skill_install(args: SkillInstallArgs) -> Result<(), StoreError> {
Ok(())
}
+fn run_ui_serve(args: UiServeArgs) -> Result<(), StoreError> {
+ ui::serve(utf8_path(args.project.project), args.bind, args.limit)
+}
+
fn resolve_bundled_skill(
requested_name: Option<&str>,
) -> Result<bundled_skill::BundledSkill, StoreError> {
@@ -712,10 +841,88 @@ fn open_store(path: &Path) -> Result<ProjectStore, StoreError> {
ProjectStore::open(utf8_path(path.to_path_buf()))
}
+fn open_or_init_store_for_binding(path: &Path) -> Result<ProjectStore, StoreError> {
+ let requested_root = utf8_path(path.to_path_buf());
+ match ProjectStore::open(requested_root.clone()) {
+ Ok(store) => Ok(store),
+ Err(StoreError::MissingProjectStore(_)) => {
+ let project_root = binding_bootstrap_root(&requested_root)?;
+ if !is_empty_directory(&project_root)? {
+ return Err(StoreError::MissingProjectStore(requested_root));
+ }
+ ProjectStore::init(
+ &project_root,
+ default_display_name_for_root(&project_root)?,
+ default_namespace_for_root(&project_root)?,
+ )
+ }
+ Err(error) => Err(error),
+ }
+}
+
fn utf8_path(path: impl Into<PathBuf>) -> Utf8PathBuf {
Utf8PathBuf::from(path.into().to_string_lossy().into_owned())
}
+fn binding_bootstrap_root(path: &Utf8Path) -> Result<Utf8PathBuf, StoreError> {
+ match fs::metadata(path.as_std_path()) {
+ Ok(metadata) if metadata.is_file() => Ok(path
+ .parent()
+ .map_or_else(|| path.to_path_buf(), Utf8Path::to_path_buf)),
+ Ok(_) => Ok(path.to_path_buf()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(path.to_path_buf()),
+ Err(error) => Err(StoreError::from(error)),
+ }
+}
+
+fn is_empty_directory(path: &Utf8Path) -> Result<bool, StoreError> {
+ match fs::metadata(path.as_std_path()) {
+ Ok(metadata) if metadata.is_dir() => {
+ let mut entries = fs::read_dir(path.as_std_path())?;
+ Ok(entries.next().transpose()?.is_none())
+ }
+ Ok(_) => Ok(false),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(error) => Err(StoreError::from(error)),
+ }
+}
+
+fn default_display_name_for_root(project_root: &Utf8Path) -> Result<NonEmptyText, StoreError> {
+ NonEmptyText::new(
+ project_root
+ .file_name()
+ .map_or_else(|| "fidget-spinner-project".to_owned(), ToOwned::to_owned),
+ )
+ .map_err(StoreError::from)
+}
+
+fn default_namespace_for_root(project_root: &Utf8Path) -> Result<NonEmptyText, StoreError> {
+ let slug = slugify_namespace_component(project_root.file_name().unwrap_or("project"));
+ NonEmptyText::new(format!("local.{slug}")).map_err(StoreError::from)
+}
+
+fn slugify_namespace_component(raw: &str) -> String {
+ let mut slug = String::new();
+ let mut previous_was_separator = false;
+ for character in raw.chars().flat_map(char::to_lowercase) {
+ if character.is_ascii_alphanumeric() {
+ slug.push(character);
+ previous_was_separator = false;
+ continue;
+ }
+ if !previous_was_separator {
+ slug.push('_');
+ previous_was_separator = true;
+ }
+ }
+ let slug = slug.trim_matches('_').to_owned();
+ if slug.is_empty() {
+ "project".to_owned()
+ } else {
+ slug
+ }
+}
+
fn to_text_vec(values: Vec<String>) -> Result<Vec<NonEmptyText>, StoreError> {
values
.into_iter()
@@ -728,6 +935,35 @@ fn to_text_set(values: Vec<String>) -> Result<BTreeSet<NonEmptyText>, StoreError
to_text_vec(values).map(BTreeSet::from_iter)
}
+fn parse_tag_set(values: Vec<String>) -> Result<BTreeSet<TagName>, StoreError> {
+ values
+ .into_iter()
+ .map(TagName::new)
+ .collect::<Result<BTreeSet<_>, _>>()
+ .map_err(StoreError::from)
+}
+
+fn explicit_cli_tags(selection: ExplicitTagSelectionArgs) -> Result<BTreeSet<TagName>, StoreError> {
+ optional_cli_tags(selection, true)?.ok_or(StoreError::NoteTagsRequired)
+}
+
+fn optional_cli_tags(
+ selection: ExplicitTagSelectionArgs,
+ required: bool,
+) -> Result<Option<BTreeSet<TagName>>, StoreError> {
+ if selection.no_tags {
+ return Ok(Some(BTreeSet::new()));
+ }
+ if selection.tags.is_empty() {
+ return if required {
+ Err(StoreError::NoteTagsRequired)
+ } else {
+ Ok(None)
+ };
+ }
+ Ok(Some(parse_tag_set(selection.tags)?))
+}
+
fn parse_env(values: Vec<String>) -> BTreeMap<String, String> {
values
.into_iter()
@@ -825,6 +1061,29 @@ fn run_git(project_root: &Utf8Path, args: &[&str]) -> Result<Option<String>, Sto
Ok(Some(text))
}
+fn maybe_print_gitignore_hint(project_root: &Utf8Path) -> Result<(), StoreError> {
+ if run_git(project_root, &["rev-parse", "--show-toplevel"])?.is_none() {
+ return Ok(());
+ }
+
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(project_root.as_str())
+ .args(["check-ignore", "-q", ".fidget_spinner"])
+ .status()?;
+
+ match status.code() {
+ Some(0) => Ok(()),
+ Some(1) => {
+ println!(
+ "note: add `.fidget_spinner/` to `.gitignore` or `.git/info/exclude` if you do not want local state in `git status`"
+ );
+ Ok(())
+ }
+ _ => Ok(()),
+ }
+}
+
fn parse_metric_observation(raw: String) -> Result<MetricObservation, StoreError> {
let parts = raw.split(':').collect::<Vec<_>>();
if parts.len() != 4 {
diff --git a/crates/fidget-spinner-cli/src/ui.rs b/crates/fidget-spinner-cli/src/ui.rs
new file mode 100644
index 0000000..0cb9c05
--- /dev/null
+++ b/crates/fidget-spinner-cli/src/ui.rs
@@ -0,0 +1,600 @@
+use std::collections::BTreeMap;
+use std::io;
+use std::net::SocketAddr;
+
+use axum::Router;
+use axum::extract::{Query, State};
+use axum::http::StatusCode;
+use axum::response::{Html, IntoResponse, Response};
+use axum::routing::get;
+use camino::Utf8PathBuf;
+use fidget_spinner_core::{DagNode, FieldValueType, NodeClass, ProjectSchema, TagName};
+use linkify::{LinkFinder, LinkKind};
+use maud::{DOCTYPE, Markup, PreEscaped, html};
+use serde::Deserialize;
+use serde_json::Value;
+use time::OffsetDateTime;
+use time::format_description::well_known::Rfc3339;
+
+use crate::{open_store, to_pretty_json};
+
+#[derive(Clone)]
+struct NavigatorState {
+ project_root: Utf8PathBuf,
+ limit: u32,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct NavigatorQuery {
+ tag: Option<String>,
+}
+
+struct NavigatorEntry {
+ node: DagNode,
+ frontier_label: Option<String>,
+}
+
+struct TagFacet {
+ name: TagName,
+ description: String,
+ count: usize,
+}
+
+pub(crate) fn serve(
+ project_root: Utf8PathBuf,
+ bind: SocketAddr,
+ limit: u32,
+) -> Result<(), fidget_spinner_store_sqlite::StoreError> {
+ let runtime = tokio::runtime::Builder::new_multi_thread()
+ .enable_io()
+ .build()
+ .map_err(fidget_spinner_store_sqlite::StoreError::from)?;
+ runtime.block_on(async move {
+ let state = NavigatorState {
+ project_root,
+ limit,
+ };
+ let app = Router::new()
+ .route("/", get(navigator))
+ .with_state(state.clone());
+ let listener = tokio::net::TcpListener::bind(bind)
+ .await
+ .map_err(fidget_spinner_store_sqlite::StoreError::from)?;
+ println!("navigator: http://{bind}/");
+ axum::serve(listener, app).await.map_err(|error| {
+ fidget_spinner_store_sqlite::StoreError::Io(io::Error::other(error.to_string()))
+ })
+ })
+}
+
+async fn navigator(
+ State(state): State<NavigatorState>,
+ Query(query): Query<NavigatorQuery>,
+) -> Response {
+ match render_navigator(state, query) {
+ Ok(markup) => Html(markup.into_string()).into_response(),
+ Err(error) => (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("navigator render failed: {error}"),
+ )
+ .into_response(),
+ }
+}
+
+fn render_navigator(
+ state: NavigatorState,
+ query: NavigatorQuery,
+) -> Result<Markup, fidget_spinner_store_sqlite::StoreError> {
+ let store = open_store(state.project_root.as_std_path())?;
+ let selected_tag = query.tag.map(TagName::new).transpose()?;
+ let schema = store.schema().clone();
+ let frontiers = store
+ .list_frontiers()?
+ .into_iter()
+ .map(|frontier| (frontier.id, frontier.label.to_string()))
+ .collect::<BTreeMap<_, _>>();
+
+ let recent_nodes = load_recent_nodes(&store, None, state.limit)?;
+ let visible_nodes = load_recent_nodes(&store, selected_tag.clone(), state.limit)?;
+ let tag_facets = store
+ .list_tags()?
+ .into_iter()
+ .map(|tag| TagFacet {
+ count: recent_nodes
+ .iter()
+ .filter(|node| node.tags.contains(&tag.name))
+ .count(),
+ description: tag.description.to_string(),
+ name: tag.name,
+ })
+ .collect::<Vec<_>>();
+ let entries = visible_nodes
+ .into_iter()
+ .map(|node| NavigatorEntry {
+ frontier_label: node
+ .frontier_id
+ .and_then(|frontier_id| frontiers.get(&frontier_id).cloned()),
+ node,
+ })
+ .collect::<Vec<_>>();
+
+ let title = selected_tag.as_ref().map_or_else(
+ || "all recent nodes".to_owned(),
+ |tag| format!("tag: {tag}"),
+ );
+ let project_name = store.config().display_name.to_string();
+
+ Ok(html! {
+ (DOCTYPE)
+ html {
+ head {
+ meta charset="utf-8";
+ meta name="viewport" content="width=device-width, initial-scale=1";
+ title { "Fidget Spinner Navigator" }
+ style { (PreEscaped(stylesheet().to_owned())) }
+ }
+ body {
+ main class="shell" {
+ aside class="rail" {
+ h1 { "Navigator" }
+ p class="project" { (project_name) }
+ nav class="tag-list" {
+ a
+ href="/"
+ class={ "tag-link " (if selected_tag.is_none() { "selected" } else { "" }) } {
+ span class="tag-name" { "all" }
+ span class="tag-count" { (recent_nodes.len()) }
+ }
+ @for facet in &tag_facets {
+ a
+ href={ "/?tag=" (facet.name.as_str()) }
+ class={ "tag-link " (if selected_tag.as_ref() == Some(&facet.name) { "selected" } else { "" }) } {
+ span class="tag-name" { (facet.name.as_str()) }
+ span class="tag-count" { (facet.count) }
+ span class="tag-description" { (facet.description.as_str()) }
+ }
+ }
+ }
+ }
+ section class="feed" {
+ header class="feed-header" {
+ h2 { (title) }
+ p class="feed-meta" {
+ (entries.len()) " shown"
+ " · "
+ (recent_nodes.len()) " recent"
+ " · "
+ (state.limit) " max"
+ }
+ }
+ @if entries.is_empty() {
+ article class="empty-state" {
+ h3 { "No matching nodes" }
+ p { "Try clearing the tag filter or recording new notes." }
+ }
+ } @else {
+ @for entry in &entries {
+ (render_entry(entry, &schema))
+ }
+ }
+ }
+ }
+ }
+ }
+ })
+}
+
+fn load_recent_nodes(
+ store: &fidget_spinner_store_sqlite::ProjectStore,
+ tag: Option<TagName>,
+ limit: u32,
+) -> Result<Vec<DagNode>, fidget_spinner_store_sqlite::StoreError> {
+ let summaries = store.list_nodes(fidget_spinner_store_sqlite::ListNodesQuery {
+ tags: tag.into_iter().collect(),
+ limit,
+ ..fidget_spinner_store_sqlite::ListNodesQuery::default()
+ })?;
+ summaries
+ .into_iter()
+ .map(|summary| {
+ store.get_node(summary.id)?.ok_or(
+ fidget_spinner_store_sqlite::StoreError::NodeNotFound(summary.id),
+ )
+ })
+ .collect()
+}
+
+fn render_entry(entry: &NavigatorEntry, schema: &ProjectSchema) -> Markup {
+ let body = entry.node.payload.field("body").and_then(Value::as_str);
+ let mut keys = entry
+ .node
+ .payload
+ .fields
+ .keys()
+ .filter(|name| name.as_str() != "body")
+ .cloned()
+ .collect::<Vec<_>>();
+ keys.sort_unstable();
+
+ html! {
+ article class="entry" id={ "node-" (entry.node.id) } {
+ header class="entry-header" {
+ div class="entry-title-row" {
+ span class={ "class-badge class-" (entry.node.class.as_str()) } {
+ (entry.node.class.as_str())
+ }
+ h3 class="entry-title" {
+ a href={ "#node-" (entry.node.id) } { (entry.node.title.as_str()) }
+ }
+ }
+ div class="entry-meta" {
+ span { (render_timestamp(entry.node.updated_at)) }
+ @if let Some(label) = &entry.frontier_label {
+ span { "frontier: " (label.as_str()) }
+ }
+ @if !entry.node.tags.is_empty() {
+ span class="tag-strip" {
+ @for tag in &entry.node.tags {
+ a class="entry-tag" href={ "/?tag=" (tag.as_str()) } { (tag.as_str()) }
+ }
+ }
+ }
+ }
+ }
+ @if let Some(summary) = &entry.node.summary {
+ p class="entry-summary" { (summary.as_str()) }
+ }
+ @if let Some(body) = body {
+ section class="entry-body" {
+ (render_string_value(body))
+ }
+ }
+ @if !keys.is_empty() {
+ dl class="field-list" {
+ @for key in &keys {
+ @if let Some(value) = entry.node.payload.field(key) {
+ (render_field(entry.node.class, schema, key, value))
+ }
+ }
+ }
+ }
+ @if !entry.node.diagnostics.items.is_empty() {
+ section class="diagnostics" {
+ h4 { "diagnostics" }
+ ul {
+ @for item in &entry.node.diagnostics.items {
+ li {
+ span class="diag-severity" { (format!("{:?}", item.severity).to_ascii_lowercase()) }
+ " "
+ (item.message.as_str())
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_field(class: NodeClass, schema: &ProjectSchema, key: &str, value: &Value) -> Markup {
+ let value_type = schema
+ .field_spec(class, key)
+ .and_then(|field| field.value_type);
+ let is_plottable = schema
+ .field_spec(class, key)
+ .is_some_and(|field| field.is_plottable());
+ html! {
+ dt {
+ (key)
+ @if let Some(value_type) = value_type {
+ span class="field-type" { (value_type.as_str()) }
+ }
+ @if is_plottable {
+ span class="field-type plottable" { "plot" }
+ }
+ }
+ dd {
+ @match value_type {
+ Some(FieldValueType::String) => {
+ @if let Some(text) = value.as_str() {
+ (render_string_value(text))
+ } @else {
+ (render_json_value(value))
+ }
+ }
+ Some(FieldValueType::Numeric) => {
+ @if let Some(number) = value.as_f64() {
+ code class="numeric" { (number) }
+ } @else {
+ (render_json_value(value))
+ }
+ }
+ Some(FieldValueType::Boolean) => {
+ @if let Some(boolean) = value.as_bool() {
+ span class={ "boolean " (if boolean { "true" } else { "false" }) } {
+ (if boolean { "true" } else { "false" })
+ }
+ } @else {
+ (render_json_value(value))
+ }
+ }
+ Some(FieldValueType::Timestamp) => {
+ @if let Some(raw) = value.as_str() {
+ time datetime=(raw) { (render_timestamp_value(raw)) }
+ } @else {
+ (render_json_value(value))
+ }
+ }
+ None => (render_json_value(value)),
+ }
+ }
+ }
+}
+
+fn render_string_value(text: &str) -> Markup {
+ let finder = LinkFinder::new();
+ html! {
+ div class="rich-text" {
+ @for line in text.lines() {
+ p {
+ @for span in finder.spans(line) {
+ @match span.kind() {
+ Some(LinkKind::Url) => a href=(span.as_str()) { (span.as_str()) },
+ _ => (span.as_str()),
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_json_value(value: &Value) -> Markup {
+ let text = to_pretty_json(value).unwrap_or_else(|_| value.to_string());
+ html! {
+ pre class="json-value" { (text) }
+ }
+}
+
+fn render_timestamp(timestamp: OffsetDateTime) -> String {
+ timestamp
+ .format(&Rfc3339)
+ .unwrap_or_else(|_| timestamp.to_string())
+}
+
+fn render_timestamp_value(raw: &str) -> String {
+ OffsetDateTime::parse(raw, &Rfc3339)
+ .map(render_timestamp)
+ .unwrap_or_else(|_| raw.to_owned())
+}
+
+fn stylesheet() -> &'static str {
+ r#"
+ :root {
+ color-scheme: light;
+ --bg: #f6f3ec;
+ --panel: #fffdf8;
+ --line: #d8d1c4;
+ --text: #22201a;
+ --muted: #746e62;
+ --accent: #2d5c4d;
+ --accent-soft: #dbe8e2;
+ --tag: #ece5d8;
+ --warn: #8b5b24;
+ }
+
+ * { box-sizing: border-box; }
+
+ body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: 15px/1.5 "Iosevka Web", "IBM Plex Mono", "SFMono-Regular", monospace;
+ }
+
+ a {
+ color: var(--accent);
+ text-decoration: none;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+
+ .shell {
+ display: grid;
+ grid-template-columns: 18rem minmax(0, 1fr);
+ min-height: 100vh;
+ }
+
+ .rail {
+ border-right: 1px solid var(--line);
+ padding: 1.25rem 1rem;
+ position: sticky;
+ top: 0;
+ align-self: start;
+ height: 100vh;
+ overflow: auto;
+ background: rgba(255, 253, 248, 0.85);
+ backdrop-filter: blur(6px);
+ }
+
+ .project, .feed-meta, .entry-meta, .entry-summary, .tag-description {
+ color: var(--muted);
+ }
+
+ .tag-list {
+ display: grid;
+ gap: 0.5rem;
+ }
+
+ .tag-link {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.2rem 0.75rem;
+ padding: 0.55rem 0.7rem;
+ border: 1px solid var(--line);
+ background: var(--panel);
+ }
+
+ .tag-link.selected {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+ }
+
+ .tag-name {
+ font-weight: 700;
+ overflow-wrap: anywhere;
+ }
+
+ .tag-count {
+ color: var(--muted);
+ }
+
+ .tag-description {
+ grid-column: 1 / -1;
+ font-size: 0.9rem;
+ }
+
+ .feed {
+ padding: 1.5rem;
+ display: grid;
+ gap: 1rem;
+ }
+
+ .feed-header {
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .entry, .empty-state {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ padding: 1rem 1.1rem;
+ }
+
+ .entry-header {
+ display: grid;
+ gap: 0.35rem;
+ margin-bottom: 0.75rem;
+ }
+
+ .entry-title-row {
+ display: flex;
+ gap: 0.75rem;
+ align-items: baseline;
+ }
+
+ .entry-title {
+ margin: 0;
+ font-size: 1.05rem;
+ }
+
+ .entry-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ font-size: 0.9rem;
+ }
+
+ .class-badge, .field-type, .entry-tag {
+ display: inline-block;
+ padding: 0.08rem 0.4rem;
+ border: 1px solid var(--line);
+ background: var(--tag);
+ font-size: 0.82rem;
+ }
+
+ .field-type.plottable {
+ background: var(--accent-soft);
+ border-color: var(--accent);
+ }
+
+ .tag-strip {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ }
+
+ .entry-body {
+ margin-bottom: 0.9rem;
+ }
+
+ .rich-text p {
+ margin: 0 0 0.55rem;
+ }
+
+ .rich-text p:last-child {
+ margin-bottom: 0;
+ }
+
+ .field-list {
+ display: grid;
+ grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
+ gap: 0.55rem 1rem;
+ margin: 0;
+ }
+
+ .field-list dt {
+ font-weight: 700;
+ display: flex;
+ gap: 0.4rem;
+ align-items: center;
+ overflow-wrap: anywhere;
+ }
+
+ .field-list dd {
+ margin: 0;
+ }
+
+ .json-value {
+ margin: 0;
+ padding: 0.6rem 0.7rem;
+ background: #f3eee4;
+ overflow: auto;
+ }
+
+ .boolean.true { color: var(--accent); }
+ .boolean.false { color: #8a2f2f; }
+ .numeric { font-size: 1rem; }
+
+ .diagnostics {
+ margin-top: 1rem;
+ padding-top: 0.8rem;
+ border-top: 1px dashed var(--line);
+ }
+
+ .diagnostics h4 {
+ margin: 0 0 0.4rem;
+ font-size: 0.9rem;
+ text-transform: lowercase;
+ }
+
+ .diagnostics ul {
+ margin: 0;
+ padding-left: 1.1rem;
+ }
+
+ .diag-severity {
+ color: var(--warn);
+ font-weight: 700;
+ }
+
+ @media (max-width: 900px) {
+ .shell {
+ grid-template-columns: 1fr;
+ }
+
+ .rail {
+ position: static;
+ height: auto;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .field-list {
+ grid-template-columns: 1fr;
+ }
+ }
+ "#
+}