Excise the Changelog feature
Kigi never publishes CDN changelogs, so the entire inherited feature
was dead weight: the welcome-menu Changelog row, the hero-box info
slot (bullets + clickable CTA), /release-notes with its /changelog
alias, and the ChangelogManager CDN-fetch/disk-cache pipeline
(Effect::FetchChangelog, TaskResult::ChangelogFetched, startup and
post-login fetch kickoffs, AppView cache fields, mouse hover/click
handling). Welcome menu is now [Import] / New worktree / Resume
session / Quit; the hero box keeps title + version + subtitle and its
layout math simplifies to 3 + menu rows (verified equivalent by the
surviving boundary tests). Action::ShowReleaseNotes and the DocViewer
modal stay — /docs uses them. builtin.rs keeps deleting stale
CHANGELOG.{json,md} caches written by kigi ≤ 0.1.0.
kigi-shell-base drops its reqwest 'blocking' feature (only the deleted
module used it). -1206 lines net.
Gates: fmt clean, workspace check/clippy --all-targets 0/0, kigi-tui
lib 6609 / shell-base 56 / shell util:: 258 all passing, welcome pty
e2e (3 tests incl. braille logo) passing.
This commit is contained in:
@@ -13,7 +13,7 @@ default-bazel = []
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
//! Changelog fetching from CDN with local disk cache.
|
||||
//!
|
||||
//! Both markdown (`*.external.md`) and JSON (`*.external.json`) changelogs
|
||||
//! are published per-version alongside the Kigi GitHub distribution.
|
||||
//!
|
||||
//! `ChangelogManager::fetch()` retrieves both formats in parallel and
|
||||
//! returns a `Changelog` with optional markdown + structured entries.
|
||||
//! Consumers pick the format they need:
|
||||
//! - `/release-notes` uses `changelog.markdown` for rich scrollback display
|
||||
//! - Welcome screen uses `changelog.entries` for bullet rendering
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Base URL for published changelogs. Kigi distributes via GitHub, so
|
||||
/// per-version changelogs live in the release repository. Unreachable or
|
||||
/// missing files degrade gracefully to the on-disk cache (see `fetch_with`).
|
||||
const CHANGELOG_BASE: &str =
|
||||
"https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/changelogs";
|
||||
const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
/// A single structured changelog entry from the published JSON changelog.
|
||||
///
|
||||
/// Shape must match the output of `render_external_json` in `changelog.sh`:
|
||||
/// `{category, description, breaking_change}`
|
||||
/// If you change fields here, update `changelog.sh:render_external_json` too.
|
||||
///
|
||||
/// All fields use `#[serde(default)]` so a single malformed entry doesn't
|
||||
/// kill the entire array parse. Entries with an empty description are
|
||||
/// filtered out by `bullets_from_entries`.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct ChangelogEntry {
|
||||
/// Category label (e.g. "features", "fixes", "breaking", "performance").
|
||||
#[serde(default)]
|
||||
pub category: String,
|
||||
/// Human-readable description (may contain `**bold**` or backticks).
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Whether this entry represents a breaking change.
|
||||
#[serde(default)]
|
||||
pub breaking_change: bool,
|
||||
}
|
||||
|
||||
/// Both formats of a version's changelog, fetched together.
|
||||
pub struct Changelog {
|
||||
/// Rendered markdown (for `/release-notes` display).
|
||||
pub markdown: Option<String>,
|
||||
/// Structured entries (for welcome screen bullets).
|
||||
pub entries: Option<Vec<ChangelogEntry>>,
|
||||
}
|
||||
|
||||
/// Manages changelog retrieval from CDN with local disk caching.
|
||||
///
|
||||
/// Single entry point: `fetch()` returns both markdown and JSON in one
|
||||
/// `Changelog` struct. Each format is fetched independently with its own
|
||||
/// cache file, so a failure in one doesn't block the other.
|
||||
pub struct ChangelogManager {
|
||||
md_cache: PathBuf,
|
||||
json_cache: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for ChangelogManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChangelogManager {
|
||||
pub fn new() -> Self {
|
||||
// Prefer live `$KIGI_SHARE_DIR` so harness-injected homes (PTY e2e) always
|
||||
// win over a OnceLock that may have been initialised earlier with a
|
||||
// different path in the same process graph.
|
||||
Self::from_env_home()
|
||||
}
|
||||
|
||||
/// Resolve cache paths from the live process environment (not the
|
||||
/// `kigi_home()` OnceLock). A seeded `$KIGI_SHARE_DIR` set on the pager
|
||||
/// process is always honoured even if some earlier init path cached a
|
||||
/// different home.
|
||||
fn from_env_home() -> Self {
|
||||
let home = std::env::var_os("KIGI_SHARE_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or_else(crate::util::kigi_home::kigi_home);
|
||||
Self {
|
||||
md_cache: home.join("CHANGELOG.md"),
|
||||
json_cache: home.join("CHANGELOG.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch both markdown and JSON changelogs for the current version.
|
||||
///
|
||||
/// Each format is fetched independently (CDN, 3 s timeout) and cached
|
||||
/// to disk. On failure, falls back to the cached copy. Either field
|
||||
/// may be `None` if offline with no cache.
|
||||
///
|
||||
/// When `KIGI_CHANGELOG_OFFLINE` is set (PTY / integration tests), skip
|
||||
/// the CDN entirely and read only the disk cache so seeded fixtures win
|
||||
/// deterministically without network races. Paths are re-resolved from
|
||||
/// `$KIGI_SHARE_DIR` so harness-injected env always applies.
|
||||
///
|
||||
/// JSON is only cached after a successful parse to avoid poisoning the
|
||||
/// disk cache with malformed content (the markdown cache is write-through
|
||||
/// since it's consumed as raw text).
|
||||
pub fn fetch(&self) -> Changelog {
|
||||
// Always re-resolve from env so a caller holding an older manager
|
||||
// (or OnceLock lag) still reads the live harness home.
|
||||
Self::from_env_home().fetch_with(changelog_offline(), CHANGELOG_BASE)
|
||||
}
|
||||
|
||||
/// Fetch using this manager's already-resolved cache paths, an explicit
|
||||
/// offline flag, and an explicit CDN base.
|
||||
///
|
||||
/// Split out of [`fetch`] so unit tests can drive it against a temp home
|
||||
/// without mutating process-global env (`KIGI_SHARE_DIR` /
|
||||
/// `KIGI_CHANGELOG_OFFLINE`), which races across the parallel test
|
||||
/// harness. Passing an unreachable `base` lets a test force a
|
||||
/// deterministic CDN miss instead of depending on whether the sandbox
|
||||
/// happens to block network. Production callers always go through
|
||||
/// [`fetch`], so behaviour is unchanged.
|
||||
fn fetch_with(&self, offline: bool, base: &str) -> Changelog {
|
||||
if offline {
|
||||
return Changelog {
|
||||
markdown: read_cache(&self.md_cache),
|
||||
entries: self.read_json_cache(),
|
||||
};
|
||||
}
|
||||
|
||||
let version = kigi_version::VERSION;
|
||||
let md_url = format!("{}/{}.external.md", base, version);
|
||||
|
||||
// Fetch both formats in parallel (3s timeout each → 3s total, not 6s).
|
||||
let mut markdown = None;
|
||||
let mut entries = None;
|
||||
std::thread::scope(|s| {
|
||||
let md_handle = s.spawn(|| self.fetch_and_cache(&md_url, &self.md_cache));
|
||||
let json_handle = s.spawn(|| self.fetch_json(base, version));
|
||||
markdown = md_handle.join().ok().flatten();
|
||||
entries = json_handle.join().ok().flatten();
|
||||
});
|
||||
|
||||
// If CDN is unreachable (CI sandboxes, airplane mode), fall back to
|
||||
// any on-disk seed under `$KIGI_SHARE_DIR` even when offline mode was not
|
||||
// explicitly requested — keeps PTY/integration tests deterministic.
|
||||
if markdown.is_none() {
|
||||
markdown = read_cache(&self.md_cache);
|
||||
}
|
||||
if entries.is_none() {
|
||||
entries = self.read_json_cache();
|
||||
}
|
||||
|
||||
Changelog { markdown, entries }
|
||||
}
|
||||
|
||||
/// Fetch and parse JSON changelog, caching only after successful parse.
|
||||
fn fetch_json(&self, base: &str, version: &str) -> Option<Vec<ChangelogEntry>> {
|
||||
let url = format!("{}/{}.external.json", base, version);
|
||||
|
||||
// Try remote first — only cache after successful parse.
|
||||
if let Ok(raw) = fetch_blocking(&url)
|
||||
&& !raw.trim().is_empty()
|
||||
{
|
||||
match serde_json::from_str::<Vec<ChangelogEntry>>(&raw) {
|
||||
Ok(entries) => {
|
||||
if let Err(e) = std::fs::write(&self.json_cache, &raw) {
|
||||
tracing::debug!(error = %e, "JSON changelog cache write failed");
|
||||
}
|
||||
return Some(entries);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "failed to parse JSON changelog from CDN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.read_json_cache()
|
||||
}
|
||||
|
||||
fn read_json_cache(&self) -> Option<Vec<ChangelogEntry>> {
|
||||
let cached = read_cache(&self.json_cache)?;
|
||||
match serde_json::from_str(&cached) {
|
||||
Ok(entries) => Some(entries),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "failed to parse cached JSON changelog");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared fetch-and-cache: try remote (3 s timeout), cache on success,
|
||||
/// fall back to disk cache on failure.
|
||||
fn fetch_and_cache(&self, url: &str, cache_path: &std::path::Path) -> Option<String> {
|
||||
if let Ok(content) = fetch_blocking(url)
|
||||
&& !content.trim().is_empty()
|
||||
{
|
||||
if let Err(e) = std::fs::write(cache_path, &content) {
|
||||
tracing::debug!(error = %e, path = %cache_path.display(), "cache write failed");
|
||||
}
|
||||
return Some(content);
|
||||
}
|
||||
read_cache(cache_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// When set, `ChangelogManager::fetch` skips the CDN and only reads disk cache.
|
||||
/// Used by PTY harness tests that seed `CHANGELOG.{md,json}` under a temp home.
|
||||
fn changelog_offline() -> bool {
|
||||
std::env::var_os("KIGI_CHANGELOG_OFFLINE").is_some_and(|v| !v.is_empty() && v != "0")
|
||||
}
|
||||
|
||||
fn read_cache(path: &std::path::Path) -> Option<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Strip `**bold**` markers and backticks from a description string.
|
||||
fn strip_markdown_inline(s: &str) -> String {
|
||||
s.replace("**", "").replace('`', "")
|
||||
}
|
||||
|
||||
/// Convert changelog entries to plain-text bullet strings.
|
||||
///
|
||||
/// Strips `**bold**` and backtick formatting from each description,
|
||||
/// skips entries with empty descriptions (from tolerant deserialization),
|
||||
/// and returns at most `max` entries.
|
||||
pub fn bullets_from_entries(entries: &[ChangelogEntry], max: usize) -> Vec<String> {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| !e.description.is_empty())
|
||||
.take(max)
|
||||
.map(|e| strip_markdown_inline(&e.description))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Blocking HTTP fetch. Callers (`std::thread::scope` threads) are already
|
||||
/// off the tokio runtime, so no extra thread spawn is needed.
|
||||
fn fetch_blocking(url: &str) -> anyhow::Result<String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.build()?;
|
||||
let resp = client.get(url).send()?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a manager pointing at `home` directly, bypassing the global
|
||||
/// `$KIGI_SHARE_DIR` env so tests never race the parallel harness.
|
||||
fn manager_for(home: &std::path::Path) -> ChangelogManager {
|
||||
ChangelogManager {
|
||||
md_cache: home.join("CHANGELOG.md"),
|
||||
json_cache: home.join("CHANGELOG.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_mode_reads_seeded_disk_cache_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("kigi-home");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(home.join("CHANGELOG.md"), "# seeded offline md\n").unwrap();
|
||||
std::fs::write(
|
||||
home.join("CHANGELOG.json"),
|
||||
r#"[{"category":"features","description":"seeded entry","breaking_change":false}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Offline path: read only the seeded disk cache, no network.
|
||||
let changelog = manager_for(&home).fetch_with(true, CHANGELOG_BASE);
|
||||
assert_eq!(
|
||||
changelog.markdown.as_deref(),
|
||||
Some("# seeded offline md\n"),
|
||||
"offline mode must return seeded markdown"
|
||||
);
|
||||
let entries = changelog.entries.expect("seeded json entries");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].description, "seeded entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdn_miss_falls_back_to_env_home_disk_cache() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("kigi-home-fallback");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(home.join("CHANGELOG.md"), "# fallback md\n").unwrap();
|
||||
|
||||
// Non-offline path with an unreachable CDN base: the remote fetch
|
||||
// fails deterministically (no dependency on the sandbox blocking
|
||||
// network), so the on-disk cache must win.
|
||||
let changelog = manager_for(&home).fetch_with(false, "http://127.0.0.1:1");
|
||||
assert_eq!(
|
||||
changelog.markdown.as_deref(),
|
||||
Some("# fallback md\n"),
|
||||
"CDN miss must fall back to the seeded CHANGELOG.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_strips_markdown_and_respects_max() {
|
||||
let entries = vec![
|
||||
ChangelogEntry {
|
||||
category: "features".into(),
|
||||
description: "Added **dark mode** support".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "fixes".into(),
|
||||
description: "Fixed `crash` on startup".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "performance".into(),
|
||||
description: "Faster **rendering** of `code` blocks".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
];
|
||||
|
||||
let bullets = bullets_from_entries(&entries, 2);
|
||||
assert_eq!(bullets.len(), 2);
|
||||
assert_eq!(bullets[0], "Added dark mode support");
|
||||
assert_eq!(bullets[1], "Fixed crash on startup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_skips_empty_descriptions() {
|
||||
let entries = vec![
|
||||
ChangelogEntry {
|
||||
category: "features".into(),
|
||||
description: "Good entry".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: String::new(),
|
||||
description: String::new(), // bad entry from tolerant deser
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "fixes".into(),
|
||||
description: "Another good one".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
];
|
||||
let bullets = bullets_from_entries(&entries, 10);
|
||||
assert_eq!(bullets, vec!["Good entry", "Another good one"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerant_deserialization_partial_entry() {
|
||||
// Missing description field → defaults to empty string, not a parse error
|
||||
let json = r#"[{"category":"features"},{"description":"ok"}]"#;
|
||||
let entries: Vec<ChangelogEntry> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].description, "");
|
||||
assert_eq!(entries[1].category, "");
|
||||
assert_eq!(entries[1].description, "ok");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod changelog;
|
||||
pub mod event_id;
|
||||
pub mod kigi_home;
|
||||
pub mod secure_file;
|
||||
|
||||
@@ -116,8 +116,8 @@ pub fn extract_bundled_files(kigi_home: &std::path::Path) {
|
||||
|
||||
let _ = std::fs::create_dir_all(kigi_home);
|
||||
|
||||
// Clean up cached changelog files from previous version so
|
||||
// /release-notes fetches fresh content for the new version.
|
||||
// Clean up changelog caches written by the removed changelog feature
|
||||
// (kigi <= 0.1.0 cached CDN release notes in the kigi home).
|
||||
for stale in &["CHANGELOG.json", "CHANGELOG.md"] {
|
||||
let _ = std::fs::remove_file(kigi_home.join(stale));
|
||||
}
|
||||
|
||||
@@ -395,16 +395,6 @@ Show terminal capability detection and setup info — including color level, whi
|
||||
|
||||
Aliases: `/terminal-check`, `/terminal-info`
|
||||
|
||||
### `/release-notes`
|
||||
|
||||
View release notes for the current version.
|
||||
|
||||
```
|
||||
/release-notes
|
||||
```
|
||||
|
||||
Aliases: `/changelog`
|
||||
|
||||
### `/docs`
|
||||
|
||||
Browse in-TUI How-to Guides, open online Build docs, or jump to a guide by title.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Status: alpha.** The schema below is versioned (`kigi_code.schema.version = v1`);
|
||||
> additive changes may occur without notice, renames/removals will bump the
|
||||
> version and be called out in the changelog.
|
||||
> version.
|
||||
|
||||
Kigi CLI can export usage **metrics** and **events** to your organization's
|
||||
own OpenTelemetry collector, so platform teams can monitor adoption, token
|
||||
|
||||
@@ -1471,10 +1471,6 @@ pub enum Effect {
|
||||
/// `SwitchModelComplete` so `IncompatibleAgent` can roll back.
|
||||
prev_model_id: Option<acp::ModelId>,
|
||||
},
|
||||
/// Fetch changelog from CDN (both markdown + structured JSON).
|
||||
/// Runs off the render path via `spawn_blocking`. Result is cached
|
||||
/// on `AppView` so `/release-notes` and the welcome screen share it.
|
||||
FetchChangelog,
|
||||
/// Persist memory modal fullscreen preference to `[hints]` in config.toml.
|
||||
PersistMemoryFullscreen { fullscreen: bool },
|
||||
/// Persist the project-picker opt-out to `[hints] project_picker_disabled`.
|
||||
@@ -2130,11 +2126,6 @@ pub enum TaskResult {
|
||||
/// rollback on `IncompatibleAgent`.
|
||||
prev_model_id: Option<acp::ModelId>,
|
||||
},
|
||||
/// Changelog fetched from CDN (both formats).
|
||||
ChangelogFetched {
|
||||
markdown: Option<String>,
|
||||
entries: Vec<kigi_shell::util::changelog::ChangelogEntry>,
|
||||
},
|
||||
/// Cross-session prompt history loaded from ACP.
|
||||
PromptHistoryLoaded {
|
||||
agent_id: AgentId,
|
||||
|
||||
@@ -596,12 +596,6 @@ pub struct AppView {
|
||||
/// Release-safe FPS HUD (`/debug fps`; `KIGI_FPS` env on release
|
||||
/// builds, where the dev overlay is compiled out) — see the module doc.
|
||||
pub fps_hud: crate::views::fps_hud::FpsHud,
|
||||
/// Cached changelog markdown (for `/release-notes`). Populated by
|
||||
/// `FetchChangelog` at startup; `None` until the fetch completes.
|
||||
pub changelog_markdown: Option<String>,
|
||||
/// Cached changelog bullets (for welcome screen). Populated by
|
||||
/// `FetchChangelog` at startup; empty until the fetch completes.
|
||||
pub changelog_bullets: Vec<String>,
|
||||
/// Resolved tip list from config layers.
|
||||
pub tips: Vec<String>,
|
||||
/// Selected tip for the current launch/session.
|
||||
@@ -709,10 +703,6 @@ pub struct AppView {
|
||||
pub welcome_menu_index: Option<usize>,
|
||||
/// Hit-test rects for welcome menu items (populated during render).
|
||||
pub welcome_menu_rects: Vec<ratatui::layout::Rect>,
|
||||
/// Whether the welcome menu currently includes a "Changelog" row (above
|
||||
/// Quit). Set during render; the input handler uses it to size the menu and
|
||||
/// map the extra row to the release-notes action.
|
||||
pub welcome_show_changelog_action: bool,
|
||||
/// Hit-test rect for the import-claude banner on the welcome screen.
|
||||
pub welcome_import_banner_rect: Option<ratatui::layout::Rect>,
|
||||
/// Last known mouse position (column, row), updated on every Mouse event.
|
||||
@@ -734,14 +724,8 @@ pub struct AppView {
|
||||
pub welcome_auth_url_rect: Option<ratatui::layout::Rect>,
|
||||
/// Whether the mouse pointer was last over the auth URL (for OSC 22 cursor shape).
|
||||
pub welcome_on_auth_url: bool,
|
||||
/// Mouse last over the changelog block (drives hover color + redraws).
|
||||
pub welcome_on_changelog_cta: bool,
|
||||
/// Hit-test rect for the "show full URL" fallback link.
|
||||
pub welcome_auth_fallback_rect: Option<ratatui::layout::Rect>,
|
||||
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
|
||||
/// Hit-test rect for the gate URL link on the paywall CTA.
|
||||
/// Hit-test rect for the clickable changelog info block (opens release notes).
|
||||
pub welcome_changelog_cta_rect: Option<ratatui::layout::Rect>,
|
||||
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
||||
pub auth_show_raw_url: bool,
|
||||
/// Whether mouse capture is currently disabled for raw URL mode.
|
||||
@@ -1015,8 +999,6 @@ impl AppView {
|
||||
tracing_rx: None,
|
||||
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
|
||||
fps_hud: crate::views::fps_hud::FpsHud::new(),
|
||||
changelog_markdown: None,
|
||||
changelog_bullets: Vec::new(),
|
||||
tips: Vec::new(),
|
||||
tip: None,
|
||||
welcome_prompt,
|
||||
@@ -1031,7 +1013,6 @@ impl AppView {
|
||||
minimal_state: crate::minimal_api::MinimalState::default(),
|
||||
welcome_menu_index: None,
|
||||
welcome_menu_rects: Vec::new(),
|
||||
welcome_show_changelog_action: false,
|
||||
welcome_import_banner_rect: None,
|
||||
last_mouse_pos: None,
|
||||
last_scroll_pos: None,
|
||||
@@ -1039,9 +1020,7 @@ impl AppView {
|
||||
welcome_prompt_rect: None,
|
||||
welcome_auth_url_rect: None,
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
session_picker_entries: None,
|
||||
@@ -1635,19 +1614,11 @@ impl AppView {
|
||||
new_worktree_dialog: &mut self.new_worktree_dialog,
|
||||
menu_index: &mut self.welcome_menu_index,
|
||||
menu_rects: &self.welcome_menu_rects,
|
||||
menu_count: 3
|
||||
+ if self.has_claude_import { 1 } else { 0 }
|
||||
+ if self.welcome_show_changelog_action {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
menu_count: 3 + if self.has_claude_import { 1 } else { 0 },
|
||||
prompt_rect: self.welcome_prompt_rect.as_ref(),
|
||||
import_banner_rect: self.welcome_import_banner_rect.as_ref(),
|
||||
auth_url_rect: self.welcome_auth_url_rect.as_ref(),
|
||||
auth_fallback_rect: self.welcome_auth_fallback_rect.as_ref(),
|
||||
changelog_cta_rect: self.welcome_changelog_cta_rect.as_ref(),
|
||||
on_changelog_cta: &mut self.welcome_on_changelog_cta,
|
||||
show_raw_url: &mut self.auth_show_raw_url,
|
||||
sp_entries: &mut self.session_picker_entries,
|
||||
sp_state: &mut self.session_picker_state,
|
||||
@@ -1657,8 +1628,6 @@ impl AppView {
|
||||
has_claude_import: self.has_claude_import,
|
||||
import_claude_modal: &mut self.import_claude_modal,
|
||||
welcome_doc_viewer: &mut self.welcome_doc_viewer,
|
||||
changelog_markdown: &self.changelog_markdown,
|
||||
show_changelog_action: self.welcome_show_changelog_action,
|
||||
has_pending_update: self.pending_update_version.is_some(),
|
||||
has_foreign_resume,
|
||||
cwd_has_git_ancestor: self.cwd_has_git_ancestor,
|
||||
@@ -2175,10 +2144,6 @@ struct WelcomeInputCtx<'a> {
|
||||
import_banner_rect: Option<&'a ratatui::layout::Rect>,
|
||||
auth_url_rect: Option<&'a ratatui::layout::Rect>,
|
||||
auth_fallback_rect: Option<&'a ratatui::layout::Rect>,
|
||||
/// Hit-test rect for the clickable changelog info block (opens release notes).
|
||||
changelog_cta_rect: Option<&'a ratatui::layout::Rect>,
|
||||
/// Sticky hover flag for the changelog block (redraw on enter/leave).
|
||||
on_changelog_cta: &'a mut bool,
|
||||
show_raw_url: &'a mut bool,
|
||||
sp_entries: &'a mut Option<Vec<SessionPickerEntry>>,
|
||||
sp_state: &'a mut crate::views::picker::PickerState,
|
||||
@@ -2190,10 +2155,6 @@ struct WelcomeInputCtx<'a> {
|
||||
has_claude_import: bool,
|
||||
import_claude_modal: &'a mut Option<crate::views::import_claude_modal::ImportClaudeModalState>,
|
||||
welcome_doc_viewer: &'a mut Option<crate::views::modal::ActiveModal>,
|
||||
changelog_markdown: &'a Option<String>,
|
||||
/// Whether the welcome menu currently includes a "Changelog" row (above
|
||||
/// Quit), so index→action mapping accounts for it.
|
||||
show_changelog_action: bool,
|
||||
has_pending_update: bool,
|
||||
/// A recent foreign session is available to resume when no update is pending.
|
||||
has_foreign_resume: bool,
|
||||
@@ -2601,12 +2562,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
if key!(Enter).matches(key)
|
||||
&& let Some(idx) = *ctx.menu_index
|
||||
{
|
||||
return dispatch_menu_action(
|
||||
idx,
|
||||
ctx.has_claude_import,
|
||||
ctx.show_changelog_action,
|
||||
ctx.changelog_markdown.as_deref(),
|
||||
);
|
||||
return dispatch_menu_action(idx, ctx.has_claude_import);
|
||||
}
|
||||
if crate::input::key::is_text_input_key(key) {
|
||||
*ctx.prompt_focused = true;
|
||||
@@ -2762,23 +2718,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
{
|
||||
return InputOutcome::Action(Action::DismissClaudeImport);
|
||||
}
|
||||
return dispatch_menu_action(
|
||||
i,
|
||||
ctx.has_claude_import,
|
||||
ctx.show_changelog_action,
|
||||
ctx.changelog_markdown.as_deref(),
|
||||
);
|
||||
return dispatch_menu_action(i, ctx.has_claude_import);
|
||||
}
|
||||
}
|
||||
if let Some(rect) = ctx.changelog_cta_rect
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
&& let Some(md) = ctx.changelog_markdown.as_deref()
|
||||
{
|
||||
return InputOutcome::Action(Action::ShowReleaseNotes {
|
||||
title: "Release Notes".to_string(),
|
||||
content: md.trim().to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(rect) = ctx.auth_url_rect
|
||||
&& matches!(ctx.auth_state, AuthState::Authenticating { .. })
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
@@ -2830,12 +2772,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
if ctx.has_claude_import && new_index == Some(0) {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let pos = ratatui::layout::Position::new(mouse.column, mouse.row);
|
||||
let over_cta = ctx.changelog_cta_rect.is_some_and(|r| r.contains(pos));
|
||||
if over_cta != *ctx.on_changelog_cta {
|
||||
*ctx.on_changelog_cta = over_cta;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if matches!(ctx.auth_state, AuthState::Authenticating { .. })
|
||||
&& ctx.auth_url_rect.is_some()
|
||||
{
|
||||
@@ -2885,23 +2821,12 @@ fn dispatch_pending_menu_action(items: &[PendingMenuItem], index: usize) -> Inpu
|
||||
}
|
||||
/// Dispatch an action for a welcome menu item by index.
|
||||
///
|
||||
/// Menu order: `[Import]`, New worktree, Resume session, `[Changelog]`, Quit.
|
||||
/// `show_changelog_action` is true when the Changelog row is rendered; release
|
||||
/// notes open only once `changelog_md` is available.
|
||||
fn dispatch_menu_action(
|
||||
index: usize,
|
||||
has_claude_import: bool,
|
||||
show_changelog_action: bool,
|
||||
changelog_md: Option<&str>,
|
||||
) -> InputOutcome {
|
||||
/// Menu order: `[Import]`, New worktree, Resume session, Quit.
|
||||
fn dispatch_menu_action(index: usize, has_claude_import: bool) -> InputOutcome {
|
||||
let base = if has_claude_import { 1 } else { 0 };
|
||||
let worktree_idx = base;
|
||||
let resume_idx = base + 1;
|
||||
let (changelog_idx, quit_idx) = if show_changelog_action {
|
||||
(Some(base + 2), base + 3)
|
||||
} else {
|
||||
(None, base + 2)
|
||||
};
|
||||
let quit_idx = base + 2;
|
||||
if has_claude_import && index == 0 {
|
||||
return InputOutcome::Action(Action::ImportClaudeSettings);
|
||||
}
|
||||
@@ -2911,15 +2836,6 @@ fn dispatch_menu_action(
|
||||
if index == resume_idx {
|
||||
return InputOutcome::Action(Action::FetchSessionList);
|
||||
}
|
||||
if Some(index) == changelog_idx {
|
||||
if let Some(md) = changelog_md {
|
||||
return InputOutcome::Action(Action::ShowReleaseNotes {
|
||||
title: "Release Notes".to_string(),
|
||||
content: md.trim().to_string(),
|
||||
});
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if index == quit_idx {
|
||||
return InputOutcome::Action(Action::Quit);
|
||||
}
|
||||
@@ -3264,8 +3180,6 @@ impl AppView {
|
||||
session_picker_source_filter: self.session_picker_source_filter,
|
||||
chat_mode: self.chat_mode,
|
||||
is_api_key_auth: self.is_api_key_auth,
|
||||
changelog_bullets: &self.changelog_bullets,
|
||||
changelog_has_full_notes: self.changelog_markdown.is_some(),
|
||||
};
|
||||
let result = crate::views::welcome::render_welcome(
|
||||
view_area,
|
||||
@@ -3275,12 +3189,10 @@ impl AppView {
|
||||
&mut self.session_picker_state,
|
||||
);
|
||||
self.welcome_menu_rects = result.menu_rects;
|
||||
self.welcome_show_changelog_action = result.changelog_action_present;
|
||||
self.welcome_prompt_rect = result.prompt_rect;
|
||||
self.welcome_import_banner_rect = result.import_banner_rect;
|
||||
self.welcome_auth_url_rect = result.auth_url_rect;
|
||||
self.welcome_auth_fallback_rect = result.auth_fallback_rect;
|
||||
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
|
||||
self.session_picker_state.hit_areas = result.session_picker_hit_areas;
|
||||
if let Some(modal) = self.import_claude_modal.as_mut() {
|
||||
let theme = crate::theme::Theme::current();
|
||||
@@ -4324,8 +4236,6 @@ pub(crate) mod tests {
|
||||
pending_notification_escapes: None,
|
||||
deferred_notification: None,
|
||||
tracing_rx: None,
|
||||
changelog_markdown: None,
|
||||
changelog_bullets: Vec::new(),
|
||||
tips: Vec::new(),
|
||||
tip: None,
|
||||
cli_model_override: None,
|
||||
@@ -4379,7 +4289,6 @@ pub(crate) mod tests {
|
||||
welcome_tip_typing_dismissed: false,
|
||||
welcome_menu_index: None,
|
||||
welcome_menu_rects: Vec::new(),
|
||||
welcome_show_changelog_action: false,
|
||||
welcome_import_banner_rect: None,
|
||||
last_mouse_pos: None,
|
||||
last_scroll_pos: None,
|
||||
@@ -4387,9 +4296,7 @@ pub(crate) mod tests {
|
||||
welcome_prompt_rect: None,
|
||||
welcome_auth_url_rect: None,
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
session_picker_entries: None,
|
||||
@@ -5756,64 +5663,40 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn menu_action_indices_without_changelog() {
|
||||
fn menu_action_indices() {
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(0, false, false, None),
|
||||
dispatch_menu_action(0, false),
|
||||
InputOutcome::Action(Action::OpenNewWorktreeDialog)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(1, false, false, None),
|
||||
dispatch_menu_action(1, false),
|
||||
InputOutcome::Action(Action::FetchSessionList)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(2, false, false, None),
|
||||
dispatch_menu_action(2, false),
|
||||
InputOutcome::Action(Action::Quit)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn menu_action_changelog_sits_above_quit() {
|
||||
let md = Some("# notes");
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(1, false, true, md),
|
||||
InputOutcome::Action(Action::FetchSessionList)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(2, false, true, md),
|
||||
InputOutcome::Action(Action::ShowReleaseNotes { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(3, false, true, md),
|
||||
InputOutcome::Action(Action::Quit)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn menu_action_changelog_before_fetch_is_noop() {
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(2, false, true, None),
|
||||
dispatch_menu_action(3, false),
|
||||
InputOutcome::Unchanged
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn menu_action_indices_with_import_and_changelog() {
|
||||
let md = Some("# notes");
|
||||
fn menu_action_indices_with_import() {
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(0, true, true, md),
|
||||
dispatch_menu_action(0, true),
|
||||
InputOutcome::Action(Action::ImportClaudeSettings)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(1, true, true, md),
|
||||
dispatch_menu_action(1, true),
|
||||
InputOutcome::Action(Action::OpenNewWorktreeDialog)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(2, true, true, md),
|
||||
dispatch_menu_action(2, true),
|
||||
InputOutcome::Action(Action::FetchSessionList)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(3, true, true, md),
|
||||
InputOutcome::Action(Action::ShowReleaseNotes { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(4, true, true, md),
|
||||
dispatch_menu_action(3, true),
|
||||
InputOutcome::Action(Action::Quit)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -387,9 +387,6 @@ pub(super) fn handle_auth_complete(
|
||||
// status only; shell auto-syncs post-auth
|
||||
let mut effects = dispatch(Action::RequestBundleStatus, app);
|
||||
|
||||
// Fetch changelog (mirrors startup path for interactive login).
|
||||
effects.push(Effect::FetchChangelog);
|
||||
|
||||
// Replay deferred session startup once BOTH gates are open. Auth
|
||||
// is now Done, so `session_startup_allowed()` here means "is trust
|
||||
// also resolved?" -- if trust is still Pending its question renders
|
||||
|
||||
@@ -442,11 +442,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::ChangelogFetched { markdown, entries } => {
|
||||
app.changelog_markdown = markdown;
|
||||
app.changelog_bullets = kigi_shell::util::changelog::bullets_from_entries(&entries, 3);
|
||||
vec![]
|
||||
}
|
||||
TaskResult::ClipboardAttachmentProbed {
|
||||
ctx,
|
||||
image,
|
||||
|
||||
@@ -85,8 +85,6 @@ fn test_app() -> AppView {
|
||||
pending_notification_escapes: None,
|
||||
deferred_notification: None,
|
||||
tracing_rx: None,
|
||||
changelog_markdown: None,
|
||||
changelog_bullets: Vec::new(),
|
||||
tips: Vec::new(),
|
||||
tip: None,
|
||||
cli_model_override: None,
|
||||
@@ -143,7 +141,6 @@ fn test_app() -> AppView {
|
||||
welcome_tip_typing_dismissed: false,
|
||||
welcome_menu_index: None,
|
||||
welcome_menu_rects: Vec::new(),
|
||||
welcome_show_changelog_action: false,
|
||||
welcome_import_banner_rect: None,
|
||||
last_mouse_pos: None,
|
||||
last_scroll_pos: None,
|
||||
@@ -151,9 +148,7 @@ fn test_app() -> AppView {
|
||||
welcome_prompt_rect: None,
|
||||
welcome_auth_url_rect: None,
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
session_picker_entries: None,
|
||||
|
||||
@@ -1712,27 +1712,6 @@ pub(crate) fn execute(
|
||||
TaskResult::PromptImagePreviewPrepared
|
||||
});
|
||||
}
|
||||
Effect::FetchChangelog => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let changelog = tokio::task::spawn_blocking(|| {
|
||||
kigi_shell::util::changelog::ChangelogManager::new()
|
||||
.fetch()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = % e, "changelog fetch task failed");
|
||||
kigi_shell::util::changelog::Changelog {
|
||||
markdown: None,
|
||||
entries: None,
|
||||
}
|
||||
});
|
||||
TaskResult::ChangelogFetched {
|
||||
markdown: changelog.markdown,
|
||||
entries: changelog.entries.unwrap_or_default(),
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::PersistMemoryFullscreen { fullscreen } => {
|
||||
persist_hint(
|
||||
tasks,
|
||||
|
||||
@@ -1136,12 +1136,6 @@ pub(crate) async fn run(
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
return Ok(make_run_result(&app));
|
||||
}
|
||||
// Fetch changelog off the render path so the welcome screen
|
||||
// can display bullets and /release-notes uses the cached result.
|
||||
let effs = vec![super::actions::Effect::FetchChangelog];
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
return Ok(make_run_result(&app));
|
||||
}
|
||||
}
|
||||
|
||||
if !post_render_effects.is_empty()
|
||||
|
||||
@@ -41,7 +41,6 @@ pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod queue;
|
||||
pub mod recap;
|
||||
pub mod release_notes;
|
||||
pub mod remember;
|
||||
pub mod rename;
|
||||
pub mod resume;
|
||||
@@ -121,7 +120,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
||||
Arc::new(usage::UsageCommand),
|
||||
Arc::new(queue::QueueCommand),
|
||||
Arc::new(tasks::TasksCommand),
|
||||
Arc::new(release_notes::ReleaseNotesCommand),
|
||||
Arc::new(config_agents::ConfigAgentsCommand),
|
||||
Arc::new(personas::PersonasCommand),
|
||||
Arc::new(gboom::GboomCommand),
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//! `/release-notes` -- view release notes for the current version.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Show release notes for the current pager version.
|
||||
pub struct ReleaseNotesCommand;
|
||||
|
||||
impl SlashCommand for ReleaseNotesCommand {
|
||||
fn name(&self) -> &str {
|
||||
"release-notes"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &[&str] {
|
||||
&["changelog"]
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"View release notes for the current version"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/release-notes"
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||
let changelog = kigi_shell::util::changelog::ChangelogManager::new().fetch();
|
||||
match changelog.markdown {
|
||||
Some(content) => CommandResult::Action(Action::ShowReleaseNotes {
|
||||
title: "Release Notes".to_string(),
|
||||
content: content.trim().to_string(),
|
||||
}),
|
||||
None => CommandResult::Error("No release notes available (offline).".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_notes_metadata() {
|
||||
let cmd = ReleaseNotesCommand;
|
||||
assert_eq!(cmd.name(), "release-notes");
|
||||
assert_eq!(cmd.aliases(), &["changelog"]);
|
||||
assert!(!cmd.takes_args());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_notes_returns_action_or_error() {
|
||||
let models = crate::acp::model_state::ModelState::default();
|
||||
let mut ctx = super::super::tests::make_ctx(&models);
|
||||
let result = ReleaseNotesCommand.run(&mut ctx, "");
|
||||
assert!(
|
||||
matches!(result, CommandResult::Action(_) | CommandResult::Error(_)),
|
||||
"expected Action or Error, got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Hero box component — side-by-side logo + menu inside a bordered box.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
|
||||
@@ -27,18 +27,11 @@ const HERO_SUBTITLE: &str = "Thanks for trying Kigi, give feedback with /feedbac
|
||||
|
||||
use super::{PROMPT_HEIGHT, VERSION_GAP};
|
||||
|
||||
/// Rows the "thanks" subtitle occupies. Hidden when the in-box info slot
|
||||
/// (changelog) is shown, to keep the box compact.
|
||||
fn subtitle_rows(info_height: u16) -> u16 {
|
||||
if info_height > 0 { 0 } else { 1 }
|
||||
}
|
||||
|
||||
/// Height of the hero box's right column: version + optional subtitle +
|
||||
/// optional info block + the gap before the menu + the menu itself.
|
||||
fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
|
||||
let info_gap = if info_height > 0 { 1u16 } else { 0 };
|
||||
// version(1) + subtitle + [info_gap + info] + gap-before-menu(1) + menu
|
||||
1 + subtitle_rows(info_height) + info_gap + info_height + 1 + menu_height
|
||||
/// Height of the hero box's right column: version + subtitle + the gap
|
||||
/// before the menu + the menu itself.
|
||||
fn right_col_height(menu_height: u16) -> u16 {
|
||||
// version(1) + subtitle(1) + gap-before-menu(1) + menu
|
||||
3 + menu_height
|
||||
}
|
||||
|
||||
/// Minimum content-area height the hero box needs to render without truncating:
|
||||
@@ -46,13 +39,8 @@ fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
|
||||
/// below (tip + prompt + version). The box always shows the full-height logo,
|
||||
/// so a terminal shorter than this falls back to the stacked layout instead of
|
||||
/// overflowing.
|
||||
pub(super) fn min_content_height(
|
||||
error_height: u16,
|
||||
menu_height: u16,
|
||||
tip_height: u16,
|
||||
info_height: u16,
|
||||
) -> u16 {
|
||||
let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height, info_height));
|
||||
pub(super) fn min_content_height(error_height: u16, menu_height: u16, tip_height: u16) -> u16 {
|
||||
let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height));
|
||||
let hero_box_height = 2 + V_PAD * 2 + inner;
|
||||
let gap_after_error = if error_height > 0 { 1u16 } else { 0 };
|
||||
gap_after_error + error_height + hero_box_height + 1 + WelcomeLayout::fixed_below(tip_height)
|
||||
@@ -70,33 +58,26 @@ fn left_col_width() -> u16 {
|
||||
}
|
||||
|
||||
/// Compute the hero box layout: bordered box with logo left, version + menu right.
|
||||
///
|
||||
/// Sizes the in-box info slot here (the fixed `changelog_height`) so the
|
||||
/// renderer just draws into `hero_info`.
|
||||
pub(super) fn compute_hero_box(
|
||||
content_area: Rect,
|
||||
error_height: u16,
|
||||
menu_height: u16,
|
||||
tip_height: u16,
|
||||
changelog_height: u16,
|
||||
) -> WelcomeLayout {
|
||||
let zero = Rect::default();
|
||||
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
|
||||
let fixed_below = WelcomeLayout::fixed_below(tip_height);
|
||||
|
||||
// Column widths are height-independent, so derive them once and reuse for
|
||||
// both the measurement and the rects: `hero_info.width == info_slot_width`,
|
||||
// i.e. measured == drawn.
|
||||
// both the measurement and the rects.
|
||||
let box_width = content_area.width.saturating_sub(6).min(120);
|
||||
let inner_width = box_width.saturating_sub(2);
|
||||
let left_col_width = left_col_width();
|
||||
let right_width = inner_width.saturating_sub(left_col_width);
|
||||
let info_slot_width = right_width.saturating_sub(H_INSET);
|
||||
let info_height = changelog_height;
|
||||
let menu_slot_width = right_width.saturating_sub(H_INSET);
|
||||
|
||||
let logo_rows = super::logo::full_logo_line_count();
|
||||
let info_gap = if info_height > 0 { 1u16 } else { 0 };
|
||||
let inner_height = logo_rows.max(right_col_height(menu_height, info_height));
|
||||
let inner_height = logo_rows.max(right_col_height(menu_height));
|
||||
let hero_box_height = 2 + V_PAD * 2 + inner_height;
|
||||
|
||||
let gap_after_error = if error_height > 0 { 1 } else { 0 };
|
||||
@@ -105,7 +86,7 @@ pub(super) fn compute_hero_box(
|
||||
// Top padding for vertical centering (use the default menu height so the
|
||||
// logo position stays constant regardless of picker/focus state).
|
||||
let default_menu_height = 4u16;
|
||||
let default_inner = logo_rows.max(right_col_height(default_menu_height, info_height));
|
||||
let default_inner = logo_rows.max(right_col_height(default_menu_height));
|
||||
let default_hero = 2 + V_PAD * 2 + default_inner;
|
||||
let remaining = content_area.height.saturating_sub(fixed_above);
|
||||
let top_pad = remaining
|
||||
@@ -190,39 +171,22 @@ pub(super) fn compute_hero_box(
|
||||
height: 1,
|
||||
};
|
||||
|
||||
// Subtitle line below version — hidden when the info slot is shown.
|
||||
let hero_subtitle = if subtitle_rows(info_height) > 0 {
|
||||
Rect {
|
||||
// Subtitle line below version.
|
||||
let hero_subtitle = Rect {
|
||||
x: right_x,
|
||||
y: inner.y + 1,
|
||||
width: right_width,
|
||||
height: 1,
|
||||
}
|
||||
} else {
|
||||
zero
|
||||
};
|
||||
|
||||
// Info block (changelog) below version + optional subtitle.
|
||||
let info_y = inner.y + 1 + subtitle_rows(info_height) + info_gap;
|
||||
let hero_info = if info_height > 0 {
|
||||
Rect {
|
||||
x: right_x,
|
||||
y: info_y,
|
||||
width: info_slot_width,
|
||||
height: info_height,
|
||||
}
|
||||
} else {
|
||||
zero
|
||||
};
|
||||
|
||||
// version + subtitle + info_gap + info + gap-before-menu
|
||||
let right_header_rows = 1 + subtitle_rows(info_height) + info_gap + info_height + 1;
|
||||
// version + subtitle + gap-before-menu
|
||||
let right_header_rows = 3;
|
||||
|
||||
// Menu below the header rows, left-aligned in right column.
|
||||
let hero_menu = Rect {
|
||||
x: right_x,
|
||||
y: inner.y + right_header_rows,
|
||||
width: info_slot_width,
|
||||
width: menu_slot_width,
|
||||
height: menu_height.min(inner.height.saturating_sub(right_header_rows)),
|
||||
};
|
||||
|
||||
@@ -230,7 +194,6 @@ pub(super) fn compute_hero_box(
|
||||
logo: zero,
|
||||
error,
|
||||
menu: zero,
|
||||
changelog: zero,
|
||||
tip,
|
||||
prompt,
|
||||
version: version_slot,
|
||||
@@ -238,26 +201,12 @@ pub(super) fn compute_hero_box(
|
||||
hero_logo,
|
||||
hero_version,
|
||||
hero_subtitle,
|
||||
hero_info,
|
||||
hero_menu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Changelog content shown in the hero box info slot.
|
||||
pub(super) struct ChangelogDisplay<'a> {
|
||||
pub(super) bullets: &'a [String],
|
||||
pub(super) has_full_notes: bool,
|
||||
}
|
||||
|
||||
/// Hit-test rects produced by [`render_hero_box`].
|
||||
pub(super) struct HeroBoxRects {
|
||||
/// Hit-test rect per menu item row (for click/hover).
|
||||
pub(super) menu_rects: Vec<Rect>,
|
||||
/// Clickable changelog info block, if drawn.
|
||||
pub(super) changelog_cta_rect: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Render the bordered hero box with logo left, version + subtitle + menu right.
|
||||
/// Returns the hit-test rect per menu item row (for click/hover).
|
||||
pub(super) fn render_hero_box(
|
||||
layout: &WelcomeLayout,
|
||||
buf: &mut Buffer,
|
||||
@@ -265,8 +214,7 @@ pub(super) fn render_hero_box(
|
||||
menu_items: &[(&str, &str)],
|
||||
selected: Option<usize>,
|
||||
mouse_pos: Option<(u16, u16)>,
|
||||
changelog: ChangelogDisplay<'_>,
|
||||
) -> HeroBoxRects {
|
||||
) -> Vec<Rect> {
|
||||
// Dim the box border toward the background for a softer, dimmer gray.
|
||||
let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
|
||||
.unwrap_or(theme.gray_dim);
|
||||
@@ -289,7 +237,6 @@ pub(super) fn render_hero_box(
|
||||
);
|
||||
|
||||
// Subtitle line below the version.
|
||||
if layout.hero_subtitle.height > 0 {
|
||||
let subtitle_style = Style::default().fg(theme.gray);
|
||||
buf.set_span(
|
||||
layout.hero_subtitle.x,
|
||||
@@ -297,22 +244,8 @@ pub(super) fn render_hero_box(
|
||||
&Span::styled(HERO_SUBTITLE, subtitle_style),
|
||||
layout.hero_subtitle.width,
|
||||
);
|
||||
}
|
||||
|
||||
// In-box info slot: the changelog, always in this same position.
|
||||
let mut changelog_cta_rect = None;
|
||||
if layout.hero_info.height > 0 && !changelog.bullets.is_empty() {
|
||||
changelog_cta_rect = render_hero_changelog(
|
||||
buf,
|
||||
theme,
|
||||
layout.hero_info,
|
||||
changelog.bullets,
|
||||
changelog.has_full_notes,
|
||||
mouse_pos,
|
||||
);
|
||||
}
|
||||
|
||||
let menu_rects = super::menu::render_menu(
|
||||
super::menu::render_menu(
|
||||
layout.hero_menu,
|
||||
buf,
|
||||
theme,
|
||||
@@ -320,58 +253,5 @@ pub(super) fn render_hero_box(
|
||||
selected,
|
||||
mouse_pos,
|
||||
layout.hero_menu.width,
|
||||
);
|
||||
HeroBoxRects {
|
||||
menu_rects,
|
||||
changelog_cta_rect,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the changelog block (header + bullets) in the info slot. When
|
||||
/// `clickable` (full notes exist), the whole block opens the notes on click and
|
||||
/// brightens while hovered; returns that clickable rect.
|
||||
fn render_hero_changelog(
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
area: Rect,
|
||||
bullets: &[String],
|
||||
clickable: bool,
|
||||
mouse_pos: Option<(u16, u16)>,
|
||||
) -> Option<Rect> {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hovered =
|
||||
clickable && mouse_pos.is_some_and(|(mx, my)| area.contains(Position::new(mx, my)));
|
||||
|
||||
let header_style = super::hover_style(
|
||||
theme,
|
||||
hovered,
|
||||
Style::default()
|
||||
.fg(theme.gray_bright)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
let title = "Changelog";
|
||||
buf.set_span(
|
||||
area.x,
|
||||
area.y,
|
||||
&Span::styled(title, header_style),
|
||||
area.width,
|
||||
);
|
||||
|
||||
// Bullets start 2 rows down (header + blank), matching the height budget.
|
||||
let bullet_style = super::hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
|
||||
let max_text_width = area.width.saturating_sub(4) as usize; // " • " prefix + pad
|
||||
for (i, bullet) in bullets.iter().enumerate() {
|
||||
let row = area.y + 2 + i as u16;
|
||||
if row >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
|
||||
let text = format!(" \u{2022} {truncated}");
|
||||
buf.set_span(area.x, row, &Span::styled(text, bullet_style), area.width);
|
||||
}
|
||||
|
||||
clickable.then_some(area)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! - Bottom margin
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Position, Rect};
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
|
||||
@@ -56,22 +56,12 @@ fn quit_hint_spans(theme: &Theme) -> Vec<Span<'static>> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Style for a clickable welcome block: bright primary while `hovered`, else
|
||||
/// `base`. Shared by the changelog renderer.
|
||||
pub(super) fn hover_style(theme: &Theme, hovered: bool, base: Style) -> Style {
|
||||
if hovered {
|
||||
Style::default().fg(theme.text_primary)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal margin (left and right) in normal mode.
|
||||
const H_MARGIN: u16 = 2;
|
||||
/// Horizontal margin in compact mode.
|
||||
const H_MARGIN_COMPACT: u16 = 1;
|
||||
|
||||
/// Minimum width for menu + changelog sections so they don't resize when the import row toggles.
|
||||
/// Minimum width for the menu section so it doesn't resize when the import row toggles.
|
||||
/// Derivation: "[ " (2) + import-claude label (22) + gap (4) + "ctrl+i [x]" (11) + " ]" (2) = 41.
|
||||
/// Bumped to 51 for comfortable breathing room.
|
||||
const MENU_MIN_WIDTH: u16 = 51;
|
||||
@@ -103,13 +93,6 @@ pub struct WelcomeRenderResult {
|
||||
pub auth_url_rect: Option<Rect>,
|
||||
/// Hit-test rect for the "show full URL" fallback link.
|
||||
pub auth_fallback_rect: Option<Rect>,
|
||||
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
|
||||
/// Whether a "Changelog" menu action was rendered (above Quit), so the
|
||||
/// input handler can map the extra menu row to the release-notes action
|
||||
/// once markdown is available.
|
||||
pub changelog_action_present: bool,
|
||||
/// Hit-test rect for the clickable changelog info block (opens release notes).
|
||||
pub changelog_cta_rect: Option<Rect>,
|
||||
}
|
||||
|
||||
use hero_box::HERO_BOX_MIN_WIDTH;
|
||||
@@ -124,9 +107,6 @@ pub(super) struct WelcomeLayout {
|
||||
pub(super) logo: Rect,
|
||||
pub(super) error: Rect,
|
||||
pub(super) menu: Rect,
|
||||
/// Stacked info slot below the menu (narrow layout only) — shows the
|
||||
/// changelog. Zero in the hero box layout, which uses `hero_info` instead.
|
||||
pub(super) changelog: Rect,
|
||||
pub(super) tip: Rect,
|
||||
pub(super) prompt: Rect,
|
||||
pub(super) version: Rect,
|
||||
@@ -135,8 +115,6 @@ pub(super) struct WelcomeLayout {
|
||||
pub(super) hero_logo: Rect,
|
||||
pub(super) hero_version: Rect,
|
||||
pub(super) hero_subtitle: Rect,
|
||||
/// In-box info slot — shows the changelog.
|
||||
pub(super) hero_info: Rect,
|
||||
pub(super) hero_menu: Rect,
|
||||
}
|
||||
|
||||
@@ -151,9 +129,7 @@ struct WelcomeLayoutInput {
|
||||
error_height: u16,
|
||||
menu_height: u16,
|
||||
tip_height: u16,
|
||||
/// Desired changelog height (collapsed to 0 if the terminal is too short).
|
||||
changelog_height: u16,
|
||||
/// Vertical compaction (session picker visible): skip the logo + info slot.
|
||||
/// Vertical compaction (session picker visible): skip the logo.
|
||||
compact: bool,
|
||||
/// Horizontal-inset compaction (appearance setting) for the stacked slot.
|
||||
prompt_compact: bool,
|
||||
@@ -170,22 +146,6 @@ impl WelcomeLayout {
|
||||
tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1
|
||||
}
|
||||
|
||||
pub(super) fn effective_changelog(
|
||||
content_height: u16,
|
||||
fixed_above: u16,
|
||||
content_slot: u16,
|
||||
fixed_below: u16,
|
||||
requested: u16,
|
||||
) -> (u16, u16) {
|
||||
let gap = if requested > 0 { 1u16 } else { 0 };
|
||||
let min_without = fixed_above + content_slot + 1 + fixed_below;
|
||||
if requested > 0 && content_height >= min_without + gap + requested {
|
||||
(requested, 1)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the welcome screen layout, allowing the wide hero-box variant.
|
||||
fn compute(input: WelcomeLayoutInput) -> Self {
|
||||
Self::compute_inner(input, true)
|
||||
@@ -203,17 +163,14 @@ impl WelcomeLayout {
|
||||
|
||||
/// Compute the welcome screen layout.
|
||||
///
|
||||
/// Picks hero vs stacked, then measures the info slot (the changelog) at
|
||||
/// that layout's slot width before placing rects — width is
|
||||
/// content-size-only, so it's a clean two-phase computation. `allow_hero_box`
|
||||
/// gates the wide variant; stacked-only callers pass `false`.
|
||||
/// Picks hero vs stacked. `allow_hero_box` gates the wide variant;
|
||||
/// stacked-only callers pass `false`.
|
||||
fn compute_inner(input: WelcomeLayoutInput, allow_hero_box: bool) -> Self {
|
||||
let WelcomeLayoutInput {
|
||||
content_area,
|
||||
error_height,
|
||||
menu_height,
|
||||
tip_height,
|
||||
changelog_height,
|
||||
compact,
|
||||
prompt_compact,
|
||||
} = input;
|
||||
@@ -224,26 +181,12 @@ impl WelcomeLayout {
|
||||
&& content_area.width >= HERO_BOX_MIN_WIDTH
|
||||
&& menu_height > 0
|
||||
&& content_area.height
|
||||
>= hero_box::min_content_height(
|
||||
error_height,
|
||||
menu_height,
|
||||
tip_height,
|
||||
changelog_height,
|
||||
);
|
||||
>= hero_box::min_content_height(error_height, menu_height, tip_height);
|
||||
|
||||
if use_hero_box {
|
||||
return hero_box::compute_hero_box(
|
||||
content_area,
|
||||
error_height,
|
||||
menu_height,
|
||||
tip_height,
|
||||
changelog_height,
|
||||
);
|
||||
return hero_box::compute_hero_box(content_area, error_height, menu_height, tip_height);
|
||||
}
|
||||
|
||||
// Stacked info slot: the changelog.
|
||||
let info_height = changelog_height;
|
||||
|
||||
// Stacked layout: skip the logo in compact mode (the session picker
|
||||
// needs the space); otherwise pick small/full/none by height.
|
||||
let logo_rows = if compact {
|
||||
@@ -256,19 +199,6 @@ impl WelcomeLayout {
|
||||
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
|
||||
let fixed_below = Self::fixed_below(tip_height);
|
||||
let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo
|
||||
// The stacked info slot below the menu holds the changelog.
|
||||
let (eff_changelog_height, _) = if !compact {
|
||||
Self::effective_changelog(
|
||||
content_area.height,
|
||||
fixed_above,
|
||||
menu_height,
|
||||
fixed_below,
|
||||
info_height,
|
||||
)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let eff_changelog_gap = if eff_changelog_height > 0 { 1u16 } else { 0 };
|
||||
// Compute top_pad using the *default* menu height (4 items = 7 rows) so
|
||||
// the logo position stays constant regardless of picker/focus state.
|
||||
let top_pad = if compact {
|
||||
@@ -278,36 +208,18 @@ impl WelcomeLayout {
|
||||
let remaining = content_area.height.saturating_sub(fixed_above);
|
||||
remaining
|
||||
.saturating_sub(default_menu_height)
|
||||
.saturating_sub(eff_changelog_gap + eff_changelog_height)
|
||||
.saturating_sub(fixed_below)
|
||||
/ 3
|
||||
};
|
||||
let logo_gap = 1u16;
|
||||
let flex_gap = 1u16;
|
||||
let [
|
||||
_,
|
||||
logo,
|
||||
_,
|
||||
_,
|
||||
error,
|
||||
menu,
|
||||
_,
|
||||
changelog,
|
||||
_,
|
||||
tip,
|
||||
_,
|
||||
prompt,
|
||||
_,
|
||||
version,
|
||||
] = Layout::vertical([
|
||||
let [_, logo, _, _, error, menu, _, tip, _, prompt, _, version] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_rows),
|
||||
Constraint::Length(logo_gap), // gap after logo
|
||||
Constraint::Length(gap_after_logo),
|
||||
Constraint::Length(error_height),
|
||||
Constraint::Length(menu_height),
|
||||
Constraint::Length(eff_changelog_gap),
|
||||
Constraint::Length(eff_changelog_height),
|
||||
Constraint::Min(flex_gap),
|
||||
Constraint::Length(tip_height),
|
||||
Constraint::Length(tip_gap),
|
||||
@@ -320,7 +232,6 @@ impl WelcomeLayout {
|
||||
logo,
|
||||
error,
|
||||
menu,
|
||||
changelog,
|
||||
tip,
|
||||
prompt,
|
||||
version,
|
||||
@@ -328,7 +239,6 @@ impl WelcomeLayout {
|
||||
hero_logo: zero,
|
||||
hero_version: zero,
|
||||
hero_subtitle: zero,
|
||||
hero_info: zero,
|
||||
hero_menu: zero,
|
||||
}
|
||||
}
|
||||
@@ -566,10 +476,6 @@ pub struct WelcomeRenderParams<'a> {
|
||||
/// Live working directory (tracks `Effect::SetWorkingDir`), used to pin
|
||||
/// the current repo's session group to the top of the picker.
|
||||
pub cwd: &'a std::path::Path,
|
||||
/// Cached changelog bullets for the welcome screen (up to 3).
|
||||
pub changelog_bullets: &'a [String],
|
||||
/// Whether full release notes markdown is available (controls the CTA hint).
|
||||
pub changelog_has_full_notes: bool,
|
||||
}
|
||||
|
||||
/// Render the welcome screen.
|
||||
@@ -642,8 +548,6 @@ pub fn render_welcome(
|
||||
import_banner_rect: None,
|
||||
auth_url_rect: None,
|
||||
auth_fallback_rect: None,
|
||||
changelog_action_present: false,
|
||||
changelog_cta_rect: None,
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating { auth_url, mode, .. } => {
|
||||
@@ -668,8 +572,6 @@ pub fn render_welcome(
|
||||
import_banner_rect: None,
|
||||
auth_url_rect: url_rect,
|
||||
auth_fallback_rect: fallback_rect,
|
||||
changelog_action_present: false,
|
||||
changelog_cta_rect: None,
|
||||
}
|
||||
}
|
||||
// Folder-trust question: shown after auth, before any session is
|
||||
@@ -1450,73 +1352,6 @@ fn inset_horizontal(rect: Rect, inset: u16) -> Rect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the changelog section (header + bullets), centered to the menu width.
|
||||
/// When `clickable` (full notes exist) the whole block opens the notes on click
|
||||
/// and brightens while hovered; returns that clickable rect.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_changelog_section(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
bullets: &[String],
|
||||
min_width_hint: u16,
|
||||
content_height: u16,
|
||||
clickable: bool,
|
||||
mouse_pos: Option<(u16, u16)>,
|
||||
) -> Option<Rect> {
|
||||
let menu_width = logo::logo_visual_width(content_height)
|
||||
.max(30)
|
||||
.max(min_width_hint);
|
||||
let [_, centered, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(menu_width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(area);
|
||||
|
||||
if centered.width < 20 || centered.height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hovered =
|
||||
clickable && mouse_pos.is_some_and(|(mx, my)| centered.contains(Position::new(mx, my)));
|
||||
|
||||
let header_style = hover_style(
|
||||
theme,
|
||||
hovered,
|
||||
Style::default()
|
||||
.fg(theme.gray_bright)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
let title = "Changelog";
|
||||
buf.set_span(
|
||||
centered.x,
|
||||
centered.y,
|
||||
&Span::styled(title, header_style),
|
||||
centered.width,
|
||||
);
|
||||
|
||||
let bullet_style = hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
|
||||
let max_text_width = centered.width.saturating_sub(2) as usize; // "• " prefix = 2 cols
|
||||
for (i, bullet) in bullets.iter().enumerate() {
|
||||
let row = centered.y + 2 + i as u16;
|
||||
if row >= centered.y + centered.height {
|
||||
break;
|
||||
}
|
||||
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
|
||||
let text = format!("\u{2022} {truncated}");
|
||||
buf.set_span(
|
||||
centered.x,
|
||||
row,
|
||||
&Span::styled(text, bullet_style),
|
||||
centered.width,
|
||||
);
|
||||
}
|
||||
|
||||
clickable.then_some(centered)
|
||||
}
|
||||
|
||||
/// Render the normal welcome screen (Done state -- already authenticated).
|
||||
fn render_welcome_done(
|
||||
content_area: Rect,
|
||||
@@ -1535,8 +1370,6 @@ fn render_welcome_done(
|
||||
|
||||
let in_vscode_family = welcome_in_vscode_family();
|
||||
|
||||
// Heights that don't depend on the menu — computed first so the menu
|
||||
// builder can probe the layout to decide whether to add a Changelog row.
|
||||
// Startup-warning hint height (multi-line aware).
|
||||
let hint_height = p.startup_warnings.first().map_or(0u16, |w| {
|
||||
let msg_lines = w.message.lines().count() as u16;
|
||||
@@ -1558,14 +1391,6 @@ fn render_welcome_done(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let changelog_height = if !show_picker && !p.changelog_bullets.is_empty() {
|
||||
2 + p.changelog_bullets.len() as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Changelog is reachable via this menu row (ctrl+l). Show from the first
|
||||
// frame so the menu doesn't shift while the CDN fetch completes.
|
||||
let show_changelog_action = !show_picker;
|
||||
|
||||
let owned_menu;
|
||||
let menu_items: &[(&str, &str)] = {
|
||||
@@ -1577,7 +1402,7 @@ fn render_welcome_done(
|
||||
);
|
||||
// Insert the import row at the top when there are pending `.claude/`
|
||||
// settings to import — it's the most actionable item right now.
|
||||
let mut items: Vec<(&str, &str)> = Vec::with_capacity(5);
|
||||
let mut items: Vec<(&str, &str)> = Vec::with_capacity(4);
|
||||
if p.has_claude_import {
|
||||
// The trailing "[x]" is a clickable dismiss affordance — the
|
||||
// welcome screen mouse handler treats clicks on the rightmost
|
||||
@@ -1588,10 +1413,6 @@ fn render_welcome_done(
|
||||
}
|
||||
items.push((key_w, "New worktree"));
|
||||
items.push((key_s, "Resume session"));
|
||||
// "Changelog" above Quit; no shortcut — opened by click (row or block).
|
||||
if show_changelog_action {
|
||||
items.push(("", "Changelog"));
|
||||
}
|
||||
items.push((key_q, "Quit"));
|
||||
owned_menu = items;
|
||||
owned_menu.as_slice()
|
||||
@@ -1620,7 +1441,6 @@ fn render_welcome_done(
|
||||
error_height: hint_height,
|
||||
menu_height: content_height,
|
||||
tip_height,
|
||||
changelog_height,
|
||||
compact: welcome_compact,
|
||||
prompt_compact: p.compact,
|
||||
});
|
||||
@@ -1628,9 +1448,6 @@ fn render_welcome_done(
|
||||
// Render startup warning in the error area (same slot as auth errors).
|
||||
let import_banner_rect = render_startup_warnings(layout.error, buf, theme, p.startup_warnings);
|
||||
|
||||
// Hit-rects, set by whichever layout draws each block.
|
||||
let mut changelog_cta_rect: Option<Rect> = None;
|
||||
|
||||
let (menu_rects, picker_close_button) = if show_picker {
|
||||
// Use the full area since logo/menu are hidden and shortcuts
|
||||
// are now rendered inside the picker content area.
|
||||
@@ -1663,20 +1480,9 @@ fn render_welcome_done(
|
||||
(vec![], Some(hit_areas))
|
||||
} else if layout.has_hero_box() {
|
||||
// Wide layout: render bordered hero box with logo left, version + menu right.
|
||||
let rects = hero_box::render_hero_box(
|
||||
&layout,
|
||||
buf,
|
||||
theme,
|
||||
menu_items,
|
||||
p.selected,
|
||||
p.mouse_pos,
|
||||
hero_box::ChangelogDisplay {
|
||||
bullets: p.changelog_bullets,
|
||||
has_full_notes: p.changelog_has_full_notes,
|
||||
},
|
||||
);
|
||||
changelog_cta_rect = rects.changelog_cta_rect;
|
||||
(rects.menu_rects, None)
|
||||
let menu_rects =
|
||||
hero_box::render_hero_box(&layout, buf, theme, menu_items, p.selected, p.mouse_pos);
|
||||
(menu_rects, None)
|
||||
} else {
|
||||
// Narrow layout: stacked logo above, menu below. Inset the menu the
|
||||
// same as the input bar (`prompt_inset`) so it keeps side spacing
|
||||
@@ -1697,23 +1503,6 @@ fn render_welcome_done(
|
||||
)
|
||||
};
|
||||
|
||||
// Stacked info slot below the menu (narrow layout): show the changelog,
|
||||
// mirroring the hero box. Inset to match the input bar so it lines up with
|
||||
// the menu above.
|
||||
if layout.changelog.height > 0 {
|
||||
let info_area = inset_horizontal(layout.changelog, prompt::prompt_inset(p.compact));
|
||||
changelog_cta_rect = render_changelog_section(
|
||||
info_area,
|
||||
buf,
|
||||
theme,
|
||||
p.changelog_bullets,
|
||||
MENU_MIN_WIDTH,
|
||||
content_area.height,
|
||||
p.changelog_has_full_notes,
|
||||
p.mouse_pos,
|
||||
);
|
||||
}
|
||||
|
||||
// Skip the prompt input when picker is visible to save space;
|
||||
// shortcuts are rendered inside the picker content area.
|
||||
let (cursor_pos, post_flush_escapes) = if show_picker {
|
||||
@@ -1840,8 +1629,6 @@ fn render_welcome_done(
|
||||
import_banner_rect,
|
||||
auth_url_rect: None,
|
||||
auth_fallback_rect: None,
|
||||
changelog_action_present: show_changelog_action,
|
||||
changelog_cta_rect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2278,8 +2065,6 @@ mod tests {
|
||||
session_picker_source_filter: crate::views::session_picker::SourceFilter::All,
|
||||
chat_mode: false,
|
||||
cwd: std::path::Path::new("/repo"),
|
||||
changelog_bullets: &[],
|
||||
changelog_has_full_notes: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2747,106 +2532,6 @@ mod tests {
|
||||
assert_eq!(state.query, "e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_hidden_on_short_terminal() {
|
||||
let area = Rect::new(0, 0, 80, 15);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_shown_on_tall_terminal() {
|
||||
let area = Rect::new(0, 0, 80, 50);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_hidden_when_compact() {
|
||||
let area = Rect::new(0, 0, 80, 60);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
compact: true,
|
||||
prompt_compact: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_hidden_when_zero_requested() {
|
||||
let area = Rect::new(0, 0, 80, 60);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_boundary_exact_fit() {
|
||||
// No logo at h < 22. fixed_above = 0 + 1 + 0 + 0 = 1.
|
||||
// fixed_below = 0 (tip) + 0 (tip_gap) + 3 (prompt) + 1 (ver_gap) + 1 (ver) = 5.
|
||||
// min_without_changelog = 1 + 4 (menu) + 1 (flex) + 5 = 11.
|
||||
// changelog slot = 1 (gap) + 5 (height) = 6. Threshold = 11 + 6 = 17.
|
||||
let just_fits = Rect::new(0, 0, 80, 17);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: just_fits,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 5);
|
||||
|
||||
let too_short = Rect::new(0, 0, 80, 16);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: too_short,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_hidden_when_tip_steals_space() {
|
||||
// Use narrow width to avoid hero box path, keeping stacked layout.
|
||||
// With tip_height=2: fixed_below(2) = 8. min = 1 + 4 + 1 + 8 = 14.
|
||||
// Threshold = 14 + 6 = 20. At h=19 the tip pushes changelog out.
|
||||
let with_tip = Rect::new(0, 0, 60, 19);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: with_tip,
|
||||
menu_height: 4,
|
||||
tip_height: 2,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
|
||||
// Same size without tip: threshold = 17 <= 19, changelog fits.
|
||||
let without_tip = Rect::new(0, 0, 60, 19);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: without_tip,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hero_box_active_on_wide_tall_terminal() {
|
||||
// 90 cols, 50 rows: meets the minimum for the hero box.
|
||||
@@ -2866,6 +2551,7 @@ mod tests {
|
||||
assert!(layout.hero_logo.height > 0);
|
||||
assert!(layout.hero_menu.height > 0);
|
||||
assert_eq!(layout.hero_version.height, 1);
|
||||
assert_eq!(layout.hero_subtitle.height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3056,53 +2742,6 @@ mod tests {
|
||||
assert_eq!(layout.hero_logo.y, layout.hero_box.y + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hero_box_with_changelog() {
|
||||
// The changelog renders inside the box (info slot), not in a
|
||||
// separate area below it.
|
||||
let area = Rect::new(0, 0, 100, 50);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 3,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(layout.has_hero_box());
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
assert_eq!(layout.hero_info.height, 5);
|
||||
// The subtitle is hidden when the info slot is shown.
|
||||
assert_eq!(layout.hero_subtitle.height, 0);
|
||||
assert!(layout.hero_info.y > layout.hero_version.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hero_box_keeps_one_bottom_pad_below_actions() {
|
||||
// With a changelog the subtitle is hidden, but there's still exactly
|
||||
// one padding row between the actions and the bottom border.
|
||||
// (menu=4 + info=3 fills the inner, so the menu reaches the pad.)
|
||||
let area = Rect::new(0, 0, 100, 50);
|
||||
let no_info = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
..Default::default()
|
||||
});
|
||||
let with_info = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
changelog_height: 3,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(no_info.hero_subtitle.height, 1);
|
||||
assert_eq!(with_info.hero_subtitle.height, 0);
|
||||
let menu_bottom = with_info.hero_menu.y + with_info.hero_menu.height;
|
||||
let border_bottom = with_info.hero_box.y + with_info.hero_box.height - 1;
|
||||
assert_eq!(
|
||||
border_bottom - menu_bottom,
|
||||
1,
|
||||
"one pad row below the actions"
|
||||
);
|
||||
}
|
||||
|
||||
/// Flatten a rendered buffer into one string for substring assertions.
|
||||
fn buffer_text(buf: &Buffer) -> String {
|
||||
let area = *buf.area();
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: release-notes-scroll
|
||||
description: >
|
||||
Open /release-notes (DocViewer modal) and verify keyboard + mouse-wheel scrolling
|
||||
moves the changelog body. Seeds CHANGELOG cache offline via setup is not available
|
||||
in YAML, so this scenario relies on CDN/cache if present and still asserts the
|
||||
modal chrome + scroll affordances. Prefer the pty_e2e Rust tests for deterministic
|
||||
offline seeding; this YAML exercises the scripted ptyctl runner path.
|
||||
terminal:
|
||||
rows: 40
|
||||
cols: 110
|
||||
mock:
|
||||
response: "SCRIPTED_RELEASE_NOTES_SCROLL unused for this scenario."
|
||||
steps:
|
||||
- action: wait_for_text
|
||||
text: Quit
|
||||
timeout_ms: 20000
|
||||
# Promote welcome → session and open release notes (uses CDN or disk cache).
|
||||
- action: type_text
|
||||
text: "/release-notes"
|
||||
- action: keys
|
||||
keys: "<Enter>"
|
||||
- action: wait_for_text
|
||||
text: Release Notes
|
||||
timeout_ms: 20000
|
||||
- action: assert_contains
|
||||
text: scroll
|
||||
# Keyboard scroll through the modal body.
|
||||
- action: keys
|
||||
keys: "<Down><Down><Down><Down><Down><Down><Down><Down><Down><Down>"
|
||||
- action: wait
|
||||
millis: 200
|
||||
- action: keys
|
||||
keys: "jjjjjjjjjj"
|
||||
- action: wait
|
||||
millis: 200
|
||||
# Mouse wheel at the center of the modal.
|
||||
- action: scroll
|
||||
row: 20
|
||||
col: 55
|
||||
direction: down
|
||||
count: 15
|
||||
- action: wait
|
||||
millis: 250
|
||||
- action: assert_contains
|
||||
text: Release Notes
|
||||
- action: assert_not_contains
|
||||
text: panicked
|
||||
- action: keys
|
||||
keys: "<Esc>"
|
||||
- action: screenshot
|
||||
name: release-notes-after-scroll
|
||||
note: Release notes modal after keyboard + wheel scroll.
|
||||
@@ -64,12 +64,6 @@ async fn scripted_slash_resize_storm() {
|
||||
run_scenario("slash_resize_storm.yaml").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn scripted_release_notes_scroll() {
|
||||
run_scenario("release_notes_scroll.yaml").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn scripted_mock_response() {
|
||||
|
||||
Reference in New Issue
Block a user