feat(tui): show github release notes once after upgrading
The first time a user launches a newer zot binary, the tui pops
a dismissible overlay with the release notes for that version.
Press any key to close; the version goes into config.json's
last_changelog_shown so the same notes never reappear.
Lifecycle:
- dev builds (version "" / "dev" / "0.0.0"): no fetch ever
- first-ever launch (no LastChangelogShown stored): seed it
silently with the current version so fresh installs don't
get release notes dumped at them
- subsequent launches with the same version: skipped (config
already records that version was shown)
- launch with a different version: fetch the release page from
https://api.github.com/repos/patriceckhart/zot/releases/tags/v<ver>
and open the dialog if the body is non-empty
- dismiss writes LastChangelogShown so it never repeats
Components:
- internal/agent/changelog.go: FetchChangelog/Async, and the
Should/Mark/Seed helpers around config.LastChangelogShown.
Honours $GITHUB_TOKEN exactly like the install scripts and
the existing update check, so private-repo fetches work
with auth.
- internal/agent/modes/changelog_dialog.go: the overlay.
Markdown body via the existing RenderMarkdown pipeline,
scrollable with up/down/pgup/pgdn, any other key dismisses.
- internal/agent/modes/interactive.go: new ChangelogChan and
OnChangelogDismiss config fields, single-shot select case
in Run() that opens the dialog when a payload arrives.
- internal/agent/cli.go: spawns the fetch goroutine, gates it
on ShouldShowChangelog, wires OnChangelogDismiss to
MarkChangelogShown so the version is persisted.
Best-effort: timeouts at 4s, missing tag => silent skip, network
failure => silent skip + retry on next launch (no
LastChangelogShown update if we never showed anything).
Documented in the README under the SYSTEM.md note.- commit
- b9e7517149086a88cc400f130ea4d222a53b9845
- author
- patriceckhart · · 2026-04-19 14:12 UTC
- parent
- fbad128
- signature
- unsigned
- refs
- tag: v0.0.33
6 files · +351 -22
M README.md +4 -0
| @@ -108,6 +108,10 @@ $ZOT_HOME/ | |||
| 108 | 108 | ||
| 109 | 109 | drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity + guidelines for every run. `--system-prompt` still wins per-invocation. delete the file to revert to the default. | |
| 110 | 110 | ||
| 111 | + | ## changelog on update | |
| 112 | + | ||
| 113 | + | the first time you launch a newer zot binary, the tui shows the github release notes once in a dismissible overlay. press any key to close; the version is recorded in `config.json`'s `last_changelog_shown` so the same release notes never reappear. fresh installs don't see a changelog (no upgrade has happened yet). the fetch is best-effort: a network failure or a missing release page silently skips, with another attempt on the next launch. | |
| 114 | + | ||
| 111 | 115 | ## usage | |
| 112 | 116 | ||
| 113 | 117 | ```bash | |
| @@ -108,6 +108,10 @@ $ZOT_HOME/ | |||||
| 108 | 108 | ||||
| 109 | drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity + guidelines for every run. `--system-prompt` still wins per-invocation. delete the file to revert to the default. | 109 | drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity + guidelines for every run. `--system-prompt` still wins per-invocation. delete the file to revert to the default. | ||
| 110 | 110 | ||||
| 111 | + | ## changelog on update | |||
| 112 | + | ||||
| 113 | + | the first time you launch a newer zot binary, the tui shows the github release notes once in a dismissible overlay. press any key to close; the version is recorded in `config.json`'s `last_changelog_shown` so the same release notes never reappear. fresh installs don't see a changelog (no upgrade has happened yet). the fetch is best-effort: a network failure or a missing release page silently skips, with another attempt on the next launch. | |||
| 114 | + | ||||
| 111 | ## usage | 115 | ## usage | ||
| 112 | 116 | ||||
| 113 | ```bash | 117 | ```bash | ||
A internal/agent/changelog.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ChangelogInfo is what FetchChangelog returns. Body is the markdown | |
| 14 | + | // from the GitHub release page; URL points back to that page so the | |
| 15 | + | // dialog can offer "open in browser". | |
| 16 | + | type ChangelogInfo struct { | |
| 17 | + | Version string | |
| 18 | + | Body string | |
| 19 | + | URL string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // FetchChangelog hits the GitHub releases API for the given version | |
| 23 | + | // (must already include the leading "v") and returns the release | |
| 24 | + | // notes body. Returns an empty ChangelogInfo on any failure or when | |
| 25 | + | // the body is empty; the caller treats either as "skip silently". | |
| 26 | + | // | |
| 27 | + | // Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so | |
| 28 | + | // startup never blocks on a flaky network. | |
| 29 | + | func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { | |
| 30 | + | if version == "" || version == "dev" || version == "0.0.0" { | |
| 31 | + | return ChangelogInfo{}, nil | |
| 32 | + | } | |
| 33 | + | tag := version | |
| 34 | + | if !strings.HasPrefix(tag, "v") { | |
| 35 | + | tag = "v" + tag | |
| 36 | + | } | |
| 37 | + | url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |
| 38 | + | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 39 | + | if err != nil { | |
| 40 | + | return ChangelogInfo{}, err | |
| 41 | + | } | |
| 42 | + | req.Header.Set("accept", "application/vnd.github+json") | |
| 43 | + | req.Header.Set("x-github-api-version", "2022-11-28") | |
| 44 | + | if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { | |
| 45 | + | req.Header.Set("authorization", "Bearer "+tok) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | client := &http.Client{Timeout: 4 * time.Second} | |
| 49 | + | resp, err := client.Do(req) | |
| 50 | + | if err != nil { | |
| 51 | + | return ChangelogInfo{}, err | |
| 52 | + | } | |
| 53 | + | defer resp.Body.Close() | |
| 54 | + | if resp.StatusCode != 200 { | |
| 55 | + | return ChangelogInfo{}, fmt.Errorf("github api %d", resp.StatusCode) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var body struct { | |
| 59 | + | TagName string `json:"tag_name"` | |
| 60 | + | HTMLURL string `json:"html_url"` | |
| 61 | + | Body string `json:"body"` | |
| 62 | + | } | |
| 63 | + | if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { | |
| 64 | + | return ChangelogInfo{}, err | |
| 65 | + | } | |
| 66 | + | body.Body = strings.TrimSpace(body.Body) | |
| 67 | + | if body.Body == "" { | |
| 68 | + | return ChangelogInfo{}, nil | |
| 69 | + | } | |
| 70 | + | return ChangelogInfo{ | |
| 71 | + | Version: strings.TrimPrefix(body.TagName, "v"), | |
| 72 | + | Body: body.Body, | |
| 73 | + | URL: body.HTMLURL, | |
| 74 | + | }, nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // FetchChangelogAsync runs FetchChangelog on a goroutine and delivers | |
| 78 | + | // the result on the returned channel. Channel always closes. | |
| 79 | + | func FetchChangelogAsync(version string) <-chan ChangelogInfo { | |
| 80 | + | ch := make(chan ChangelogInfo, 1) | |
| 81 | + | go func() { | |
| 82 | + | defer close(ch) | |
| 83 | + | ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) | |
| 84 | + | defer cancel() | |
| 85 | + | info, _ := FetchChangelog(ctx, version) | |
| 86 | + | ch <- info | |
| 87 | + | }() | |
| 88 | + | return ch | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // ShouldShowChangelog reports whether the running binary version | |
| 92 | + | // differs from the last version whose changelog the user dismissed. | |
| 93 | + | // Returns false on dev builds (version "" / "dev" / "0.0.0") and on | |
| 94 | + | // the first-ever launch (no LastChangelogShown stored — we don't | |
| 95 | + | // dump release notes at someone who just installed). | |
| 96 | + | func ShouldShowChangelog(currentVersion string, cfg Config) bool { | |
| 97 | + | if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { | |
| 98 | + | return false | |
| 99 | + | } | |
| 100 | + | if cfg.LastChangelogShown == "" { | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | return cfg.LastChangelogShown != currentVersion | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // MarkChangelogShown persists the version whose changelog the user | |
| 107 | + | // just dismissed. Idempotent; safe to call when the dialog wasn't | |
| 108 | + | // actually shown (e.g. fetch failed) so we don't keep retrying. | |
| 109 | + | func MarkChangelogShown(version string) error { | |
| 110 | + | cfg, _ := LoadConfig() | |
| 111 | + | if cfg.LastChangelogShown == version { | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | cfg.LastChangelogShown = version | |
| 115 | + | return SaveConfig(cfg) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // SeedChangelogVersion sets LastChangelogShown if it's currently | |
| 119 | + | // empty. Called once on first-ever launch so future upgrades | |
| 120 | + | // correctly trigger the dialog while THIS launch (which is also | |
| 121 | + | // "first-ever") doesn't. | |
| 122 | + | func SeedChangelogVersion(version string) { | |
| 123 | + | if version == "" || version == "dev" || version == "0.0.0" { | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | cfg, err := LoadConfig() | |
| 127 | + | if err != nil { | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | if cfg.LastChangelogShown != "" { | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | cfg.LastChangelogShown = version | |
| 134 | + | _ = SaveConfig(cfg) | |
| 135 | + | } | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "os" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // ChangelogInfo is what FetchChangelog returns. Body is the markdown | |||
| 14 | + | // from the GitHub release page; URL points back to that page so the | |||
| 15 | + | // dialog can offer "open in browser". | |||
| 16 | + | type ChangelogInfo struct { | |||
| 17 | + | Version string | |||
| 18 | + | Body string | |||
| 19 | + | URL string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // FetchChangelog hits the GitHub releases API for the given version | |||
| 23 | + | // (must already include the leading "v") and returns the release | |||
| 24 | + | // notes body. Returns an empty ChangelogInfo on any failure or when | |||
| 25 | + | // the body is empty; the caller treats either as "skip silently". | |||
| 26 | + | // | |||
| 27 | + | // Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so | |||
| 28 | + | // startup never blocks on a flaky network. | |||
| 29 | + | func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { | |||
| 30 | + | if version == "" || version == "dev" || version == "0.0.0" { | |||
| 31 | + | return ChangelogInfo{}, nil | |||
| 32 | + | } | |||
| 33 | + | tag := version | |||
| 34 | + | if !strings.HasPrefix(tag, "v") { | |||
| 35 | + | tag = "v" + tag | |||
| 36 | + | } | |||
| 37 | + | url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |||
| 38 | + | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return ChangelogInfo{}, err | |||
| 41 | + | } | |||
| 42 | + | req.Header.Set("accept", "application/vnd.github+json") | |||
| 43 | + | req.Header.Set("x-github-api-version", "2022-11-28") | |||
| 44 | + | if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { | |||
| 45 | + | req.Header.Set("authorization", "Bearer "+tok) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | client := &http.Client{Timeout: 4 * time.Second} | |||
| 49 | + | resp, err := client.Do(req) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return ChangelogInfo{}, err | |||
| 52 | + | } | |||
| 53 | + | defer resp.Body.Close() | |||
| 54 | + | if resp.StatusCode != 200 { | |||
| 55 | + | return ChangelogInfo{}, fmt.Errorf("github api %d", resp.StatusCode) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var body struct { | |||
| 59 | + | TagName string `json:"tag_name"` | |||
| 60 | + | HTMLURL string `json:"html_url"` | |||
| 61 | + | Body string `json:"body"` | |||
| 62 | + | } | |||
| 63 | + | if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { | |||
| 64 | + | return ChangelogInfo{}, err | |||
| 65 | + | } | |||
| 66 | + | body.Body = strings.TrimSpace(body.Body) | |||
| 67 | + | if body.Body == "" { | |||
| 68 | + | return ChangelogInfo{}, nil | |||
| 69 | + | } | |||
| 70 | + | return ChangelogInfo{ | |||
| 71 | + | Version: strings.TrimPrefix(body.TagName, "v"), | |||
| 72 | + | Body: body.Body, | |||
| 73 | + | URL: body.HTMLURL, | |||
| 74 | + | }, nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // FetchChangelogAsync runs FetchChangelog on a goroutine and delivers | |||
| 78 | + | // the result on the returned channel. Channel always closes. | |||
| 79 | + | func FetchChangelogAsync(version string) <-chan ChangelogInfo { | |||
| 80 | + | ch := make(chan ChangelogInfo, 1) | |||
| 81 | + | go func() { | |||
| 82 | + | defer close(ch) | |||
| 83 | + | ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) | |||
| 84 | + | defer cancel() | |||
| 85 | + | info, _ := FetchChangelog(ctx, version) | |||
| 86 | + | ch <- info | |||
| 87 | + | }() | |||
| 88 | + | return ch | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // ShouldShowChangelog reports whether the running binary version | |||
| 92 | + | // differs from the last version whose changelog the user dismissed. | |||
| 93 | + | // Returns false on dev builds (version "" / "dev" / "0.0.0") and on | |||
| 94 | + | // the first-ever launch (no LastChangelogShown stored — we don't | |||
| 95 | + | // dump release notes at someone who just installed). | |||
| 96 | + | func ShouldShowChangelog(currentVersion string, cfg Config) bool { | |||
| 97 | + | if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { | |||
| 98 | + | return false | |||
| 99 | + | } | |||
| 100 | + | if cfg.LastChangelogShown == "" { | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | return cfg.LastChangelogShown != currentVersion | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // MarkChangelogShown persists the version whose changelog the user | |||
| 107 | + | // just dismissed. Idempotent; safe to call when the dialog wasn't | |||
| 108 | + | // actually shown (e.g. fetch failed) so we don't keep retrying. | |||
| 109 | + | func MarkChangelogShown(version string) error { | |||
| 110 | + | cfg, _ := LoadConfig() | |||
| 111 | + | if cfg.LastChangelogShown == version { | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | cfg.LastChangelogShown = version | |||
| 115 | + | return SaveConfig(cfg) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // SeedChangelogVersion sets LastChangelogShown if it's currently | |||
| 119 | + | // empty. Called once on first-ever launch so future upgrades | |||
| 120 | + | // correctly trigger the dialog while THIS launch (which is also | |||
| 121 | + | // "first-ever") doesn't. | |||
| 122 | + | func SeedChangelogVersion(version string) { | |||
| 123 | + | if version == "" || version == "dev" || version == "0.0.0" { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | cfg, err := LoadConfig() | |||
| 127 | + | if err != nil { | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | if cfg.LastChangelogShown != "" { | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | cfg.LastChangelogShown = version | |||
| 134 | + | _ = SaveConfig(cfg) | |||
| 135 | + | } | |||
M internal/agent/cli.go +32 -0
| @@ -384,6 +384,34 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 384 | 384 | } | |
| 385 | 385 | }() | |
| 386 | 386 | ||
| 387 | + | // Changelog: when the running version differs from the last | |
| 388 | + | // version whose release notes the user dismissed, fetch the | |
| 389 | + | // release body from GitHub and have the TUI show it once. On | |
| 390 | + | // first-ever launch (no prior LastChangelogShown), seed the | |
| 391 | + | // stored version silently — don't dump release notes at someone | |
| 392 | + | // who just installed. | |
| 393 | + | changelogCh := make(chan modes.ChangelogPayload, 1) | |
| 394 | + | go func() { | |
| 395 | + | defer close(changelogCh) | |
| 396 | + | cfg, _ := LoadConfig() | |
| 397 | + | if cfg.LastChangelogShown == "" { | |
| 398 | + | SeedChangelogVersion(version) | |
| 399 | + | return | |
| 400 | + | } | |
| 401 | + | if !ShouldShowChangelog(version, cfg) { | |
| 402 | + | return | |
| 403 | + | } | |
| 404 | + | info := <-FetchChangelogAsync(version) | |
| 405 | + | if info.Body == "" { | |
| 406 | + | return | |
| 407 | + | } | |
| 408 | + | changelogCh <- modes.ChangelogPayload{ | |
| 409 | + | Version: info.Version, | |
| 410 | + | Body: info.Body, | |
| 411 | + | URL: info.URL, | |
| 412 | + | } | |
| 413 | + | }() | |
| 414 | + | ||
| 387 | 415 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 388 | 416 | Terminal: term, | |
| 389 | 417 | Theme: tui.Dark, | |
| @@ -407,6 +435,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 407 | 435 | BuildAgentFor: buildAgentFor, | |
| 408 | 436 | LoadSession: loadSession, | |
| 409 | 437 | Extensions: extMgr, | |
| 438 | + | ChangelogChan: changelogCh, | |
| 439 | + | OnChangelogDismiss: func() { | |
| 440 | + | _ = MarkChangelogShown(version) | |
| 441 | + | }, | |
| 410 | 442 | SkillSnapshot: func() []*skills.Skill { | |
| 411 | 443 | if args.NoSkill { | |
| 412 | 444 | // --no-skill: nothing for the picker to show. | |
| @@ -384,6 +384,34 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 384 | } | 384 | } | ||
| 385 | }() | 385 | }() | ||
| 386 | 386 | ||||
| 387 | + | // Changelog: when the running version differs from the last | |||
| 388 | + | // version whose release notes the user dismissed, fetch the | |||
| 389 | + | // release body from GitHub and have the TUI show it once. On | |||
| 390 | + | // first-ever launch (no prior LastChangelogShown), seed the | |||
| 391 | + | // stored version silently — don't dump release notes at someone | |||
| 392 | + | // who just installed. | |||
| 393 | + | changelogCh := make(chan modes.ChangelogPayload, 1) | |||
| 394 | + | go func() { | |||
| 395 | + | defer close(changelogCh) | |||
| 396 | + | cfg, _ := LoadConfig() | |||
| 397 | + | if cfg.LastChangelogShown == "" { | |||
| 398 | + | SeedChangelogVersion(version) | |||
| 399 | + | return | |||
| 400 | + | } | |||
| 401 | + | if !ShouldShowChangelog(version, cfg) { | |||
| 402 | + | return | |||
| 403 | + | } | |||
| 404 | + | info := <-FetchChangelogAsync(version) | |||
| 405 | + | if info.Body == "" { | |||
| 406 | + | return | |||
| 407 | + | } | |||
| 408 | + | changelogCh <- modes.ChangelogPayload{ | |||
| 409 | + | Version: info.Version, | |||
| 410 | + | Body: info.Body, | |||
| 411 | + | URL: info.URL, | |||
| 412 | + | } | |||
| 413 | + | }() | |||
| 414 | + | ||||
| 387 | iv = modes.NewInteractive(modes.InteractiveConfig{ | 415 | iv = modes.NewInteractive(modes.InteractiveConfig{ | ||
| 388 | Terminal: term, | 416 | Terminal: term, | ||
| 389 | Theme: tui.Dark, | 417 | Theme: tui.Dark, | ||
| @@ -407,6 +435,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 407 | BuildAgentFor: buildAgentFor, | 435 | BuildAgentFor: buildAgentFor, | ||
| 408 | LoadSession: loadSession, | 436 | LoadSession: loadSession, | ||
| 409 | Extensions: extMgr, | 437 | Extensions: extMgr, | ||
| 438 | + | ChangelogChan: changelogCh, | |||
| 439 | + | OnChangelogDismiss: func() { | |||
| 440 | + | _ = MarkChangelogShown(version) | |||
| 441 | + | }, | |||
| 410 | SkillSnapshot: func() []*skills.Skill { | 442 | SkillSnapshot: func() []*skills.Skill { | ||
| 411 | if args.NoSkill { | 443 | if args.NoSkill { | ||
| 412 | // --no-skill: nothing for the picker to show. | 444 | // --no-skill: nothing for the picker to show. | ||
M internal/agent/config.go +7 -0
| @@ -20,6 +20,13 @@ type Config struct { | |||
| 20 | 20 | Model string `json:"model"` | |
| 21 | 21 | Reasoning string `json:"reasoning"` | |
| 22 | 22 | Theme string `json:"theme"` | |
| 23 | + | ||
| 24 | + | // LastChangelogShown is the version whose release-notes | |
| 25 | + | // dialog the user has already seen. When the running binary's | |
| 26 | + | // version differs, the next interactive run shows the | |
| 27 | + | // changelog (fetched from the GitHub release page) once and | |
| 28 | + | // updates this field. Empty means "never shown". | |
| 29 | + | LastChangelogShown string `json:"last_changelog_shown,omitempty"` | |
| 23 | 30 | } | |
| 24 | 31 | ||
| 25 | 32 | // ZotHome returns $ZOT_HOME or the OS-default data dir. | |
| @@ -20,6 +20,13 @@ type Config struct { | |||||
| 20 | Model string `json:"model"` | 20 | Model string `json:"model"` | ||
| 21 | Reasoning string `json:"reasoning"` | 21 | Reasoning string `json:"reasoning"` | ||
| 22 | Theme string `json:"theme"` | 22 | Theme string `json:"theme"` | ||
| 23 | + | ||||
| 24 | + | // LastChangelogShown is the version whose release-notes | |||
| 25 | + | // dialog the user has already seen. When the running binary's | |||
| 26 | + | // version differs, the next interactive run shows the | |||
| 27 | + | // changelog (fetched from the GitHub release page) once and | |||
| 28 | + | // updates this field. Empty means "never shown". | |||
| 29 | + | LastChangelogShown string `json:"last_changelog_shown,omitempty"` | |||
| 23 | } | 30 | } | ||
| 24 | 31 | ||||
| 25 | // ZotHome returns $ZOT_HOME or the OS-default data dir. | 32 | // ZotHome returns $ZOT_HOME or the OS-default data dir. | ||
A internal/agent/modes/changelog_dialog.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // changelogDialog is the one-shot release-notes overlay shown the | |
| 11 | + | // first time a user launches a new zot version. The body is the | |
| 12 | + | // markdown from the GitHub release page, rendered through the same | |
| 13 | + | // pipeline used for assistant messages so code fences + bold + | |
| 14 | + | // links look right. | |
| 15 | + | // | |
| 16 | + | // Any key dismisses; the parent Interactive then persists the | |
| 17 | + | // version-shown marker so the dialog never reappears for that | |
| 18 | + | // version. | |
| 19 | + | type changelogDialog struct { | |
| 20 | + | active bool | |
| 21 | + | version string | |
| 22 | + | url string | |
| 23 | + | body string | |
| 24 | + | scroll int | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func newChangelogDialog() *changelogDialog { return &changelogDialog{} } | |
| 28 | + | ||
| 29 | + | // Open populates and shows the dialog. | |
| 30 | + | func (d *changelogDialog) Open(version, url, body string) { | |
| 31 | + | d.active = true | |
| 32 | + | d.version = version | |
| 33 | + | d.url = url | |
| 34 | + | d.body = strings.TrimSpace(body) | |
| 35 | + | d.scroll = 0 | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Close hides the dialog. | |
| 39 | + | func (d *changelogDialog) Close() { d.active = false } | |
| 40 | + | ||
| 41 | + | // Active reports whether the overlay is visible and consuming keys. | |
| 42 | + | func (d *changelogDialog) Active() bool { return d != nil && d.active } | |
| 43 | + | ||
| 44 | + | // HandleKey: any key (other than scroll) closes the dialog. Returns | |
| 45 | + | // closed=true when the user dismissed; the parent uses that as the | |
| 46 | + | // signal to persist LastChangelogShown. | |
| 47 | + | func (d *changelogDialog) HandleKey(k tui.Key) (closed bool) { | |
| 48 | + | if !d.Active() { | |
| 49 | + | return false | |
| 50 | + | } | |
| 51 | + | switch k.Kind { | |
| 52 | + | case tui.KeyUp: | |
| 53 | + | if d.scroll > 0 { | |
| 54 | + | d.scroll-- | |
| 55 | + | } | |
| 56 | + | return false | |
| 57 | + | case tui.KeyDown: | |
| 58 | + | d.scroll++ | |
| 59 | + | return false | |
| 60 | + | case tui.KeyPageUp: | |
| 61 | + | d.scroll -= 8 | |
| 62 | + | if d.scroll < 0 { | |
| 63 | + | d.scroll = 0 | |
| 64 | + | } | |
| 65 | + | return false | |
| 66 | + | case tui.KeyPageDown: | |
| 67 | + | d.scroll += 8 | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | d.Close() | |
| 71 | + | return true | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Render returns the dialog lines. | |
| 75 | + | func (d *changelogDialog) Render(th tui.Theme, width int) []string { | |
| 76 | + | if !d.Active() { | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | title := fmt.Sprintf("zot %s \u2014 release notes (any key to dismiss)", d.version) | |
| 80 | + | out := []string{frameHeaderColor(th, title, width, th.Accent)} | |
| 81 | + | if d.url != "" { | |
| 82 | + | out = append(out, " "+th.FG256(th.Muted, d.url)) | |
| 83 | + | out = append(out, "") | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | rendered := tui.RenderMarkdown(d.body, th, width-4) | |
| 87 | + | bodyLines := strings.Split(rendered, "\n") | |
| 88 | + | ||
| 89 | + | const maxRows = 18 | |
| 90 | + | if d.scroll > len(bodyLines)-1 { | |
| 91 | + | d.scroll = len(bodyLines) - 1 | |
| 92 | + | } | |
| 93 | + | if d.scroll < 0 { | |
| 94 | + | d.scroll = 0 | |
| 95 | + | } | |
| 96 | + | end := d.scroll + maxRows | |
| 97 | + | if end > len(bodyLines) { | |
| 98 | + | end = len(bodyLines) | |
| 99 | + | } | |
| 100 | + | for _, line := range bodyLines[d.scroll:end] { | |
| 101 | + | out = append(out, " "+line) | |
| 102 | + | } | |
| 103 | + | if end < len(bodyLines) { | |
| 104 | + | out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end))) | |
| 105 | + | } | |
| 106 | + | out = append(out, frameRuleColor(th, width, th.Accent)) | |
| 107 | + | return out | |
| 108 | + | } | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/internal/tui" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // changelogDialog is the one-shot release-notes overlay shown the | |||
| 11 | + | // first time a user launches a new zot version. The body is the | |||
| 12 | + | // markdown from the GitHub release page, rendered through the same | |||
| 13 | + | // pipeline used for assistant messages so code fences + bold + | |||
| 14 | + | // links look right. | |||
| 15 | + | // | |||
| 16 | + | // Any key dismisses; the parent Interactive then persists the | |||
| 17 | + | // version-shown marker so the dialog never reappears for that | |||
| 18 | + | // version. | |||
| 19 | + | type changelogDialog struct { | |||
| 20 | + | active bool | |||
| 21 | + | version string | |||
| 22 | + | url string | |||
| 23 | + | body string | |||
| 24 | + | scroll int | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func newChangelogDialog() *changelogDialog { return &changelogDialog{} } | |||
| 28 | + | ||||
| 29 | + | // Open populates and shows the dialog. | |||
| 30 | + | func (d *changelogDialog) Open(version, url, body string) { | |||
| 31 | + | d.active = true | |||
| 32 | + | d.version = version | |||
| 33 | + | d.url = url | |||
| 34 | + | d.body = strings.TrimSpace(body) | |||
| 35 | + | d.scroll = 0 | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Close hides the dialog. | |||
| 39 | + | func (d *changelogDialog) Close() { d.active = false } | |||
| 40 | + | ||||
| 41 | + | // Active reports whether the overlay is visible and consuming keys. | |||
| 42 | + | func (d *changelogDialog) Active() bool { return d != nil && d.active } | |||
| 43 | + | ||||
| 44 | + | // HandleKey: any key (other than scroll) closes the dialog. Returns | |||
| 45 | + | // closed=true when the user dismissed; the parent uses that as the | |||
| 46 | + | // signal to persist LastChangelogShown. | |||
| 47 | + | func (d *changelogDialog) HandleKey(k tui.Key) (closed bool) { | |||
| 48 | + | if !d.Active() { | |||
| 49 | + | return false | |||
| 50 | + | } | |||
| 51 | + | switch k.Kind { | |||
| 52 | + | case tui.KeyUp: | |||
| 53 | + | if d.scroll > 0 { | |||
| 54 | + | d.scroll-- | |||
| 55 | + | } | |||
| 56 | + | return false | |||
| 57 | + | case tui.KeyDown: | |||
| 58 | + | d.scroll++ | |||
| 59 | + | return false | |||
| 60 | + | case tui.KeyPageUp: | |||
| 61 | + | d.scroll -= 8 | |||
| 62 | + | if d.scroll < 0 { | |||
| 63 | + | d.scroll = 0 | |||
| 64 | + | } | |||
| 65 | + | return false | |||
| 66 | + | case tui.KeyPageDown: | |||
| 67 | + | d.scroll += 8 | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | d.Close() | |||
| 71 | + | return true | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Render returns the dialog lines. | |||
| 75 | + | func (d *changelogDialog) Render(th tui.Theme, width int) []string { | |||
| 76 | + | if !d.Active() { | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | title := fmt.Sprintf("zot %s \u2014 release notes (any key to dismiss)", d.version) | |||
| 80 | + | out := []string{frameHeaderColor(th, title, width, th.Accent)} | |||
| 81 | + | if d.url != "" { | |||
| 82 | + | out = append(out, " "+th.FG256(th.Muted, d.url)) | |||
| 83 | + | out = append(out, "") | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | rendered := tui.RenderMarkdown(d.body, th, width-4) | |||
| 87 | + | bodyLines := strings.Split(rendered, "\n") | |||
| 88 | + | ||||
| 89 | + | const maxRows = 18 | |||
| 90 | + | if d.scroll > len(bodyLines)-1 { | |||
| 91 | + | d.scroll = len(bodyLines) - 1 | |||
| 92 | + | } | |||
| 93 | + | if d.scroll < 0 { | |||
| 94 | + | d.scroll = 0 | |||
| 95 | + | } | |||
| 96 | + | end := d.scroll + maxRows | |||
| 97 | + | if end > len(bodyLines) { | |||
| 98 | + | end = len(bodyLines) | |||
| 99 | + | } | |||
| 100 | + | for _, line := range bodyLines[d.scroll:end] { | |||
| 101 | + | out = append(out, " "+line) | |||
| 102 | + | } | |||
| 103 | + | if end < len(bodyLines) { | |||
| 104 | + | out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end))) | |||
| 105 | + | } | |||
| 106 | + | out = append(out, frameRuleColor(th, width, th.Accent)) | |||
| 107 | + | return out | |||
| 108 | + | } | |||
M internal/agent/modes/interactive.go +65 -22
| @@ -90,6 +90,27 @@ type InteractiveConfig struct { | |||
| 90 | 90 | // discovered SKILL.md files. Re-invoked each time /skills opens | |
| 91 | 91 | // so the picker reflects edits made during the session. | |
| 92 | 92 | SkillSnapshot func() []*skills.Skill | |
| 93 | + | ||
| 94 | + | // ChangelogChan, if non-nil, delivers release-notes for the | |
| 95 | + | // current binary version once at startup. Interactive opens a | |
| 96 | + | // dismissible overlay when the channel produces a non-empty | |
| 97 | + | // body. Receiver fires at most once. | |
| 98 | + | ChangelogChan <-chan ChangelogPayload | |
| 99 | + | ||
| 100 | + | // OnChangelogDismiss, if non-nil, is called once the user | |
| 101 | + | // closes the changelog overlay. The cli wires this to a | |
| 102 | + | // MarkChangelogShown call so the same version doesn't show | |
| 103 | + | // again on the next launch. | |
| 104 | + | OnChangelogDismiss func() | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ChangelogPayload mirrors agent.ChangelogInfo without the import | |
| 108 | + | // cycle. The cli builds one from the http response, the tui opens | |
| 109 | + | // the overlay when one arrives. | |
| 110 | + | type ChangelogPayload struct { | |
| 111 | + | Version string | |
| 112 | + | Body string | |
| 113 | + | URL string | |
| 93 | 114 | } | |
| 94 | 115 | ||
| 95 | 116 | // Interactive is the TUI chat loop. | |
| @@ -134,14 +155,15 @@ type Interactive struct { | |||
| 134 | 155 | // while the check hasn't completed or nothing is available. | |
| 135 | 156 | updateInfo UpdateInfo | |
| 136 | 157 | ||
| 137 | - | dialog *loginDialog | |
| 138 | - | modelDialog *modelDialog | |
| 139 | - | sessionDialog *sessionDialog | |
| 140 | - | jumpDialog *jumpDialog | |
| 141 | - | btwDialog *btwDialog | |
| 142 | - | skillsDialog *skillsDialog | |
| 143 | - | suggest *slashSuggester | |
| 144 | - | spin *spinner | |
| 158 | + | dialog *loginDialog | |
| 159 | + | modelDialog *modelDialog | |
| 160 | + | sessionDialog *sessionDialog | |
| 161 | + | jumpDialog *jumpDialog | |
| 162 | + | btwDialog *btwDialog | |
| 163 | + | skillsDialog *skillsDialog | |
| 164 | + | changelogDialog *changelogDialog | |
| 165 | + | suggest *slashSuggester | |
| 166 | + | spin *spinner | |
| 145 | 167 | ||
| 146 | 168 | // parkedTurn is the 1-based turn number the viewport is currently | |
| 147 | 169 | // scrolled to by /jump. 0 = not parked, showing the tail as usual. | |
| @@ -179,18 +201,19 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 179 | 201 | Theme: cfg.Theme, | |
| 180 | 202 | ImageProto: tui.DetectImageProtocol(), | |
| 181 | 203 | }, | |
| 182 | - | ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")), | |
| 183 | - | rend: tui.NewRenderer(cfg.Terminal), | |
| 184 | - | toolCalls: map[string]*tui.ToolCallView{}, | |
| 185 | - | dirty: make(chan struct{}, 8), | |
| 186 | - | dialog: newLoginDialog(), | |
| 187 | - | modelDialog: newModelDialog(), | |
| 188 | - | sessionDialog: newSessionDialog(), | |
| 189 | - | jumpDialog: newJumpDialog(), | |
| 190 | - | btwDialog: newBtwDialog(), | |
| 191 | - | skillsDialog: newSkillsDialog(), | |
| 192 | - | suggest: newSlashSuggester(), | |
| 193 | - | spin: newSpinner(), | |
| 204 | + | ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")), | |
| 205 | + | rend: tui.NewRenderer(cfg.Terminal), | |
| 206 | + | toolCalls: map[string]*tui.ToolCallView{}, | |
| 207 | + | dirty: make(chan struct{}, 8), | |
| 208 | + | dialog: newLoginDialog(), | |
| 209 | + | modelDialog: newModelDialog(), | |
| 210 | + | sessionDialog: newSessionDialog(), | |
| 211 | + | jumpDialog: newJumpDialog(), | |
| 212 | + | btwDialog: newBtwDialog(), | |
| 213 | + | skillsDialog: newSkillsDialog(), | |
| 214 | + | changelogDialog: newChangelogDialog(), | |
| 215 | + | suggest: newSlashSuggester(), | |
| 216 | + | spin: newSpinner(), | |
| 194 | 217 | } | |
| 195 | 218 | if cfg.Agent != nil { | |
| 196 | 219 | i.agent = cfg.Agent | |
| @@ -339,7 +362,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 339 | 362 | ||
| 340 | 363 | i.invalidate() | |
| 341 | 364 | ||
| 342 | - | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select | |
| 365 | + | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select | |
| 366 | + | changelog := i.cfg.ChangelogChan // single-shot, see case below | |
| 343 | 367 | ||
| 344 | 368 | for { | |
| 345 | 369 | select { | |
| @@ -361,6 +385,12 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 361 | 385 | i.invalidate() | |
| 362 | 386 | } | |
| 363 | 387 | updates = nil // single-shot; subsequent iterations skip this case | |
| 388 | + | case cl, ok := <-changelog: | |
| 389 | + | if ok && cl.Body != "" { | |
| 390 | + | i.changelogDialog.Open(cl.Version, cl.URL, cl.Body) | |
| 391 | + | i.invalidate() | |
| 392 | + | } | |
| 393 | + | changelog = nil // single-shot | |
| 364 | 394 | case <-i.dirty: | |
| 365 | 395 | requestRedraw() | |
| 366 | 396 | case <-tick.C: | |
| @@ -371,7 +401,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 371 | 401 | // and the AfterFunc-driven invalidate got dropped on a | |
| 372 | 402 | // full channel. | |
| 373 | 403 | drainPending() | |
| 374 | - | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() { | |
| 404 | + | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() { | |
| 375 | 405 | requestRedraw() // keep the spinner / dialog animation moving | |
| 376 | 406 | } | |
| 377 | 407 | } | |
| @@ -527,6 +557,8 @@ func (i *Interactive) redraw() { | |||
| 527 | 557 | dialog = i.btwDialog.Render(i.cfg.Theme, cols) | |
| 528 | 558 | case i.skillsDialog.Active(): | |
| 529 | 559 | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | |
| 560 | + | case i.changelogDialog.Active(): | |
| 561 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | |
| 530 | 562 | } | |
| 531 | 563 | ||
| 532 | 564 | // Slash-command autocomplete: popup above the status line, only | |
| @@ -794,6 +826,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 794 | 826 | i.invalidate() | |
| 795 | 827 | return false | |
| 796 | 828 | } | |
| 829 | + | if i.changelogDialog.Active() { | |
| 830 | + | if closed := i.changelogDialog.HandleKey(k); closed { | |
| 831 | + | // User dismissed; let the parent persist the | |
| 832 | + | // LastChangelogShown marker via the close callback. | |
| 833 | + | if i.cfg.OnChangelogDismiss != nil { | |
| 834 | + | i.cfg.OnChangelogDismiss() | |
| 835 | + | } | |
| 836 | + | } | |
| 837 | + | i.invalidate() | |
| 838 | + | return false | |
| 839 | + | } | |
| 797 | 840 | ||
| 798 | 841 | // Global keys. | |
| 799 | 842 | switch k.Kind { | |
| @@ -90,6 +90,27 @@ type InteractiveConfig struct { | |||||
| 90 | // discovered SKILL.md files. Re-invoked each time /skills opens | 90 | // discovered SKILL.md files. Re-invoked each time /skills opens | ||
| 91 | // so the picker reflects edits made during the session. | 91 | // so the picker reflects edits made during the session. | ||
| 92 | SkillSnapshot func() []*skills.Skill | 92 | SkillSnapshot func() []*skills.Skill | ||
| 93 | + | ||||
| 94 | + | // ChangelogChan, if non-nil, delivers release-notes for the | |||
| 95 | + | // current binary version once at startup. Interactive opens a | |||
| 96 | + | // dismissible overlay when the channel produces a non-empty | |||
| 97 | + | // body. Receiver fires at most once. | |||
| 98 | + | ChangelogChan <-chan ChangelogPayload | |||
| 99 | + | ||||
| 100 | + | // OnChangelogDismiss, if non-nil, is called once the user | |||
| 101 | + | // closes the changelog overlay. The cli wires this to a | |||
| 102 | + | // MarkChangelogShown call so the same version doesn't show | |||
| 103 | + | // again on the next launch. | |||
| 104 | + | OnChangelogDismiss func() | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // ChangelogPayload mirrors agent.ChangelogInfo without the import | |||
| 108 | + | // cycle. The cli builds one from the http response, the tui opens | |||
| 109 | + | // the overlay when one arrives. | |||
| 110 | + | type ChangelogPayload struct { | |||
| 111 | + | Version string | |||
| 112 | + | Body string | |||
| 113 | + | URL string | |||
| 93 | } | 114 | } | ||
| 94 | 115 | ||||
| 95 | // Interactive is the TUI chat loop. | 116 | // Interactive is the TUI chat loop. | ||
| @@ -134,14 +155,15 @@ type Interactive struct { | |||||
| 134 | // while the check hasn't completed or nothing is available. | 155 | // while the check hasn't completed or nothing is available. | ||
| 135 | updateInfo UpdateInfo | 156 | updateInfo UpdateInfo | ||
| 136 | 157 | ||||
| 137 | - | dialog *loginDialog | 158 | + | dialog *loginDialog |
| 138 | - | modelDialog *modelDialog | 159 | + | modelDialog *modelDialog |
| 139 | - | sessionDialog *sessionDialog | 160 | + | sessionDialog *sessionDialog |
| 140 | - | jumpDialog *jumpDialog | 161 | + | jumpDialog *jumpDialog |
| 141 | - | btwDialog *btwDialog | 162 | + | btwDialog *btwDialog |
| 142 | - | skillsDialog *skillsDialog | 163 | + | skillsDialog *skillsDialog |
| 143 | - | suggest *slashSuggester | 164 | + | changelogDialog *changelogDialog |
| 144 | - | spin *spinner | 165 | + | suggest *slashSuggester |
| 166 | + | spin *spinner | |||
| 145 | 167 | ||||
| 146 | // parkedTurn is the 1-based turn number the viewport is currently | 168 | // parkedTurn is the 1-based turn number the viewport is currently | ||
| 147 | // scrolled to by /jump. 0 = not parked, showing the tail as usual. | 169 | // scrolled to by /jump. 0 = not parked, showing the tail as usual. | ||
| @@ -179,18 +201,19 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 179 | Theme: cfg.Theme, | 201 | Theme: cfg.Theme, | ||
| 180 | ImageProto: tui.DetectImageProtocol(), | 202 | ImageProto: tui.DetectImageProtocol(), | ||
| 181 | }, | 203 | }, | ||
| 182 | - | ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")), | 204 | + | ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")), |
| 183 | - | rend: tui.NewRenderer(cfg.Terminal), | 205 | + | rend: tui.NewRenderer(cfg.Terminal), |
| 184 | - | toolCalls: map[string]*tui.ToolCallView{}, | 206 | + | toolCalls: map[string]*tui.ToolCallView{}, |
| 185 | - | dirty: make(chan struct{}, 8), | 207 | + | dirty: make(chan struct{}, 8), |
| 186 | - | dialog: newLoginDialog(), | 208 | + | dialog: newLoginDialog(), |
| 187 | - | modelDialog: newModelDialog(), | 209 | + | modelDialog: newModelDialog(), |
| 188 | - | sessionDialog: newSessionDialog(), | 210 | + | sessionDialog: newSessionDialog(), |
| 189 | - | jumpDialog: newJumpDialog(), | 211 | + | jumpDialog: newJumpDialog(), |
| 190 | - | btwDialog: newBtwDialog(), | 212 | + | btwDialog: newBtwDialog(), |
| 191 | - | skillsDialog: newSkillsDialog(), | 213 | + | skillsDialog: newSkillsDialog(), |
| 192 | - | suggest: newSlashSuggester(), | 214 | + | changelogDialog: newChangelogDialog(), |
| 193 | - | spin: newSpinner(), | 215 | + | suggest: newSlashSuggester(), |
| 216 | + | spin: newSpinner(), | |||
| 194 | } | 217 | } | ||
| 195 | if cfg.Agent != nil { | 218 | if cfg.Agent != nil { | ||
| 196 | i.agent = cfg.Agent | 219 | i.agent = cfg.Agent | ||
| @@ -339,7 +362,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 339 | 362 | ||||
| 340 | i.invalidate() | 363 | i.invalidate() | ||
| 341 | 364 | ||||
| 342 | - | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select | 365 | + | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select |
| 366 | + | changelog := i.cfg.ChangelogChan // single-shot, see case below | |||
| 343 | 367 | ||||
| 344 | for { | 368 | for { | ||
| 345 | select { | 369 | select { | ||
| @@ -361,6 +385,12 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 361 | i.invalidate() | 385 | i.invalidate() | ||
| 362 | } | 386 | } | ||
| 363 | updates = nil // single-shot; subsequent iterations skip this case | 387 | updates = nil // single-shot; subsequent iterations skip this case | ||
| 388 | + | case cl, ok := <-changelog: | |||
| 389 | + | if ok && cl.Body != "" { | |||
| 390 | + | i.changelogDialog.Open(cl.Version, cl.URL, cl.Body) | |||
| 391 | + | i.invalidate() | |||
| 392 | + | } | |||
| 393 | + | changelog = nil // single-shot | |||
| 364 | case <-i.dirty: | 394 | case <-i.dirty: | ||
| 365 | requestRedraw() | 395 | requestRedraw() | ||
| 366 | case <-tick.C: | 396 | case <-tick.C: | ||
| @@ -371,7 +401,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 371 | // and the AfterFunc-driven invalidate got dropped on a | 401 | // and the AfterFunc-driven invalidate got dropped on a | ||
| 372 | // full channel. | 402 | // full channel. | ||
| 373 | drainPending() | 403 | drainPending() | ||
| 374 | - | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() { | 404 | + | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() { |
| 375 | requestRedraw() // keep the spinner / dialog animation moving | 405 | requestRedraw() // keep the spinner / dialog animation moving | ||
| 376 | } | 406 | } | ||
| 377 | } | 407 | } | ||
| @@ -527,6 +557,8 @@ func (i *Interactive) redraw() { | |||||
| 527 | dialog = i.btwDialog.Render(i.cfg.Theme, cols) | 557 | dialog = i.btwDialog.Render(i.cfg.Theme, cols) | ||
| 528 | case i.skillsDialog.Active(): | 558 | case i.skillsDialog.Active(): | ||
| 529 | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | 559 | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | ||
| 560 | + | case i.changelogDialog.Active(): | |||
| 561 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | |||
| 530 | } | 562 | } | ||
| 531 | 563 | ||||
| 532 | // Slash-command autocomplete: popup above the status line, only | 564 | // Slash-command autocomplete: popup above the status line, only | ||
| @@ -794,6 +826,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 794 | i.invalidate() | 826 | i.invalidate() | ||
| 795 | return false | 827 | return false | ||
| 796 | } | 828 | } | ||
| 829 | + | if i.changelogDialog.Active() { | |||
| 830 | + | if closed := i.changelogDialog.HandleKey(k); closed { | |||
| 831 | + | // User dismissed; let the parent persist the | |||
| 832 | + | // LastChangelogShown marker via the close callback. | |||
| 833 | + | if i.cfg.OnChangelogDismiss != nil { | |||
| 834 | + | i.cfg.OnChangelogDismiss() | |||
| 835 | + | } | |||
| 836 | + | } | |||
| 837 | + | i.invalidate() | |||
| 838 | + | return false | |||
| 839 | + | } | |||
| 797 | 840 | ||||
| 798 | // Global keys. | 841 | // Global keys. | ||
| 799 | switch k.Kind { | 842 | switch k.Kind { | ||