Skip to content

Comments

Add interactive TUI for browsing Honeybadger data#17

Draft
stympy wants to merge 20 commits intoadd-more-endpointsfrom
claude/add-honeybadger-tui-GQx3C
Draft

Add interactive TUI for browsing Honeybadger data#17
stympy wants to merge 20 commits intoadd-more-endpointsfrom
claude/add-honeybadger-tui-GQx3C

Conversation

@stympy
Copy link
Member

@stympy stympy commented Jan 7, 2026

Adds a new hb tui command that launches an interactive terminal UI for browsing Honeybadger data, similar to how e1s works for AWS ECS.

Features:

  • Hierarchical navigation from accounts to projects to resources
  • Browse faults with drill-down to notices and affected users
  • View deployments, uptime sites (with outages/checks), check-ins
  • View teams with members and invitations
  • View project integrations
  • Vim-style keybindings (j/k/h/l) plus arrow keys
  • Help modal with ? key
  • Refresh with r key

Navigation hierarchy:

  Accounts 
    └── Account Menu (Projects, Teams, Users, Invitations) 
    ├── Projects
    │   └── Project Menu
    │       ├── Faults → Notices, Affected Users
    │       ├── Deployments
    │       ├── Uptime Sites → Outages, Checks
    │       ├── Check-ins
    │       └── Integrations
    └── Teams → Members, Invitations

Uses tview library for the terminal UI.

Adds a new `hb tui` command that launches an interactive terminal UI
for browsing Honeybadger data, similar to how e1s works for AWS ECS.

Features:
- Hierarchical navigation from accounts to projects to resources
- Browse faults with drill-down to notices and affected users
- View deployments, uptime sites (with outages/checks), check-ins
- View teams with members and invitations
- View project integrations
- Vim-style keybindings (j/k/h/l) plus arrow keys
- Help modal with ? key
- Refresh with r key

Navigation hierarchy:
  Accounts
  └── Account Menu (Projects, Teams, Users, Invitations)
      ├── Projects
      │   └── Project Menu
      │       ├── Faults → Notices, Affected Users
      │       ├── Deployments
      │       ├── Uptime Sites → Outages, Checks
      │       ├── Check-ins
      │       └── Integrations
      └── Teams → Members, Invitations

Uses tview library for the terminal UI.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a comprehensive interactive TUI (Terminal User Interface) for browsing Honeybadger data. The implementation introduces a new hb tui command that provides hierarchical navigation through accounts, projects, faults, deployments, uptime sites, check-ins, teams, and related resources using Vim-style keybindings.

Key changes:

  • New TUI framework using tview library with navigation stack and breadcrumb UI
  • Complete view implementations for all major Honeybadger resources with drill-down capability
  • Keyboard-driven interface with help modal and refresh functionality

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
go.mod Updates Go version and adds TUI dependencies (tcell, tview, honeybadger-io/api-go)
go.sum Adds checksums for new TUI dependencies
cmd/tui.go Command entry point that initializes API client and launches TUI
tui/app.go Core TUI application with navigation stack, input handling, and view lifecycle
tui/accounts.go Views for browsing accounts, users, and invitations
tui/projects.go Views for projects and integrations
tui/faults.go Views for faults, notices, and affected users
tui/deployments.go Views for deployments with detail drill-down
tui/checkins.go Views for check-ins (cron monitoring)
tui/uptime.go Views for uptime sites, outages, and checks
tui/teams.go Views for teams, members, and invitations

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 88 to 92
func (v *AccountsView) renderTable() {
// Clear existing rows (keep header)
for row := v.table.GetRowCount() - 1; row > 0; row-- {
v.table.RemoveRow(row)
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This table clearing pattern is duplicated across multiple files (accounts.go, teams.go, uptime.go, faults.go, projects.go, deployments.go, checkins.go). Consider extracting this into a shared helper function to reduce code duplication and improve maintainability. For example, create a helper method like clearTableRows(table *tview.Table) in a shared utilities file.

Suggested change
func (v *AccountsView) renderTable() {
// Clear existing rows (keep header)
for row := v.table.GetRowCount() - 1; row > 0; row-- {
v.table.RemoveRow(row)
}
func clearTableRows(table *tview.Table) {
for row := table.GetRowCount() - 1; row > 0; row-- {
table.RemoveRow(row)
}
}
func (v *AccountsView) renderTable() {
// Clear existing rows (keep header)
clearTableRows(v.table)

Copilot uses AI. Check for mistakes.
Comment on lines 115 to 149
// HandleInput handles keyboard input
func (v *AccountsView) HandleInput(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'j':
row, col := v.table.GetSelection()
if row < v.table.GetRowCount()-1 {
v.table.Select(row+1, col)
}
return nil
case 'k':
row, col := v.table.GetSelection()
if row > 1 {
v.table.Select(row-1, col)
}
return nil
case 'l':
row, _ := v.table.GetSelection()
if row > 0 && row <= len(v.accounts) {
account := v.accounts[row-1]
v.drillDown(account)
}
return nil
}

if event.Key() == tcell.KeyEnter || event.Key() == tcell.KeyRight {
row, _ := v.table.GetSelection()
if row > 0 && row <= len(v.accounts) {
account := v.accounts[row-1]
v.drillDown(account)
}
return nil
}

return event
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HandleInput keyboard navigation logic is heavily duplicated across all view types. Consider creating a base handler or helper functions for common navigation patterns (j/k for up/down in tables, j/k for list navigation, h for back). This would significantly reduce code duplication and make it easier to maintain consistent keyboard behavior across all views.

Copilot uses AI. Check for mistakes.
tui/app.go Outdated
Comment on lines 124 to 132
// Refresh the view to load data
go func() {
if err := view.Refresh(); err != nil {
a.app.QueueUpdateDraw(func() {
a.ShowError(err)
})
}
a.app.Draw()
}()
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goroutine spawned in Push method does not have a mechanism to be canceled when the view is popped before the refresh completes. If a user quickly navigates away from a view, the goroutine will continue running and potentially call ShowError on a view that's no longer visible. Consider using a cancellable context that gets canceled in the Pop method, or checking if the view is still active before updating the UI.

Copilot uses AI. Check for mistakes.
tui/app.go Outdated
client: client,
pages: tview.NewPages(),
navStack: make([]View, 0),
ctx: context.Background(),
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The context is created using context.Background() with no cancellation mechanism. This means that if the application is stopped, any in-flight API requests will not be canceled and could continue running. Consider using a context that can be canceled when the application stops, and propagate this through the API calls to enable proper cleanup.

Copilot uses AI. Check for mistakes.
tui/uptime.go Outdated
Comment on lines 428 to 429
if len(reason) > 40 {
reason = reason[:37] + "..."
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String truncation logic is not UTF-8 aware and will panic if the string contains multi-byte characters and the slice index falls in the middle of a character. Use a UTF-8-aware truncation function or the runes package to safely truncate strings that may contain Unicode characters. For example, convert to runes first: runes := []rune(reason); if len(runes) > 40 { reason = string(runes[:37]) + "..." }

Suggested change
if len(reason) > 40 {
reason = reason[:37] + "..."
runes := []rune(reason)
if len(runes) > 40 {
reason = string(runes[:37]) + "..."

Copilot uses AI. Check for mistakes.
tui/faults.go Outdated
Comment on lines 192 to 223
title := v.fault.Klass
if len(title) > 50 {
title = title[:47] + "..."
}
v.list.SetTitle(fmt.Sprintf(" %s ", title)).
SetBorder(true).
SetBorderColor(tcell.ColorDarkCyan)

v.list.AddItem("Details", "View fault details", 'd', func() {
detailsView := NewFaultDetailsView(v.app, v.projectID, v.fault.ID)
v.app.Push(detailsView)
})

v.list.AddItem("Notices", fmt.Sprintf("View notices (%d total)", v.fault.NoticesCount), 'n', func() {
noticesView := NewNoticesView(v.app, v.projectID, v.fault.ID)
v.app.Push(noticesView)
})

v.list.AddItem("Affected Users", "View affected users", 'u', func() {
usersView := NewAffectedUsersView(v.app, v.projectID, v.fault.ID)
v.app.Push(usersView)
})

v.list.SetSelectedBackgroundColor(tcell.ColorDarkCyan)
}

// Name returns the view name
func (v *FaultMenuView) Name() string {
name := v.fault.Klass
if len(name) > 30 {
name = name[:27] + "..."
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String truncation logic is not UTF-8 aware and will panic if the string contains multi-byte characters and the slice index falls in the middle of a character. Use a UTF-8-aware truncation function or the runes package to safely truncate strings that may contain Unicode characters.

Copilot uses AI. Check for mistakes.
tui/faults.go Outdated
Comment on lines 99 to 100
if len(message) > 40 {
message = message[:37] + "..."
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String truncation logic is not UTF-8 aware and will panic if the string contains multi-byte characters and the slice index falls in the middle of a character. Use a UTF-8-aware truncation function or the runes package to safely truncate strings that may contain Unicode characters. For example, convert to runes first: runes := []rune(message); if len(runes) > 40 { message = string(runes[:37]) + "..." }

Suggested change
if len(message) > 40 {
message = message[:37] + "..."
runes := []rune(message)
if len(runes) > 40 {
message = string(runes[:37]) + "..."

Copilot uses AI. Check for mistakes.
tui/uptime.go Outdated
Comment on lines 106 to 107
if len(url) > 40 {
url = url[:37] + "..."
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String truncation logic is not UTF-8 aware and will panic if the string contains multi-byte characters and the slice index falls in the middle of a character. Use a UTF-8-aware truncation function or the runes package to safely truncate strings that may contain Unicode characters. For example, convert to runes first: runes := []rune(url); if len(runes) > 40 { url = string(runes[:37]) + "..." }

Suggested change
if len(url) > 40 {
url = url[:37] + "..."
runes := []rune(url)
if len(runes) > 40 {
url = string(runes[:37]) + "..."

Copilot uses AI. Check for mistakes.
Comment on lines 98 to 106
revision := d.Revision
if len(revision) > 12 {
revision = revision[:12]
}

repo := d.Repository
if len(repo) > 30 {
repo = repo[:27] + "..."
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String truncation logic is not UTF-8 aware and will panic if the string contains multi-byte characters and the slice index falls in the middle of a character. Use a UTF-8-aware truncation function or the runes package to safely truncate strings that may contain Unicode characters.

Copilot uses AI. Check for mistakes.
tui/faults.go Outdated
Comment on lines 453 to 461
message := notice.Message
if len(message) > 50 {
message = message[:47] + "..."
}

id := notice.ID
if len(id) > 12 {
id = id[:12] + "..."
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String truncation logic is not UTF-8 aware and will panic if the string contains multi-byte characters and the slice index falls in the middle of a character. Use a UTF-8-aware truncation function or the runes package to safely truncate strings that may contain Unicode characters.

Copilot uses AI. Check for mistakes.
- Add helpers.go with reusable functions for table clearing, string
  truncation, and keyboard navigation handling
- Update truncateString to be UTF-8 aware using runes
- Add context cancellation support to app goroutines to prevent leaks
- Consolidate duplicate keyboard handling code across all view files
- Net reduction of ~345 lines through code deduplication

Addresses Copilot review comments on PR #17.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 12 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

f.Environment,
f.Component,
f.Action,
f.CreatedAt.Format("2006-01-02 15:04:05"),
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil pointer dereference: The code calls f.CreatedAt.Format() without checking if CreatedAt is nil. If the API returns a fault with a nil CreatedAt field, this will cause a panic. Consider using the formatTime helper function (which handles nil) for this field as well, or add a nil check.

Suggested change
f.CreatedAt.Format("2006-01-02 15:04:05"),
formatTime(f.CreatedAt),

Copilot uses AI. Check for mistakes.
tui/faults.go Outdated
v.table.SetCell(row, 1, tview.NewTableCell(message).SetExpansion(3))
v.table.SetCell(row, 2, tview.NewTableCell(notice.EnvironmentName).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(notice.Environment.Hostname).SetExpansion(2))
v.table.SetCell(row, 4, tview.NewTableCell(notice.CreatedAt.Format("2006-01-02 15:04")).SetExpansion(2))
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil pointer dereference: The code calls notice.CreatedAt.Format() without checking if CreatedAt is nil. If the API returns a notice with a nil CreatedAt field, this will cause a panic. Consider adding a nil check or using a helper function similar to formatTime.

Copilot uses AI. Check for mistakes.
tui/teams.go Outdated
Comment on lines 365 to 368
v.table.SetCell(row, 0, tview.NewTableCell(fmt.Sprintf("%d", inv.ID)).SetExpansion(1))
v.table.SetCell(row, 1, tview.NewTableCell(inv.Email).SetExpansion(2))
v.table.SetCell(row, 2, tview.NewTableCell(admin).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(inv.CreatedAt.Format("2006-01-02")).SetExpansion(1))
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil pointer dereference: The code calls inv.CreatedAt.Format() without checking if CreatedAt is nil. If the API returns an invitation with a nil CreatedAt field, this will cause a panic. Consider adding a nil check or using a helper function similar to formatTime.

Suggested change
v.table.SetCell(row, 0, tview.NewTableCell(fmt.Sprintf("%d", inv.ID)).SetExpansion(1))
v.table.SetCell(row, 1, tview.NewTableCell(inv.Email).SetExpansion(2))
v.table.SetCell(row, 2, tview.NewTableCell(admin).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(inv.CreatedAt.Format("2006-01-02")).SetExpansion(1))
created := ""
if inv.CreatedAt != nil {
created = inv.CreatedAt.Format("2006-01-02")
}
v.table.SetCell(row, 0, tview.NewTableCell(fmt.Sprintf("%d", inv.ID)).SetExpansion(1))
v.table.SetCell(row, 1, tview.NewTableCell(inv.Email).SetExpansion(2))
v.table.SetCell(row, 2, tview.NewTableCell(admin).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(created).SetExpansion(1))

Copilot uses AI. Check for mistakes.
tui/app.go Outdated

[green]Actions:[white]
r Refresh current view
/ Search (in list views)
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text mentions a search feature with "/" key, but there's no implementation for search functionality in the input capture handler (lines 76-116) or anywhere else in the codebase. Either remove this from the help text or implement the search feature to avoid misleading users.

Suggested change
/ Search (in list views)

Copilot uses AI. Check for mistakes.
tui/uptime.go Outdated
upColor = tcell.ColorGreen
}

v.table.SetCell(row, 0, tview.NewTableCell(check.CreatedAt.Format("2006-01-02 15:04:05")).SetExpansion(2))
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil pointer dereference: The code calls check.CreatedAt.Format() without checking if CreatedAt is nil. If the API returns an uptime check with a nil CreatedAt field, this will cause a panic. Consider adding a nil check or using a helper function similar to formatTime.

Suggested change
v.table.SetCell(row, 0, tview.NewTableCell(check.CreatedAt.Format("2006-01-02 15:04:05")).SetExpansion(2))
createdAt := ""
if check.CreatedAt != nil {
createdAt = check.CreatedAt.Format("2006-01-02 15:04:05")
}
v.table.SetCell(row, 0, tview.NewTableCell(createdAt).SetExpansion(2))

Copilot uses AI. Check for mistakes.
Comment on lines 128 to 156
go func() {
// Check if context is cancelled before starting
select {
case <-a.ctx.Done():
return
default:
}

if err := view.Refresh(); err != nil {
// Check if context is cancelled before showing error
select {
case <-a.ctx.Done():
return
default:
a.app.QueueUpdateDraw(func() {
a.ShowError(err)
})
}
}

// Check if context is cancelled before drawing
select {
case <-a.ctx.Done():
return
default:
a.app.Draw()
}
}()
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition here. The goroutine at line 128 accesses the view after it has been pushed to the navigation stack, but there's no guarantee that the view hasn't been popped off the stack (via Pop() called from another goroutine or user input) before the Refresh() completes. If a user rapidly navigates forward and back, the goroutine may attempt to update a view that is no longer active, or worse, call ShowError after the context is cancelled. While the context cancellation checks help, consider tracking which view is currently active and only showing errors for the current view.

Copilot uses AI. Check for mistakes.
tui/checkins.go Outdated
}

text += fmt.Sprintf("\n\n[yellow]Project ID:[white] %d", ci.ProjectID)
text += fmt.Sprintf("\n[yellow]Created:[white] %s", ci.CreatedAt.Format("2006-01-02 15:04:05"))
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil pointer dereference: The code calls ci.CreatedAt.Format() without checking if CreatedAt is nil. If the API returns a check-in with a nil CreatedAt field, this will cause a panic. Consider adding a nil check or using a helper function similar to formatTime used elsewhere in the codebase.

Suggested change
text += fmt.Sprintf("\n[yellow]Created:[white] %s", ci.CreatedAt.Format("2006-01-02 15:04:05"))
createdAtStr := ""
if ci.CreatedAt != nil {
createdAtStr = ci.CreatedAt.Format("2006-01-02 15:04:05")
}
text += fmt.Sprintf("\n[yellow]Created:[white] %s", createdAtStr)

Copilot uses AI. Check for mistakes.
Comment on lines +408 to +411
v.table.SetCell(row, 0, tview.NewTableCell(id).SetExpansion(1))
v.table.SetCell(row, 1, tview.NewTableCell(message).SetExpansion(3))
v.table.SetCell(row, 2, tview.NewTableCell(notice.EnvironmentName).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(notice.Environment.Hostname).SetExpansion(2))
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil pointer dereference: The code accesses notice.Environment.Hostname without checking if notice.Environment is nil. If the API returns a notice with a nil Environment field, this will cause a panic. Consider adding a nil check before accessing nested fields.

Suggested change
v.table.SetCell(row, 0, tview.NewTableCell(id).SetExpansion(1))
v.table.SetCell(row, 1, tview.NewTableCell(message).SetExpansion(3))
v.table.SetCell(row, 2, tview.NewTableCell(notice.EnvironmentName).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(notice.Environment.Hostname).SetExpansion(2))
hostname := ""
if notice.Environment != nil {
hostname = notice.Environment.Hostname
}
v.table.SetCell(row, 0, tview.NewTableCell(id).SetExpansion(1))
v.table.SetCell(row, 1, tview.NewTableCell(message).SetExpansion(3))
v.table.SetCell(row, 2, tview.NewTableCell(notice.EnvironmentName).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(hostname).SetExpansion(2))

Copilot uses AI. Check for mistakes.
tui/app.go Outdated
a.footer = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("[yellow]↑↓/jk[white] Navigate [yellow]Enter/→[white] Select [yellow]Esc/←[white] Back [yellow]r[white] Refresh [yellow]q[white] Quit [yellow]?[white] Help")
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the footer navigation text, there's an inconsistency between what keys are mentioned here and what is handled in the code. The footer shows "Esc/←" and "Enter/→" for Back and Select, but the code also handles 'h' for back and 'l' for select (Vim-style). Either include these in the footer help text or remove them from the description to avoid user confusion. The help modal at line 205 does mention these keys, but the persistent footer should too for discoverability.

Suggested change
SetText("[yellow]↑↓/jk[white] Navigate [yellow]Enter/→[white] Select [yellow]Esc/←[white] Back [yellow]r[white] Refresh [yellow]q[white] Quit [yellow]?[white] Help")
SetText("[yellow]↑↓/jk[white] Navigate [yellow]Enter/l/→[white] Select [yellow]Esc/h/←[white] Back [yellow]r[white] Refresh [yellow]q[white] Quit [yellow]?[white] Help")

Copilot uses AI. Check for mistakes.
d.Repository,
d.LocalUsername,
d.ProjectID,
d.CreatedAt.Format("2006-01-02 15:04:05"),
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil pointer dereference: The code calls d.CreatedAt.Format() without checking if CreatedAt is nil. If the API returns a deployment with a nil CreatedAt field, this will cause a panic. Consider using the formatTime helper function defined at line 213 in this same file, which already handles nil values properly.

Suggested change
d.CreatedAt.Format("2006-01-02 15:04:05"),
formatTime(d.CreatedAt),

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@stympy stympy marked this pull request as draft February 10, 2026 17:34
@stympy stympy removed the request for review from a team February 12, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants