detached 8e546bd

tui: show 'update available' banner at top of chat

- internal/agent/update.go: check github releases api for a newer
  tag than the compiled-in version, cached in $ZOT_HOME/update-check.json
  with a 12h ttl so startup never hits the network twice. honours
  $GITHUB_TOKEN for the window while the repo is private; falls back
  to silent no-op on any failure. skipped entirely on dev builds
  (version = 0.0.0 or dev)
- internal/agent/modes/update_banner.go: yellow-framed block with
  the new version, the current version, the one-liner install
  command appropriate for the platform, and a link to the release
  page. rendered above the welcome / transcript so it's the first
  thing the user sees
- wired through via InteractiveConfig.UpdateInfoChan to avoid an
  import cycle (modes -> agent). cli.go kicks off the check async
  and feeds the result in

note: no 'dismiss' key yet \u2014 the banner stays until you update to
the shown version. if the nagging gets annoying we can add a
per-version dismiss cache later.
commit
8e546bde705d844abf1d453887bdf49494d9513a
author
patriceckhart · · 2026-04-18 09:49 UTC
parent
728cf4e
signature
unsigned
refs
tag: v0.0.5
5 files · +368 -20
M internal/agent/cli.go +37 -19
@@ -199,26 +199,44 @@ func runInteractive(ctx context.Context, args Args, version string) error {
199199 }
200200
201201 term := tui.NewProcTerm()
202+
203+ // Kick off the async update check so the banner can appear when the
204+ // http response eventually arrives (usually <1s on cached DNS). Map
205+ // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
206+ updateCh := make(chan modes.UpdateInfo, 1)
207+ go func() {
208+ defer close(updateCh)
209+ src := <-CheckForUpdateAsync(ZotHome(), version)
210+ updateCh <- modes.UpdateInfo{
211+ Current: src.Current,
212+ Latest: src.Latest,
213+ Available: src.Available,
214+ URL: src.URL,
215+ }
216+ }()
217+
202218 iv := modes.NewInteractive(modes.InteractiveConfig{
203- Terminal: term,
204- Theme: tui.Dark,
205- Model: r.Model,
206- Provider: r.Provider,
207- AuthMethod: r.AuthMethod,
208- BaseURL: r.BaseURL,
209- Reasoning: r.Reasoning,
210- SystemPrompt: r.SystemPrompt,
211- Tools: r.ToolRegistry,
212- MaxSteps: r.MaxSteps,
213- CWD: r.CWD,
214- ZotHome: ZotHome(),
215- Sandbox: sharedSandbox,
216- Agent: ag,
217- InitialInput: args.Prompt,
218- AuthManager: mgr,
219- BuildAgent: buildAgent,
220- BuildAgentFor: buildAgentFor,
221- LoadSession: loadSession,
219+ Terminal: term,
220+ Theme: tui.Dark,
221+ Model: r.Model,
222+ Provider: r.Provider,
223+ AuthMethod: r.AuthMethod,
224+ BaseURL: r.BaseURL,
225+ Reasoning: r.Reasoning,
226+ SystemPrompt: r.SystemPrompt,
227+ Tools: r.ToolRegistry,
228+ MaxSteps: r.MaxSteps,
229+ CWD: r.CWD,
230+ ZotHome: ZotHome(),
231+ Version: version,
232+ UpdateInfoChan: updateCh,
233+ Sandbox: sharedSandbox,
234+ Agent: ag,
235+ InitialInput: args.Prompt,
236+ AuthManager: mgr,
237+ BuildAgent: buildAgent,
238+ BuildAgentFor: buildAgentFor,
239+ LoadSession: loadSession,
222240 PersistModel: func(providerName, model string) {
223241 // Update config.json so next launch uses the same pick.
224242 cfg, _ := LoadConfig()
@@ -199,26 +199,44 @@ func runInteractive(ctx context.Context, args Args, version string) error {
199 }199 }
200 200
201 term := tui.NewProcTerm()201 term := tui.NewProcTerm()
202+
203+ // Kick off the async update check so the banner can appear when the
204+ // http response eventually arrives (usually <1s on cached DNS). Map
205+ // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
206+ updateCh := make(chan modes.UpdateInfo, 1)
207+ go func() {
208+ defer close(updateCh)
209+ src := <-CheckForUpdateAsync(ZotHome(), version)
210+ updateCh <- modes.UpdateInfo{
211+ Current: src.Current,
212+ Latest: src.Latest,
213+ Available: src.Available,
214+ URL: src.URL,
215+ }
216+ }()
217+
202 iv := modes.NewInteractive(modes.InteractiveConfig{218 iv := modes.NewInteractive(modes.InteractiveConfig{
203- Terminal: term,219+ Terminal: term,
204- Theme: tui.Dark,220+ Theme: tui.Dark,
205- Model: r.Model,221+ Model: r.Model,
206- Provider: r.Provider,222+ Provider: r.Provider,
207- AuthMethod: r.AuthMethod,223+ AuthMethod: r.AuthMethod,
208- BaseURL: r.BaseURL,224+ BaseURL: r.BaseURL,
209- Reasoning: r.Reasoning,225+ Reasoning: r.Reasoning,
210- SystemPrompt: r.SystemPrompt,226+ SystemPrompt: r.SystemPrompt,
211- Tools: r.ToolRegistry,227+ Tools: r.ToolRegistry,
212- MaxSteps: r.MaxSteps,228+ MaxSteps: r.MaxSteps,
213- CWD: r.CWD,229+ CWD: r.CWD,
214- ZotHome: ZotHome(),230+ ZotHome: ZotHome(),
215- Sandbox: sharedSandbox,231+ Version: version,
216- Agent: ag,232+ UpdateInfoChan: updateCh,
217- InitialInput: args.Prompt,233+ Sandbox: sharedSandbox,
218- AuthManager: mgr,234+ Agent: ag,
219- BuildAgent: buildAgent,235+ InitialInput: args.Prompt,
220- BuildAgentFor: buildAgentFor,236+ AuthManager: mgr,
221- LoadSession: loadSession,237+ BuildAgent: buildAgent,
238+ BuildAgentFor: buildAgentFor,
239+ LoadSession: loadSession,
222 PersistModel: func(providerName, model string) {240 PersistModel: func(providerName, model string) {
223 // Update config.json so next launch uses the same pick.241 // Update config.json so next launch uses the same pick.
224 cfg, _ := LoadConfig()242 cfg, _ := LoadConfig()
M internal/agent/modes/dialog_frame.go +23 -0
@@ -30,3 +30,26 @@ func frameRule(th tui.Theme, width int) string {
3030 }
3131 return th.FG256(th.Muted, strings.Repeat("─", width))
3232 }
33+
34+// frameHeaderColor is like frameHeader but renders in a caller-supplied
35+// 256-color code. Used by the update-available banner which wants a
36+// yellow accent on the rules and title.
37+func frameHeaderColor(th tui.Theme, title string, width, color int) string {
38+ label := "── " + title + " "
39+ if width <= 0 {
40+ return th.FG256(color, label)
41+ }
42+ padLen := width - len(label)
43+ if padLen < 0 {
44+ padLen = 0
45+ }
46+ return th.FG256(color, label+strings.Repeat("─", padLen))
47+}
48+
49+// frameRuleColor is like frameRule in an explicit color.
50+func frameRuleColor(th tui.Theme, width, color int) string {
51+ if width <= 0 {
52+ width = 1
53+ }
54+ return th.FG256(color, strings.Repeat("─", width))
55+}
@@ -30,3 +30,26 @@ func frameRule(th tui.Theme, width int) string {
30 }30 }
31 return th.FG256(th.Muted, strings.Repeat("─", width))31 return th.FG256(th.Muted, strings.Repeat("─", width))
32 }32 }
33+
34+// frameHeaderColor is like frameHeader but renders in a caller-supplied
35+// 256-color code. Used by the update-available banner which wants a
36+// yellow accent on the rules and title.
37+func frameHeaderColor(th tui.Theme, title string, width, color int) string {
38+ label := "── " + title + " "
39+ if width <= 0 {
40+ return th.FG256(color, label)
41+ }
42+ padLen := width - len(label)
43+ if padLen < 0 {
44+ padLen = 0
45+ }
46+ return th.FG256(color, label+strings.Repeat("─", padLen))
47+}
48+
49+// frameRuleColor is like frameRule in an explicit color.
50+func frameRuleColor(th tui.Theme, width, color int) string {
51+ if width <= 0 {
52+ width = 1
53+ }
54+ return th.FG256(color, strings.Repeat("─", width))
55+}
M internal/agent/modes/interactive.go +38 -1
@@ -48,9 +48,22 @@ type InteractiveConfig struct {
4848 // If providerOverride is empty, the current provider is kept.
4949 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
5050
51- // ZotHome is the root directory for sessions/, used by /sessions.
51+ // ZotHome is the root directory for sessions/, used by /sessions
52+ // and the update-check cache.
5253 ZotHome string
5354
55+ // Version is the binary's current version (from main.version).
56+ // Used only for display; the update check itself is done outside
57+ // this package to avoid an import cycle.
58+ Version string
59+
60+ // UpdateInfoChan is an optional channel that delivers the result
61+ // of the github-release update check. Interactive reads at most
62+ // one value, drops it if the check reported nothing, and otherwise
63+ // surfaces a yellow "update available" banner at the top of the
64+ // chat. Nil channel = no banner, no startup cost.
65+ UpdateInfoChan <-chan UpdateInfo
66+
5467 // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock.
5568 Sandbox *tools.Sandbox
5669
@@ -105,6 +118,10 @@ type Interactive struct {
105118 // condense pass from a regular assistant turn.
106119 autoCompacting bool
107120
121+ // updateInfo is the result of the async update check. Zero value
122+ // while the check hasn't completed or nothing is available.
123+ updateInfo UpdateInfo
124+
108125 dialog *loginDialog
109126 modelDialog *modelDialog
110127 sessionDialog *sessionDialog
@@ -241,6 +258,8 @@ func (i *Interactive) Run(ctx context.Context) error {
241258
242259 i.invalidate()
243260
261+ updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
262+
244263 for {
245264 select {
246265 case <-ctx.Done():
@@ -253,6 +272,14 @@ func (i *Interactive) Run(ctx context.Context) error {
253272 case ev := <-authEvents:
254273 i.handleAuthEvent(ev)
255274 i.invalidate()
275+ case info, ok := <-updates:
276+ if ok && info.Available {
277+ i.mu.Lock()
278+ i.updateInfo = info
279+ i.mu.Unlock()
280+ i.invalidate()
281+ }
282+ updates = nil // single-shot; subsequent iterations skip this case
256283 case <-i.dirty:
257284 requestRedraw()
258285 case <-tick.C:
@@ -349,6 +376,16 @@ func (i *Interactive) redraw() {
349376 chat = append(welcomeBanner(i.cfg.Theme), chat...)
350377 }
351378
379+ // Update-available banner: prepended above everything else so it's
380+ // the first thing the user sees when opening a new zot session.
381+ // Once rendered, it stays until the user updates to a newer
382+ // version — we don't persist a "dismissed" flag because this is
383+ // cheap and re-showing it is how most users remember to update.
384+ if i.updateInfo.Available {
385+ banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
386+ chat = append(banner, chat...)
387+ }
388+
352389 // /help block: appended to the transcript so it appears at the
353390 // bottom of the chat area (right above the status bar / editor).
354391 // Prepending it would push long conversations off the top of the
@@ -48,9 +48,22 @@ type InteractiveConfig struct {
48 // If providerOverride is empty, the current provider is kept.48 // If providerOverride is empty, the current provider is kept.
49 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)49 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
50 50
51- // ZotHome is the root directory for sessions/, used by /sessions.51+ // ZotHome is the root directory for sessions/, used by /sessions
52+ // and the update-check cache.
52 ZotHome string53 ZotHome string
53 54
55+ // Version is the binary's current version (from main.version).
56+ // Used only for display; the update check itself is done outside
57+ // this package to avoid an import cycle.
58+ Version string
59+
60+ // UpdateInfoChan is an optional channel that delivers the result
61+ // of the github-release update check. Interactive reads at most
62+ // one value, drops it if the check reported nothing, and otherwise
63+ // surfaces a yellow "update available" banner at the top of the
64+ // chat. Nil channel = no banner, no startup cost.
65+ UpdateInfoChan <-chan UpdateInfo
66+
54 // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock.67 // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock.
55 Sandbox *tools.Sandbox68 Sandbox *tools.Sandbox
56 69
@@ -105,6 +118,10 @@ type Interactive struct {
105 // condense pass from a regular assistant turn.118 // condense pass from a regular assistant turn.
106 autoCompacting bool119 autoCompacting bool
107 120
121+ // updateInfo is the result of the async update check. Zero value
122+ // while the check hasn't completed or nothing is available.
123+ updateInfo UpdateInfo
124+
108 dialog *loginDialog125 dialog *loginDialog
109 modelDialog *modelDialog126 modelDialog *modelDialog
110 sessionDialog *sessionDialog127 sessionDialog *sessionDialog
@@ -241,6 +258,8 @@ func (i *Interactive) Run(ctx context.Context) error {
241 258
242 i.invalidate()259 i.invalidate()
243 260
261+ updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
262+
244 for {263 for {
245 select {264 select {
246 case <-ctx.Done():265 case <-ctx.Done():
@@ -253,6 +272,14 @@ func (i *Interactive) Run(ctx context.Context) error {
253 case ev := <-authEvents:272 case ev := <-authEvents:
254 i.handleAuthEvent(ev)273 i.handleAuthEvent(ev)
255 i.invalidate()274 i.invalidate()
275+ case info, ok := <-updates:
276+ if ok && info.Available {
277+ i.mu.Lock()
278+ i.updateInfo = info
279+ i.mu.Unlock()
280+ i.invalidate()
281+ }
282+ updates = nil // single-shot; subsequent iterations skip this case
256 case <-i.dirty:283 case <-i.dirty:
257 requestRedraw()284 requestRedraw()
258 case <-tick.C:285 case <-tick.C:
@@ -349,6 +376,16 @@ func (i *Interactive) redraw() {
349 chat = append(welcomeBanner(i.cfg.Theme), chat...)376 chat = append(welcomeBanner(i.cfg.Theme), chat...)
350 }377 }
351 378
379+ // Update-available banner: prepended above everything else so it's
380+ // the first thing the user sees when opening a new zot session.
381+ // Once rendered, it stays until the user updates to a newer
382+ // version — we don't persist a "dismissed" flag because this is
383+ // cheap and re-showing it is how most users remember to update.
384+ if i.updateInfo.Available {
385+ banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
386+ chat = append(banner, chat...)
387+ }
388+
352 // /help block: appended to the transcript so it appears at the389 // /help block: appended to the transcript so it appears at the
353 // bottom of the chat area (right above the status bar / editor).390 // bottom of the chat area (right above the status bar / editor).
354 // Prepending it would push long conversations off the top of the391 // Prepending it would push long conversations off the top of the
A internal/agent/modes/update_banner.go +58 -0
@@ -0,0 +1,58 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "runtime"
6+
7+ "github.com/patriceckhart/zot/internal/tui"
8+)
9+
10+// UpdateInfo mirrors agent.UpdateInfo without the import cycle. The
11+// parent package builds one of these via agent.CheckForUpdate and
12+// passes it in through InteractiveConfig.UpdateInfoChan.
13+type UpdateInfo struct {
14+ Current string
15+ Latest string
16+ Available bool
17+ URL string
18+}
19+
20+// renderUpdateBanner builds the "new version available" block shown at
21+// the top of the chat area. Yellow-framed like a warning, but worded
22+// gently since this is informational, not urgent.
23+//
24+// Returns nil when no update is available, so callers can just
25+// append (or prepend) unconditionally.
26+func renderUpdateBanner(th tui.Theme, info UpdateInfo, width int) []string {
27+ if !info.Available {
28+ return nil
29+ }
30+ color := th.Warning
31+ out := []string{
32+ frameHeaderColor(th, "update available", width, color),
33+ }
34+
35+ title := fmt.Sprintf("zot %s is available (you're on %s).", info.Latest, info.Current)
36+ out = append(out, " "+th.FG256(color, tui.Bold(title)))
37+
38+ cmd := recommendedUpdateCommand()
39+ out = append(out, " "+th.FG256(th.Muted, "run: ")+th.FG256(th.ToolOut, cmd))
40+
41+ if info.URL != "" {
42+ out = append(out, " "+th.FG256(th.Muted, "changelog: "+info.URL))
43+ }
44+
45+ out = append(out, frameRuleColor(th, width, color))
46+ out = append(out, "")
47+ return out
48+}
49+
50+// recommendedUpdateCommand returns the one-liner appropriate for the
51+// user's platform. All three point at the public install scripts so a
52+// single command updates in place.
53+func recommendedUpdateCommand() string {
54+ if runtime.GOOS == "windows" {
55+ return `iwr -useb https://raw.githubusercontent.com/patriceckhart/zot/main/install.ps1 | iex`
56+ }
57+ return `curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash`
58+}
@@ -0,0 +1,58 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "runtime"
6+
7+ "github.com/patriceckhart/zot/internal/tui"
8+)
9+
10+// UpdateInfo mirrors agent.UpdateInfo without the import cycle. The
11+// parent package builds one of these via agent.CheckForUpdate and
12+// passes it in through InteractiveConfig.UpdateInfoChan.
13+type UpdateInfo struct {
14+ Current string
15+ Latest string
16+ Available bool
17+ URL string
18+}
19+
20+// renderUpdateBanner builds the "new version available" block shown at
21+// the top of the chat area. Yellow-framed like a warning, but worded
22+// gently since this is informational, not urgent.
23+//
24+// Returns nil when no update is available, so callers can just
25+// append (or prepend) unconditionally.
26+func renderUpdateBanner(th tui.Theme, info UpdateInfo, width int) []string {
27+ if !info.Available {
28+ return nil
29+ }
30+ color := th.Warning
31+ out := []string{
32+ frameHeaderColor(th, "update available", width, color),
33+ }
34+
35+ title := fmt.Sprintf("zot %s is available (you're on %s).", info.Latest, info.Current)
36+ out = append(out, " "+th.FG256(color, tui.Bold(title)))
37+
38+ cmd := recommendedUpdateCommand()
39+ out = append(out, " "+th.FG256(th.Muted, "run: ")+th.FG256(th.ToolOut, cmd))
40+
41+ if info.URL != "" {
42+ out = append(out, " "+th.FG256(th.Muted, "changelog: "+info.URL))
43+ }
44+
45+ out = append(out, frameRuleColor(th, width, color))
46+ out = append(out, "")
47+ return out
48+}
49+
50+// recommendedUpdateCommand returns the one-liner appropriate for the
51+// user's platform. All three point at the public install scripts so a
52+// single command updates in place.
53+func recommendedUpdateCommand() string {
54+ if runtime.GOOS == "windows" {
55+ return `iwr -useb https://raw.githubusercontent.com/patriceckhart/zot/main/install.ps1 | iex`
56+ }
57+ return `curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash`
58+}
A internal/agent/update.go +212 -0
@@ -0,0 +1,212 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+// updateCheckTTL is how often we hit the GitHub API to look for a new
17+// release. Half a day is frequent enough to notice the same day a
18+// release ships without spamming the API on every zot launch.
19+const updateCheckTTL = 12 * time.Hour
20+
21+// updateCheckFile is the on-disk cache, keyed to the current binary
22+// version. Writes a JSON blob with the last observed latest tag and
23+// when we checked, so we can skip the network call on most launches.
24+const updateCheckFile = "update-check.json"
25+
26+// githubReleasesAPI is the REST endpoint we query. Using the API (not
27+// the HTML redirect) because the JSON response is stable and small.
28+const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest"
29+
30+// UpdateInfo describes the result of an update check. Zero-value means
31+// "no update available, no error, don't show anything".
32+type UpdateInfo struct {
33+ Current string // e.g. "0.0.4"
34+ Latest string // e.g. "0.0.5"
35+ Available bool // true when latest > current
36+ URL string // release page url for the changelog link
37+}
38+
39+// updateCache is the on-disk structure written to $ZOT_HOME.
40+type updateCache struct {
41+ CheckedAt time.Time `json:"checked_at"`
42+ // The version that was current when we last checked. Invalidates
43+ // the cache if the binary itself has been updated since.
44+ CurrentAt string `json:"current_at"`
45+ Latest string `json:"latest"`
46+ URL string `json:"url"`
47+}
48+
49+// CheckForUpdate returns info about a newer release, using a cached
50+// result when one is fresh enough. Designed to be called at tui
51+// startup and rendered as a dismissible banner.
52+//
53+// Always returns a usable UpdateInfo (zero-value on error). The
54+// banner renderer skips the display when Available is false, so a
55+// network failure silently no-ops; we never block startup on this.
56+func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo {
57+ // Dev builds ("0.0.0") never have an update to offer. Skip.
58+ if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" {
59+ return UpdateInfo{}
60+ }
61+
62+ cachePath := filepath.Join(zotHome, updateCheckFile)
63+ if c, ok := readUpdateCache(cachePath); ok {
64+ // Cache is fresh and tracks the same binary version.
65+ if time.Since(c.CheckedAt) < updateCheckTTL && c.CurrentAt == currentVersion {
66+ return buildInfo(currentVersion, c.Latest, c.URL)
67+ }
68+ }
69+
70+ latest, url, err := fetchLatestRelease(ctx)
71+ if err != nil {
72+ // Network or auth failure (common while the repo is private
73+ // and no GITHUB_TOKEN is set). Silent no-op; we'll try again
74+ // after the TTL on the next launch.
75+ return UpdateInfo{}
76+ }
77+
78+ _ = writeUpdateCache(cachePath, updateCache{
79+ CheckedAt: time.Now().UTC(),
80+ CurrentAt: currentVersion,
81+ Latest: latest,
82+ URL: url,
83+ })
84+
85+ return buildInfo(currentVersion, latest, url)
86+}
87+
88+// CheckForUpdateAsync runs CheckForUpdate in a goroutine, delivers the
89+// result to the returned channel, and never blocks startup. The
90+// channel is always closed; receivers should `ok`-check.
91+func CheckForUpdateAsync(zotHome, currentVersion string) <-chan UpdateInfo {
92+ ch := make(chan UpdateInfo, 1)
93+ go func() {
94+ defer close(ch)
95+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
96+ defer cancel()
97+ ch <- CheckForUpdate(ctx, zotHome, currentVersion)
98+ }()
99+ return ch
100+}
101+
102+func buildInfo(current, latest, url string) UpdateInfo {
103+ info := UpdateInfo{
104+ Current: current,
105+ Latest: strings.TrimPrefix(latest, "v"),
106+ URL: url,
107+ }
108+ info.Available = versionLess(info.Current, info.Latest)
109+ return info
110+}
111+
112+// versionLess returns a < b for dotted semver-ish tags like "0.0.4".
113+// Non-numeric components compare as zero, which is fine for zot's
114+// x.y.z-only scheme.
115+func versionLess(a, b string) bool {
116+ as := splitVersion(a)
117+ bs := splitVersion(b)
118+ for i := 0; i < 3; i++ {
119+ av, bv := 0, 0
120+ if i < len(as) {
121+ av = as[i]
122+ }
123+ if i < len(bs) {
124+ bv = bs[i]
125+ }
126+ if av != bv {
127+ return av < bv
128+ }
129+ }
130+ return false
131+}
132+
133+func splitVersion(s string) []int {
134+ s = strings.TrimPrefix(s, "v")
135+ // Strip build-metadata suffix like "(abc1234, 2026-04-18)".
136+ if i := strings.IndexAny(s, " ("); i > 0 {
137+ s = s[:i]
138+ }
139+ parts := strings.Split(s, ".")
140+ out := make([]int, 0, len(parts))
141+ for _, p := range parts {
142+ n, _ := strconv.Atoi(p)
143+ out = append(out, n)
144+ }
145+ return out
146+}
147+
148+// fetchLatestRelease queries the GitHub API for the latest published
149+// release. Honours $GITHUB_TOKEN for private repos.
150+func fetchLatestRelease(ctx context.Context) (tag, url string, err error) {
151+ req, err := http.NewRequestWithContext(ctx, "GET", githubReleasesAPI, nil)
152+ if err != nil {
153+ return "", "", err
154+ }
155+ req.Header.Set("accept", "application/vnd.github+json")
156+ req.Header.Set("x-github-api-version", "2022-11-28")
157+ if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
158+ req.Header.Set("authorization", "Bearer "+tok)
159+ }
160+
161+ client := &http.Client{Timeout: 4 * time.Second}
162+ resp, err := client.Do(req)
163+ if err != nil {
164+ return "", "", err
165+ }
166+ defer resp.Body.Close()
167+ if resp.StatusCode != 200 {
168+ return "", "", fmt.Errorf("github api %d", resp.StatusCode)
169+ }
170+
171+ var body struct {
172+ TagName string `json:"tag_name"`
173+ HTMLURL string `json:"html_url"`
174+ }
175+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
176+ return "", "", err
177+ }
178+ return body.TagName, body.HTMLURL, nil
179+}
180+
181+// readUpdateCache loads the last check result. Returns ok=false on
182+// any error (missing file, bad json) so callers just treat it as a
183+// cache miss and re-fetch.
184+func readUpdateCache(path string) (updateCache, bool) {
185+ b, err := os.ReadFile(path)
186+ if err != nil {
187+ return updateCache{}, false
188+ }
189+ var c updateCache
190+ if err := json.Unmarshal(b, &c); err != nil {
191+ return updateCache{}, false
192+ }
193+ return c, true
194+}
195+
196+func writeUpdateCache(path string, c updateCache) error {
197+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
198+ return err
199+ }
200+ b, err := json.MarshalIndent(c, "", " ")
201+ if err != nil {
202+ return err
203+ }
204+ return os.WriteFile(path, b, 0o600)
205+}
206+
207+// cachedLatest, cachedLatestOnce: used elsewhere in the binary if we
208+// want a synchronous read without triggering a network call.
209+var (
210+ cachedLatest string
211+ cachedLatestOnce sync.Once
212+)
@@ -0,0 +1,212 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+// updateCheckTTL is how often we hit the GitHub API to look for a new
17+// release. Half a day is frequent enough to notice the same day a
18+// release ships without spamming the API on every zot launch.
19+const updateCheckTTL = 12 * time.Hour
20+
21+// updateCheckFile is the on-disk cache, keyed to the current binary
22+// version. Writes a JSON blob with the last observed latest tag and
23+// when we checked, so we can skip the network call on most launches.
24+const updateCheckFile = "update-check.json"
25+
26+// githubReleasesAPI is the REST endpoint we query. Using the API (not
27+// the HTML redirect) because the JSON response is stable and small.
28+const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest"
29+
30+// UpdateInfo describes the result of an update check. Zero-value means
31+// "no update available, no error, don't show anything".
32+type UpdateInfo struct {
33+ Current string // e.g. "0.0.4"
34+ Latest string // e.g. "0.0.5"
35+ Available bool // true when latest > current
36+ URL string // release page url for the changelog link
37+}
38+
39+// updateCache is the on-disk structure written to $ZOT_HOME.
40+type updateCache struct {
41+ CheckedAt time.Time `json:"checked_at"`
42+ // The version that was current when we last checked. Invalidates
43+ // the cache if the binary itself has been updated since.
44+ CurrentAt string `json:"current_at"`
45+ Latest string `json:"latest"`
46+ URL string `json:"url"`
47+}
48+
49+// CheckForUpdate returns info about a newer release, using a cached
50+// result when one is fresh enough. Designed to be called at tui
51+// startup and rendered as a dismissible banner.
52+//
53+// Always returns a usable UpdateInfo (zero-value on error). The
54+// banner renderer skips the display when Available is false, so a
55+// network failure silently no-ops; we never block startup on this.
56+func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo {
57+ // Dev builds ("0.0.0") never have an update to offer. Skip.
58+ if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" {
59+ return UpdateInfo{}
60+ }
61+
62+ cachePath := filepath.Join(zotHome, updateCheckFile)
63+ if c, ok := readUpdateCache(cachePath); ok {
64+ // Cache is fresh and tracks the same binary version.
65+ if time.Since(c.CheckedAt) < updateCheckTTL && c.CurrentAt == currentVersion {
66+ return buildInfo(currentVersion, c.Latest, c.URL)
67+ }
68+ }
69+
70+ latest, url, err := fetchLatestRelease(ctx)
71+ if err != nil {
72+ // Network or auth failure (common while the repo is private
73+ // and no GITHUB_TOKEN is set). Silent no-op; we'll try again
74+ // after the TTL on the next launch.
75+ return UpdateInfo{}
76+ }
77+
78+ _ = writeUpdateCache(cachePath, updateCache{
79+ CheckedAt: time.Now().UTC(),
80+ CurrentAt: currentVersion,
81+ Latest: latest,
82+ URL: url,
83+ })
84+
85+ return buildInfo(currentVersion, latest, url)
86+}
87+
88+// CheckForUpdateAsync runs CheckForUpdate in a goroutine, delivers the
89+// result to the returned channel, and never blocks startup. The
90+// channel is always closed; receivers should `ok`-check.
91+func CheckForUpdateAsync(zotHome, currentVersion string) <-chan UpdateInfo {
92+ ch := make(chan UpdateInfo, 1)
93+ go func() {
94+ defer close(ch)
95+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
96+ defer cancel()
97+ ch <- CheckForUpdate(ctx, zotHome, currentVersion)
98+ }()
99+ return ch
100+}
101+
102+func buildInfo(current, latest, url string) UpdateInfo {
103+ info := UpdateInfo{
104+ Current: current,
105+ Latest: strings.TrimPrefix(latest, "v"),
106+ URL: url,
107+ }
108+ info.Available = versionLess(info.Current, info.Latest)
109+ return info
110+}
111+
112+// versionLess returns a < b for dotted semver-ish tags like "0.0.4".
113+// Non-numeric components compare as zero, which is fine for zot's
114+// x.y.z-only scheme.
115+func versionLess(a, b string) bool {
116+ as := splitVersion(a)
117+ bs := splitVersion(b)
118+ for i := 0; i < 3; i++ {
119+ av, bv := 0, 0
120+ if i < len(as) {
121+ av = as[i]
122+ }
123+ if i < len(bs) {
124+ bv = bs[i]
125+ }
126+ if av != bv {
127+ return av < bv
128+ }
129+ }
130+ return false
131+}
132+
133+func splitVersion(s string) []int {
134+ s = strings.TrimPrefix(s, "v")
135+ // Strip build-metadata suffix like "(abc1234, 2026-04-18)".
136+ if i := strings.IndexAny(s, " ("); i > 0 {
137+ s = s[:i]
138+ }
139+ parts := strings.Split(s, ".")
140+ out := make([]int, 0, len(parts))
141+ for _, p := range parts {
142+ n, _ := strconv.Atoi(p)
143+ out = append(out, n)
144+ }
145+ return out
146+}
147+
148+// fetchLatestRelease queries the GitHub API for the latest published
149+// release. Honours $GITHUB_TOKEN for private repos.
150+func fetchLatestRelease(ctx context.Context) (tag, url string, err error) {
151+ req, err := http.NewRequestWithContext(ctx, "GET", githubReleasesAPI, nil)
152+ if err != nil {
153+ return "", "", err
154+ }
155+ req.Header.Set("accept", "application/vnd.github+json")
156+ req.Header.Set("x-github-api-version", "2022-11-28")
157+ if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
158+ req.Header.Set("authorization", "Bearer "+tok)
159+ }
160+
161+ client := &http.Client{Timeout: 4 * time.Second}
162+ resp, err := client.Do(req)
163+ if err != nil {
164+ return "", "", err
165+ }
166+ defer resp.Body.Close()
167+ if resp.StatusCode != 200 {
168+ return "", "", fmt.Errorf("github api %d", resp.StatusCode)
169+ }
170+
171+ var body struct {
172+ TagName string `json:"tag_name"`
173+ HTMLURL string `json:"html_url"`
174+ }
175+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
176+ return "", "", err
177+ }
178+ return body.TagName, body.HTMLURL, nil
179+}
180+
181+// readUpdateCache loads the last check result. Returns ok=false on
182+// any error (missing file, bad json) so callers just treat it as a
183+// cache miss and re-fetch.
184+func readUpdateCache(path string) (updateCache, bool) {
185+ b, err := os.ReadFile(path)
186+ if err != nil {
187+ return updateCache{}, false
188+ }
189+ var c updateCache
190+ if err := json.Unmarshal(b, &c); err != nil {
191+ return updateCache{}, false
192+ }
193+ return c, true
194+}
195+
196+func writeUpdateCache(path string, c updateCache) error {
197+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
198+ return err
199+ }
200+ b, err := json.MarshalIndent(c, "", " ")
201+ if err != nil {
202+ return err
203+ }
204+ return os.WriteFile(path, b, 0o600)
205+}
206+
207+// cachedLatest, cachedLatestOnce: used elsewhere in the binary if we
208+// want a synchronous read without triggering a network call.
209+var (
210+ cachedLatest string
211+ cachedLatestOnce sync.Once
212+)

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