Skip to content

Commit 73c0f25

Browse files
maxbeizerCopilot
andauthored
feat: show version in header with upgrade notification (#215)
Display the current version next to the tagline in the header banner. On startup, asynchronously check the latest GitHub release — if a newer version exists, show an upgrade nudge (e.g., '⬆ v0.9.0 available') in amber next to the version. - Thread version from cmd.Version → NewModel → ProgramContext → header - Add checkLatestVersion() command using gh api (non-blocking) - Header.renderVersionBadge() shows version + optional upgrade nudge Fixes #214 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d24cb48 commit 73c0f25

7 files changed

Lines changed: 88 additions & 28 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ terminal UI for visualizing and managing GitHub Copilot coding agent sessions.
3131
data.SetDebug(debugFlag)
3232

3333
// Create the Bubble Tea program
34-
model := tui.NewModel(repoFlag, debugFlag, demoFlag, snapshotFlag)
34+
model := tui.NewModel(repoFlag, debugFlag, demoFlag, snapshotFlag, Version)
3535
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseAllMotion())
3636

3737
// Run the program

internal/tui/commands.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ type errMsg struct {
6767
err error
6868
}
6969

70+
type latestVersionMsg struct {
71+
version string // e.g., "v0.8.2"
72+
}
73+
7074
type conversationLoadedMsg struct {
7175
messages []conversation.ChatMessage
7276
}
@@ -471,3 +475,16 @@ func (m Model) fetchGitDiff(workDir string) tea.Cmd {
471475
return gitDiffLoadedMsg{result}
472476
}
473477
}
478+
479+
// checkLatestVersion queries GitHub for the latest release tag.
480+
func checkLatestVersion() tea.Msg {
481+
out, err := exec.Command("gh", "api",
482+
"repos/maxbeizer/gh-agent-viz/releases/latest",
483+
"--jq", ".tag_name",
484+
).Output()
485+
if err != nil {
486+
return latestVersionMsg{} // silently ignore
487+
}
488+
tag := strings.TrimSpace(string(out))
489+
return latestVersionMsg{version: tag}
490+
}

internal/tui/components/header/header.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type Model struct {
4242
tabCount lipgloss.Style
4343
title string
4444
tagline string
45+
version string
46+
upgradeVersion string // non-empty when a newer version is available
4547
filter *string
4648
counts FilterCounts
4749
useAsciiHeader bool
@@ -59,21 +61,27 @@ const bannerWidth = 27
5961
const minHeightForBanner = 15
6062

6163
// New creates a new header model
62-
func New(titleStyle, tabActive, tabInactive, tabCount lipgloss.Style, title string, filter *string, useAsciiHeader bool) Model {
64+
func New(titleStyle, tabActive, tabInactive, tabCount lipgloss.Style, title string, filter *string, useAsciiHeader bool, version string) Model {
6365
return Model{
6466
titleStyle: titleStyle,
6567
tabActive: tabActive,
6668
tabInactive: tabInactive,
6769
tabCount: tabCount,
6870
title: title,
6971
tagline: taglines[rand.Intn(len(taglines))],
72+
version: version,
7073
filter: filter,
7174
useAsciiHeader: useAsciiHeader,
7275
width: 80,
7376
height: 24,
7477
}
7578
}
7679

80+
// SetUpgradeVersion sets the latest available version for the upgrade nudge.
81+
func (m *Model) SetUpgradeVersion(v string) {
82+
m.upgradeVersion = v
83+
}
84+
7785
// SetSize updates the terminal dimensions for responsive layout
7886
func (m *Model) SetSize(width, height int) {
7987
m.width = width
@@ -131,13 +139,19 @@ func (m Model) View() string {
131139
Bold(true).
132140
Foreground(m.titleStyle.GetForeground())
133141
styledBanner := bannerStyle.Render(Banner)
142+
143+
// Build info block: tagline + version
144+
var infoParts []string
134145
if m.tagline != "" {
135146
tagStyle := lipgloss.NewStyle().
136147
Foreground(lipgloss.AdaptiveColor{Light: "245", Dark: "250"}).
137148
Italic(true)
138-
tagBlock := tagStyle.Render(m.tagline)
139-
styledBanner = lipgloss.JoinHorizontal(lipgloss.Center, styledBanner, " ", tagBlock)
149+
infoParts = append(infoParts, tagStyle.Render(m.tagline))
140150
}
151+
infoParts = append(infoParts, m.renderVersionBadge())
152+
infoBlock := strings.Join(infoParts, "\n")
153+
154+
styledBanner = lipgloss.JoinHorizontal(lipgloss.Center, styledBanner, " ", infoBlock)
141155
return styledBanner + "\n" + tabLine + "\n" + separator + "\n"
142156
}
143157

@@ -161,12 +175,16 @@ func (m Model) ViewBannerOnly() string {
161175
Bold(true).
162176
Foreground(m.titleStyle.GetForeground())
163177
styledBanner := bannerStyle.Render(Banner)
178+
179+
var infoParts []string
164180
if m.tagline != "" {
165181
tagStyle := lipgloss.NewStyle().
166182
Foreground(lipgloss.AdaptiveColor{Light: "245", Dark: "250"}).
167183
Italic(true)
168-
styledBanner = lipgloss.JoinHorizontal(lipgloss.Center, styledBanner, " ", tagStyle.Render(m.tagline))
184+
infoParts = append(infoParts, tagStyle.Render(m.tagline))
169185
}
186+
infoParts = append(infoParts, m.renderVersionBadge())
187+
styledBanner = lipgloss.JoinHorizontal(lipgloss.Center, styledBanner, " ", strings.Join(infoParts, "\n"))
170188
return styledBanner + "\n" + separator + "\n"
171189
}
172190

@@ -178,3 +196,19 @@ func (m Model) ViewBannerOnly() string {
178196

179197
return separator + "\n"
180198
}
199+
200+
// renderVersionBadge returns the version label, with an upgrade nudge if available.
201+
func (m Model) renderVersionBadge() string {
202+
if m.version == "" {
203+
return ""
204+
}
205+
versionStyle := lipgloss.NewStyle().Faint(true)
206+
badge := versionStyle.Render("v" + strings.TrimPrefix(m.version, "v"))
207+
208+
if m.upgradeVersion != "" && m.upgradeVersion != m.version {
209+
upgradeStyle := lipgloss.NewStyle().
210+
Foreground(lipgloss.AdaptiveColor{Light: "214", Dark: "214"})
211+
badge += " " + upgradeStyle.Render("⬆ "+m.upgradeVersion+" available")
212+
}
213+
return badge
214+
}

internal/tui/components/header/header_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99

1010
func newTestModel(title string, filter *string) Model {
1111
s := lipgloss.NewStyle()
12-
return New(s, s, s, s, title, filter, false)
12+
return New(s, s, s, s, title, filter, false, "v0.8.2")
1313
}
1414

1515
func TestNew(t *testing.T) {
@@ -94,7 +94,7 @@ func TestView_EndsWithNewline(t *testing.T) {
9494

9595
func newTestModelWithBanner(title string, filter *string, useAscii bool) Model {
9696
s := lipgloss.NewStyle()
97-
m := New(s, s, s, s, title, filter, useAscii)
97+
m := New(s, s, s, s, title, filter, useAscii, "v0.8.2")
9898
m.SetSize(80, 24)
9999
return m
100100
}

internal/tui/context.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type ProgramContext struct {
2020
Height int
2121
Error error
2222
Debug bool
23+
Version string
2324
StatusFilter string // "all", "attention", "active", "completed", "failed"
2425
Counts FilterCounts
2526
}

internal/tui/ui.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ type Model struct {
8181
}
8282

8383
// NewModel creates a new TUI model
84-
func NewModel(repo string, debug bool, demo bool, snapshotPath string) Model {
84+
func NewModel(repo string, debug bool, demo bool, snapshotPath string, version string) Model {
8585
ctx := NewProgramContext()
8686
ctx.Debug = debug
87+
ctx.Version = version
8788
cfg, err := config.Load("")
8889
if err == nil {
8990
ctx.Config = cfg
@@ -145,7 +146,7 @@ func NewModel(repo string, debug bool, demo bool, snapshotPath string) Model {
145146
ctx: ctx,
146147
theme: theme,
147148
keys: keys,
148-
header: header.New(theme.Title, theme.TabActive, theme.TabInactive, theme.TabCount, "⚡ Agent Sessions", &ctx.StatusFilter, ctx.Config.AsciiHeaderEnabled()),
149+
header: header.New(theme.Title, theme.TabActive, theme.TabInactive, theme.TabCount, "⚡ Agent Sessions", &ctx.StatusFilter, ctx.Config.AsciiHeaderEnabled(), ctx.Version),
149150
footer: footer.New(theme.Footer, footerKeys),
150151
help: help.New(),
151152
taskList: tasklist.NewWithStore(theme.Title, theme.TableHeader, theme.TableRow, theme.TableRowSelected, theme.SectionHeader, StatusIcon, animIconFunc, dismissedStore),
@@ -179,6 +180,7 @@ func (m Model) Init() tea.Cmd {
179180
m.loadSpinner.Tick,
180181
m.fetchLocalSessions, // Phase 1: fast, shows content immediately
181182
m.fetchAgentTasks, // Phase 2: runs concurrently, returns when API responds
183+
checkLatestVersion, // Non-blocking: check for updates
182184
}
183185
if m.ctx.Config.AnimationsEnabled() {
184186
cmds = append(cmds, m.animationTickCmd())
@@ -366,6 +368,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
366368
}
367369
return m, nil
368370

371+
case latestVersionMsg:
372+
if msg.version != "" {
373+
m.header.SetUpgradeVersion(msg.version)
374+
}
375+
return m, nil
376+
369377
case conversationLoadedMsg:
370378
m.conversationView.SetMessages(msg.messages)
371379
m.showConversation = true

internal/tui/ui_test.go

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func TestResumeSessionErr_ValidNeedsInputSession(t *testing.T) {
4848
}
4949

5050
func TestResumeSession_ValidSessionReturnsCmd(t *testing.T) {
51-
m := NewModel("", false, false, "")
51+
m := NewModel("", false, false, "", "dev")
5252

5353
task := &data.Session{
5454
ID: "test-session-123",
@@ -163,7 +163,7 @@ func TestResumeSessionErr_NormalizesStatusCase(t *testing.T) {
163163
}
164164

165165
func TestResumeSession_DetailViewResume(t *testing.T) {
166-
m := NewModel("", false, false, "")
166+
m := NewModel("", false, false, "", "dev")
167167
m.taskList.SetTasks([]data.Session{
168168
{
169169
ID: "local-1",
@@ -182,7 +182,7 @@ func TestResumeSession_DetailViewResume(t *testing.T) {
182182
}
183183

184184
func TestResumeSession_LogViewResume(t *testing.T) {
185-
m := NewModel("", false, false, "")
185+
m := NewModel("", false, false, "", "dev")
186186
m.taskList.SetTasks([]data.Session{
187187
{
188188
ID: "local-1",
@@ -201,7 +201,7 @@ func TestResumeSession_LogViewResume(t *testing.T) {
201201
}
202202

203203
func TestUpdateFooterHints_DetailViewShowsResumeForResumableSession(t *testing.T) {
204-
m := NewModel("", false, false, "")
204+
m := NewModel("", false, false, "", "dev")
205205
m.viewMode = ViewModeDetail
206206
m.taskList.SetTasks([]data.Session{
207207
{
@@ -224,7 +224,7 @@ func TestUpdateFooterHints_DetailViewShowsResumeForResumableSession(t *testing.T
224224
}
225225

226226
func TestCycleFilterForwardAndBackward(t *testing.T) {
227-
m := NewModel("", false, false, "")
227+
m := NewModel("", false, false, "", "dev")
228228
// New order: attention → active → completed → failed → all
229229
m.ctx.StatusFilter = "attention"
230230

@@ -250,7 +250,7 @@ func TestCycleFilterForwardAndBackward(t *testing.T) {
250250
}
251251

252252
func TestHandleListKeys_AJumpsToAttention(t *testing.T) {
253-
m := NewModel("", false, false, "")
253+
m := NewModel("", false, false, "", "dev")
254254
m.ctx.StatusFilter = "active" // start on a different tab
255255

256256
updated, cmd := m.handleListKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
@@ -264,7 +264,7 @@ func TestHandleListKeys_AJumpsToAttention(t *testing.T) {
264264
}
265265

266266
func TestHandleListKeys_AJumpsToAttentionFromAttention(t *testing.T) {
267-
m := NewModel("", false, false, "")
267+
m := NewModel("", false, false, "", "dev")
268268
m.ctx.StatusFilter = "attention"
269269

270270
updated, cmd := m.handleListKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
@@ -278,7 +278,7 @@ func TestHandleListKeys_AJumpsToAttentionFromAttention(t *testing.T) {
278278
}
279279

280280
func TestHandleListKeys_LocalSessionLogSwitchesToLogView(t *testing.T) {
281-
m := NewModel("", false, false, "")
281+
m := NewModel("", false, false, "", "dev")
282282
m.taskList.SetTasks([]data.Session{
283283
{
284284
ID: "local-1",
@@ -301,15 +301,15 @@ func TestHandleListKeys_LocalSessionLogSwitchesToLogView(t *testing.T) {
301301
}
302302

303303
func TestView_NotReadyShowsStartupText(t *testing.T) {
304-
m := NewModel("", false, false, "")
304+
m := NewModel("", false, false, "", "dev")
305305
view := m.View()
306306
if view != "Loading sessions..." {
307307
t.Fatalf("expected startup text, got %q", view)
308308
}
309309
}
310310

311311
func TestUpdateFooterHints_LocalSessionShowsOnlyAvailableActions(t *testing.T) {
312-
m := NewModel("", false, false, "")
312+
m := NewModel("", false, false, "", "dev")
313313
m.viewMode = ViewModeList
314314
m.taskList.SetTasks([]data.Session{
315315
{
@@ -332,7 +332,7 @@ func TestUpdateFooterHints_LocalSessionShowsOnlyAvailableActions(t *testing.T) {
332332
}
333333

334334
func TestUpdateFooterHints_AgentSessionWithoutPRHidesOpenPRHint(t *testing.T) {
335-
m := NewModel("", false, false, "")
335+
m := NewModel("", false, false, "", "dev")
336336
m.viewMode = ViewModeList
337337
m.taskList.SetTasks([]data.Session{
338338
{
@@ -356,7 +356,7 @@ func TestUpdateFooterHints_AgentSessionWithoutPRHidesOpenPRHint(t *testing.T) {
356356
}
357357

358358
func TestHandleLogKeys_FTogglesFollowMode(t *testing.T) {
359-
m := NewModel("", false, false, "")
359+
m := NewModel("", false, false, "", "dev")
360360
m.taskList.SetTasks([]data.Session{
361361
{
362362
ID: "agent-1",
@@ -383,7 +383,7 @@ func TestHandleLogKeys_FTogglesFollowMode(t *testing.T) {
383383
}
384384

385385
func TestHandleLogKeys_ScrollUpPausesFollowMode(t *testing.T) {
386-
m := NewModel("", false, false, "")
386+
m := NewModel("", false, false, "", "dev")
387387
m.viewMode = ViewModeLog
388388
m.logView.SetLive(true)
389389
m.logView.SetFollowMode(true)
@@ -396,7 +396,7 @@ func TestHandleLogKeys_ScrollUpPausesFollowMode(t *testing.T) {
396396
}
397397

398398
func TestHandleLogKeys_GotoBottomEnablesFollowMode(t *testing.T) {
399-
m := NewModel("", false, false, "")
399+
m := NewModel("", false, false, "", "dev")
400400
m.viewMode = ViewModeLog
401401
m.logView.SetLive(true)
402402
m.logView.SetFollowMode(false)
@@ -409,7 +409,7 @@ func TestHandleLogKeys_GotoBottomEnablesFollowMode(t *testing.T) {
409409
}
410410

411411
func TestHandleLogKeys_EscClearsLiveMode(t *testing.T) {
412-
m := NewModel("", false, false, "")
412+
m := NewModel("", false, false, "", "dev")
413413
m.viewMode = ViewModeLog
414414
m.logView.SetLive(true)
415415
m.logView.SetFollowMode(true)
@@ -439,7 +439,7 @@ func TestIsSessionRunning(t *testing.T) {
439439
}
440440

441441
func TestView_LoadingScreenShownBeforeDataLoads(t *testing.T) {
442-
m := NewModel("", false, false, "")
442+
m := NewModel("", false, false, "", "dev")
443443
// Simulate terminal sizing (sets ready = true)
444444
m.ready = true
445445
m.ctx.Width = 80
@@ -466,7 +466,7 @@ func TestView_LoadingScreenShownBeforeDataLoads(t *testing.T) {
466466
}
467467

468468
func TestView_LoadingScreenTransitionsAfterInitialLoad(t *testing.T) {
469-
m := NewModel("", false, true, "") // demo mode for simplicity
469+
m := NewModel("", false, true, "", "dev") // demo mode for simplicity
470470
m.ready = true
471471
m.ctx.Width = 120
472472
m.ctx.Height = 40
@@ -488,7 +488,7 @@ func TestLoadingTaglines_NotEmpty(t *testing.T) {
488488
}
489489

490490
func TestHandleListKeys_GOpensGitActivity(t *testing.T) {
491-
m := NewModel("", false, false, "")
491+
m := NewModel("", false, false, "", "dev")
492492
m.taskList.SetTasks([]data.Session{
493493
{
494494
ID: "local-1",
@@ -510,7 +510,7 @@ func TestHandleListKeys_GOpensGitActivity(t *testing.T) {
510510
}
511511

512512
func TestHandleListKeys_GToastsForNonLocalSession(t *testing.T) {
513-
m := NewModel("", false, false, "")
513+
m := NewModel("", false, false, "", "dev")
514514
m.taskList.SetTasks([]data.Session{
515515
{
516516
ID: "agent-1",

0 commit comments

Comments
 (0)