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
13 changes: 13 additions & 0 deletions crates/protocol/src/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ pub struct ProvidersStatus {
pub model_catalogs: HashMap<ProviderKind, Vec<agent::ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionStatus>,
pub tcode_update: TcodeUpdateStatus,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub acp_marketplace_items: Vec<AcpMarketplaceItem>,
pub acp_registry_loading: bool,
Expand All@@ -107,6 +108,15 @@ pub struct ProviderVersionStatus {
pub update_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TcodeUpdateStatus {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMarketplaceItem {
pub id: String,
Expand DownExpand Up@@ -318,6 +328,9 @@ pub enum RuntimeNotice {
provider: ProviderKind,
version: String,
},
TcodeUpdateAvailable {
version: String,
},
UpdatingProvider {
provider: ProviderKind,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/protocol/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,8 @@ pub use event::{
AcpMarketplaceItem, EventEnvelope, GitActionRequest, GitStatusStatus, IndexSnapshot,
ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError,
RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent,
SessionEventRecord, SessionStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus,
Topic,
SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
};
pub use query::{
ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse,
Expand Down
7 changes: 7 additions & 0 deletions crates/protocol/src/tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,13 @@ fn round_trips_top_level_wire_types() {
update_command: Some("npm install -g @openai/codex@latest".into()),
},
)]),
tcode_update: TcodeUpdateStatus {
current: "1.2.3".into(),
latest: Some("1.2.4".into()),
release_url: Some("https://github.com/Tryanks/tcode/releases/tag/v1.2.4".into()),
update_available: true,
checking: false,
},
provider_snapshots: HashMap::from([(
"codex".into(),
tcode_core::provider_status::ProviderSnapshot {
Expand Down
30 changes: 26 additions & 4 deletions crates/runtime/src/app/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,8 @@ use tcode_protocol::ThreadExportFormat;
use tcode_protocol::{
AcpMarketplaceItem, EventEnvelope, ExternalThread, GitStatusStatus, IndexSnapshot, PathEntry,
ProviderVersionStatus as ProtocolProviderVersionStatus, ProvidersStatus, QueuedMessageStatus,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TcodeUpdateStatus,
TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic,
};
use tcode_services::acp_registry::{
Registry, RegistryAgent, cached, install, load, platform_key, resolve_recipe, uninstall,
Expand All@@ -74,8 +74,8 @@ use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::{
InstallSource, detect_install_source, is_update_available, npm_package, parse_version,
update_command, update_command_string,
InstallSource, detect_install_source, fetch_latest_tcode_release, is_update_available,
npm_package, parse_version, tcode_update_available, update_command, update_command_string,
};
use tcode_services::workspace::list_workspace;

Expand DownExpand Up@@ -284,6 +284,28 @@ pub struct ProviderVersionState {
pub install_source: InstallSource,
}

/// The result of checking the running tcode build against GitHub Releases.
#[derive(Debug, Clone)]
pub struct TcodeUpdateState {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

impl Default for TcodeUpdateState {
fn default() -> Self {
Self {
current: env!("CARGO_PKG_VERSION").to_string(),
latest: None,
release_url: None,
update_available: false,
checking: false,
}
}
}

pub struct AppState {
store: SessionStore,
settings_store: SettingsStore,
Expand Down
52 changes: 48 additions & 4 deletions crates/runtime/src/app/providers.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ pub struct ProviderCatalog {
pub model_catalogs: HashMap<ProviderKind, Vec<ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionState>,
pub tcode_update: TcodeUpdateState,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub(super) provider_secret_names: HashMap<String, HashSet<String>>,
}
Expand All@@ -17,6 +18,7 @@ impl ProviderCatalog {
model_catalogs,
models_loading: HashMap::new(),
provider_versions: HashMap::new(),
tcode_update: TcodeUpdateState::default(),
provider_snapshots: HashMap::new(),
provider_secret_names,
}
Expand DownExpand Up@@ -49,6 +51,13 @@ impl ProviderCatalog {
)
})
.collect(),
tcode_update: TcodeUpdateStatus {
current: self.tcode_update.current.clone(),
latest: self.tcode_update.latest.clone(),
release_url: self.tcode_update.release_url.clone(),
update_available: self.tcode_update.update_available,
checking: self.tcode_update.checking,
},
provider_snapshots: self.provider_snapshots.clone(),
acp_marketplace_items,
acp_registry_loading,
Expand All@@ -66,7 +75,8 @@ impl ProviderCatalog {
|| self
.provider_versions
.values()
.any(|status| status.checking),
.any(|status| status.checking)
|| self.tcode_update.checking,
secret_names: self.provider_secret_names.clone(),
}
}
Expand DownExpand Up@@ -384,9 +394,8 @@ impl AppState {
}
}

/// Check every provider's installed vs. latest version in the background,
/// storing results in `provider_versions` and toasting once per provider
/// that has an update available.
/// Check every provider and the running tcode build in the background,
/// storing results and toasting once for each newly available update.
pub fn check_provider_versions(&mut self, cx: &mut HostCx) {
for provider in NATIVE_PROVIDER_KINDS {
let binary = self.resolve_provider_binary(provider);
Expand DownExpand Up@@ -473,6 +482,41 @@ impl AppState {
});
});
}

if self.providers.tcode_update.checking {
return;
}
self.providers.tcode_update.checking = true;
let current = self.providers.tcode_update.current.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let release = host_cx.unblock(fetch_latest_tcode_release).await;
host_cx.enqueue(move |state, cx| {
let already = state.providers.tcode_update.update_available;
let update_available = release.as_ref().is_some_and(|release| {
(!release.prerelease || current.contains('-'))
&& tcode_update_available(&current, &release.tag_name).unwrap_or(false)
});
let status = &mut state.providers.tcode_update;
status.checking = false;
status.latest = release
.as_ref()
.map(|release| release.tag_name.trim_start_matches('v').to_string());
status.release_url = release.as_ref().map(|release| release.html_url.clone());
status.update_available = update_available;
if update_available
&& !already
&& let Some(version) = &status.latest
{
emit_runtime(
cx,
RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable {
version: version.clone(),
}),
);
}
});
});
}

/// Run the provider's self-update command (per its detected install source),
Expand Down
142 changes: 135 additions & 7 deletions crates/services/src/version_check.rs
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,97 @@
//! Provider CLI version checks and self-update command mapping (s3 §6).
//! Provider CLI and tcode release version checks (s3 §6).
//!
//! Pure helpers: parse a version out of `<provider> --version` output, compare
//! it against the latest published version, guess how the binary was installed
//! (Homebrew / npm / native installer), and derive the update command for that
//! install source. All I/O (spawning `--version`, `npm view`, the update
//! command itself) lives in the later runtime caller; this module stays
//! unit-testable.
//! Helpers parse and compare versions, infer provider install sources, and map
//! those sources to update commands. The tcode release lookup lives here beside
//! its fixture-testable JSON parser; process spawning and provider updates stay
//! in the runtime caller.

use std::io::Read as _;
use std::path::Path;
use std::time::Duration;

use agent::ProviderKind;
use serde::Deserialize;

const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest";

/// The release metadata needed by the app's update notice.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TcodeRelease {
pub tag_name: String,
pub html_url: String,
#[serde(default)]
pub prerelease: bool,
}

/// Fetch the latest published tcode release. Network, rate-limit, and response
/// errors deliberately collapse to `None`: update checks must never disrupt
/// app startup or provider checks.
pub fn fetch_latest_tcode_release() -> Option<TcodeRelease> {
let response = ureq::get(TCODE_LATEST_RELEASE_URL)
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("User-Agent", "tcode-update-check")
.timeout(Duration::from_secs(10))
.call()
.ok()?;
let mut body = Vec::new();
response
.into_reader()
.take(1024 * 1024)
.read_to_end(&mut body)
.ok()?;
parse_tcode_release(&body)
}

/// Parse the subset of GitHub's release JSON used by the update surface.
pub fn parse_tcode_release(bytes: &[u8]) -> Option<TcodeRelease> {
serde_json::from_slice(bytes).ok()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TcodeVersion {
core: (u32, u32, u32),
prerelease: bool,
}

fn parse_tcode_version(raw: &str) -> Option<TcodeVersion> {
let raw = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
let without_build = raw.split_once('+').map_or(raw, |(core, _)| core);
let (core, prerelease) = match without_build.split_once('-') {
Some((core, suffix)) if !suffix.is_empty() => (core, true),
Some(_) => return None,
None => (without_build, false),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(TcodeVersion {
core: (major, minor, patch),
prerelease,
})
}

/// Compare a running app version with a GitHub release tag.
///
/// Prerelease releases are ignored for stable builds. A prerelease build may
/// advance to a newer prerelease numeric triple or to the stable release with
/// the same numeric triple. Malformed input returns `None`.
pub fn tcode_update_available(running: &str, latest_tag: &str) -> Option<bool> {
let running = parse_tcode_version(running)?;
let latest = parse_tcode_version(latest_tag)?;
if latest.prerelease && !running.prerelease {
return Some(false);
}
Some(match latest.core.cmp(&running.core) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => running.prerelease && !latest.prerelease,
})
}

/// The npm package name whose published version is the provider's "latest".
/// `npm view <pkg> version` works for every native provider (verified 2026-07);
Expand DownExpand Up@@ -218,6 +300,52 @@ mod tests {
assert!(!is_update_available("unknown", "2.0.0"));
}

#[test]
fn compares_tcode_release_versions() {
assert_eq!(tcode_update_available("0.4.0", "v0.4.0"), Some(false));
assert_eq!(tcode_update_available("0.4.0", "v0.4.1"), Some(true));
assert_eq!(tcode_update_available("0.4.1", "v0.4.0"), Some(false));
}

#[test]
fn handles_tcode_prereleases() {
assert_eq!(
tcode_update_available("0.4.0", "v0.5.0-beta.1"),
Some(false)
);
assert_eq!(tcode_update_available("0.5.0-beta.1", "v0.5.0"), Some(true));
assert_eq!(
tcode_update_available("0.5.0-beta.1", "v0.6.0-beta.1"),
Some(true)
);
}

#[test]
fn malformed_tcode_release_tag_has_no_comparison() {
assert_eq!(tcode_update_available("0.4.0", "latest"), None);
assert_eq!(tcode_update_available("0.4", "v0.4.1"), None);
}

#[test]
fn parses_github_release_json() {
let release = parse_tcode_release(
br#"{
"tag_name": "v0.4.1",
"html_url": "https://github.com/Tryanks/tcode/releases/tag/v0.4.1",
"prerelease": false,
"assets": [{"name": "SHA256SUMS.txt"}]
}"#,
)
.expect("release fixture should parse");

assert_eq!(release.tag_name, "v0.4.1");
assert_eq!(
release.html_url,
"https://github.com/Tryanks/tcode/releases/tag/v0.4.1"
);
assert!(!release.prerelease);
}

#[test]
fn detects_install_source_from_path() {
// Homebrew does not exist on Windows, where `detect_install_source`
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/src/runtime_event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve
version = version
)
.into_owned(),
RuntimeNotice::TcodeUpdateAvailable { version } => {
crate::tr!("notice.tcode_update_available", version = version).into_owned()
}
RuntimeNotice::UpdatingProvider { provider } => crate::tr!(
"notice.updating_provider",
provider = provider.display_name()
Expand DownExpand Up@@ -354,6 +357,9 @@ mod tests {
provider: ProviderKind::Codex,
version: "1.2.3".into(),
},
RuntimeNotice::TcodeUpdateAvailable {
version: "1.2.3".into(),
},
RuntimeNotice::UpdatingProvider {
provider: ProviderKind::ClaudeCode,
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
13 changes: 13 additions & 0 deletions crates/protocol/src/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ pub struct ProvidersStatus {
pub model_catalogs: HashMap<ProviderKind, Vec<agent::ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionStatus>,
pub tcode_update: TcodeUpdateStatus,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub acp_marketplace_items: Vec<AcpMarketplaceItem>,
pub acp_registry_loading: bool,
Expand All@@ -107,6 +108,15 @@ pub struct ProviderVersionStatus {
pub update_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TcodeUpdateStatus {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMarketplaceItem {
pub id: String,
Expand DownExpand Up@@ -318,6 +328,9 @@ pub enum RuntimeNotice {
provider: ProviderKind,
version: String,
},
TcodeUpdateAvailable {
version: String,
},
UpdatingProvider {
provider: ProviderKind,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/protocol/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,8 @@ pub use event::{
AcpMarketplaceItem, EventEnvelope, GitActionRequest, GitStatusStatus, IndexSnapshot,
ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError,
RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent,
SessionEventRecord, SessionStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus,
Topic,
SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
};
pub use query::{
ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse,
Expand Down
7 changes: 7 additions & 0 deletions crates/protocol/src/tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,13 @@ fn round_trips_top_level_wire_types() {
update_command: Some("npm install -g @openai/codex@latest".into()),
},
)]),
tcode_update: TcodeUpdateStatus {
current: "1.2.3".into(),
latest: Some("1.2.4".into()),
release_url: Some("https://github.com/Tryanks/tcode/releases/tag/v1.2.4".into()),
update_available: true,
checking: false,
},
provider_snapshots: HashMap::from([(
"codex".into(),
tcode_core::provider_status::ProviderSnapshot {
Expand Down
30 changes: 26 additions & 4 deletions crates/runtime/src/app/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,8 @@ use tcode_protocol::ThreadExportFormat;
use tcode_protocol::{
AcpMarketplaceItem, EventEnvelope, ExternalThread, GitStatusStatus, IndexSnapshot, PathEntry,
ProviderVersionStatus as ProtocolProviderVersionStatus, ProvidersStatus, QueuedMessageStatus,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TcodeUpdateStatus,
TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic,
};
use tcode_services::acp_registry::{
Registry, RegistryAgent, cached, install, load, platform_key, resolve_recipe, uninstall,
Expand All@@ -74,8 +74,8 @@ use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::{
InstallSource, detect_install_source, is_update_available, npm_package, parse_version,
update_command, update_command_string,
InstallSource, detect_install_source, fetch_latest_tcode_release, is_update_available,
npm_package, parse_version, tcode_update_available, update_command, update_command_string,
};
use tcode_services::workspace::list_workspace;

Expand DownExpand Up@@ -284,6 +284,28 @@ pub struct ProviderVersionState {
pub install_source: InstallSource,
}

/// The result of checking the running tcode build against GitHub Releases.
#[derive(Debug, Clone)]
pub struct TcodeUpdateState {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

impl Default for TcodeUpdateState {
fn default() -> Self {
Self {
current: env!("CARGO_PKG_VERSION").to_string(),
latest: None,
release_url: None,
update_available: false,
checking: false,
}
}
}

pub struct AppState {
store: SessionStore,
settings_store: SettingsStore,
Expand Down
52 changes: 48 additions & 4 deletions crates/runtime/src/app/providers.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ pub struct ProviderCatalog {
pub model_catalogs: HashMap<ProviderKind, Vec<ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionState>,
pub tcode_update: TcodeUpdateState,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub(super) provider_secret_names: HashMap<String, HashSet<String>>,
}
Expand All@@ -17,6 +18,7 @@ impl ProviderCatalog {
model_catalogs,
models_loading: HashMap::new(),
provider_versions: HashMap::new(),
tcode_update: TcodeUpdateState::default(),
provider_snapshots: HashMap::new(),
provider_secret_names,
}
Expand DownExpand Up@@ -49,6 +51,13 @@ impl ProviderCatalog {
)
})
.collect(),
tcode_update: TcodeUpdateStatus {
current: self.tcode_update.current.clone(),
latest: self.tcode_update.latest.clone(),
release_url: self.tcode_update.release_url.clone(),
update_available: self.tcode_update.update_available,
checking: self.tcode_update.checking,
},
provider_snapshots: self.provider_snapshots.clone(),
acp_marketplace_items,
acp_registry_loading,
Expand All@@ -66,7 +75,8 @@ impl ProviderCatalog {
|| self
.provider_versions
.values()
.any(|status| status.checking),
.any(|status| status.checking)
|| self.tcode_update.checking,
secret_names: self.provider_secret_names.clone(),
}
}
Expand DownExpand Up@@ -384,9 +394,8 @@ impl AppState {
}
}

/// Check every provider's installed vs. latest version in the background,
/// storing results in `provider_versions` and toasting once per provider
/// that has an update available.
/// Check every provider and the running tcode build in the background,
/// storing results and toasting once for each newly available update.
pub fn check_provider_versions(&mut self, cx: &mut HostCx) {
for provider in NATIVE_PROVIDER_KINDS {
let binary = self.resolve_provider_binary(provider);
Expand DownExpand Up@@ -473,6 +482,41 @@ impl AppState {
});
});
}

if self.providers.tcode_update.checking {
return;
}
self.providers.tcode_update.checking = true;
let current = self.providers.tcode_update.current.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let release = host_cx.unblock(fetch_latest_tcode_release).await;
host_cx.enqueue(move |state, cx| {
let already = state.providers.tcode_update.update_available;
let update_available = release.as_ref().is_some_and(|release| {
(!release.prerelease || current.contains('-'))
&& tcode_update_available(&current, &release.tag_name).unwrap_or(false)
});
let status = &mut state.providers.tcode_update;
status.checking = false;
status.latest = release
.as_ref()
.map(|release| release.tag_name.trim_start_matches('v').to_string());
status.release_url = release.as_ref().map(|release| release.html_url.clone());
status.update_available = update_available;
if update_available
&& !already
&& let Some(version) = &status.latest
{
emit_runtime(
cx,
RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable {
version: version.clone(),
}),
);
}
});
});
}

/// Run the provider's self-update command (per its detected install source),
Expand Down
142 changes: 135 additions & 7 deletions crates/services/src/version_check.rs
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,97 @@
//! Provider CLI version checks and self-update command mapping (s3 §6).
//! Provider CLI and tcode release version checks (s3 §6).
//!
//! Pure helpers: parse a version out of `<provider> --version` output, compare
//! it against the latest published version, guess how the binary was installed
//! (Homebrew / npm / native installer), and derive the update command for that
//! install source. All I/O (spawning `--version`, `npm view`, the update
//! command itself) lives in the later runtime caller; this module stays
//! unit-testable.
//! Helpers parse and compare versions, infer provider install sources, and map
//! those sources to update commands. The tcode release lookup lives here beside
//! its fixture-testable JSON parser; process spawning and provider updates stay
//! in the runtime caller.

use std::io::Read as _;
use std::path::Path;
use std::time::Duration;

use agent::ProviderKind;
use serde::Deserialize;

const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest";

/// The release metadata needed by the app's update notice.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TcodeRelease {
pub tag_name: String,
pub html_url: String,
#[serde(default)]
pub prerelease: bool,
}

/// Fetch the latest published tcode release. Network, rate-limit, and response
/// errors deliberately collapse to `None`: update checks must never disrupt
/// app startup or provider checks.
pub fn fetch_latest_tcode_release() -> Option<TcodeRelease> {
let response = ureq::get(TCODE_LATEST_RELEASE_URL)
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("User-Agent", "tcode-update-check")
.timeout(Duration::from_secs(10))
.call()
.ok()?;
let mut body = Vec::new();
response
.into_reader()
.take(1024 * 1024)
.read_to_end(&mut body)
.ok()?;
parse_tcode_release(&body)
}

/// Parse the subset of GitHub's release JSON used by the update surface.
pub fn parse_tcode_release(bytes: &[u8]) -> Option<TcodeRelease> {
serde_json::from_slice(bytes).ok()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TcodeVersion {
core: (u32, u32, u32),
prerelease: bool,
}

fn parse_tcode_version(raw: &str) -> Option<TcodeVersion> {
let raw = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
let without_build = raw.split_once('+').map_or(raw, |(core, _)| core);
let (core, prerelease) = match without_build.split_once('-') {
Some((core, suffix)) if !suffix.is_empty() => (core, true),
Some(_) => return None,
None => (without_build, false),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(TcodeVersion {
core: (major, minor, patch),
prerelease,
})
}

/// Compare a running app version with a GitHub release tag.
///
/// Prerelease releases are ignored for stable builds. A prerelease build may
/// advance to a newer prerelease numeric triple or to the stable release with
/// the same numeric triple. Malformed input returns `None`.
pub fn tcode_update_available(running: &str, latest_tag: &str) -> Option<bool> {
let running = parse_tcode_version(running)?;
let latest = parse_tcode_version(latest_tag)?;
if latest.prerelease && !running.prerelease {
return Some(false);
}
Some(match latest.core.cmp(&running.core) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => running.prerelease && !latest.prerelease,
})
}

/// The npm package name whose published version is the provider's "latest".
/// `npm view <pkg> version` works for every native provider (verified 2026-07);
Expand DownExpand Up@@ -218,6 +300,52 @@ mod tests {
assert!(!is_update_available("unknown", "2.0.0"));
}

#[test]
fn compares_tcode_release_versions() {
assert_eq!(tcode_update_available("0.4.0", "v0.4.0"), Some(false));
assert_eq!(tcode_update_available("0.4.0", "v0.4.1"), Some(true));
assert_eq!(tcode_update_available("0.4.1", "v0.4.0"), Some(false));
}

#[test]
fn handles_tcode_prereleases() {
assert_eq!(
tcode_update_available("0.4.0", "v0.5.0-beta.1"),
Some(false)
);
assert_eq!(tcode_update_available("0.5.0-beta.1", "v0.5.0"), Some(true));
assert_eq!(
tcode_update_available("0.5.0-beta.1", "v0.6.0-beta.1"),
Some(true)
);
}

#[test]
fn malformed_tcode_release_tag_has_no_comparison() {
assert_eq!(tcode_update_available("0.4.0", "latest"), None);
assert_eq!(tcode_update_available("0.4", "v0.4.1"), None);
}

#[test]
fn parses_github_release_json() {
let release = parse_tcode_release(
br#"{
"tag_name": "v0.4.1",
"html_url": "https://github.com/Tryanks/tcode/releases/tag/v0.4.1",
"prerelease": false,
"assets": [{"name": "SHA256SUMS.txt"}]
}"#,
)
.expect("release fixture should parse");

assert_eq!(release.tag_name, "v0.4.1");
assert_eq!(
release.html_url,
"https://github.com/Tryanks/tcode/releases/tag/v0.4.1"
);
assert!(!release.prerelease);
}

#[test]
fn detects_install_source_from_path() {
// Homebrew does not exist on Windows, where `detect_install_source`
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/src/runtime_event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve
version = version
)
.into_owned(),
RuntimeNotice::TcodeUpdateAvailable { version } => {
crate::tr!("notice.tcode_update_available", version = version).into_owned()
}
RuntimeNotice::UpdatingProvider { provider } => crate::tr!(
"notice.updating_provider",
provider = provider.display_name()
Expand DownExpand Up@@ -354,6 +357,9 @@ mod tests {
provider: ProviderKind::Codex,
version: "1.2.3".into(),
},
RuntimeNotice::TcodeUpdateAvailable {
version: "1.2.3".into(),
},
RuntimeNotice::UpdatingProvider {
provider: ProviderKind::ClaudeCode,
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
13 changes: 13 additions & 0 deletions crates/protocol/src/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ pub struct ProvidersStatus {
pub model_catalogs: HashMap<ProviderKind, Vec<agent::ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionStatus>,
pub tcode_update: TcodeUpdateStatus,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub acp_marketplace_items: Vec<AcpMarketplaceItem>,
pub acp_registry_loading: bool,
Expand All@@ -107,6 +108,15 @@ pub struct ProviderVersionStatus {
pub update_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TcodeUpdateStatus {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMarketplaceItem {
pub id: String,
Expand DownExpand Up@@ -318,6 +328,9 @@ pub enum RuntimeNotice {
provider: ProviderKind,
version: String,
},
TcodeUpdateAvailable {
version: String,
},
UpdatingProvider {
provider: ProviderKind,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/protocol/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,8 @@ pub use event::{
AcpMarketplaceItem, EventEnvelope, GitActionRequest, GitStatusStatus, IndexSnapshot,
ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError,
RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent,
SessionEventRecord, SessionStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus,
Topic,
SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
};
pub use query::{
ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse,
Expand Down
7 changes: 7 additions & 0 deletions crates/protocol/src/tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,13 @@ fn round_trips_top_level_wire_types() {
update_command: Some("npm install -g @openai/codex@latest".into()),
},
)]),
tcode_update: TcodeUpdateStatus {
current: "1.2.3".into(),
latest: Some("1.2.4".into()),
release_url: Some("https://github.com/Tryanks/tcode/releases/tag/v1.2.4".into()),
update_available: true,
checking: false,
},
provider_snapshots: HashMap::from([(
"codex".into(),
tcode_core::provider_status::ProviderSnapshot {
Expand Down
30 changes: 26 additions & 4 deletions crates/runtime/src/app/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,8 @@ use tcode_protocol::ThreadExportFormat;
use tcode_protocol::{
AcpMarketplaceItem, EventEnvelope, ExternalThread, GitStatusStatus, IndexSnapshot, PathEntry,
ProviderVersionStatus as ProtocolProviderVersionStatus, ProvidersStatus, QueuedMessageStatus,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TcodeUpdateStatus,
TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic,
};
use tcode_services::acp_registry::{
Registry, RegistryAgent, cached, install, load, platform_key, resolve_recipe, uninstall,
Expand All@@ -74,8 +74,8 @@ use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::{
InstallSource, detect_install_source, is_update_available, npm_package, parse_version,
update_command, update_command_string,
InstallSource, detect_install_source, fetch_latest_tcode_release, is_update_available,
npm_package, parse_version, tcode_update_available, update_command, update_command_string,
};
use tcode_services::workspace::list_workspace;

Expand DownExpand Up@@ -284,6 +284,28 @@ pub struct ProviderVersionState {
pub install_source: InstallSource,
}

/// The result of checking the running tcode build against GitHub Releases.
#[derive(Debug, Clone)]
pub struct TcodeUpdateState {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

impl Default for TcodeUpdateState {
fn default() -> Self {
Self {
current: env!("CARGO_PKG_VERSION").to_string(),
latest: None,
release_url: None,
update_available: false,
checking: false,
}
}
}

pub struct AppState {
store: SessionStore,
settings_store: SettingsStore,
Expand Down
52 changes: 48 additions & 4 deletions crates/runtime/src/app/providers.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ pub struct ProviderCatalog {
pub model_catalogs: HashMap<ProviderKind, Vec<ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionState>,
pub tcode_update: TcodeUpdateState,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub(super) provider_secret_names: HashMap<String, HashSet<String>>,
}
Expand All@@ -17,6 +18,7 @@ impl ProviderCatalog {
model_catalogs,
models_loading: HashMap::new(),
provider_versions: HashMap::new(),
tcode_update: TcodeUpdateState::default(),
provider_snapshots: HashMap::new(),
provider_secret_names,
}
Expand DownExpand Up@@ -49,6 +51,13 @@ impl ProviderCatalog {
)
})
.collect(),
tcode_update: TcodeUpdateStatus {
current: self.tcode_update.current.clone(),
latest: self.tcode_update.latest.clone(),
release_url: self.tcode_update.release_url.clone(),
update_available: self.tcode_update.update_available,
checking: self.tcode_update.checking,
},
provider_snapshots: self.provider_snapshots.clone(),
acp_marketplace_items,
acp_registry_loading,
Expand All@@ -66,7 +75,8 @@ impl ProviderCatalog {
|| self
.provider_versions
.values()
.any(|status| status.checking),
.any(|status| status.checking)
|| self.tcode_update.checking,
secret_names: self.provider_secret_names.clone(),
}
}
Expand DownExpand Up@@ -384,9 +394,8 @@ impl AppState {
}
}

/// Check every provider's installed vs. latest version in the background,
/// storing results in `provider_versions` and toasting once per provider
/// that has an update available.
/// Check every provider and the running tcode build in the background,
/// storing results and toasting once for each newly available update.
pub fn check_provider_versions(&mut self, cx: &mut HostCx) {
for provider in NATIVE_PROVIDER_KINDS {
let binary = self.resolve_provider_binary(provider);
Expand DownExpand Up@@ -473,6 +482,41 @@ impl AppState {
});
});
}

if self.providers.tcode_update.checking {
return;
}
self.providers.tcode_update.checking = true;
let current = self.providers.tcode_update.current.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let release = host_cx.unblock(fetch_latest_tcode_release).await;
host_cx.enqueue(move |state, cx| {
let already = state.providers.tcode_update.update_available;
let update_available = release.as_ref().is_some_and(|release| {
(!release.prerelease || current.contains('-'))
&& tcode_update_available(&current, &release.tag_name).unwrap_or(false)
});
let status = &mut state.providers.tcode_update;
status.checking = false;
status.latest = release
.as_ref()
.map(|release| release.tag_name.trim_start_matches('v').to_string());
status.release_url = release.as_ref().map(|release| release.html_url.clone());
status.update_available = update_available;
if update_available
&& !already
&& let Some(version) = &status.latest
{
emit_runtime(
cx,
RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable {
version: version.clone(),
}),
);
}
});
});
}

/// Run the provider's self-update command (per its detected install source),
Expand Down
142 changes: 135 additions & 7 deletions crates/services/src/version_check.rs
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,97 @@
//! Provider CLI version checks and self-update command mapping (s3 §6).
//! Provider CLI and tcode release version checks (s3 §6).
//!
//! Pure helpers: parse a version out of `<provider> --version` output, compare
//! it against the latest published version, guess how the binary was installed
//! (Homebrew / npm / native installer), and derive the update command for that
//! install source. All I/O (spawning `--version`, `npm view`, the update
//! command itself) lives in the later runtime caller; this module stays
//! unit-testable.
//! Helpers parse and compare versions, infer provider install sources, and map
//! those sources to update commands. The tcode release lookup lives here beside
//! its fixture-testable JSON parser; process spawning and provider updates stay
//! in the runtime caller.

use std::io::Read as _;
use std::path::Path;
use std::time::Duration;

use agent::ProviderKind;
use serde::Deserialize;

const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest";

/// The release metadata needed by the app's update notice.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TcodeRelease {
pub tag_name: String,
pub html_url: String,
#[serde(default)]
pub prerelease: bool,
}

/// Fetch the latest published tcode release. Network, rate-limit, and response
/// errors deliberately collapse to `None`: update checks must never disrupt
/// app startup or provider checks.
pub fn fetch_latest_tcode_release() -> Option<TcodeRelease> {
let response = ureq::get(TCODE_LATEST_RELEASE_URL)
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("User-Agent", "tcode-update-check")
.timeout(Duration::from_secs(10))
.call()
.ok()?;
let mut body = Vec::new();
response
.into_reader()
.take(1024 * 1024)
.read_to_end(&mut body)
.ok()?;
parse_tcode_release(&body)
}

/// Parse the subset of GitHub's release JSON used by the update surface.
pub fn parse_tcode_release(bytes: &[u8]) -> Option<TcodeRelease> {
serde_json::from_slice(bytes).ok()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TcodeVersion {
core: (u32, u32, u32),
prerelease: bool,
}

fn parse_tcode_version(raw: &str) -> Option<TcodeVersion> {
let raw = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
let without_build = raw.split_once('+').map_or(raw, |(core, _)| core);
let (core, prerelease) = match without_build.split_once('-') {
Some((core, suffix)) if !suffix.is_empty() => (core, true),
Some(_) => return None,
None => (without_build, false),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(TcodeVersion {
core: (major, minor, patch),
prerelease,
})
}

/// Compare a running app version with a GitHub release tag.
///
/// Prerelease releases are ignored for stable builds. A prerelease build may
/// advance to a newer prerelease numeric triple or to the stable release with
/// the same numeric triple. Malformed input returns `None`.
pub fn tcode_update_available(running: &str, latest_tag: &str) -> Option<bool> {
let running = parse_tcode_version(running)?;
let latest = parse_tcode_version(latest_tag)?;
if latest.prerelease && !running.prerelease {
return Some(false);
}
Some(match latest.core.cmp(&running.core) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => running.prerelease && !latest.prerelease,
})
}

/// The npm package name whose published version is the provider's "latest".
/// `npm view <pkg> version` works for every native provider (verified 2026-07);
Expand DownExpand Up@@ -218,6 +300,52 @@ mod tests {
assert!(!is_update_available("unknown", "2.0.0"));
}

#[test]
fn compares_tcode_release_versions() {
assert_eq!(tcode_update_available("0.4.0", "v0.4.0"), Some(false));
assert_eq!(tcode_update_available("0.4.0", "v0.4.1"), Some(true));
assert_eq!(tcode_update_available("0.4.1", "v0.4.0"), Some(false));
}

#[test]
fn handles_tcode_prereleases() {
assert_eq!(
tcode_update_available("0.4.0", "v0.5.0-beta.1"),
Some(false)
);
assert_eq!(tcode_update_available("0.5.0-beta.1", "v0.5.0"), Some(true));
assert_eq!(
tcode_update_available("0.5.0-beta.1", "v0.6.0-beta.1"),
Some(true)
);
}

#[test]
fn malformed_tcode_release_tag_has_no_comparison() {
assert_eq!(tcode_update_available("0.4.0", "latest"), None);
assert_eq!(tcode_update_available("0.4", "v0.4.1"), None);
}

#[test]
fn parses_github_release_json() {
let release = parse_tcode_release(
br#"{
"tag_name": "v0.4.1",
"html_url": "https://github.com/Tryanks/tcode/releases/tag/v0.4.1",
"prerelease": false,
"assets": [{"name": "SHA256SUMS.txt"}]
}"#,
)
.expect("release fixture should parse");

assert_eq!(release.tag_name, "v0.4.1");
assert_eq!(
release.html_url,
"https://github.com/Tryanks/tcode/releases/tag/v0.4.1"
);
assert!(!release.prerelease);
}

#[test]
fn detects_install_source_from_path() {
// Homebrew does not exist on Windows, where `detect_install_source`
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/src/runtime_event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve
version = version
)
.into_owned(),
RuntimeNotice::TcodeUpdateAvailable { version } => {
crate::tr!("notice.tcode_update_available", version = version).into_owned()
}
RuntimeNotice::UpdatingProvider { provider } => crate::tr!(
"notice.updating_provider",
provider = provider.display_name()
Expand DownExpand Up@@ -354,6 +357,9 @@ mod tests {
provider: ProviderKind::Codex,
version: "1.2.3".into(),
},
RuntimeNotice::TcodeUpdateAvailable {
version: "1.2.3".into(),
},
RuntimeNotice::UpdatingProvider {
provider: ProviderKind::ClaudeCode,
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
13 changes: 13 additions & 0 deletions crates/protocol/src/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ pub struct ProvidersStatus {
pub model_catalogs: HashMap<ProviderKind, Vec<agent::ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionStatus>,
pub tcode_update: TcodeUpdateStatus,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub acp_marketplace_items: Vec<AcpMarketplaceItem>,
pub acp_registry_loading: bool,
Expand All@@ -107,6 +108,15 @@ pub struct ProviderVersionStatus {
pub update_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TcodeUpdateStatus {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMarketplaceItem {
pub id: String,
Expand DownExpand Up@@ -318,6 +328,9 @@ pub enum RuntimeNotice {
provider: ProviderKind,
version: String,
},
TcodeUpdateAvailable {
version: String,
},
UpdatingProvider {
provider: ProviderKind,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/protocol/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,8 @@ pub use event::{
AcpMarketplaceItem, EventEnvelope, GitActionRequest, GitStatusStatus, IndexSnapshot,
ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError,
RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent,
SessionEventRecord, SessionStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus,
Topic,
SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
};
pub use query::{
ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse,
Expand Down
7 changes: 7 additions & 0 deletions crates/protocol/src/tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,13 @@ fn round_trips_top_level_wire_types() {
update_command: Some("npm install -g @openai/codex@latest".into()),
},
)]),
tcode_update: TcodeUpdateStatus {
current: "1.2.3".into(),
latest: Some("1.2.4".into()),
release_url: Some("https://github.com/Tryanks/tcode/releases/tag/v1.2.4".into()),
update_available: true,
checking: false,
},
provider_snapshots: HashMap::from([(
"codex".into(),
tcode_core::provider_status::ProviderSnapshot {
Expand Down
30 changes: 26 additions & 4 deletions crates/runtime/src/app/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,8 @@ use tcode_protocol::ThreadExportFormat;
use tcode_protocol::{
AcpMarketplaceItem, EventEnvelope, ExternalThread, GitStatusStatus, IndexSnapshot, PathEntry,
ProviderVersionStatus as ProtocolProviderVersionStatus, ProvidersStatus, QueuedMessageStatus,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TcodeUpdateStatus,
TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic,
};
use tcode_services::acp_registry::{
Registry, RegistryAgent, cached, install, load, platform_key, resolve_recipe, uninstall,
Expand All@@ -74,8 +74,8 @@ use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::{
InstallSource, detect_install_source, is_update_available, npm_package, parse_version,
update_command, update_command_string,
InstallSource, detect_install_source, fetch_latest_tcode_release, is_update_available,
npm_package, parse_version, tcode_update_available, update_command, update_command_string,
};
use tcode_services::workspace::list_workspace;

Expand DownExpand Up@@ -284,6 +284,28 @@ pub struct ProviderVersionState {
pub install_source: InstallSource,
}

/// The result of checking the running tcode build against GitHub Releases.
#[derive(Debug, Clone)]
pub struct TcodeUpdateState {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

impl Default for TcodeUpdateState {
fn default() -> Self {
Self {
current: env!("CARGO_PKG_VERSION").to_string(),
latest: None,
release_url: None,
update_available: false,
checking: false,
}
}
}

pub struct AppState {
store: SessionStore,
settings_store: SettingsStore,
Expand Down
52 changes: 48 additions & 4 deletions crates/runtime/src/app/providers.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ pub struct ProviderCatalog {
pub model_catalogs: HashMap<ProviderKind, Vec<ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionState>,
pub tcode_update: TcodeUpdateState,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub(super) provider_secret_names: HashMap<String, HashSet<String>>,
}
Expand All@@ -17,6 +18,7 @@ impl ProviderCatalog {
model_catalogs,
models_loading: HashMap::new(),
provider_versions: HashMap::new(),
tcode_update: TcodeUpdateState::default(),
provider_snapshots: HashMap::new(),
provider_secret_names,
}
Expand DownExpand Up@@ -49,6 +51,13 @@ impl ProviderCatalog {
)
})
.collect(),
tcode_update: TcodeUpdateStatus {
current: self.tcode_update.current.clone(),
latest: self.tcode_update.latest.clone(),
release_url: self.tcode_update.release_url.clone(),
update_available: self.tcode_update.update_available,
checking: self.tcode_update.checking,
},
provider_snapshots: self.provider_snapshots.clone(),
acp_marketplace_items,
acp_registry_loading,
Expand All@@ -66,7 +75,8 @@ impl ProviderCatalog {
|| self
.provider_versions
.values()
.any(|status| status.checking),
.any(|status| status.checking)
|| self.tcode_update.checking,
secret_names: self.provider_secret_names.clone(),
}
}
Expand DownExpand Up@@ -384,9 +394,8 @@ impl AppState {
}
}

/// Check every provider's installed vs. latest version in the background,
/// storing results in `provider_versions` and toasting once per provider
/// that has an update available.
/// Check every provider and the running tcode build in the background,
/// storing results and toasting once for each newly available update.
pub fn check_provider_versions(&mut self, cx: &mut HostCx) {
for provider in NATIVE_PROVIDER_KINDS {
let binary = self.resolve_provider_binary(provider);
Expand DownExpand Up@@ -473,6 +482,41 @@ impl AppState {
});
});
}

if self.providers.tcode_update.checking {
return;
}
self.providers.tcode_update.checking = true;
let current = self.providers.tcode_update.current.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let release = host_cx.unblock(fetch_latest_tcode_release).await;
host_cx.enqueue(move |state, cx| {
let already = state.providers.tcode_update.update_available;
let update_available = release.as_ref().is_some_and(|release| {
(!release.prerelease || current.contains('-'))
&& tcode_update_available(&current, &release.tag_name).unwrap_or(false)
});
let status = &mut state.providers.tcode_update;
status.checking = false;
status.latest = release
.as_ref()
.map(|release| release.tag_name.trim_start_matches('v').to_string());
status.release_url = release.as_ref().map(|release| release.html_url.clone());
status.update_available = update_available;
if update_available
&& !already
&& let Some(version) = &status.latest
{
emit_runtime(
cx,
RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable {
version: version.clone(),
}),
);
}
});
});
}

/// Run the provider's self-update command (per its detected install source),
Expand Down
142 changes: 135 additions & 7 deletions crates/services/src/version_check.rs
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,97 @@
//! Provider CLI version checks and self-update command mapping (s3 §6).
//! Provider CLI and tcode release version checks (s3 §6).
//!
//! Pure helpers: parse a version out of `<provider> --version` output, compare
//! it against the latest published version, guess how the binary was installed
//! (Homebrew / npm / native installer), and derive the update command for that
//! install source. All I/O (spawning `--version`, `npm view`, the update
//! command itself) lives in the later runtime caller; this module stays
//! unit-testable.
//! Helpers parse and compare versions, infer provider install sources, and map
//! those sources to update commands. The tcode release lookup lives here beside
//! its fixture-testable JSON parser; process spawning and provider updates stay
//! in the runtime caller.

use std::io::Read as _;
use std::path::Path;
use std::time::Duration;

use agent::ProviderKind;
use serde::Deserialize;

const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest";

/// The release metadata needed by the app's update notice.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TcodeRelease {
pub tag_name: String,
pub html_url: String,
#[serde(default)]
pub prerelease: bool,
}

/// Fetch the latest published tcode release. Network, rate-limit, and response
/// errors deliberately collapse to `None`: update checks must never disrupt
/// app startup or provider checks.
pub fn fetch_latest_tcode_release() -> Option<TcodeRelease> {
let response = ureq::get(TCODE_LATEST_RELEASE_URL)
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("User-Agent", "tcode-update-check")
.timeout(Duration::from_secs(10))
.call()
.ok()?;
let mut body = Vec::new();
response
.into_reader()
.take(1024 * 1024)
.read_to_end(&mut body)
.ok()?;
parse_tcode_release(&body)
}

/// Parse the subset of GitHub's release JSON used by the update surface.
pub fn parse_tcode_release(bytes: &[u8]) -> Option<TcodeRelease> {
serde_json::from_slice(bytes).ok()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TcodeVersion {
core: (u32, u32, u32),
prerelease: bool,
}

fn parse_tcode_version(raw: &str) -> Option<TcodeVersion> {
let raw = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
let without_build = raw.split_once('+').map_or(raw, |(core, _)| core);
let (core, prerelease) = match without_build.split_once('-') {
Some((core, suffix)) if !suffix.is_empty() => (core, true),
Some(_) => return None,
None => (without_build, false),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(TcodeVersion {
core: (major, minor, patch),
prerelease,
})
}

/// Compare a running app version with a GitHub release tag.
///
/// Prerelease releases are ignored for stable builds. A prerelease build may
/// advance to a newer prerelease numeric triple or to the stable release with
/// the same numeric triple. Malformed input returns `None`.
pub fn tcode_update_available(running: &str, latest_tag: &str) -> Option<bool> {
let running = parse_tcode_version(running)?;
let latest = parse_tcode_version(latest_tag)?;
if latest.prerelease && !running.prerelease {
return Some(false);
}
Some(match latest.core.cmp(&running.core) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => running.prerelease && !latest.prerelease,
})
}

/// The npm package name whose published version is the provider's "latest".
/// `npm view <pkg> version` works for every native provider (verified 2026-07);
Expand DownExpand Up@@ -218,6 +300,52 @@ mod tests {
assert!(!is_update_available("unknown", "2.0.0"));
}

#[test]
fn compares_tcode_release_versions() {
assert_eq!(tcode_update_available("0.4.0", "v0.4.0"), Some(false));
assert_eq!(tcode_update_available("0.4.0", "v0.4.1"), Some(true));
assert_eq!(tcode_update_available("0.4.1", "v0.4.0"), Some(false));
}

#[test]
fn handles_tcode_prereleases() {
assert_eq!(
tcode_update_available("0.4.0", "v0.5.0-beta.1"),
Some(false)
);
assert_eq!(tcode_update_available("0.5.0-beta.1", "v0.5.0"), Some(true));
assert_eq!(
tcode_update_available("0.5.0-beta.1", "v0.6.0-beta.1"),
Some(true)
);
}

#[test]
fn malformed_tcode_release_tag_has_no_comparison() {
assert_eq!(tcode_update_available("0.4.0", "latest"), None);
assert_eq!(tcode_update_available("0.4", "v0.4.1"), None);
}

#[test]
fn parses_github_release_json() {
let release = parse_tcode_release(
br#"{
"tag_name": "v0.4.1",
"html_url": "https://github.com/Tryanks/tcode/releases/tag/v0.4.1",
"prerelease": false,
"assets": [{"name": "SHA256SUMS.txt"}]
}"#,
)
.expect("release fixture should parse");

assert_eq!(release.tag_name, "v0.4.1");
assert_eq!(
release.html_url,
"https://github.com/Tryanks/tcode/releases/tag/v0.4.1"
);
assert!(!release.prerelease);
}

#[test]
fn detects_install_source_from_path() {
// Homebrew does not exist on Windows, where `detect_install_source`
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/src/runtime_event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve
version = version
)
.into_owned(),
RuntimeNotice::TcodeUpdateAvailable { version } => {
crate::tr!("notice.tcode_update_available", version = version).into_owned()
}
RuntimeNotice::UpdatingProvider { provider } => crate::tr!(
"notice.updating_provider",
provider = provider.display_name()
Expand DownExpand Up@@ -354,6 +357,9 @@ mod tests {
provider: ProviderKind::Codex,
version: "1.2.3".into(),
},
RuntimeNotice::TcodeUpdateAvailable {
version: "1.2.3".into(),
},
RuntimeNotice::UpdatingProvider {
provider: ProviderKind::ClaudeCode,
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
13 changes: 13 additions & 0 deletions crates/protocol/src/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ pub struct ProvidersStatus {
pub model_catalogs: HashMap<ProviderKind, Vec<agent::ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionStatus>,
pub tcode_update: TcodeUpdateStatus,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub acp_marketplace_items: Vec<AcpMarketplaceItem>,
pub acp_registry_loading: bool,
Expand All@@ -107,6 +108,15 @@ pub struct ProviderVersionStatus {
pub update_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TcodeUpdateStatus {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMarketplaceItem {
pub id: String,
Expand DownExpand Up@@ -318,6 +328,9 @@ pub enum RuntimeNotice {
provider: ProviderKind,
version: String,
},
TcodeUpdateAvailable {
version: String,
},
UpdatingProvider {
provider: ProviderKind,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/protocol/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,8 @@ pub use event::{
AcpMarketplaceItem, EventEnvelope, GitActionRequest, GitStatusStatus, IndexSnapshot,
ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError,
RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent,
SessionEventRecord, SessionStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus,
Topic,
SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
};
pub use query::{
ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse,
Expand Down
7 changes: 7 additions & 0 deletions crates/protocol/src/tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,13 @@ fn round_trips_top_level_wire_types() {
update_command: Some("npm install -g @openai/codex@latest".into()),
},
)]),
tcode_update: TcodeUpdateStatus {
current: "1.2.3".into(),
latest: Some("1.2.4".into()),
release_url: Some("https://github.com/Tryanks/tcode/releases/tag/v1.2.4".into()),
update_available: true,
checking: false,
},
provider_snapshots: HashMap::from([(
"codex".into(),
tcode_core::provider_status::ProviderSnapshot {
Expand Down
30 changes: 26 additions & 4 deletions crates/runtime/src/app/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,8 @@ use tcode_protocol::ThreadExportFormat;
use tcode_protocol::{
AcpMarketplaceItem, EventEnvelope, ExternalThread, GitStatusStatus, IndexSnapshot, PathEntry,
ProviderVersionStatus as ProtocolProviderVersionStatus, ProvidersStatus, QueuedMessageStatus,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TcodeUpdateStatus,
TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic,
};
use tcode_services::acp_registry::{
Registry, RegistryAgent, cached, install, load, platform_key, resolve_recipe, uninstall,
Expand All@@ -74,8 +74,8 @@ use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::{
InstallSource, detect_install_source, is_update_available, npm_package, parse_version,
update_command, update_command_string,
InstallSource, detect_install_source, fetch_latest_tcode_release, is_update_available,
npm_package, parse_version, tcode_update_available, update_command, update_command_string,
};
use tcode_services::workspace::list_workspace;

Expand DownExpand Up@@ -284,6 +284,28 @@ pub struct ProviderVersionState {
pub install_source: InstallSource,
}

/// The result of checking the running tcode build against GitHub Releases.
#[derive(Debug, Clone)]
pub struct TcodeUpdateState {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

impl Default for TcodeUpdateState {
fn default() -> Self {
Self {
current: env!("CARGO_PKG_VERSION").to_string(),
latest: None,
release_url: None,
update_available: false,
checking: false,
}
}
}

pub struct AppState {
store: SessionStore,
settings_store: SettingsStore,
Expand Down
52 changes: 48 additions & 4 deletions crates/runtime/src/app/providers.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ pub struct ProviderCatalog {
pub model_catalogs: HashMap<ProviderKind, Vec<ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionState>,
pub tcode_update: TcodeUpdateState,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub(super) provider_secret_names: HashMap<String, HashSet<String>>,
}
Expand All@@ -17,6 +18,7 @@ impl ProviderCatalog {
model_catalogs,
models_loading: HashMap::new(),
provider_versions: HashMap::new(),
tcode_update: TcodeUpdateState::default(),
provider_snapshots: HashMap::new(),
provider_secret_names,
}
Expand DownExpand Up@@ -49,6 +51,13 @@ impl ProviderCatalog {
)
})
.collect(),
tcode_update: TcodeUpdateStatus {
current: self.tcode_update.current.clone(),
latest: self.tcode_update.latest.clone(),
release_url: self.tcode_update.release_url.clone(),
update_available: self.tcode_update.update_available,
checking: self.tcode_update.checking,
},
provider_snapshots: self.provider_snapshots.clone(),
acp_marketplace_items,
acp_registry_loading,
Expand All@@ -66,7 +75,8 @@ impl ProviderCatalog {
|| self
.provider_versions
.values()
.any(|status| status.checking),
.any(|status| status.checking)
|| self.tcode_update.checking,
secret_names: self.provider_secret_names.clone(),
}
}
Expand DownExpand Up@@ -384,9 +394,8 @@ impl AppState {
}
}

/// Check every provider's installed vs. latest version in the background,
/// storing results in `provider_versions` and toasting once per provider
/// that has an update available.
/// Check every provider and the running tcode build in the background,
/// storing results and toasting once for each newly available update.
pub fn check_provider_versions(&mut self, cx: &mut HostCx) {
for provider in NATIVE_PROVIDER_KINDS {
let binary = self.resolve_provider_binary(provider);
Expand DownExpand Up@@ -473,6 +482,41 @@ impl AppState {
});
});
}

if self.providers.tcode_update.checking {
return;
}
self.providers.tcode_update.checking = true;
let current = self.providers.tcode_update.current.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let release = host_cx.unblock(fetch_latest_tcode_release).await;
host_cx.enqueue(move |state, cx| {
let already = state.providers.tcode_update.update_available;
let update_available = release.as_ref().is_some_and(|release| {
(!release.prerelease || current.contains('-'))
&& tcode_update_available(&current, &release.tag_name).unwrap_or(false)
});
let status = &mut state.providers.tcode_update;
status.checking = false;
status.latest = release
.as_ref()
.map(|release| release.tag_name.trim_start_matches('v').to_string());
status.release_url = release.as_ref().map(|release| release.html_url.clone());
status.update_available = update_available;
if update_available
&& !already
&& let Some(version) = &status.latest
{
emit_runtime(
cx,
RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable {
version: version.clone(),
}),
);
}
});
});
}

/// Run the provider's self-update command (per its detected install source),
Expand Down
142 changes: 135 additions & 7 deletions crates/services/src/version_check.rs
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,97 @@
//! Provider CLI version checks and self-update command mapping (s3 §6).
//! Provider CLI and tcode release version checks (s3 §6).
//!
//! Pure helpers: parse a version out of `<provider> --version` output, compare
//! it against the latest published version, guess how the binary was installed
//! (Homebrew / npm / native installer), and derive the update command for that
//! install source. All I/O (spawning `--version`, `npm view`, the update
//! command itself) lives in the later runtime caller; this module stays
//! unit-testable.
//! Helpers parse and compare versions, infer provider install sources, and map
//! those sources to update commands. The tcode release lookup lives here beside
//! its fixture-testable JSON parser; process spawning and provider updates stay
//! in the runtime caller.

use std::io::Read as _;
use std::path::Path;
use std::time::Duration;

use agent::ProviderKind;
use serde::Deserialize;

const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest";

/// The release metadata needed by the app's update notice.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TcodeRelease {
pub tag_name: String,
pub html_url: String,
#[serde(default)]
pub prerelease: bool,
}

/// Fetch the latest published tcode release. Network, rate-limit, and response
/// errors deliberately collapse to `None`: update checks must never disrupt
/// app startup or provider checks.
pub fn fetch_latest_tcode_release() -> Option<TcodeRelease> {
let response = ureq::get(TCODE_LATEST_RELEASE_URL)
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("User-Agent", "tcode-update-check")
.timeout(Duration::from_secs(10))
.call()
.ok()?;
let mut body = Vec::new();
response
.into_reader()
.take(1024 * 1024)
.read_to_end(&mut body)
.ok()?;
parse_tcode_release(&body)
}

/// Parse the subset of GitHub's release JSON used by the update surface.
pub fn parse_tcode_release(bytes: &[u8]) -> Option<TcodeRelease> {
serde_json::from_slice(bytes).ok()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TcodeVersion {
core: (u32, u32, u32),
prerelease: bool,
}

fn parse_tcode_version(raw: &str) -> Option<TcodeVersion> {
let raw = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
let without_build = raw.split_once('+').map_or(raw, |(core, _)| core);
let (core, prerelease) = match without_build.split_once('-') {
Some((core, suffix)) if !suffix.is_empty() => (core, true),
Some(_) => return None,
None => (without_build, false),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(TcodeVersion {
core: (major, minor, patch),
prerelease,
})
}

/// Compare a running app version with a GitHub release tag.
///
/// Prerelease releases are ignored for stable builds. A prerelease build may
/// advance to a newer prerelease numeric triple or to the stable release with
/// the same numeric triple. Malformed input returns `None`.
pub fn tcode_update_available(running: &str, latest_tag: &str) -> Option<bool> {
let running = parse_tcode_version(running)?;
let latest = parse_tcode_version(latest_tag)?;
if latest.prerelease && !running.prerelease {
return Some(false);
}
Some(match latest.core.cmp(&running.core) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => running.prerelease && !latest.prerelease,
})
}

/// The npm package name whose published version is the provider's "latest".
/// `npm view <pkg> version` works for every native provider (verified 2026-07);
Expand DownExpand Up@@ -218,6 +300,52 @@ mod tests {
assert!(!is_update_available("unknown", "2.0.0"));
}

#[test]
fn compares_tcode_release_versions() {
assert_eq!(tcode_update_available("0.4.0", "v0.4.0"), Some(false));
assert_eq!(tcode_update_available("0.4.0", "v0.4.1"), Some(true));
assert_eq!(tcode_update_available("0.4.1", "v0.4.0"), Some(false));
}

#[test]
fn handles_tcode_prereleases() {
assert_eq!(
tcode_update_available("0.4.0", "v0.5.0-beta.1"),
Some(false)
);
assert_eq!(tcode_update_available("0.5.0-beta.1", "v0.5.0"), Some(true));
assert_eq!(
tcode_update_available("0.5.0-beta.1", "v0.6.0-beta.1"),
Some(true)
);
}

#[test]
fn malformed_tcode_release_tag_has_no_comparison() {
assert_eq!(tcode_update_available("0.4.0", "latest"), None);
assert_eq!(tcode_update_available("0.4", "v0.4.1"), None);
}

#[test]
fn parses_github_release_json() {
let release = parse_tcode_release(
br#"{
"tag_name": "v0.4.1",
"html_url": "https://github.com/Tryanks/tcode/releases/tag/v0.4.1",
"prerelease": false,
"assets": [{"name": "SHA256SUMS.txt"}]
}"#,
)
.expect("release fixture should parse");

assert_eq!(release.tag_name, "v0.4.1");
assert_eq!(
release.html_url,
"https://github.com/Tryanks/tcode/releases/tag/v0.4.1"
);
assert!(!release.prerelease);
}

#[test]
fn detects_install_source_from_path() {
// Homebrew does not exist on Windows, where `detect_install_source`
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/src/runtime_event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve
version = version
)
.into_owned(),
RuntimeNotice::TcodeUpdateAvailable { version } => {
crate::tr!("notice.tcode_update_available", version = version).into_owned()
}
RuntimeNotice::UpdatingProvider { provider } => crate::tr!(
"notice.updating_provider",
provider = provider.display_name()
Expand DownExpand Up@@ -354,6 +357,9 @@ mod tests {
provider: ProviderKind::Codex,
version: "1.2.3".into(),
},
RuntimeNotice::TcodeUpdateAvailable {
version: "1.2.3".into(),
},
RuntimeNotice::UpdatingProvider {
provider: ProviderKind::ClaudeCode,
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
13 changes: 13 additions & 0 deletions crates/protocol/src/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ pub struct ProvidersStatus {
pub model_catalogs: HashMap<ProviderKind, Vec<agent::ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionStatus>,
pub tcode_update: TcodeUpdateStatus,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub acp_marketplace_items: Vec<AcpMarketplaceItem>,
pub acp_registry_loading: bool,
Expand All@@ -107,6 +108,15 @@ pub struct ProviderVersionStatus {
pub update_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TcodeUpdateStatus {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMarketplaceItem {
pub id: String,
Expand DownExpand Up@@ -318,6 +328,9 @@ pub enum RuntimeNotice {
provider: ProviderKind,
version: String,
},
TcodeUpdateAvailable {
version: String,
},
UpdatingProvider {
provider: ProviderKind,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/protocol/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,8 @@ pub use event::{
AcpMarketplaceItem, EventEnvelope, GitActionRequest, GitStatusStatus, IndexSnapshot,
ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError,
RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent,
SessionEventRecord, SessionStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus,
Topic,
SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
};
pub use query::{
ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse,
Expand Down
7 changes: 7 additions & 0 deletions crates/protocol/src/tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,13 @@ fn round_trips_top_level_wire_types() {
update_command: Some("npm install -g @openai/codex@latest".into()),
},
)]),
tcode_update: TcodeUpdateStatus {
current: "1.2.3".into(),
latest: Some("1.2.4".into()),
release_url: Some("https://github.com/Tryanks/tcode/releases/tag/v1.2.4".into()),
update_available: true,
checking: false,
},
provider_snapshots: HashMap::from([(
"codex".into(),
tcode_core::provider_status::ProviderSnapshot {
Expand Down
30 changes: 26 additions & 4 deletions crates/runtime/src/app/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,8 @@ use tcode_protocol::ThreadExportFormat;
use tcode_protocol::{
AcpMarketplaceItem, EventEnvelope, ExternalThread, GitStatusStatus, IndexSnapshot, PathEntry,
ProviderVersionStatus as ProtocolProviderVersionStatus, ProvidersStatus, QueuedMessageStatus,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TcodeUpdateStatus,
TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic,
};
use tcode_services::acp_registry::{
Registry, RegistryAgent, cached, install, load, platform_key, resolve_recipe, uninstall,
Expand All@@ -74,8 +74,8 @@ use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::{
InstallSource, detect_install_source, is_update_available, npm_package, parse_version,
update_command, update_command_string,
InstallSource, detect_install_source, fetch_latest_tcode_release, is_update_available,
npm_package, parse_version, tcode_update_available, update_command, update_command_string,
};
use tcode_services::workspace::list_workspace;

Expand DownExpand Up@@ -284,6 +284,28 @@ pub struct ProviderVersionState {
pub install_source: InstallSource,
}

/// The result of checking the running tcode build against GitHub Releases.
#[derive(Debug, Clone)]
pub struct TcodeUpdateState {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

impl Default for TcodeUpdateState {
fn default() -> Self {
Self {
current: env!("CARGO_PKG_VERSION").to_string(),
latest: None,
release_url: None,
update_available: false,
checking: false,
}
}
}

pub struct AppState {
store: SessionStore,
settings_store: SettingsStore,
Expand Down
52 changes: 48 additions & 4 deletions crates/runtime/src/app/providers.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ pub struct ProviderCatalog {
pub model_catalogs: HashMap<ProviderKind, Vec<ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionState>,
pub tcode_update: TcodeUpdateState,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub(super) provider_secret_names: HashMap<String, HashSet<String>>,
}
Expand All@@ -17,6 +18,7 @@ impl ProviderCatalog {
model_catalogs,
models_loading: HashMap::new(),
provider_versions: HashMap::new(),
tcode_update: TcodeUpdateState::default(),
provider_snapshots: HashMap::new(),
provider_secret_names,
}
Expand DownExpand Up@@ -49,6 +51,13 @@ impl ProviderCatalog {
)
})
.collect(),
tcode_update: TcodeUpdateStatus {
current: self.tcode_update.current.clone(),
latest: self.tcode_update.latest.clone(),
release_url: self.tcode_update.release_url.clone(),
update_available: self.tcode_update.update_available,
checking: self.tcode_update.checking,
},
provider_snapshots: self.provider_snapshots.clone(),
acp_marketplace_items,
acp_registry_loading,
Expand All@@ -66,7 +75,8 @@ impl ProviderCatalog {
|| self
.provider_versions
.values()
.any(|status| status.checking),
.any(|status| status.checking)
|| self.tcode_update.checking,
secret_names: self.provider_secret_names.clone(),
}
}
Expand DownExpand Up@@ -384,9 +394,8 @@ impl AppState {
}
}

/// Check every provider's installed vs. latest version in the background,
/// storing results in `provider_versions` and toasting once per provider
/// that has an update available.
/// Check every provider and the running tcode build in the background,
/// storing results and toasting once for each newly available update.
pub fn check_provider_versions(&mut self, cx: &mut HostCx) {
for provider in NATIVE_PROVIDER_KINDS {
let binary = self.resolve_provider_binary(provider);
Expand DownExpand Up@@ -473,6 +482,41 @@ impl AppState {
});
});
}

if self.providers.tcode_update.checking {
return;
}
self.providers.tcode_update.checking = true;
let current = self.providers.tcode_update.current.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let release = host_cx.unblock(fetch_latest_tcode_release).await;
host_cx.enqueue(move |state, cx| {
let already = state.providers.tcode_update.update_available;
let update_available = release.as_ref().is_some_and(|release| {
(!release.prerelease || current.contains('-'))
&& tcode_update_available(&current, &release.tag_name).unwrap_or(false)
});
let status = &mut state.providers.tcode_update;
status.checking = false;
status.latest = release
.as_ref()
.map(|release| release.tag_name.trim_start_matches('v').to_string());
status.release_url = release.as_ref().map(|release| release.html_url.clone());
status.update_available = update_available;
if update_available
&& !already
&& let Some(version) = &status.latest
{
emit_runtime(
cx,
RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable {
version: version.clone(),
}),
);
}
});
});
}

/// Run the provider's self-update command (per its detected install source),
Expand Down
142 changes: 135 additions & 7 deletions crates/services/src/version_check.rs
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,97 @@
//! Provider CLI version checks and self-update command mapping (s3 §6).
//! Provider CLI and tcode release version checks (s3 §6).
//!
//! Pure helpers: parse a version out of `<provider> --version` output, compare
//! it against the latest published version, guess how the binary was installed
//! (Homebrew / npm / native installer), and derive the update command for that
//! install source. All I/O (spawning `--version`, `npm view`, the update
//! command itself) lives in the later runtime caller; this module stays
//! unit-testable.
//! Helpers parse and compare versions, infer provider install sources, and map
//! those sources to update commands. The tcode release lookup lives here beside
//! its fixture-testable JSON parser; process spawning and provider updates stay
//! in the runtime caller.

use std::io::Read as _;
use std::path::Path;
use std::time::Duration;

use agent::ProviderKind;
use serde::Deserialize;

const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest";

/// The release metadata needed by the app's update notice.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TcodeRelease {
pub tag_name: String,
pub html_url: String,
#[serde(default)]
pub prerelease: bool,
}

/// Fetch the latest published tcode release. Network, rate-limit, and response
/// errors deliberately collapse to `None`: update checks must never disrupt
/// app startup or provider checks.
pub fn fetch_latest_tcode_release() -> Option<TcodeRelease> {
let response = ureq::get(TCODE_LATEST_RELEASE_URL)
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("User-Agent", "tcode-update-check")
.timeout(Duration::from_secs(10))
.call()
.ok()?;
let mut body = Vec::new();
response
.into_reader()
.take(1024 * 1024)
.read_to_end(&mut body)
.ok()?;
parse_tcode_release(&body)
}

/// Parse the subset of GitHub's release JSON used by the update surface.
pub fn parse_tcode_release(bytes: &[u8]) -> Option<TcodeRelease> {
serde_json::from_slice(bytes).ok()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TcodeVersion {
core: (u32, u32, u32),
prerelease: bool,
}

fn parse_tcode_version(raw: &str) -> Option<TcodeVersion> {
let raw = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
let without_build = raw.split_once('+').map_or(raw, |(core, _)| core);
let (core, prerelease) = match without_build.split_once('-') {
Some((core, suffix)) if !suffix.is_empty() => (core, true),
Some(_) => return None,
None => (without_build, false),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(TcodeVersion {
core: (major, minor, patch),
prerelease,
})
}

/// Compare a running app version with a GitHub release tag.
///
/// Prerelease releases are ignored for stable builds. A prerelease build may
/// advance to a newer prerelease numeric triple or to the stable release with
/// the same numeric triple. Malformed input returns `None`.
pub fn tcode_update_available(running: &str, latest_tag: &str) -> Option<bool> {
let running = parse_tcode_version(running)?;
let latest = parse_tcode_version(latest_tag)?;
if latest.prerelease && !running.prerelease {
return Some(false);
}
Some(match latest.core.cmp(&running.core) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => running.prerelease && !latest.prerelease,
})
}

/// The npm package name whose published version is the provider's "latest".
/// `npm view <pkg> version` works for every native provider (verified 2026-07);
Expand DownExpand Up@@ -218,6 +300,52 @@ mod tests {
assert!(!is_update_available("unknown", "2.0.0"));
}

#[test]
fn compares_tcode_release_versions() {
assert_eq!(tcode_update_available("0.4.0", "v0.4.0"), Some(false));
assert_eq!(tcode_update_available("0.4.0", "v0.4.1"), Some(true));
assert_eq!(tcode_update_available("0.4.1", "v0.4.0"), Some(false));
}

#[test]
fn handles_tcode_prereleases() {
assert_eq!(
tcode_update_available("0.4.0", "v0.5.0-beta.1"),
Some(false)
);
assert_eq!(tcode_update_available("0.5.0-beta.1", "v0.5.0"), Some(true));
assert_eq!(
tcode_update_available("0.5.0-beta.1", "v0.6.0-beta.1"),
Some(true)
);
}

#[test]
fn malformed_tcode_release_tag_has_no_comparison() {
assert_eq!(tcode_update_available("0.4.0", "latest"), None);
assert_eq!(tcode_update_available("0.4", "v0.4.1"), None);
}

#[test]
fn parses_github_release_json() {
let release = parse_tcode_release(
br#"{
"tag_name": "v0.4.1",
"html_url": "https://github.com/Tryanks/tcode/releases/tag/v0.4.1",
"prerelease": false,
"assets": [{"name": "SHA256SUMS.txt"}]
}"#,
)
.expect("release fixture should parse");

assert_eq!(release.tag_name, "v0.4.1");
assert_eq!(
release.html_url,
"https://github.com/Tryanks/tcode/releases/tag/v0.4.1"
);
assert!(!release.prerelease);
}

#[test]
fn detects_install_source_from_path() {
// Homebrew does not exist on Windows, where `detect_install_source`
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/src/runtime_event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve
version = version
)
.into_owned(),
RuntimeNotice::TcodeUpdateAvailable { version } => {
crate::tr!("notice.tcode_update_available", version = version).into_owned()
}
RuntimeNotice::UpdatingProvider { provider } => crate::tr!(
"notice.updating_provider",
provider = provider.display_name()
Expand DownExpand Up@@ -354,6 +357,9 @@ mod tests {
provider: ProviderKind::Codex,
version: "1.2.3".into(),
},
RuntimeNotice::TcodeUpdateAvailable {
version: "1.2.3".into(),
},
RuntimeNotice::UpdatingProvider {
provider: ProviderKind::ClaudeCode,
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
13 changes: 13 additions & 0 deletions crates/protocol/src/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ pub struct ProvidersStatus {
pub model_catalogs: HashMap<ProviderKind, Vec<agent::ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionStatus>,
pub tcode_update: TcodeUpdateStatus,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub acp_marketplace_items: Vec<AcpMarketplaceItem>,
pub acp_registry_loading: bool,
Expand All@@ -107,6 +108,15 @@ pub struct ProviderVersionStatus {
pub update_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TcodeUpdateStatus {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMarketplaceItem {
pub id: String,
Expand DownExpand Up@@ -318,6 +328,9 @@ pub enum RuntimeNotice {
provider: ProviderKind,
version: String,
},
TcodeUpdateAvailable {
version: String,
},
UpdatingProvider {
provider: ProviderKind,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/protocol/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,8 @@ pub use event::{
AcpMarketplaceItem, EventEnvelope, GitActionRequest, GitStatusStatus, IndexSnapshot,
ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError,
RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent,
SessionEventRecord, SessionStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus,
Topic,
SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
};
pub use query::{
ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse,
Expand Down
7 changes: 7 additions & 0 deletions crates/protocol/src/tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,13 @@ fn round_trips_top_level_wire_types() {
update_command: Some("npm install -g @openai/codex@latest".into()),
},
)]),
tcode_update: TcodeUpdateStatus {
current: "1.2.3".into(),
latest: Some("1.2.4".into()),
release_url: Some("https://github.com/Tryanks/tcode/releases/tag/v1.2.4".into()),
update_available: true,
checking: false,
},
provider_snapshots: HashMap::from([(
"codex".into(),
tcode_core::provider_status::ProviderSnapshot {
Expand Down
30 changes: 26 additions & 4 deletions crates/runtime/src/app/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,8 @@ use tcode_protocol::ThreadExportFormat;
use tcode_protocol::{
AcpMarketplaceItem, EventEnvelope, ExternalThread, GitStatusStatus, IndexSnapshot, PathEntry,
ProviderVersionStatus as ProtocolProviderVersionStatus, ProvidersStatus, QueuedMessageStatus,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TcodeUpdateStatus,
TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic,
};
use tcode_services::acp_registry::{
Registry, RegistryAgent, cached, install, load, platform_key, resolve_recipe, uninstall,
Expand All@@ -74,8 +74,8 @@ use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::{
InstallSource, detect_install_source, is_update_available, npm_package, parse_version,
update_command, update_command_string,
InstallSource, detect_install_source, fetch_latest_tcode_release, is_update_available,
npm_package, parse_version, tcode_update_available, update_command, update_command_string,
};
use tcode_services::workspace::list_workspace;

Expand DownExpand Up@@ -284,6 +284,28 @@ pub struct ProviderVersionState {
pub install_source: InstallSource,
}

/// The result of checking the running tcode build against GitHub Releases.
#[derive(Debug, Clone)]
pub struct TcodeUpdateState {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

impl Default for TcodeUpdateState {
fn default() -> Self {
Self {
current: env!("CARGO_PKG_VERSION").to_string(),
latest: None,
release_url: None,
update_available: false,
checking: false,
}
}
}

pub struct AppState {
store: SessionStore,
settings_store: SettingsStore,
Expand Down
52 changes: 48 additions & 4 deletions crates/runtime/src/app/providers.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ pub struct ProviderCatalog {
pub model_catalogs: HashMap<ProviderKind, Vec<ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionState>,
pub tcode_update: TcodeUpdateState,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub(super) provider_secret_names: HashMap<String, HashSet<String>>,
}
Expand All@@ -17,6 +18,7 @@ impl ProviderCatalog {
model_catalogs,
models_loading: HashMap::new(),
provider_versions: HashMap::new(),
tcode_update: TcodeUpdateState::default(),
provider_snapshots: HashMap::new(),
provider_secret_names,
}
Expand DownExpand Up@@ -49,6 +51,13 @@ impl ProviderCatalog {
)
})
.collect(),
tcode_update: TcodeUpdateStatus {
current: self.tcode_update.current.clone(),
latest: self.tcode_update.latest.clone(),
release_url: self.tcode_update.release_url.clone(),
update_available: self.tcode_update.update_available,
checking: self.tcode_update.checking,
},
provider_snapshots: self.provider_snapshots.clone(),
acp_marketplace_items,
acp_registry_loading,
Expand All@@ -66,7 +75,8 @@ impl ProviderCatalog {
|| self
.provider_versions
.values()
.any(|status| status.checking),
.any(|status| status.checking)
|| self.tcode_update.checking,
secret_names: self.provider_secret_names.clone(),
}
}
Expand DownExpand Up@@ -384,9 +394,8 @@ impl AppState {
}
}

/// Check every provider's installed vs. latest version in the background,
/// storing results in `provider_versions` and toasting once per provider
/// that has an update available.
/// Check every provider and the running tcode build in the background,
/// storing results and toasting once for each newly available update.
pub fn check_provider_versions(&mut self, cx: &mut HostCx) {
for provider in NATIVE_PROVIDER_KINDS {
let binary = self.resolve_provider_binary(provider);
Expand DownExpand Up@@ -473,6 +482,41 @@ impl AppState {
});
});
}

if self.providers.tcode_update.checking {
return;
}
self.providers.tcode_update.checking = true;
let current = self.providers.tcode_update.current.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let release = host_cx.unblock(fetch_latest_tcode_release).await;
host_cx.enqueue(move |state, cx| {
let already = state.providers.tcode_update.update_available;
let update_available = release.as_ref().is_some_and(|release| {
(!release.prerelease || current.contains('-'))
&& tcode_update_available(&current, &release.tag_name).unwrap_or(false)
});
let status = &mut state.providers.tcode_update;
status.checking = false;
status.latest = release
.as_ref()
.map(|release| release.tag_name.trim_start_matches('v').to_string());
status.release_url = release.as_ref().map(|release| release.html_url.clone());
status.update_available = update_available;
if update_available
&& !already
&& let Some(version) = &status.latest
{
emit_runtime(
cx,
RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable {
version: version.clone(),
}),
);
}
});
});
}

/// Run the provider's self-update command (per its detected install source),
Expand Down
142 changes: 135 additions & 7 deletions crates/services/src/version_check.rs
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,97 @@
//! Provider CLI version checks and self-update command mapping (s3 §6).
//! Provider CLI and tcode release version checks (s3 §6).
//!
//! Pure helpers: parse a version out of `<provider> --version` output, compare
//! it against the latest published version, guess how the binary was installed
//! (Homebrew / npm / native installer), and derive the update command for that
//! install source. All I/O (spawning `--version`, `npm view`, the update
//! command itself) lives in the later runtime caller; this module stays
//! unit-testable.
//! Helpers parse and compare versions, infer provider install sources, and map
//! those sources to update commands. The tcode release lookup lives here beside
//! its fixture-testable JSON parser; process spawning and provider updates stay
//! in the runtime caller.

use std::io::Read as _;
use std::path::Path;
use std::time::Duration;

use agent::ProviderKind;
use serde::Deserialize;

const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest";

/// The release metadata needed by the app's update notice.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TcodeRelease {
pub tag_name: String,
pub html_url: String,
#[serde(default)]
pub prerelease: bool,
}

/// Fetch the latest published tcode release. Network, rate-limit, and response
/// errors deliberately collapse to `None`: update checks must never disrupt
/// app startup or provider checks.
pub fn fetch_latest_tcode_release() -> Option<TcodeRelease> {
let response = ureq::get(TCODE_LATEST_RELEASE_URL)
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("User-Agent", "tcode-update-check")
.timeout(Duration::from_secs(10))
.call()
.ok()?;
let mut body = Vec::new();
response
.into_reader()
.take(1024 * 1024)
.read_to_end(&mut body)
.ok()?;
parse_tcode_release(&body)
}

/// Parse the subset of GitHub's release JSON used by the update surface.
pub fn parse_tcode_release(bytes: &[u8]) -> Option<TcodeRelease> {
serde_json::from_slice(bytes).ok()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TcodeVersion {
core: (u32, u32, u32),
prerelease: bool,
}

fn parse_tcode_version(raw: &str) -> Option<TcodeVersion> {
let raw = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
let without_build = raw.split_once('+').map_or(raw, |(core, _)| core);
let (core, prerelease) = match without_build.split_once('-') {
Some((core, suffix)) if !suffix.is_empty() => (core, true),
Some(_) => return None,
None => (without_build, false),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(TcodeVersion {
core: (major, minor, patch),
prerelease,
})
}

/// Compare a running app version with a GitHub release tag.
///
/// Prerelease releases are ignored for stable builds. A prerelease build may
/// advance to a newer prerelease numeric triple or to the stable release with
/// the same numeric triple. Malformed input returns `None`.
pub fn tcode_update_available(running: &str, latest_tag: &str) -> Option<bool> {
let running = parse_tcode_version(running)?;
let latest = parse_tcode_version(latest_tag)?;
if latest.prerelease && !running.prerelease {
return Some(false);
}
Some(match latest.core.cmp(&running.core) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => running.prerelease && !latest.prerelease,
})
}

/// The npm package name whose published version is the provider's "latest".
/// `npm view <pkg> version` works for every native provider (verified 2026-07);
Expand DownExpand Up@@ -218,6 +300,52 @@ mod tests {
assert!(!is_update_available("unknown", "2.0.0"));
}

#[test]
fn compares_tcode_release_versions() {
assert_eq!(tcode_update_available("0.4.0", "v0.4.0"), Some(false));
assert_eq!(tcode_update_available("0.4.0", "v0.4.1"), Some(true));
assert_eq!(tcode_update_available("0.4.1", "v0.4.0"), Some(false));
}

#[test]
fn handles_tcode_prereleases() {
assert_eq!(
tcode_update_available("0.4.0", "v0.5.0-beta.1"),
Some(false)
);
assert_eq!(tcode_update_available("0.5.0-beta.1", "v0.5.0"), Some(true));
assert_eq!(
tcode_update_available("0.5.0-beta.1", "v0.6.0-beta.1"),
Some(true)
);
}

#[test]
fn malformed_tcode_release_tag_has_no_comparison() {
assert_eq!(tcode_update_available("0.4.0", "latest"), None);
assert_eq!(tcode_update_available("0.4", "v0.4.1"), None);
}

#[test]
fn parses_github_release_json() {
let release = parse_tcode_release(
br#"{
"tag_name": "v0.4.1",
"html_url": "https://github.com/Tryanks/tcode/releases/tag/v0.4.1",
"prerelease": false,
"assets": [{"name": "SHA256SUMS.txt"}]
}"#,
)
.expect("release fixture should parse");

assert_eq!(release.tag_name, "v0.4.1");
assert_eq!(
release.html_url,
"https://github.com/Tryanks/tcode/releases/tag/v0.4.1"
);
assert!(!release.prerelease);
}

#[test]
fn detects_install_source_from_path() {
// Homebrew does not exist on Windows, where `detect_install_source`
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/src/runtime_event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve
version = version
)
.into_owned(),
RuntimeNotice::TcodeUpdateAvailable { version } => {
crate::tr!("notice.tcode_update_available", version = version).into_owned()
}
RuntimeNotice::UpdatingProvider { provider } => crate::tr!(
"notice.updating_provider",
provider = provider.display_name()
Expand DownExpand Up@@ -354,6 +357,9 @@ mod tests {
provider: ProviderKind::Codex,
version: "1.2.3".into(),
},
RuntimeNotice::TcodeUpdateAvailable {
version: "1.2.3".into(),
},
RuntimeNotice::UpdatingProvider {
provider: ProviderKind::ClaudeCode,
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
13 changes: 13 additions & 0 deletions crates/protocol/src/event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ pub struct ProvidersStatus {
pub model_catalogs: HashMap<ProviderKind, Vec<agent::ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionStatus>,
pub tcode_update: TcodeUpdateStatus,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub acp_marketplace_items: Vec<AcpMarketplaceItem>,
pub acp_registry_loading: bool,
Expand All@@ -107,6 +108,15 @@ pub struct ProviderVersionStatus {
pub update_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TcodeUpdateStatus {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMarketplaceItem {
pub id: String,
Expand DownExpand Up@@ -318,6 +328,9 @@ pub enum RuntimeNotice {
provider: ProviderKind,
version: String,
},
TcodeUpdateAvailable {
version: String,
},
UpdatingProvider {
provider: ProviderKind,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/protocol/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,8 @@ pub use event::{
AcpMarketplaceItem, EventEnvelope, GitActionRequest, GitStatusStatus, IndexSnapshot,
ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError,
RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent,
SessionEventRecord, SessionStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus,
Topic,
SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
};
pub use query::{
ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse,
Expand Down
7 changes: 7 additions & 0 deletions crates/protocol/src/tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,13 @@ fn round_trips_top_level_wire_types() {
update_command: Some("npm install -g @openai/codex@latest".into()),
},
)]),
tcode_update: TcodeUpdateStatus {
current: "1.2.3".into(),
latest: Some("1.2.4".into()),
release_url: Some("https://github.com/Tryanks/tcode/releases/tag/v1.2.4".into()),
update_available: true,
checking: false,
},
provider_snapshots: HashMap::from([(
"codex".into(),
tcode_core::provider_status::ProviderSnapshot {
Expand Down
30 changes: 26 additions & 4 deletions crates/runtime/src/app/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,8 @@ use tcode_protocol::ThreadExportFormat;
use tcode_protocol::{
AcpMarketplaceItem, EventEnvelope, ExternalThread, GitStatusStatus, IndexSnapshot, PathEntry,
ProviderVersionStatus as ProtocolProviderVersionStatus, ProvidersStatus, QueuedMessageStatus,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TerminalContextStatus,
TerminalSplitStatus, TerminalStatus, Topic,
RecentDir, ServerEvent, SessionEventRecord, SessionStatus, TcodeUpdateStatus,
TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic,
};
use tcode_services::acp_registry::{
Registry, RegistryAgent, cached, install, load, platform_key, resolve_recipe, uninstall,
Expand All@@ -74,8 +74,8 @@ use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::{
InstallSource, detect_install_source, is_update_available, npm_package, parse_version,
update_command, update_command_string,
InstallSource, detect_install_source, fetch_latest_tcode_release, is_update_available,
npm_package, parse_version, tcode_update_available, update_command, update_command_string,
};
use tcode_services::workspace::list_workspace;

Expand DownExpand Up@@ -284,6 +284,28 @@ pub struct ProviderVersionState {
pub install_source: InstallSource,
}

/// The result of checking the running tcode build against GitHub Releases.
#[derive(Debug, Clone)]
pub struct TcodeUpdateState {
pub current: String,
pub latest: Option<String>,
pub release_url: Option<String>,
pub update_available: bool,
pub checking: bool,
}

impl Default for TcodeUpdateState {
fn default() -> Self {
Self {
current: env!("CARGO_PKG_VERSION").to_string(),
latest: None,
release_url: None,
update_available: false,
checking: false,
}
}
}

pub struct AppState {
store: SessionStore,
settings_store: SettingsStore,
Expand Down
52 changes: 48 additions & 4 deletions crates/runtime/src/app/providers.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ pub struct ProviderCatalog {
pub model_catalogs: HashMap<ProviderKind, Vec<ModelSpec>>,
pub models_loading: HashMap<ProviderKind, bool>,
pub provider_versions: HashMap<ProviderKind, ProviderVersionState>,
pub tcode_update: TcodeUpdateState,
pub provider_snapshots: HashMap<String, ProviderSnapshot>,
pub(super) provider_secret_names: HashMap<String, HashSet<String>>,
}
Expand All@@ -17,6 +18,7 @@ impl ProviderCatalog {
model_catalogs,
models_loading: HashMap::new(),
provider_versions: HashMap::new(),
tcode_update: TcodeUpdateState::default(),
provider_snapshots: HashMap::new(),
provider_secret_names,
}
Expand DownExpand Up@@ -49,6 +51,13 @@ impl ProviderCatalog {
)
})
.collect(),
tcode_update: TcodeUpdateStatus {
current: self.tcode_update.current.clone(),
latest: self.tcode_update.latest.clone(),
release_url: self.tcode_update.release_url.clone(),
update_available: self.tcode_update.update_available,
checking: self.tcode_update.checking,
},
provider_snapshots: self.provider_snapshots.clone(),
acp_marketplace_items,
acp_registry_loading,
Expand All@@ -66,7 +75,8 @@ impl ProviderCatalog {
|| self
.provider_versions
.values()
.any(|status| status.checking),
.any(|status| status.checking)
|| self.tcode_update.checking,
secret_names: self.provider_secret_names.clone(),
}
}
Expand DownExpand Up@@ -384,9 +394,8 @@ impl AppState {
}
}

/// Check every provider's installed vs. latest version in the background,
/// storing results in `provider_versions` and toasting once per provider
/// that has an update available.
/// Check every provider and the running tcode build in the background,
/// storing results and toasting once for each newly available update.
pub fn check_provider_versions(&mut self, cx: &mut HostCx) {
for provider in NATIVE_PROVIDER_KINDS {
let binary = self.resolve_provider_binary(provider);
Expand DownExpand Up@@ -473,6 +482,41 @@ impl AppState {
});
});
}

if self.providers.tcode_update.checking {
return;
}
self.providers.tcode_update.checking = true;
let current = self.providers.tcode_update.current.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let release = host_cx.unblock(fetch_latest_tcode_release).await;
host_cx.enqueue(move |state, cx| {
let already = state.providers.tcode_update.update_available;
let update_available = release.as_ref().is_some_and(|release| {
(!release.prerelease || current.contains('-'))
&& tcode_update_available(&current, &release.tag_name).unwrap_or(false)
});
let status = &mut state.providers.tcode_update;
status.checking = false;
status.latest = release
.as_ref()
.map(|release| release.tag_name.trim_start_matches('v').to_string());
status.release_url = release.as_ref().map(|release| release.html_url.clone());
status.update_available = update_available;
if update_available
&& !already
&& let Some(version) = &status.latest
{
emit_runtime(
cx,
RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable {
version: version.clone(),
}),
);
}
});
});
}

/// Run the provider's self-update command (per its detected install source),
Expand Down
142 changes: 135 additions & 7 deletions crates/services/src/version_check.rs
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,97 @@
//! Provider CLI version checks and self-update command mapping (s3 §6).
//! Provider CLI and tcode release version checks (s3 §6).
//!
//! Pure helpers: parse a version out of `<provider> --version` output, compare
//! it against the latest published version, guess how the binary was installed
//! (Homebrew / npm / native installer), and derive the update command for that
//! install source. All I/O (spawning `--version`, `npm view`, the update
//! command itself) lives in the later runtime caller; this module stays
//! unit-testable.
//! Helpers parse and compare versions, infer provider install sources, and map
//! those sources to update commands. The tcode release lookup lives here beside
//! its fixture-testable JSON parser; process spawning and provider updates stay
//! in the runtime caller.

use std::io::Read as _;
use std::path::Path;
use std::time::Duration;

use agent::ProviderKind;
use serde::Deserialize;

const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest";

/// The release metadata needed by the app's update notice.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TcodeRelease {
pub tag_name: String,
pub html_url: String,
#[serde(default)]
pub prerelease: bool,
}

/// Fetch the latest published tcode release. Network, rate-limit, and response
/// errors deliberately collapse to `None`: update checks must never disrupt
/// app startup or provider checks.
pub fn fetch_latest_tcode_release() -> Option<TcodeRelease> {
let response = ureq::get(TCODE_LATEST_RELEASE_URL)
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("User-Agent", "tcode-update-check")
.timeout(Duration::from_secs(10))
.call()
.ok()?;
let mut body = Vec::new();
response
.into_reader()
.take(1024 * 1024)
.read_to_end(&mut body)
.ok()?;
parse_tcode_release(&body)
}

/// Parse the subset of GitHub's release JSON used by the update surface.
pub fn parse_tcode_release(bytes: &[u8]) -> Option<TcodeRelease> {
serde_json::from_slice(bytes).ok()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TcodeVersion {
core: (u32, u32, u32),
prerelease: bool,
}

fn parse_tcode_version(raw: &str) -> Option<TcodeVersion> {
let raw = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
let without_build = raw.split_once('+').map_or(raw, |(core, _)| core);
let (core, prerelease) = match without_build.split_once('-') {
Some((core, suffix)) if !suffix.is_empty() => (core, true),
Some(_) => return None,
None => (without_build, false),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(TcodeVersion {
core: (major, minor, patch),
prerelease,
})
}

/// Compare a running app version with a GitHub release tag.
///
/// Prerelease releases are ignored for stable builds. A prerelease build may
/// advance to a newer prerelease numeric triple or to the stable release with
/// the same numeric triple. Malformed input returns `None`.
pub fn tcode_update_available(running: &str, latest_tag: &str) -> Option<bool> {
let running = parse_tcode_version(running)?;
let latest = parse_tcode_version(latest_tag)?;
if latest.prerelease && !running.prerelease {
return Some(false);
}
Some(match latest.core.cmp(&running.core) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => running.prerelease && !latest.prerelease,
})
}

/// The npm package name whose published version is the provider's "latest".
/// `npm view <pkg> version` works for every native provider (verified 2026-07);
Expand DownExpand Up@@ -218,6 +300,52 @@ mod tests {
assert!(!is_update_available("unknown", "2.0.0"));
}

#[test]
fn compares_tcode_release_versions() {
assert_eq!(tcode_update_available("0.4.0", "v0.4.0"), Some(false));
assert_eq!(tcode_update_available("0.4.0", "v0.4.1"), Some(true));
assert_eq!(tcode_update_available("0.4.1", "v0.4.0"), Some(false));
}

#[test]
fn handles_tcode_prereleases() {
assert_eq!(
tcode_update_available("0.4.0", "v0.5.0-beta.1"),
Some(false)
);
assert_eq!(tcode_update_available("0.5.0-beta.1", "v0.5.0"), Some(true));
assert_eq!(
tcode_update_available("0.5.0-beta.1", "v0.6.0-beta.1"),
Some(true)
);
}

#[test]
fn malformed_tcode_release_tag_has_no_comparison() {
assert_eq!(tcode_update_available("0.4.0", "latest"), None);
assert_eq!(tcode_update_available("0.4", "v0.4.1"), None);
}

#[test]
fn parses_github_release_json() {
let release = parse_tcode_release(
br#"{
"tag_name": "v0.4.1",
"html_url": "https://github.com/Tryanks/tcode/releases/tag/v0.4.1",
"prerelease": false,
"assets": [{"name": "SHA256SUMS.txt"}]
}"#,
)
.expect("release fixture should parse");

assert_eq!(release.tag_name, "v0.4.1");
assert_eq!(
release.html_url,
"https://github.com/Tryanks/tcode/releases/tag/v0.4.1"
);
assert!(!release.prerelease);
}

#[test]
fn detects_install_source_from_path() {
// Homebrew does not exist on Windows, where `detect_install_source`
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/src/runtime_event.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve
version = version
)
.into_owned(),
RuntimeNotice::TcodeUpdateAvailable { version } => {
crate::tr!("notice.tcode_update_available", version = version).into_owned()
}
RuntimeNotice::UpdatingProvider { provider } => crate::tr!(
"notice.updating_provider",
provider = provider.display_name()
Expand DownExpand Up@@ -354,6 +357,9 @@ mod tests {
provider: ProviderKind::Codex,
version: "1.2.3".into(),
},
RuntimeNotice::TcodeUpdateAvailable {
version: "1.2.3".into(),
},
RuntimeNotice::UpdatingProvider {
provider: ProviderKind::ClaudeCode,
},
Expand Down
Loading