detached b9e7517

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/
108108
109109 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.
110110
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+
111115 ## usage
112116
113117 ```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 ## usage115 ## usage
112 116
113 ```bash117 ```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 {
384384 }
385385 }()
386386
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+
387415 iv = modes.NewInteractive(modes.InteractiveConfig{
388416 Terminal: term,
389417 Theme: tui.Dark,
@@ -407,6 +435,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
407435 BuildAgentFor: buildAgentFor,
408436 LoadSession: loadSession,
409437 Extensions: extMgr,
438+ ChangelogChan: changelogCh,
439+ OnChangelogDismiss: func() {
440+ _ = MarkChangelogShown(version)
441+ },
410442 SkillSnapshot: func() []*skills.Skill {
411443 if args.NoSkill {
412444 // --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 {
2020 Model string `json:"model"`
2121 Reasoning string `json:"reasoning"`
2222 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"`
2330 }
2431
2532 // 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 {
9090 // discovered SKILL.md files. Re-invoked each time /skills opens
9191 // so the picker reflects edits made during the session.
9292 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
93114 }
94115
95116 // Interactive is the TUI chat loop.
@@ -134,14 +155,15 @@ type Interactive struct {
134155 // while the check hasn't completed or nothing is available.
135156 updateInfo UpdateInfo
136157
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
145167
146168 // parkedTurn is the 1-based turn number the viewport is currently
147169 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
@@ -179,18 +201,19 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
179201 Theme: cfg.Theme,
180202 ImageProto: tui.DetectImageProtocol(),
181203 },
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(),
194217 }
195218 if cfg.Agent != nil {
196219 i.agent = cfg.Agent
@@ -339,7 +362,8 @@ func (i *Interactive) Run(ctx context.Context) error {
339362
340363 i.invalidate()
341364
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
343367
344368 for {
345369 select {
@@ -361,6 +385,12 @@ func (i *Interactive) Run(ctx context.Context) error {
361385 i.invalidate()
362386 }
363387 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
364394 case <-i.dirty:
365395 requestRedraw()
366396 case <-tick.C:
@@ -371,7 +401,7 @@ func (i *Interactive) Run(ctx context.Context) error {
371401 // and the AfterFunc-driven invalidate got dropped on a
372402 // full channel.
373403 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() {
375405 requestRedraw() // keep the spinner / dialog animation moving
376406 }
377407 }
@@ -527,6 +557,8 @@ func (i *Interactive) redraw() {
527557 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
528558 case i.skillsDialog.Active():
529559 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
560+ case i.changelogDialog.Active():
561+ dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
530562 }
531563
532564 // 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) {
794826 i.invalidate()
795827 return false
796828 }
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+ }
797840
798841 // Global keys.
799842 switch k.Kind {
@@ -90,6 +90,27 @@ type InteractiveConfig struct {
90 // discovered SKILL.md files. Re-invoked each time /skills opens90 // 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.Skill92 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 UpdateInfo156 updateInfo UpdateInfo
136 157
137- dialog *loginDialog158+ dialog *loginDialog
138- modelDialog *modelDialog159+ modelDialog *modelDialog
139- sessionDialog *sessionDialog160+ sessionDialog *sessionDialog
140- jumpDialog *jumpDialog161+ jumpDialog *jumpDialog
141- btwDialog *btwDialog162+ btwDialog *btwDialog
142- skillsDialog *skillsDialog163+ skillsDialog *skillsDialog
143- suggest *slashSuggester164+ changelogDialog *changelogDialog
144- spin *spinner165+ suggest *slashSuggester
166+ spin *spinner
145 167
146 // parkedTurn is the 1-based turn number the viewport is currently168 // 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.Agent219 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 select365+ 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 case387 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 a401 // 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 moving405 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, only564 // 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 false827 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close