From 60b9c5434b324c08b20bbd972ecf9e28fbfdb6b4 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:57:19 -0700 Subject: [PATCH] fix(tui): replace alpha badges with version Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .agents/skills/tui-development/SKILL.md | 6 +- crates/openshell-core/build.rs | 78 +++++++++--------- crates/openshell-core/build_version.rs | 103 ++++++++++++++++++++++++ crates/openshell-core/src/lib.rs | 12 ++- crates/openshell-tui/src/ui/mod.rs | 40 +++++++-- crates/openshell-tui/src/ui/splash.rs | 9 +-- 6 files changed, 187 insertions(+), 61 deletions(-) create mode 100644 crates/openshell-core/build_version.rs diff --git a/.agents/skills/tui-development/SKILL.md b/.agents/skills/tui-development/SKILL.md index f570f283f0..472268abc0 100644 --- a/.agents/skills/tui-development/SKILL.md +++ b/.agents/skills/tui-development/SKILL.md @@ -53,7 +53,7 @@ Gateway (discovered via openshell_bootstrap::list_gateways()) The **title bar** always reflects this hierarchy, reading left-to-right from general to specific: ``` - OpenShell │ Current Gateway: [source] () │ Workspace: + OpenShell v │ Current Gateway: [source] () │ Workspace: ``` ## 3. Navigation & Screen Architecture @@ -142,8 +142,8 @@ Every frame renders four vertical regions: ### Title bar examples -- Dashboard: ` >_ OpenShell ALPHA | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard` -- Sandbox detail: ` >_ OpenShell ALPHA | Current Gateway: openshell [local] (Healthy) | Workspace: team-a | Sandbox: my-sandbox` +- Dashboard: ` >_ OpenShell v | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard` +- Sandbox detail: ` >_ OpenShell v | Current Gateway: openshell [local] (Healthy) | Workspace: team-a | Sandbox: my-sandbox` ### Adding a new screen diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index 9caaf8eb17..38c961b1d4 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -4,16 +4,20 @@ use std::env; use std::path::{Path, PathBuf}; +mod build_version; + const PROTO_REL: &str = "../../proto"; fn main() -> Result<(), Box> { // --- Git-derived version --- - // Compute a version from `git describe` for local builds. In Docker/CI - // builds where .git is absent, this silently does nothing and the binary - // falls back to CARGO_PKG_VERSION (which is already sed-patched by the - // build pipeline). + // Compute a version from tags and commit metadata for local builds. In + // Docker/CI builds where .git is absent, this silently does nothing and + // the binary falls back to CARGO_PKG_VERSION (which is already sed-patched + // by the build pipeline). println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../.git/logs/HEAD"); println!("cargo:rerun-if-changed=../../.git/refs/tags"); + println!("cargo:rerun-if-changed=../../.git/packed-refs"); if let Some(version) = git_version() { println!("cargo:rustc-env=OPENSHELL_GIT_VERSION={version}"); @@ -72,53 +76,43 @@ fn collect_proto_files(dir: &Path, out: &mut Vec) -> std::io::Result<() Ok(()) } -/// Derive a version string from `git describe --tags`. +/// Derive the release or development version from git metadata. /// /// Implements the "guess-next-dev" convention used by the release pipeline -/// (`setuptools-scm`): when there are commits past the last tag, the patch -/// version is bumped and `-dev.+g` is appended. +/// (`tasks/scripts/release.py`): exact stable and prerelease tags retain their +/// version. Otherwise, the latest merged stable release gets a patch bump and +/// `-dev.+g` is appended. /// /// Examples: -/// on tag v0.0.3 → "0.0.3" -/// 3 commits past v0.0.3 → "0.0.4-dev.3+g2bf9969" +/// on tag v0.1.0-pre.1 → "0.1.0-pre.1" +/// 3 commits past v0.0.3 → "0.0.4-dev.3+g2bf9969ab" /// -/// Returns `None` when git is unavailable or the repo has no matching tags. +/// Returns `None` when git metadata cannot be read. fn git_version() -> Option { - // Match numeric release tags only (e.g. `v0.0.29`). The bare glob `v*` - // also matches non-release tags like `vm-dev` or `vm-prod`; when one of - // those lands on the same commit as a release tag, `git describe` picks - // it and the resulting version string collapses to `m-dev` after the - // leading `v` is stripped below. Requiring a digit after `v` excludes - // those development tags without losing any release tag. - let output = std::process::Command::new("git") - .args(["describe", "--tags", "--long", "--match", "v[0-9]*"]) - .output() - .ok()?; - - if !output.status.success() { - return None; + let exact_tags = git_output(&["tag", "--points-at", "HEAD"])?; + if let Some(version) = build_version::exact_release_version(exact_tags.lines()) { + return Some(version); } - let desc = String::from_utf8(output.stdout).ok()?; - let desc = desc.trim(); - let desc = desc.strip_prefix('v').unwrap_or(desc); + let merged_tags = git_output(&["tag", "--merged", "HEAD", "--list", "v*.*.*"])?; + let latest_tag = build_version::latest_stable_tag(merged_tags.lines()); + let revision_range = latest_tag + .as_deref() + .map_or_else(|| "HEAD".to_string(), |tag| format!("{tag}..HEAD")); + let distance = git_output(&["rev-list", "--count", &revision_range])? + .parse() + .ok()?; + let sha = git_output(&["rev-parse", "--short=9", "HEAD"])?; - // `git describe --long` format: --g - // Split from the right to handle tags that contain hyphens. - let (rest, sha) = desc.rsplit_once('-')?; - let (tag, commits_str) = rest.rsplit_once('-')?; - let commits: u32 = commits_str.parse().ok()?; + build_version::next_dev_version(latest_tag.as_deref(), distance, &sha) +} - if commits == 0 { - // Exactly on a tag — use the tag version as-is. - return Some(tag.to_string()); +fn git_output(args: &[&str]) -> Option { + let output = std::process::Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; } - - // Bump patch version (guess-next-dev scheme). - let mut parts = tag.splitn(3, '.'); - let major = parts.next()?; - let minor = parts.next()?; - let patch: u32 = parts.next()?.parse().ok()?; - - Some(format!("{major}.{minor}.{}-dev.{commits}+{sha}", patch + 1)) + String::from_utf8(output.stdout) + .ok() + .map(|output| output.trim().to_string()) } diff --git a/crates/openshell-core/build_version.rs b/crates/openshell-core/build_version.rs new file mode 100644 index 0000000000..944808be93 --- /dev/null +++ b/crates/openshell-core/build_version.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type StableVersion = (u32, u32, u32); +type PrereleaseVersion = (u32, u32, u32, u32); + +fn parse_stable_tag(tag: &str) -> Option { + let tag = tag.strip_prefix('v').unwrap_or(tag); + let mut parts = tag.split('.'); + let version = ( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + ); + parts.next().is_none().then_some(version) +} + +fn parse_prerelease_tag(tag: &str) -> Option { + let tag = tag.strip_prefix('v').unwrap_or(tag); + let (base, sequence) = tag.rsplit_once("-pre.")?; + let (major, minor, patch) = parse_stable_tag(base)?; + let sequence = sequence.parse().ok()?; + (sequence > 0).then_some((major, minor, patch, sequence)) +} + +pub fn exact_release_version<'a>(tags: impl Iterator) -> Option { + let tags = tags.collect::>(); + + if let Some(((major, minor, patch), _)) = tags + .iter() + .filter_map(|tag| parse_stable_tag(tag).map(|version| (version, tag))) + .max_by_key(|(version, _)| *version) + { + return Some(format!("{major}.{minor}.{patch}")); + } + + tags.iter() + .filter_map(|tag| parse_prerelease_tag(tag)) + .max() + .map(|(major, minor, patch, sequence)| format!("{major}.{minor}.{patch}-pre.{sequence}")) +} + +pub fn latest_stable_tag<'a>(tags: impl Iterator) -> Option { + tags.filter_map(|tag| parse_stable_tag(tag).map(|version| (version, tag))) + .max_by_key(|(version, _)| *version) + .map(|(_, tag)| tag.to_string()) +} + +pub fn next_dev_version(tag: Option<&str>, distance: u32, sha: &str) -> Option { + let (major, minor, patch) = tag.map_or(Some((0, 0, 0)), parse_stable_tag)?; + Some(format!( + "{major}.{minor}.{}-dev.{distance}+g{sha}", + patch + 1 + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_stable_release_wins_over_prerelease() { + let tags = ["v0.1.0-pre.2", "v0.1.0", "vm-dev"]; + assert_eq!( + exact_release_version(tags.into_iter()).as_deref(), + Some("0.1.0") + ); + } + + #[test] + fn exact_prerelease_uses_highest_sequence() { + let tags = ["v0.1.0-pre.1", "v0.1.0-pre.2"]; + assert_eq!( + exact_release_version(tags.into_iter()).as_deref(), + Some("0.1.0-pre.2") + ); + } + + #[test] + fn latest_stable_ignores_prerelease_and_non_release_tags() { + let tags = ["v0.0.116", "v0.1.0-pre.1", "vm-dev", "v0.0.99"]; + assert_eq!( + latest_stable_tag(tags.into_iter()).as_deref(), + Some("v0.0.116") + ); + } + + #[test] + fn next_dev_version_bumps_latest_stable_patch() { + assert_eq!( + next_dev_version(Some("v0.0.116"), 32, "5b925dd8a").as_deref(), + Some("0.0.117-dev.32+g5b925dd8a") + ); + } + + #[test] + fn next_dev_version_without_a_release_starts_at_first_patch() { + assert_eq!( + next_dev_version(None, 7, "abcdef123").as_deref(), + Some("0.0.1-dev.7+gabcdef123") + ); + } +} diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 9aecfcb1f1..03ae8a30fd 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -64,15 +64,19 @@ pub use metadata::{ /// Build version string derived from git metadata. /// -/// For local builds this is computed by `build.rs` via `git describe` using -/// the guess-next-dev scheme (e.g. `0.0.4-dev.6+g2bf9969`). In Docker/CI -/// builds where `.git` is absent, falls back to `CARGO_PKG_VERSION` which -/// is already set correctly by the build pipeline's sed patch. +/// For local builds this is computed by `build.rs` from the exact release tag +/// or the latest merged stable tag using the guess-next-dev scheme (e.g. +/// `0.0.4-dev.6+g2bf9969ab`). In Docker/CI builds where `.git` is absent, it +/// falls back to `CARGO_PKG_VERSION`, which the build pipeline already stamps. pub const VERSION: &str = match option_env!("OPENSHELL_GIT_VERSION") { Some(v) => v, None => env!("CARGO_PKG_VERSION"), }; +#[cfg(test)] +#[path = "../build_version.rs"] +mod build_version; + /// Encoded protobuf `FileDescriptorSet` for every proto in `proto/`. /// /// Emitted by `build.rs` via `tonic_build::configure().file_descriptor_set_path(...)`. diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 01171fdb5d..2521184320 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -143,10 +143,8 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { .find(|gateway| gateway.name == app.gateway_name) .map_or("unknown", app::GatewayEntry::source_label); - let mut parts: Vec> = vec![ - Span::styled(" >_ OpenShell ", t.accent_bold), - Span::styled(" ALPHA ", t.badge), - Span::styled(" | ", t.muted), + let mut parts: Vec> = title_bar_brand_spans(t); + parts.extend([ Span::styled("Current Gateway: ", t.text), Span::styled(&app.gateway_name, t.heading), Span::styled(" [", t.muted), @@ -155,7 +153,7 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { status_span, Span::styled(")", t.muted), Span::styled(" | ", t.muted), - ]; + ]); parts.push(Span::styled("Workspace: ", t.text)); parts.push(Span::styled(app.workspace_display(), t.heading)); @@ -180,6 +178,14 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { frame.render_widget(Paragraph::new(title).style(t.title_bar), area); } +fn title_bar_brand_spans(theme: &Theme) -> Vec> { + vec![ + Span::styled(" >_ OpenShell ", theme.accent_bold), + Span::styled(format!("v{}", openshell_core::VERSION), theme.muted), + Span::styled(" | ", theme.muted), + ] +} + fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { let t = &app.theme; let spans = match app.screen { @@ -707,4 +713,28 @@ mod tests { .collect(); assert!(text.contains("[w] Workspace"), "nav bar was: {text:?}"); } + + #[test] + fn title_bar_brand_renders_resolved_version_without_alpha_badge() { + let expected = format!(" >_ OpenShell v{} | ", openshell_core::VERSION); + let width = u16::try_from(expected.len()).unwrap(); + let backend = TestBackend::new(width, 1); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new(Line::from(title_bar_brand_spans(&Theme::dark()))), + frame.size(), + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let rendered = (0..width) + .map(|x| buffer.get(x, 0).symbol()) + .collect::(); + assert_eq!(rendered, expected); + assert!(!rendered.contains("ALPHA")); + } } diff --git a/crates/openshell-tui/src/ui/splash.rs b/crates/openshell-tui/src/ui/splash.rs index 7f889f9951..a46667fb32 100644 --- a/crates/openshell-tui/src/ui/splash.rs +++ b/crates/openshell-tui/src/ui/splash.rs @@ -104,16 +104,11 @@ pub fn draw(frame: &mut Frame<'_>, area: Rect, theme: &crate::theme::Theme) { frame.render_widget(Paragraph::new(content_lines), chunks[0]); - // -- Footer: version + ALPHA badge on line 1, prompt on line 2 -- + // -- Footer: version on line 1, prompt on line 2 -- let version = format!("v{}", openshell_core::VERSION); - let alpha_badge = "ALPHA"; let footer = Paragraph::new(vec![ - Line::from(vec![ - Span::styled(version, t.accent), - Span::styled(" ", t.muted), - Span::styled(alpha_badge, t.title_bar), - ]), + Line::from(Span::styled(version, t.accent)), Line::from(Span::styled("press any key ░", t.muted)), ]);