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
6 changes: 3 additions & 3 deletions .agents/skills/tui-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <name> [source] (<status>) │ Workspace: <name|all> │ <screen/context>
OpenShell v<version> │ Current Gateway: <name> [source] (<status>) │ Workspace: <name|all> │ <screen/context>
```

## 3. Navigation & Screen Architecture
Expand Down Expand Up @@ -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<version> | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard`
- Sandbox detail: ` >_ OpenShell v<version> | Current Gateway: openshell [local] (Healthy) | Workspace: team-a | Sandbox: my-sandbox`

### Adding a new screen

Expand Down
78 changes: 36 additions & 42 deletions crates/openshell-core/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@
use std::env;
use std::path::{Path, PathBuf};

mod build_version;

const PROTO_REL: &str = "../../proto";

fn main() -> Result<(), Box<dyn std::error::Error>> {
// --- 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}");
Expand Down Expand Up @@ -72,53 +76,43 @@ fn collect_proto_files(dir: &Path, out: &mut Vec<PathBuf>) -> 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.<N>+g<sha>` 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.<N>+g<sha>` 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<String> {
// 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: <tag>-<N>-g<sha>
// 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<String> {
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())
}
103 changes: 103 additions & 0 deletions crates/openshell-core/build_version.rs
Original file line number Diff line number Diff line change
@@ -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<StableVersion> {
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<PrereleaseVersion> {
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<Item = &'a str>) -> Option<String> {
let tags = tags.collect::<Vec<_>>();

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<Item = &'a str>) -> Option<String> {
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<String> {
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")
);
}
}
12 changes: 8 additions & 4 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)`.
Expand Down
40 changes: 35 additions & 5 deletions crates/openshell-tui/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Span<'_>> = vec![
Span::styled(" >_ OpenShell ", t.accent_bold),
Span::styled(" ALPHA ", t.badge),
Span::styled(" | ", t.muted),
let mut parts: Vec<Span<'_>> = title_bar_brand_spans(t);
parts.extend([
Span::styled("Current Gateway: ", t.text),
Span::styled(&app.gateway_name, t.heading),
Span::styled(" [", t.muted),
Expand All @@ -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));
Expand All @@ -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<Span<'static>> {
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 {
Expand Down Expand Up @@ -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::<String>();
assert_eq!(rendered, expected);
assert!(!rendered.contains("ALPHA"));
}
}
9 changes: 2 additions & 7 deletions crates/openshell-tui/src/ui/splash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
]);

Expand Down
Loading