Skip to content

Commit 2615364

Browse files
Show inline release notes in update prompt
Fetch GitHub release body metadata during startup update checks and cache concise bullet notes with the latest version. Render those notes directly in the update prompt, falling back to the full release link only when no notes are available. Tests: CC=clang cargo test -p codex-tui --release update_prompt; CC=clang cargo test -p codex-tui --release release_notes_body_extracts_top_bullets; CC=clang cargo test -p codex-tui --release upgrade_info_preserves_cached_release_notes; just fix -p codex-tui Co-authored-by: Open Codex <hff582580@gmail.com>
1 parent b6f62e4 commit 2615364

3 files changed

Lines changed: 221 additions & 31 deletions

File tree

codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
---
22
source: tui/src/update_prompt.rs
3-
assertion_line: 268
3+
assertion_line: 293
44
expression: terminal.backend()
55
---
66

77
✨ Update available! CURRENT_VERSION -> NEXT_VERSION
88

9-
Release notes: https://github.com/LEON-gittech/codex
9+
What's new:
10+
- Fix revoke conversation-only restore scope.
11+
- Show concrete release notes in update prompts.
12+
Full release notes: https://github.com/LEON-gittech/Open-Codex-CLI/releases/tag/rust-vNEXT_VERSION
1013

11-
1. Update now (runs `npm install -g @leonw24/open-codex@latest`)
14+
1. Update now (runs `npm install -g @leonw24/open-codex@latest`)
1215
2. Skip
1316
3. Skip until next version
1417

codex-rs/tui/src/update_prompt.rs

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,14 @@ pub(crate) async fn run_update_prompt_if_needed(
3636
tui: &mut Tui,
3737
config: &Config,
3838
) -> Result<UpdatePromptOutcome> {
39-
let Some(latest_version) = updates::get_upgrade_version_for_popup(config).await else {
39+
let Some(upgrade_info) = updates::get_upgrade_info_for_popup(config).await else {
4040
return Ok(UpdatePromptOutcome::Continue);
4141
};
4242
let Some(update_action) = crate::update_action::get_update_action() else {
4343
return Ok(UpdatePromptOutcome::Continue);
4444
};
4545

46-
let mut screen =
47-
UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action);
46+
let mut screen = UpdatePromptScreen::new(tui.frame_requester(), upgrade_info, update_action);
4847
tui.draw(u16::MAX, |frame| {
4948
frame.render_widget_ref(&screen, frame.area());
5049
})?;
@@ -92,7 +91,7 @@ enum UpdateSelection {
9291

9392
struct UpdatePromptScreen {
9493
request_frame: FrameRequester,
95-
latest_version: String,
94+
upgrade_info: updates::UpgradeInfo,
9695
current_version: String,
9796
update_action: UpdateAction,
9897
highlighted: UpdateSelection,
@@ -102,12 +101,12 @@ struct UpdatePromptScreen {
102101
impl UpdatePromptScreen {
103102
fn new(
104103
request_frame: FrameRequester,
105-
latest_version: String,
104+
upgrade_info: updates::UpgradeInfo,
106105
update_action: UpdateAction,
107106
) -> Self {
108107
Self {
109108
request_frame,
110-
latest_version,
109+
upgrade_info,
111110
current_version: env!("CARGO_PKG_VERSION").to_string(),
112111
update_action,
113112
highlighted: UpdateSelection::UpdateNow,
@@ -159,7 +158,7 @@ impl UpdatePromptScreen {
159158
}
160159

161160
fn latest_version(&self) -> &str {
162-
self.latest_version.as_str()
161+
self.upgrade_info.latest_version.as_str()
163162
}
164163
}
165164

@@ -187,7 +186,7 @@ impl WidgetRef for &UpdatePromptScreen {
187186
let mut column = ColumnRenderable::new();
188187

189188
let update_command = self.update_action.command_str();
190-
let release_notes_url = self.update_action.release_notes_url();
189+
let release_notes_url = self.upgrade_info.release_notes_url.as_str();
191190

192191
column.push("");
193192
column.push(Line::from(vec![
@@ -197,18 +196,35 @@ impl WidgetRef for &UpdatePromptScreen {
197196
format!(
198197
"{current} -> {latest}",
199198
current = self.current_version,
200-
latest = self.latest_version
199+
latest = self.upgrade_info.latest_version.as_str()
201200
)
202201
.dim(),
203202
]));
204203
column.push("");
205-
column.push(
206-
Line::from(vec![
207-
"Release notes: ".dim(),
208-
release_notes_url.dim().underlined(),
209-
])
210-
.inset(Insets::tlbr(0, 2, 0, 0)),
211-
);
204+
if self.upgrade_info.release_notes.is_empty() {
205+
column.push(
206+
Line::from(vec![
207+
"Release notes: ".dim(),
208+
release_notes_url.dim().underlined(),
209+
])
210+
.inset(Insets::tlbr(0, 2, 0, 0)),
211+
);
212+
} else {
213+
column.push(Line::from("What's new:".bold()).inset(Insets::tlbr(0, 2, 0, 0)));
214+
for note in &self.upgrade_info.release_notes {
215+
column.push(
216+
Line::from(vec!["- ".dim(), note.as_str().into()])
217+
.inset(Insets::tlbr(0, 4, 0, 0)),
218+
);
219+
}
220+
column.push(
221+
Line::from(vec![
222+
"Full release notes: ".dim(),
223+
release_notes_url.dim().underlined(),
224+
])
225+
.inset(Insets::tlbr(0, 2, 0, 0)),
226+
);
227+
}
212228
column.push("");
213229
column.push(selection_option_row(
214230
0,
@@ -251,7 +267,16 @@ mod tests {
251267
fn new_prompt() -> UpdatePromptScreen {
252268
let mut screen = UpdatePromptScreen::new(
253269
FrameRequester::test_dummy(),
254-
"NEXT_VERSION".into(),
270+
updates::UpgradeInfo {
271+
latest_version: "NEXT_VERSION".into(),
272+
release_notes: vec![
273+
"Fix revoke conversation-only restore scope.".into(),
274+
"Show concrete release notes in update prompts.".into(),
275+
],
276+
release_notes_url:
277+
"https://github.com/LEON-gittech/Open-Codex-CLI/releases/tag/rust-vNEXT_VERSION"
278+
.into(),
279+
},
255280
UpdateAction::NpmGlobalLatest,
256281
);
257282
screen.current_version = "CURRENT_VERSION".into();
@@ -261,7 +286,7 @@ mod tests {
261286
#[test]
262287
fn update_prompt_snapshot() {
263288
let screen = new_prompt();
264-
let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal");
289+
let mut terminal = Terminal::new(VT100Backend::new(110, 14)).expect("terminal");
265290
terminal
266291
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
267292
.expect("render update prompt");

codex-rs/tui/src/updates.rs

Lines changed: 172 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,39 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
4343
upgrade_version_from_info(info)
4444
}
4545

46+
#[derive(Debug, Clone, PartialEq, Eq)]
47+
pub(crate) struct UpgradeInfo {
48+
pub(crate) latest_version: String,
49+
pub(crate) release_notes: Vec<String>,
50+
pub(crate) release_notes_url: String,
51+
}
52+
4653
#[derive(Serialize, Deserialize, Debug, Clone)]
4754
struct VersionInfo {
4855
latest_version: String,
56+
#[serde(default)]
57+
release_notes: Vec<String>,
58+
#[serde(default)]
59+
release_notes_url: Option<String>,
4960
// ISO-8601 timestamp (RFC3339)
5061
last_checked_at: DateTime<Utc>,
5162
#[serde(default)]
5263
dismissed_version: Option<String>,
5364
}
5465

66+
#[derive(Deserialize, Debug)]
67+
struct GithubReleaseInfo {
68+
html_url: Option<String>,
69+
body: Option<String>,
70+
}
71+
5572
const VERSION_FILENAME: &str = "version.json";
5673
const CACHE_REFRESH_INTERVAL: Duration = Duration::hours(20);
5774
const STARTUP_UPDATE_CHECK_TIMEOUT: StdDuration = StdDuration::from_secs(3);
75+
const RELEASE_NOTES_LIMIT: usize = 5;
76+
const OPEN_CODEX_RELEASES_URL: &str = "https://github.com/LEON-gittech/Open-Codex-CLI/releases";
77+
const OPEN_CODEX_RELEASE_API_URL_PREFIX: &str =
78+
"https://api.github.com/repos/LEON-gittech/Open-Codex-CLI/releases/tags/rust-v";
5879

5980
fn version_filepath(config: &Config) -> PathBuf {
6081
config.codex_home.join(VERSION_FILENAME).into_path_buf()
@@ -74,12 +95,21 @@ fn should_refresh_version_info(info: Option<&VersionInfo>, now: DateTime<Utc>) -
7495
}
7596

7697
fn upgrade_version_from_info(info: Option<VersionInfo>) -> Option<String> {
77-
info.and_then(|info| {
78-
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
79-
Some(info.latest_version)
80-
} else {
81-
None
82-
}
98+
upgrade_info_from_info(info).map(|info| info.latest_version)
99+
}
100+
101+
fn upgrade_info_from_info(info: Option<VersionInfo>) -> Option<UpgradeInfo> {
102+
let info = info?;
103+
if !is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
104+
return None;
105+
}
106+
107+
Some(UpgradeInfo {
108+
release_notes_url: info
109+
.release_notes_url
110+
.unwrap_or_else(|| fallback_release_notes_url(&info.latest_version)),
111+
latest_version: info.latest_version,
112+
release_notes: info.release_notes,
83113
})
84114
}
85115

@@ -95,11 +125,22 @@ async fn check_for_update(
95125
.json::<NpmPackageInfo>()
96126
.await?;
97127
let latest_version = npm_registry::latest_version(&package_info)?.to_string();
128+
let release_notes = fetch_release_notes(&latest_version)
129+
.await
130+
.unwrap_or_else(|err| {
131+
tracing::error!("Failed to fetch release notes for version {latest_version}: {err}");
132+
ReleaseNotes {
133+
url: fallback_release_notes_url(&latest_version),
134+
notes: Vec::new(),
135+
}
136+
});
98137

99138
// Preserve any previously dismissed version if present.
100139
let prev_info = read_version_info(version_file).ok();
101140
let info = VersionInfo {
102141
latest_version,
142+
release_notes: release_notes.notes,
143+
release_notes_url: Some(release_notes.url),
103144
last_checked_at: Utc::now(),
104145
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
105146
};
@@ -112,9 +153,77 @@ async fn check_for_update(
112153
Ok(())
113154
}
114155

156+
struct ReleaseNotes {
157+
url: String,
158+
notes: Vec<String>,
159+
}
160+
161+
async fn fetch_release_notes(version: &str) -> anyhow::Result<ReleaseNotes> {
162+
let url = format!("{OPEN_CODEX_RELEASE_API_URL_PREFIX}{version}");
163+
let release = create_client()
164+
.get(&url)
165+
.header(reqwest::header::USER_AGENT, "open-codex-update-check")
166+
.send()
167+
.await?
168+
.error_for_status()?
169+
.json::<GithubReleaseInfo>()
170+
.await?;
171+
172+
let notes = release
173+
.body
174+
.as_deref()
175+
.map(release_note_items_from_body)
176+
.unwrap_or_default();
177+
178+
Ok(ReleaseNotes {
179+
url: release
180+
.html_url
181+
.unwrap_or_else(|| fallback_release_notes_url(version)),
182+
notes,
183+
})
184+
}
185+
186+
fn fallback_release_notes_url(version: &str) -> String {
187+
format!("{OPEN_CODEX_RELEASES_URL}/tag/rust-v{version}")
188+
}
189+
190+
fn release_note_items_from_body(body: &str) -> Vec<String> {
191+
body.lines()
192+
.filter_map(|line| {
193+
let trimmed = line.trim();
194+
trimmed
195+
.strip_prefix("- ")
196+
.or_else(|| trimmed.strip_prefix("* "))
197+
})
198+
.map(clean_release_note_item)
199+
.filter(|line| !line.is_empty())
200+
.take(RELEASE_NOTES_LIMIT)
201+
.collect()
202+
}
203+
204+
fn clean_release_note_item(item: &str) -> String {
205+
let mut cleaned = String::with_capacity(item.len());
206+
let mut previous_was_space = false;
207+
for ch in item.chars() {
208+
if ch == '`' {
209+
continue;
210+
}
211+
if ch.is_whitespace() {
212+
if !previous_was_space {
213+
cleaned.push(' ');
214+
}
215+
previous_was_space = true;
216+
} else {
217+
cleaned.push(ch);
218+
previous_was_space = false;
219+
}
220+
}
221+
cleaned.trim().to_string()
222+
}
223+
115224
/// Returns the latest version to show in a popup, if it should be shown.
116225
/// This respects the user's dismissal choice for the current latest version.
117-
pub async fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
226+
pub async fn get_upgrade_info_for_popup(config: &Config) -> Option<UpgradeInfo> {
118227
if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) {
119228
return None;
120229
}
@@ -141,14 +250,14 @@ pub async fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
141250
}
142251
}
143252

144-
let latest = upgrade_version_from_info(info)?;
253+
let upgrade_info = upgrade_info_from_info(info)?;
145254
// If the user dismissed this exact version previously, do not show the popup.
146255
if let Ok(info) = read_version_info(&version_file)
147-
&& info.dismissed_version.as_deref() == Some(latest.as_str())
256+
&& info.dismissed_version.as_deref() == Some(upgrade_info.latest_version.as_str())
148257
{
149258
return None;
150259
}
151-
Some(latest)
260+
Some(upgrade_info)
152261
}
153262

154263
/// Persist a dismissal for the current latest version so we don't show
@@ -167,3 +276,56 @@ pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<(
167276
tokio::fs::write(version_file, json_line).await?;
168277
Ok(())
169278
}
279+
280+
#[cfg(test)]
281+
mod tests {
282+
use super::*;
283+
use pretty_assertions::assert_eq;
284+
285+
#[test]
286+
fn release_notes_body_extracts_top_bullets() {
287+
let body = r#"
288+
## 0.130.11
289+
290+
- Fix revoke conversation-only restores so they do not touch files.
291+
- Add inline release notes to the update prompt.
292+
- Collapse extra spacing.
293+
- Fourth item.
294+
- Fifth item.
295+
- Sixth item is ignored.
296+
297+
Details after the list are ignored.
298+
"#;
299+
300+
assert_eq!(
301+
release_note_items_from_body(body),
302+
vec![
303+
"Fix revoke conversation-only restores so they do not touch files.".to_string(),
304+
"Add inline release notes to the update prompt.".to_string(),
305+
"Collapse extra spacing.".to_string(),
306+
"Fourth item.".to_string(),
307+
"Fifth item.".to_string(),
308+
]
309+
);
310+
}
311+
312+
#[test]
313+
fn upgrade_info_preserves_cached_release_notes() {
314+
let info = VersionInfo {
315+
latest_version: "999.0.0".to_string(),
316+
release_notes: vec!["One concrete change.".to_string()],
317+
release_notes_url: Some("https://example.test/release".to_string()),
318+
last_checked_at: Utc::now(),
319+
dismissed_version: None,
320+
};
321+
322+
assert_eq!(
323+
upgrade_info_from_info(Some(info)),
324+
Some(UpgradeInfo {
325+
latest_version: "999.0.0".to_string(),
326+
release_notes: vec!["One concrete change.".to_string()],
327+
release_notes_url: "https://example.test/release".to_string(),
328+
})
329+
);
330+
}
331+
}

0 commit comments

Comments
 (0)