feat: implement basic TUI framework with host list

- Add Bubble Tea TUI model with Init/Update/View
- Implement host list screen with keyboard navigation (up/down/enter/q)
- Add styled rendering for hosts, selection, tags
- Create hostkeeper tui CLI command
- Add tests for TUI initialization and host loading
- Update project state documentation
This commit is contained in:
swanadiva
2026-06-23 13:54:49 +07:00
parent d6b810de47
commit fbe444c3ab
5 changed files with 293 additions and 8 deletions
+19 -8
View File
@@ -2,7 +2,7 @@
> **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions
>
> **Last Updated**: 2024-06-23 (Session 6)
> **Last Updated**: 2024-06-23 (Session 7)
> **Current Status**: Implementation In Progress - Tasks 1-8 Complete
> **Phase**: MVP Development (Phase 1)
@@ -33,8 +33,8 @@
**Bug Fix**: Fixed deadlock in JSON storage (RLock within Lock)
### What Needs to Happen Next
🔄 **Task 10**: TUI implementation (Bubble Tea)
🔄 **Task 11**: Export/Import commands
🔄 Key management commands
🔄 Build and test core features
🔄 Prepare MVP release
@@ -54,11 +54,11 @@
| **SSH Client** | ✅ 100% | Password + key auth, Execute, Connect/Close |
| **CLI Framework** | ✅ 100% | Cobra root, version, completion commands |
| **CLI Commands** | 🟡 60% | add + list + connect + edit + delete commands done |
| **TUI** | 🔲 0% | Terminal user interface |
| **Testing** | 🟡 50% | Error + SSH + add + list + connect + edit + delete tests passing |
| **TUI** | 🟡 40% | Basic TUI with host list navigation |
| **Testing** | 🟡 50% | Error + SSH + add + list + connect + edit + delete + TUI tests passing |
| **Documentation** | 🔲 0% | Usage guides and API docs |
### Overall Progress: **~65% Complete** (Tasks 1-9 + edit/delete done)
### Overall Progress: **~70% Complete** (Tasks 1-10 done)
---
@@ -159,7 +159,17 @@
- `cmd/hostkeeper/delete.go` — Delete command with --force flag to skip confirmation
- `cmd/hostkeeper/delete_test.go` — Tests for command existence, alias, and flags
#### 🔲 Task 10-14: Remaining Tasks
#### Task 10: Basic TUI Implementation
- **Status**: ✅ Completed
- **Priority**: HIGH
- **Deliverables**: Bubble Tea TUI with host list screen
- **Files Created**:
- `pkg/tui/tui.go` — TUI model with Init/Update/View (Bubble Tea)
- `pkg/tui/host_list.go` — Host list renderer with keyboard navigation
- `pkg/tui/tui_test.go` — Tests for TUI initialization and host loading
- `cmd/hostkeeper/tui.go` — CLI `tui` command
#### 🔲 Task 11-14: Remaining Tasks
- **Status**: Not Started
- **Details**: See `docs/plans/2024-06-22-hostkeeper-implementation.md`
@@ -168,7 +178,7 @@
## 🗺️ Development Roadmap
### Current Week Focus
**Target**: Complete Tasks 9+ (Core CLI Commands)
**Target**: Complete Tasks 10+ (TUI, Export/Import)
### This Sprint
- [x] Project setup and dependencies
@@ -182,6 +192,7 @@
- [x] Connect host command
- [x] Edit host command
- [x] Delete host command
- [x] TUI implementation
### Next Sprint
- [ ] TUI implementation (Bubble Tea)
@@ -548,7 +559,7 @@ cat go.mod
- [x] Milestone 1: Foundation (Tasks 1-6) - Week 1 ✅ COMPLETE
- [x] Task 7-9: Add, List, Connect commands ✅ COMPLETE
- [x] Edit & Delete commands ✅ COMPLETE
- [ ] Milestone 2: Core Features (Tasks 7-10) - Week 2-3 (85% complete)
- [x] Milestone 2: Core Features (Tasks 7-10) - Week 2-3 ✅ COMPLETE
- [ ] Milestone 3: Polish & Release (Tasks 11-14) - Week 4
---
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/tui"
)
// tuiCmd represents the tui command
var tuiCmd = &cobra.Command{
Use: "tui",
Short: "Launch terminal user interface",
Long: `Launch an interactive terminal user interface for managing SSH hosts and connections.`,
RunE: runTUI,
}
func init() {
rootCmd.AddCommand(tuiCmd)
}
func runTUI(cmd *cobra.Command, args []string) error {
cfg := appCfg
if cfg == nil {
var err error
cfg, err = config.New()
if err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
}
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
ctx := context.Background()
hosts, err := store.ListHosts(ctx)
if err != nil {
return fmt.Errorf("failed to load hosts: %w", err)
}
model := tui.New()
model.LoadHosts(hosts)
p := tea.NewProgram(model)
if _, err := p.Run(); err != nil {
return fmt.Errorf("failed to run TUI: %w", err)
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
var (
HostNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
HostDetailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
SelectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Background(lipgloss.Color("235")).Padding(0, 1)
TagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
)
// renderHostList renders the host list screen
func renderHostList(m *Model) string {
var b strings.Builder
b.WriteString(TitleStyle.Render("HOSTKEEPER - SSH Manager"))
b.WriteString("\n\n")
if len(m.Hosts) == 0 {
b.WriteString(SubtitleStyle.Render("No hosts found. Add your first host with: hostkeeper add"))
b.WriteString("\n\n")
b.WriteString(InfoStyle.Render("Press 'q' to quit"))
return b.String()
}
for i, host := range m.Hosts {
if i == m.SelectedIndex {
b.WriteString(renderSelectedHost(host))
} else {
b.WriteString(renderHost(host))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(SubtitleStyle.Render("\u2191\u2193: Navigate | Enter: Connect | q: Quit"))
return b.String()
}
// renderHost renders a single host
func renderHost(host *models.Host) string {
var b strings.Builder
b.WriteString(HostNameStyle.Render(host.Name))
b.WriteString("\n")
details := fmt.Sprintf(" %s@%s:%d", host.Username, host.Hostname, host.Port)
b.WriteString(HostDetailStyle.Render(details))
if len(host.Tags) > 0 {
tags := formatTagsForTUI(host.Tags)
b.WriteString(" " + TagStyle.Render(tags))
}
return b.String()
}
// renderSelectedHost renders the selected host with highlight
func renderSelectedHost(host *models.Host) string {
hostText := renderHost(host)
return SelectedStyle.Render(hostText)
}
// formatTagsForTUI formats tags for TUI display
func formatTagsForTUI(tags []string) string {
if len(tags) == 0 {
return ""
}
var formatted []string
for _, tag := range tags {
formatted = append(formatted, "["+tag+"]")
}
return strings.Join(formatted, " ")
}
+101
View File
@@ -0,0 +1,101 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
// Styles for TUI components
var (
TitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true)
SubtitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
HighlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
InfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("117"))
)
// Screen represents different TUI screens
type Screen int
const (
ScreenHostList Screen = iota
ScreenConnection
ScreenSettings
)
// Model represents the main TUI model
type Model struct {
CurrentScreen Screen
Hosts []*models.Host
SelectedIndex int
Error error
Quit bool
}
// New creates a new TUI model
func New() *Model {
return &Model{
CurrentScreen: ScreenHostList,
SelectedIndex: 0,
Quit: false,
}
}
// Init initializes the TUI
func (m *Model) Init() tea.Cmd {
return nil
}
// Update handles messages and updates the model
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
m.Quit = true
return m, tea.Quit
case "up", "k":
if m.SelectedIndex > 0 {
m.SelectedIndex--
}
case "down", "j":
if m.SelectedIndex < len(m.Hosts)-1 {
m.SelectedIndex++
}
case "enter", " ":
if len(m.Hosts) > 0 {
return m, tea.Quit
}
}
}
return m, nil
}
// View renders the TUI
func (m *Model) View() string {
if m.Quit {
return "Thanks for using hostkeeper!\n"
}
switch m.CurrentScreen {
case ScreenHostList:
return renderHostList(m)
default:
return "Screen not implemented yet"
}
}
// LoadHosts loads hosts into the TUI model
func (m *Model) LoadHosts(hosts []*models.Host) {
m.Hosts = hosts
if len(hosts) > 0 && m.SelectedIndex >= len(hosts) {
m.SelectedIndex = len(hosts) - 1
}
}
+32
View File
@@ -0,0 +1,32 @@
package tui
import (
"testing"
)
func TestTUIInitialization(t *testing.T) {
ui := New()
if ui == nil {
t.Fatal("Failed to initialize TUI")
}
if ui.CurrentScreen != ScreenHostList {
t.Errorf("expected CurrentScreen ScreenHostList, got %d", ui.CurrentScreen)
}
if ui.Quit {
t.Error("expected Quit to be false")
}
}
func TestTUILoadHosts(t *testing.T) {
ui := New()
if ui == nil {
t.Fatal("Failed to initialize TUI")
}
ui.LoadHosts(nil)
if ui.Hosts != nil {
t.Error("expected Hosts to be nil")
}
}