From b7880fffab190ebe8ec4d05306d3ab5122452bfa Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 26 May 2026 15:52:57 -0500 Subject: [PATCH 1/4] feat(ui): add terminal picker Closes #4 --- flake.nix | 2 +- go.mod | 30 +- go.sum | 49 +++ internal/ui/root.go | 763 +++++++++++++++++++++++++++++++++++++ internal/ui/root_test.go | 369 ++++++++++++++++++ internal/ui/theme/theme.go | 37 ++ main.go | 51 ++- main_test.go | 195 ++++++---- 8 files changed, 1409 insertions(+), 87 deletions(-) create mode 100644 go.sum create mode 100644 internal/ui/root.go create mode 100644 internal/ui/root_test.go create mode 100644 internal/ui/theme/theme.go diff --git a/flake.nix b/flake.nix index ca85f68..6a2cad9 100644 --- a/flake.nix +++ b/flake.nix @@ -16,7 +16,7 @@ pname = "limen"; version = "0.1.0"; src = ./.; - vendorHash = null; + vendorHash = "sha256-zYQ6hMFBFDPNlViB7EmnxbXozardyYeHiUSiG4uS/9Y="; ldflags = [ "-s" diff --git a/go.mod b/go.mod index 3864451..5c95377 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,31 @@ module github.com/KofTwentyTwo/limen -go 1.22 +go 1.24.0 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..08e1be3 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b h1:Cw2ewBSl8ZRJFPlBHDiSpRNCATyzc/7yffziFeT0ymQ= +github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/internal/ui/root.go b/internal/ui/root.go new file mode 100644 index 0000000..cb671b4 --- /dev/null +++ b/internal/ui/root.go @@ -0,0 +1,763 @@ +// Package ui contains limen's Bubble Tea models. +package ui + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/KofTwentyTwo/limen/internal/config" + limenexec "github.com/KofTwentyTwo/limen/internal/exec" + "github.com/KofTwentyTwo/limen/internal/format" + "github.com/KofTwentyTwo/limen/internal/probe" + "github.com/KofTwentyTwo/limen/internal/state" + "github.com/KofTwentyTwo/limen/internal/ui/theme" +) + +const ( + defaultWidth = 100 + defaultHeight = 30 + + tinyTerminalMessage = "terminal too small for limen UI; falling back to bare tmux new" +) + +type stage int + +const ( + stageHosts stage = iota + stageSessions + stageNewSession +) + +// App is the data needed to start the TUI. +type App struct { + Config config.Config + State state.State + ProbeResults <-chan probe.ProbeResult + Now func() time.Time + Hostname string +} + +// Result is the selected command plan or quit outcome from the TUI. +type Result struct { + Plan limenexec.Plan + HasPlan bool + ExitCode int + Message string +} + +// Run starts the Bubble Tea program and returns the final user decision. +func Run(app App) (Result, error) { + program := tea.NewProgram(NewModel(app), tea.WithAltScreen()) + finalModel, err := program.Run() + if err != nil { + return Result{ExitCode: 1}, err + } + + model, ok := finalModel.(Model) + if !ok { + return Result{ExitCode: 1}, fmt.Errorf("unexpected final model %T", finalModel) + } + return model.Result(), nil +} + +// Model is the root Bubble Tea model for both picker stages. +type Model struct { + stage stage + width int + height int + hosts []hostEntry + hostCursor int + sessionCursor int + filter string + filtering bool + help bool + newName string + inputError string + probeResults <-chan probe.ProbeResult + now func() time.Time + result Result +} + +type hostEntry struct { + Name string + DisplayName string + Connection string + Description string + Remote *config.Host + Probing bool + Reachable bool + Sessions []probe.SessionInfo + LastAttached time.Time +} + +type sessionRow struct { + New bool + Session probe.SessionInfo +} + +type probeMsg probe.ProbeResult +type probesDoneMsg struct{} + +// NewModel constructs the initial root model. +func NewModel(app App) Model { + now := app.Now + if now == nil { + now = time.Now + } + + hostname := app.Hostname + if hostname == "" { + if local, err := os.Hostname(); err == nil { + hostname = local + } + } + + hosts := make([]hostEntry, 0, len(app.Config.Hosts)+1) + hosts = append(hosts, hostEntry{ + Name: probe.LocalhostName, + DisplayName: "localhost (" + shortHostname(hostname) + ")", + Connection: "localhost", + Probing: true, + LastAttached: app.State.LastAttached[probe.LocalhostName], + }) + for _, host := range app.Config.Hosts { + hostCopy := host + hosts = append(hosts, hostEntry{ + Name: host.Name, + DisplayName: host.Name, + Connection: connectionLine(host), + Description: host.Description, + Remote: &hostCopy, + Probing: true, + LastAttached: app.State.LastAttached[host.Name], + }) + } + + probeResults := app.ProbeResults + if probeResults == nil { + probeResults = probe.Start(contextBackground(), app.Config.Hosts, probe.Options{}) + } + + return Model{ + width: defaultWidth, + height: defaultHeight, + hosts: hosts, + probeResults: probeResults, + now: now, + } +} + +func contextBackground() context.Context { + return context.Background() +} + +// Init starts listening for probe results. +func (m Model) Init() tea.Cmd { + return waitForProbe(m.probeResults) +} + +// Update applies keyboard, resize, and probe messages. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if msg.Width < 50 || msg.Height < 15 { + m.acceptPlan(mustPlan(limenexec.LocalNew(""))) + m.result.Message = tinyTerminalMessage + return m, tea.Quit + } + return m, nil + case probeMsg: + m.applyProbeResult(probe.ProbeResult(msg)) + m.clampCursor() + return m, waitForProbe(m.probeResults) + case probesDoneMsg: + return m, nil + case tea.KeyMsg: + return m.updateKey(msg) + default: + return m, nil + } +} + +// View renders the active picker stage. +func (m Model) View() string { + if m.width < 50 || m.height < 15 { + return tinyTerminalMessage + } + + var body string + if m.width < 80 || m.height < 24 { + body = m.singleColumnView() + } else { + body = m.splitView() + } + if m.help { + body = body + "\n\n" + m.helpView() + } + return body + "\n\n" + m.footer() +} + +// Result returns the final user decision. +func (m Model) Result() Result { + return m.result +} + +func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.help { + switch msg.String() { + case "?", "esc": + m.help = false + return m, nil + } + } + + if m.filtering { + return m.updateFilterKey(msg) + } + + switch msg.String() { + case "ctrl+c": + m.result.ExitCode = 130 + return m, tea.Quit + case "q": + m.result.ExitCode = 0 + return m, tea.Quit + case "?": + m.help = !m.help + return m, nil + case "/": + if m.stage != stageNewSession { + m.filtering = true + m.filter = "" + } + return m, nil + case "up", "k": + m.moveCursor(-1) + return m, nil + case "down", "j": + m.moveCursor(1) + return m, nil + case "home", "g": + m.setCursor(0) + return m, nil + case "end", "G": + m.setCursor(m.entryCount() - 1) + return m, nil + case "esc": + return m.escape() + case "enter": + return m.enter() + case "backspace": + if m.stage == stageNewSession && len(m.newName) > 0 { + m.newName = m.newName[:len(m.newName)-1] + m.inputError = "" + } + return m, nil + } + + if msg.Type == tea.KeyRunes && m.stage == stageNewSession { + m.newName += string(msg.Runes) + m.inputError = "" + } + return m, nil +} + +func (m Model) updateFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.filtering = false + m.filter = "" + m.clampCursor() + return m, nil + case "enter": + m.filtering = false + return m.enter() + case "backspace": + if len(m.filter) > 0 { + m.filter = m.filter[:len(m.filter)-1] + m.clampCursor() + } + return m, nil + } + if msg.Type == tea.KeyRunes { + m.filter += string(msg.Runes) + m.clampCursor() + } + return m, nil +} + +func (m Model) enter() (tea.Model, tea.Cmd) { + switch m.stage { + case stageHosts: + indexes := m.filteredHostIndexes() + if len(indexes) == 0 { + return m, nil + } + m.hostCursor = indexes[m.hostCursor] + m.stage = stageSessions + m.sessionCursor = 0 + m.filter = "" + m.filtering = false + return m, nil + case stageSessions: + rows := m.filteredSessionRows() + if len(rows) == 0 { + return m, nil + } + + row := rows[m.sessionCursor] + if row.New { + m.stage = stageNewSession + m.newName = "" + m.inputError = "" + m.filter = "" + m.filtering = false + return m, nil + } + host := m.selectedHost() + session := row.Session + if host.Remote == nil { + m.acceptPlan(limenexec.LocalAttach(session.Name)) + } else { + m.acceptPlan(limenexec.RemoteAttach(*host.Remote, session.Name)) + } + return m, tea.Quit + case stageNewSession: + host := m.selectedHost() + var plan limenexec.Plan + var err error + if host.Remote == nil { + plan, err = limenexec.LocalNew(m.newName) + } else { + plan, err = limenexec.RemoteNew(*host.Remote, m.newName) + } + if err != nil { + m.inputError = err.Error() + return m, nil + } + m.acceptPlan(plan) + return m, tea.Quit + default: + return m, nil + } +} + +func (m Model) escape() (tea.Model, tea.Cmd) { + if m.stage == stageHosts { + m.acceptPlan(mustPlan(limenexec.LocalNew(""))) + return m, tea.Quit + } + + host := m.selectedHost() + var plan limenexec.Plan + if host.Remote == nil { + plan = mustPlan(limenexec.LocalNew("")) + } else { + plan = mustPlan(limenexec.RemoteNew(*host.Remote, "")) + } + m.acceptPlan(plan) + return m, tea.Quit +} + +func (m *Model) acceptPlan(plan limenexec.Plan) { + m.result = Result{Plan: plan, HasPlan: true, ExitCode: 0} +} + +func (m *Model) applyProbeResult(result probe.ProbeResult) { + for i := range m.hosts { + if m.hosts[i].Name != result.HostName { + continue + } + m.hosts[i].Probing = false + m.hosts[i].Reachable = result.Reachable + m.hosts[i].Sessions = result.Sessions + return + } +} + +func waitForProbe(results <-chan probe.ProbeResult) tea.Cmd { + return func() tea.Msg { + result, ok := <-results + if !ok { + return probesDoneMsg{} + } + return probeMsg(result) + } +} + +func (m Model) splitView() string { + leftWidth := maxInt(30, int(float64(m.width)*0.40)) + rightWidth := maxInt(30, m.width-leftWidth-4) + height := maxInt(10, m.height-6) + + left := theme.Pane.Width(leftWidth).Height(height).Render(m.listView()) + right := theme.Pane.Width(rightWidth).Height(height).Render(m.detailView()) + return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) +} + +func (m Model) singleColumnView() string { + return theme.Pane.Width(maxInt(30, m.width-4)).Render(m.listView()) +} + +func (m Model) listView() string { + var builder strings.Builder + builder.WriteString(theme.Title.Render(m.leftTitle())) + builder.WriteString("\n\n") + + switch m.stage { + case stageHosts: + m.writeHostRows(&builder) + case stageSessions, stageNewSession: + m.writeSessionRows(&builder) + } + return builder.String() +} + +func (m Model) leftTitle() string { + if m.stage == stageHosts { + return "limen" + } + return m.selectedHost().DisplayName +} + +func (m Model) writeHostRows(builder *strings.Builder) { + indexes := m.filteredHostIndexes() + if len(indexes) == 0 { + builder.WriteString(theme.Dimmed.Render("no matching hosts")) + return + } + for row, index := range indexes { + host := m.hosts[index] + cursor := " " + if row == m.hostCursor { + cursor = ">" + } + line := fmt.Sprintf("%s %s %-24s %s", cursor, statusDot(host), host.DisplayName, sessionSummary(host)) + if row == m.hostCursor { + line = theme.Selected.Render(line) + } + builder.WriteString(line) + builder.WriteString("\n") + } +} + +func (m Model) writeSessionRows(builder *strings.Builder) { + rows := m.filteredSessionRows() + if len(rows) == 0 { + builder.WriteString(theme.Dimmed.Render("no matching sessions")) + return + } + + for row, entry := range rows { + cursor := " " + if row == m.sessionCursor { + cursor = ">" + } + line := cursor + " " + sessionRowText(entry) + if row == m.sessionCursor { + line = theme.Selected.Render(line) + } + builder.WriteString(line) + builder.WriteString("\n") + if entry.New && len(rows) > 1 { + builder.WriteString(theme.Dimmed.Render("──────────────────────────────")) + builder.WriteString("\n") + } + } +} + +func (m Model) detailView() string { + if m.stage == stageNewSession { + return m.newSessionView() + } + if m.stage == stageSessions { + return m.sessionDetailView() + } + + host := m.selectedHost() + var builder strings.Builder + builder.WriteString(theme.Title.Render("DETAILS")) + builder.WriteString("\n\n") + builder.WriteString(host.DisplayName) + builder.WriteString("\n") + builder.WriteString(theme.Dimmed.Render("──────────────────────────────────")) + builder.WriteString("\n") + builder.WriteString(host.Connection) + builder.WriteString("\n\n") + builder.WriteString("Status: ") + builder.WriteString(statusText(host)) + builder.WriteString("\n") + builder.WriteString("Sessions: ") + builder.WriteString(sessionDetail(host)) + builder.WriteString("\n") + builder.WriteString("Last attached: ") + builder.WriteString(format.RelativeTime(host.LastAttached, m.now())) + if host.Description != "" { + builder.WriteString("\n\n") + builder.WriteString(host.Description) + } + return builder.String() +} + +func (m Model) sessionDetailView() string { + rows := m.filteredSessionRows() + if len(rows) == 0 { + return theme.Title.Render("DETAILS") + "\n\n" + theme.Dimmed.Render("no matching sessions") + } + if rows[m.sessionCursor].New { + return m.newSessionView() + } + + session := rows[m.sessionCursor].Session + var builder strings.Builder + builder.WriteString(theme.Title.Render("DETAILS")) + builder.WriteString("\n\n") + builder.WriteString(session.Name) + builder.WriteString("\n") + builder.WriteString(theme.Dimmed.Render("──────────────────────────────────")) + builder.WriteString("\n\n") + builder.WriteString("Windows: ") + builder.WriteString(fmt.Sprintf("%d", session.Windows)) + builder.WriteString("\n") + if !session.Created.IsZero() { + builder.WriteString("Created: ") + builder.WriteString(session.Created.Format("Mon Jan 2 15:04")) + builder.WriteString("\n") + } + builder.WriteString("Attached: ") + if session.Attached { + builder.WriteString("★ currently attached") + } else { + builder.WriteString("no") + } + return builder.String() +} + +func (m Model) newSessionView() string { + var builder strings.Builder + builder.WriteString(theme.Title.Render("+ New session")) + builder.WriteString("\n\n") + builder.WriteString("Name for the new session:\n\n") + builder.WriteString("> ") + builder.WriteString(m.newName) + builder.WriteString("\n\n") + if m.inputError != "" { + builder.WriteString(m.inputError) + builder.WriteString("\n") + } + builder.WriteString(theme.Dimmed.Render("Enter to create. Esc creates an unnamed session.")) + return builder.String() +} + +func (m Model) helpView() string { + escapeHelp := "esc skip" + if m.stage != stageHosts { + escapeHelp = "esc new unnamed" + } + return theme.Pane.Render("↑/k up ↓/j down enter connect / search " + escapeHelp + " q quit ctrl+c quit") +} + +func (m Model) footer() string { + if m.filtering { + return theme.Dimmed.Render("/ " + m.filter + " · enter select · esc cancel filter") + } + if m.stage == stageNewSession { + return theme.Dimmed.Render("enter create · esc new unnamed · q quit") + } + if m.stage == stageSessions { + return theme.Dimmed.Render("↑↓ navigate · enter connect · / search · ? help · esc new unnamed") + } + return theme.Dimmed.Render("↑↓ navigate · enter connect · / search · ? help · esc skip") +} + +func (m Model) selectedHost() hostEntry { + if len(m.hosts) == 0 { + return hostEntry{Name: probe.LocalhostName, DisplayName: "localhost", Connection: "localhost"} + } + if m.hostCursor < 0 || m.hostCursor >= len(m.hosts) { + return m.hosts[0] + } + return m.hosts[m.hostCursor] +} + +func (m Model) sessionEntries() []probe.SessionInfo { + sessions := append([]probe.SessionInfo(nil), m.selectedHost().Sessions...) + return sessions +} + +func (m *Model) moveCursor(delta int) { + m.setCursor(m.currentCursor() + delta) +} + +func (m *Model) setCursor(value int) { + count := m.entryCount() + if count == 0 { + m.hostCursor = 0 + m.sessionCursor = 0 + return + } + if value < 0 { + value = 0 + } + if value >= count { + value = count - 1 + } + switch m.stage { + case stageHosts: + m.hostCursor = value + case stageSessions, stageNewSession: + m.sessionCursor = value + } +} + +func (m *Model) clampCursor() { + m.setCursor(m.currentCursor()) +} + +func (m Model) currentCursor() int { + if m.stage == stageHosts { + return m.hostCursor + } + return m.sessionCursor +} + +func (m Model) entryCount() int { + if m.stage == stageHosts { + return len(m.filteredHostIndexes()) + } + return len(m.filteredSessionRows()) +} + +func (m Model) filteredHostIndexes() []int { + indexes := make([]int, 0, len(m.hosts)) + filter := strings.ToLower(m.filter) + for i, host := range m.hosts { + if filter == "" || strings.Contains(strings.ToLower(host.DisplayName), filter) || strings.Contains(strings.ToLower(host.Connection), filter) { + indexes = append(indexes, i) + } + } + return indexes +} + +func (m Model) filteredSessionRows() []sessionRow { + filter := strings.ToLower(m.filter) + rows := make([]sessionRow, 0, len(m.sessionEntries())+1) + + if filter == "" || strings.Contains("new session", filter) { + rows = append(rows, sessionRow{New: true}) + } + for _, session := range m.sessionEntries() { + if filter == "" || strings.Contains(strings.ToLower(session.Name), filter) { + rows = append(rows, sessionRow{Session: session}) + } + } + return rows +} + +func sessionRowText(row sessionRow) string { + if row.New { + return "+ New session" + } + + marker := " " + if row.Session.Attached { + marker = "●" + } + star := "" + if row.Session.Attached { + star = " ★" + } + return fmt.Sprintf("%s %-18s %d windows%s", marker, row.Session.Name, row.Session.Windows, star) +} + +func statusDot(host hostEntry) string { + switch { + case host.Probing: + return theme.ProbingStyle.Render("…") + case host.Reachable: + return theme.OnlineStyle.Render("●") + default: + return theme.UnreachableStyle.Render("○") + } +} + +func statusText(host hostEntry) string { + switch { + case host.Probing: + return theme.ProbingStyle.Render("… probing") + case host.Reachable: + return theme.OnlineStyle.Render("● online") + default: + return theme.UnreachableStyle.Render("○ unreachable") + } +} + +func sessionSummary(host hostEntry) string { + if host.Probing { + return "probing" + } + if !host.Reachable { + return "unreach." + } + if len(host.Sessions) == 0 { + return "—" + } + return fmt.Sprintf("%d sess.", len(host.Sessions)) +} + +func sessionDetail(host hostEntry) string { + if host.Probing { + return "probing" + } + if !host.Reachable { + return "n/a" + } + if len(host.Sessions) == 0 { + return "—" + } + names := make([]string, 0, len(host.Sessions)) + for _, session := range host.Sessions { + names = append(names, session.Name) + } + return fmt.Sprintf("%d (%s)", len(host.Sessions), strings.Join(names, ", ")) +} + +func connectionLine(host config.Host) string { + target := host.Hostname + if host.User != "" { + target = host.User + "@" + target + } + if host.Port != 0 && host.Port != 22 { + target = fmt.Sprintf("%s:%d", target, host.Port) + } + return target +} + +func shortHostname(hostname string) string { + if hostname == "" { + return "unknown" + } + return strings.SplitN(hostname, ".", 2)[0] +} + +func mustPlan(plan limenexec.Plan, err error) limenexec.Plan { + if err != nil { + return limenexec.Plan{} + } + return plan +} + +func maxInt(a int, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/ui/root_test.go b/internal/ui/root_test.go new file mode 100644 index 0000000..4e69fd1 --- /dev/null +++ b/internal/ui/root_test.go @@ -0,0 +1,369 @@ +package ui + +import ( + "bytes" + "reflect" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + + "github.com/KofTwentyTwo/limen/internal/config" + limenexec "github.com/KofTwentyTwo/limen/internal/exec" + "github.com/KofTwentyTwo/limen/internal/probe" + "github.com/KofTwentyTwo/limen/internal/state" +) + +func TestTeatestHostSelectionAdvancesToSessionPicker(t *testing.T) { + model := NewModel(testApp()) + tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(100, 30)) + defer func() { + _ = tm.Quit() + }() + + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("localhost (renova)")) + }, teatest.WithDuration(2*time.Second)) + + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("+ New session")) + }, teatest.WithDuration(2*time.Second)) +} + +func TestSelectExistingRemoteSessionProducesAttachPlan(t *testing.T) { + model := NewModel(testApp()) + model = updateModel(t, model, probeMsg(probe.ProbeResult{ + HostName: "prod", + Reachable: true, + Sessions: []probe.SessionInfo{{Name: "api", Windows: 3, Attached: true}}, + })) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + + want := limenexec.Plan{ + HostName: "prod", + Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "api"}, + } + if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) { + t.Fatalf("result = %#v, want plan %#v", model.result, want) + } +} + +func TestSessionFilterSelectsMatchingSession(t *testing.T) { + model := modelWithProdSessions(t, + probe.SessionInfo{Name: "api", Windows: 3}, + probe.SessionInfo{Name: "ops", Windows: 1}, + ) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + for _, r := range "ops" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + + want := limenexec.Plan{ + HostName: "prod", + Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "ops"}, + } + if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) { + t.Fatalf("result = %#v, want plan %#v", model.result, want) + } +} + +func TestEscapeFromHostPickerProducesLocalNewPlan(t *testing.T) { + model := NewModel(testApp()) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc}) + + want := limenexec.Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}} + if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) { + t.Fatalf("result = %#v, want plan %#v", model.result, want) + } +} + +func TestEscapeFromRemoteSessionPickerProducesRemoteNewPlan(t *testing.T) { + model := NewModel(testApp()) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc}) + + want := limenexec.Plan{ + HostName: "prod", + Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "new"}, + } + if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) { + t.Fatalf("result = %#v, want plan %#v", model.result, want) + } +} + +func TestFilterSelectsMatchingHost(t *testing.T) { + model := NewModel(testApp()) + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + for _, r := range "prod" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + + if model.stage != stageSessions { + t.Fatalf("stage = %v, want sessions", model.stage) + } + if got := model.selectedHost().Name; got != "prod" { + t.Fatalf("selected host = %q, want prod", got) + } +} + +func TestFilterCanBeCanceledAfterNoMatches(t *testing.T) { + model := NewModel(testApp()) + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + for _, r := range "missing" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + + if view := model.View(); !strings.Contains(view, "no matching hosts") { + t.Fatalf("View() = %q, want no-match message", view) + } + + model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc}) + if model.filtering || model.filter != "" { + t.Fatalf("filtering = %v, filter = %q; want canceled", model.filtering, model.filter) + } + if view := model.View(); !strings.Contains(view, "prod") { + t.Fatalf("View() = %q, want unfiltered host list", view) + } +} + +func TestNewSessionPromptRejectsInvalidName(t *testing.T) { + model := NewModel(testApp()) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + for _, r := range "bad:name" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + + if model.inputError != "name cannot contain : or ." { + t.Fatalf("inputError = %q", model.inputError) + } + if model.result.HasPlan { + t.Fatalf("result = %#v, want no plan", model.result) + } +} + +func TestNewSessionPromptCreatesTrimmedRemoteSession(t *testing.T) { + model := NewModel(testApp()) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + for _, r := range " api " { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + + want := limenexec.Plan{ + HostName: "prod", + Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "new", "-s", "api"}, + } + if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) { + t.Fatalf("result = %#v, want plan %#v", model.result, want) + } +} + +func TestSessionPickerRendersSessionDetails(t *testing.T) { + created := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC) + model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3, Attached: true, Created: created}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + + view := model.View() + for _, want := range []string{"api", "Windows: 3", "Created: Tue May 26 13:45", "currently attached"} { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q:\n%s", want, view) + } + } +} + +func TestProbeUpdatesClampSessionCursor(t *testing.T) { + model := modelWithProdSessions(t, + probe.SessionInfo{Name: "api", Windows: 3}, + probe.SessionInfo{Name: "ops", Windows: 1}, + ) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnd}) + model = updateModel(t, model, probeMsg(probe.ProbeResult{ + HostName: "prod", + Reachable: true, + Sessions: []probe.SessionInfo{{Name: "api", Windows: 3}}, + })) + + if model.sessionCursor != 1 { + t.Fatalf("sessionCursor = %d, want clamped to 1", model.sessionCursor) + } + if view := model.View(); !strings.Contains(view, "api") { + t.Fatalf("View() = %q, want remaining session", view) + } +} + +func TestHostDetailsRenderProbeStateAndStateTimestamp(t *testing.T) { + model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + + view := model.View() + for _, want := range []string{"deploy@prod.example.com", "online", "1 (api)", "1 hour ago", "Production application servers."} { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q:\n%s", want, view) + } + } +} + +func TestUnreachableHostDetailsShowNoSessionInventory(t *testing.T) { + model := NewModel(testApp()) + model = updateModel(t, model, probeMsg(probe.ProbeResult{HostName: "prod", Reachable: false})) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + + view := model.View() + for _, want := range []string{"unreachable", "Sessions: n/a"} { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q:\n%s", want, view) + } + } +} + +func TestSingleColumnViewOmitsDetailsPane(t *testing.T) { + model := updateModel(t, NewModel(testApp()), tea.WindowSizeMsg{Width: 70, Height: 20}) + + view := model.View() + if !strings.Contains(view, "limen") { + t.Fatalf("View() = %q, want list pane", view) + } + if strings.Contains(view, "DETAILS") { + t.Fatalf("View() = %q, want single-column layout without details pane", view) + } +} + +func TestHelpOverlayTogglesAndEscapeDismisses(t *testing.T) { + model := NewModel(testApp()) + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + if !model.help { + t.Fatal("help = false, want true") + } + if view := model.View(); !strings.Contains(view, "ctrl+c quit") { + t.Fatalf("View() = %q, want help overlay", view) + } + + model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc}) + if model.help { + t.Fatal("help = true, want false") + } + if model.result.HasPlan { + t.Fatalf("result = %#v, want no plan after dismissing help", model.result) + } +} + +func TestQuitKeysReturnExitCodes(t *testing.T) { + tests := []struct { + name string + key tea.KeyMsg + want int + }{ + {name: "q", key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}, want: 0}, + {name: "ctrl-c", key: tea.KeyMsg{Type: tea.KeyCtrlC}, want: 130}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := press(t, NewModel(testApp()), tt.key) + if model.result.ExitCode != tt.want { + t.Fatalf("exit code = %d, want %d", model.result.ExitCode, tt.want) + } + if model.result.HasPlan { + t.Fatalf("result = %#v, want no plan", model.result) + } + }) + } +} + +func TestTinyTerminalFallsBackToLocalNew(t *testing.T) { + model := NewModel(testApp()) + model = updateModel(t, model, tea.WindowSizeMsg{Width: 40, Height: 10}) + + want := limenexec.Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}} + if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) { + t.Fatalf("result = %#v, want plan %#v", model.result, want) + } + if model.result.Message != tinyTerminalMessage { + t.Fatalf("message = %q, want %q", model.result.Message, tinyTerminalMessage) + } + if model.View() != tinyTerminalMessage { + t.Fatalf("View() = %q, want tiny terminal message", model.View()) + } +} + +func TestWaitForProbeReturnsResultAndDoneMessages(t *testing.T) { + probes := make(chan probe.ProbeResult, 1) + probes <- probe.ProbeResult{HostName: "prod", Reachable: true} + close(probes) + + got, ok := waitForProbe(probes)().(probeMsg) + if !ok { + t.Fatalf("first probe message type = %T, want probeMsg", got) + } + if got.HostName != "prod" || !got.Reachable { + t.Fatalf("first probe message = %#v", got) + } + if got := waitForProbe(probes)(); got != (probesDoneMsg{}) { + t.Fatalf("second probe message = %#v, want done", got) + } +} + +func testApp() App { + probes := make(chan probe.ProbeResult) + close(probes) + return App{ + Config: config.Config{Hosts: []config.Host{{ + Name: "prod", + Hostname: "prod.example.com", + User: "deploy", + Description: "Production application servers.", + }}}, + State: state.State{LastAttached: map[string]time.Time{ + "prod": time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC), + }}, + ProbeResults: probes, + Now: func() time.Time { return time.Date(2026, 5, 26, 14, 45, 0, 0, time.UTC) }, + Hostname: "renova.local", + } +} + +func modelWithProdSessions(t *testing.T, sessions ...probe.SessionInfo) Model { + t.Helper() + return updateModel(t, NewModel(testApp()), probeMsg(probe.ProbeResult{ + HostName: "prod", + Reachable: true, + Sessions: sessions, + })) +} + +func press(t *testing.T, model Model, key tea.KeyMsg) Model { + t.Helper() + return updateModel(t, model, key) +} + +func updateModel(t *testing.T, model Model, msg tea.Msg) Model { + t.Helper() + updated, _ := model.Update(msg) + next, ok := updated.(Model) + if !ok { + t.Fatalf("updated model type = %T, want ui.Model", updated) + } + return next +} diff --git a/internal/ui/theme/theme.go b/internal/ui/theme/theme.go new file mode 100644 index 0000000..fedd596 --- /dev/null +++ b/internal/ui/theme/theme.go @@ -0,0 +1,37 @@ +// Package theme defines the fixed v1 terminal styling for limen. +package theme + +import "github.com/charmbracelet/lipgloss" + +var ( + Accent = lipgloss.Color("#5fd7ff") + Online = lipgloss.Color("#5fdf87") + Unreachable = lipgloss.Color("#6c6c6c") + Probing = lipgloss.Color("#87afdf") + Dim = lipgloss.Color("#8a8a8a") + + Title = lipgloss.NewStyle(). + Foreground(Accent). + Bold(true) + + Pane = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Accent). + Padding(0, 1) + + Selected = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#0f1720")). + Background(Accent) + + Dimmed = lipgloss.NewStyle(). + Foreground(Dim) + + OnlineStyle = lipgloss.NewStyle(). + Foreground(Online) + + UnreachableStyle = lipgloss.NewStyle(). + Foreground(Unreachable) + + ProbingStyle = lipgloss.NewStyle(). + Foreground(Probing) +) diff --git a/main.go b/main.go index 17b2e99..a9bc521 100644 --- a/main.go +++ b/main.go @@ -1,34 +1,56 @@ package main import ( - "encoding/json" + "errors" "flag" "fmt" "io" "os" "github.com/KofTwentyTwo/limen/internal/config" + limenexec "github.com/KofTwentyTwo/limen/internal/exec" "github.com/KofTwentyTwo/limen/internal/state" + "github.com/KofTwentyTwo/limen/internal/ui" ) -type output struct { - Config config.Config `json:"config"` - State state.State `json:"state"` +var version = "dev" + +type appDeps struct { + runUI func(ui.App) (ui.Result, error) + handoff func(limenexec.Plan, limenexec.Options) int } func main() { - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) + os.Exit(runWithDeps(os.Args[1:], os.Stdout, os.Stderr, appDeps{ + runUI: ui.Run, + handoff: limenexec.Handoff, + })) } func run(args []string, stdout io.Writer, stderr io.Writer) int { + return runWithDeps(args, stdout, stderr, appDeps{ + runUI: ui.Run, + handoff: limenexec.Handoff, + }) +} + +func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { flags := flag.NewFlagSet("limen", flag.ContinueOnError) flags.SetOutput(stderr) configPath := flags.String("config", config.DefaultPath(), "path to hosts.json") statePath := flags.String("state", state.DefaultPath(), "path to state.json") + showVersion := flags.Bool("version", false, "print version") if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } return 2 } + if *showVersion { + fmt.Fprintln(stdout, version) + return 0 + } if flags.NArg() != 0 { fmt.Fprintf(stderr, "limen: unexpected argument %q\n", flags.Arg(0)) return 2 @@ -41,12 +63,21 @@ func run(args []string, stdout io.Writer, stderr io.Writer) int { } st := state.Load(*statePath) - encoder := json.NewEncoder(stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(output{Config: cfg, State: st}); err != nil { - fmt.Fprintf(stderr, "limen: cannot write output: %v\n", err) + + result, err := deps.runUI(ui.App{Config: cfg, State: st}) + if err != nil { + fmt.Fprintf(stderr, "limen: ui failed: %v\n", err) return 1 } + if !result.HasPlan { + return result.ExitCode + } + if result.Message != "" { + fmt.Fprintln(stderr, result.Message) + } - return 0 + return deps.handoff(result.Plan, limenexec.Options{ + StatePath: *statePath, + Stderr: stderr, + }) } diff --git a/main_test.go b/main_test.go index cd3b8f8..601099f 100644 --- a/main_test.go +++ b/main_test.go @@ -2,15 +2,18 @@ package main import ( "bytes" - "encoding/json" "errors" "os" "path/filepath" + "reflect" + "strings" "testing" - "time" + + limenexec "github.com/KofTwentyTwo/limen/internal/exec" + "github.com/KofTwentyTwo/limen/internal/ui" ) -func TestRunPrintsConfigAndStateAsJSON(t *testing.T) { +func TestRunLoadsConfigStateAndHandsOffSelectedPlan(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "hosts.json") statePath := filepath.Join(dir, "state.json") @@ -22,88 +25,101 @@ func TestRunPrintsConfigAndStateAsJSON(t *testing.T) { }`), 0o600); err != nil { t.Fatalf("write config fixture: %v", err) } - - if err := os.WriteFile(statePath, []byte(`{ - "lastAttached": { - "prod": "2026-05-26T13:45:00Z" - } - }`), 0o600); err != nil { + if err := os.WriteFile(statePath, []byte(`{"lastAttached":{"prod":"2026-05-26T13:45:00Z"}}`), 0o600); err != nil { t.Fatalf("write state fixture: %v", err) } - var stdout bytes.Buffer - var stderr bytes.Buffer - code := run([]string{"--config", configPath, "--state", statePath}, &stdout, &stderr) + wantPlan := limenexec.Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod.example.com", "tmux", "new"}} + var sawHosts int + var sawStateEntries int + var handedPlan limenexec.Plan + var handedOptions limenexec.Options + + code := runWithDeps([]string{"--config", configPath, "--state", statePath}, &bytes.Buffer{}, &bytes.Buffer{}, appDeps{ + runUI: func(app ui.App) (ui.Result, error) { + sawHosts = len(app.Config.Hosts) + sawStateEntries = len(app.State.LastAttached) + return ui.Result{Plan: wantPlan, HasPlan: true}, nil + }, + handoff: func(plan limenexec.Plan, opts limenexec.Options) int { + handedPlan = plan + handedOptions = opts + return 0 + }, + }) if code != 0 { - t.Fatalf("run exit code = %d, want 0; stderr = %q", code, stderr.String()) + t.Fatalf("run exit code = %d, want 0", code) } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) + if sawHosts != 1 { + t.Fatalf("UI saw %d hosts, want 1", sawHosts) } - - var got struct { - Config struct { - Hosts []struct { - Name string `json:"name"` - Hostname string `json:"hostname"` - User string `json:"user,omitempty"` - } `json:"hosts"` - } `json:"config"` - State struct { - LastAttached map[string]time.Time `json:"lastAttached"` - } `json:"state"` + if sawStateEntries != 1 { + t.Fatalf("UI saw %d state entries, want 1", sawStateEntries) } - if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { - t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + if !reflect.DeepEqual(handedPlan, wantPlan) { + t.Fatalf("handoff plan = %#v, want %#v", handedPlan, wantPlan) } - - if len(got.Config.Hosts) != 1 { - t.Fatalf("len(hosts) = %d, want 1", len(got.Config.Hosts)) - } - if got.Config.Hosts[0].Name != "prod" || got.Config.Hosts[0].Hostname != "prod.example.com" || got.Config.Hosts[0].User != "deploy" { - t.Fatalf("host = %#v", got.Config.Hosts[0]) - } - - wantTime := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC) - if gotTime := got.State.LastAttached["prod"]; !gotTime.Equal(wantTime) { - t.Fatalf("state timestamp = %s, want %s", gotTime, wantTime) + if handedOptions.StatePath != statePath { + t.Fatalf("handoff state path = %q, want %q", handedOptions.StatePath, statePath) } } -func TestRunWarnsForMissingConfigAndPrintsEmptyHosts(t *testing.T) { +func TestRunPrintsResultMessageBeforeHandoff(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "hosts.json") + if err := os.WriteFile(configPath, []byte(`{"hosts":[]}`), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + + var stderr bytes.Buffer + code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &bytes.Buffer{}, &stderr, appDeps{ + runUI: func(app ui.App) (ui.Result, error) { + return ui.Result{ + Plan: limenexec.Plan{HostName: "localhost", Argv: []string{"tmux", "new"}}, + HasPlan: true, + Message: "terminal too small for limen UI; falling back to bare tmux new", + }, nil + }, + handoff: func(plan limenexec.Plan, opts limenexec.Options) int { + return 0 + }, + }) + + if code != 0 { + t.Fatalf("run exit code = %d, want 0", code) + } + want := "terminal too small for limen UI; falling back to bare tmux new\n" + if stderr.String() != want { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } +} + +func TestRunWarnsForMissingConfigAndStillStartsUI(t *testing.T) { configPath := filepath.Join(t.TempDir(), "hosts.json") statePath := filepath.Join(t.TempDir(), "state.json") - - var stdout bytes.Buffer var stderr bytes.Buffer - code := run([]string{"--config", configPath, "--state", statePath}, &stdout, &stderr) + var sawHosts int + + code := runWithDeps([]string{"--config", configPath, "--state", statePath}, &bytes.Buffer{}, &stderr, appDeps{ + runUI: func(app ui.App) (ui.Result, error) { + sawHosts = len(app.Config.Hosts) + return ui.Result{ExitCode: 0}, nil + }, + handoff: func(plan limenexec.Plan, opts limenexec.Options) int { + t.Fatal("handoff should not be called") + return 1 + }, + }) if code != 0 { t.Fatalf("run exit code = %d, want 0; stderr = %q", code, stderr.String()) } - wantWarning := "limen: no hosts.json found at " + configPath + "; only localhost will be available\n" if stderr.String() != wantWarning { t.Fatalf("stderr = %q, want %q", stderr.String(), wantWarning) } - - var got struct { - Config struct { - Hosts []struct{} `json:"hosts"` - } `json:"config"` - State struct { - LastAttached map[string]time.Time `json:"lastAttached"` - } `json:"state"` - } - if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { - t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) - } - if len(got.Config.Hosts) != 0 { - t.Fatalf("len(hosts) = %d, want 0", len(got.Config.Hosts)) - } - if len(got.State.LastAttached) != 0 { - t.Fatalf("len(lastAttached) = %d, want 0", len(got.State.LastAttached)) + if sawHosts != 0 { + t.Fatalf("UI saw %d configured hosts, want 0", sawHosts) } } @@ -115,7 +131,7 @@ func TestRunReturnsFailureForConfigErrors(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := run([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &stdout, &stderr) + code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &stdout, &stderr, appDeps{}) if code != 1 { t.Fatalf("run exit code = %d, want 1", code) @@ -164,29 +180,58 @@ func TestRunReturnsUsageFailureForUnexpectedArguments(t *testing.T) { } } -func TestRunReturnsFailureWhenJSONOutputCannotBeWritten(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "hosts.json") - statePath := filepath.Join(t.TempDir(), "state.json") +func TestRunReturnsVersion(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := run([]string{"--version"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("run exit code = %d, want 0", code) + } + if stdout.String() != version+"\n" { + t.Fatalf("stdout = %q, want version", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRunReturnsHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := run([]string{"--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("run exit code = %d, want 0", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if got := stderr.String(); !strings.Contains(got, "Usage of limen") { + t.Fatalf("stderr = %q, want usage", got) + } +} + +func TestRunReturnsUIFailure(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "hosts.json") if err := os.WriteFile(configPath, []byte(`{"hosts":[]}`), 0o600); err != nil { t.Fatalf("write config fixture: %v", err) } var stderr bytes.Buffer - code := run([]string{"--config", configPath, "--state", statePath}, failingWriter{}, &stderr) + code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &bytes.Buffer{}, &stderr, appDeps{ + runUI: func(app ui.App) (ui.Result, error) { + return ui.Result{}, errors.New("terminal unavailable") + }, + }) if code != 1 { t.Fatalf("run exit code = %d, want 1", code) } - - want := "limen: cannot write output: write failed\n" + want := "limen: ui failed: terminal unavailable\n" if stderr.String() != want { t.Fatalf("stderr = %q, want %q", stderr.String(), want) } } - -type failingWriter struct{} - -func (failingWriter) Write([]byte) (int, error) { - return 0, errors.New("write failed") -} -- 2.54.0 From 5376dc561e49d49ac1c89b79d5523e7fc84777be Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 26 May 2026 16:09:46 -0500 Subject: [PATCH 2/4] feat(ui): add picker typeahead --- docs/DESIGN.md | 10 +-- internal/ui/root.go | 65 ++++++++++++++++++++ internal/ui/root_test.go | 127 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 195 insertions(+), 7 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e5ef4d4..3e95502 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -126,8 +126,8 @@ The first screen the user sees. Split-pane layout. Host list on the left, detail - The header line above the boxes says `limen` (left) and `DETAILS` (right), styled with the accent color. - Status dots: filled green circle (`●`) for online, hollow circle (`○`) for unreachable, dash (`—`) for "no sessions yet" indication when applicable. - Session count column right-aligned. -- localhost is always the first entry. Its display name is `localhost ()` where short-hostname comes from `hostname -s`. This makes it immediately clear which physical machine the user is sitting at. -- Hosts are listed in the order declared in the config file. No automatic reordering. (Sorting by "most recently attached" is a future feature, called out as such.) +- localhost's display name is `localhost ()` where short-hostname comes from `hostname -s`. This makes it immediately clear which physical machine the user is sitting at. +- Hosts are listed A-Z by display name, case-insensitive. The implicit localhost entry participates in this ordering. - A footer line shows the keyboard cheat sheet, dimmed. **Details pane content (for the highlighted host):** @@ -149,6 +149,8 @@ The first screen the user sees. Split-pane layout. Host list on the left, detail | `End` / `G` | Jump to last entry | | `Enter` | Accept; advance to stage 2 with this host as target | | `/` | Open search/filter mode (filter the list as you type) | +| Text input | Start search/filter mode with the typed character | +| `Tab` | Complete the filter to the highlighted host name | | `Esc` | If in search mode, cancel filter. Otherwise, escape semantics (see §5.5) | | `?` | Toggle help overlay | | `Ctrl+C` / `q` | Quit `limen` entirely without exec'ing anything. Exit code 130 / 0 respectively | @@ -177,7 +179,7 @@ Same split-pane layout as stage 1, applied to sessions on the chosen host. The u **Layout requirements:** - The left-pane header shows the host name (e.g. `prod`), not the word "limen", so the user knows where they are. -- `+ New session` is always the first entry, followed by a horizontal separator (`─` rule), followed by existing sessions in tmux's default ordering (most recently created first, matching `tmux ls`). +- `+ New session` is always the first entry, followed by a horizontal separator (`─` rule), followed by existing sessions sorted A-Z by name, case-insensitive. - `●` marks the currently-attached session if any (i.e., the one a tmux client is currently inside). On localhost, there can be one. On remotes accessed via `ssh ... tmux ls`, attached-state comes from `#{session_attached}`. - `★` next to the window count marks the currently-attached session (redundant with the dot for users who scan the right column). @@ -212,7 +214,7 @@ Same as stage 1, with the addition that when the highlighted entry is `+ New ses ### 5.4 Search / filter mode -In either stage, `/` opens an inline filter. The list narrows to entries matching the typed substring (case-insensitive, fuzzy match optional but not required for v1 — simple substring is fine). Esc cancels filter. Enter selects the top match. +In either picker stage, `/` opens an inline filter. Typing a letter while not already filtering also opens the inline filter and seeds it with that character, so the user can type directly toward the target host or session without first pressing `/`. The list narrows to entries matching the typed substring (case-insensitive, fuzzy match optional but not required for v1 — simple substring is fine). Tab completes the filter to the highlighted host or session name. Esc cancels filter. Enter selects the highlighted match. While filter is active, the cheat-sheet footer changes to indicate `esc cancel filter`. diff --git a/internal/ui/root.go b/internal/ui/root.go index cb671b4..6b74070 100644 --- a/internal/ui/root.go +++ b/internal/ui/root.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os" + "sort" "strings" "time" @@ -138,6 +139,9 @@ func NewModel(app App) Model { LastAttached: app.State.LastAttached[host.Name], }) } + sort.SliceStable(hosts, func(i int, j int) bool { + return labelLess(hosts[i].DisplayName, hosts[j].DisplayName) + }) probeResults := app.ProbeResults if probeResults == nil { @@ -223,6 +227,13 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.updateFilterKey(msg) } + if msg.Type == tea.KeyRunes && m.stage != stageNewSession && startsTypeahead(msg.String()) { + m.filtering = true + m.filter = string(msg.Runes) + m.clampCursor() + return m, nil + } + switch msg.String() { case "ctrl+c": m.result.ExitCode = 130 @@ -239,6 +250,12 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.filter = "" } return m, nil + case "tab": + if m.stage != stageNewSession { + m.filtering = true + m.completeFilter() + } + return m, nil case "up", "k": m.moveCursor(-1) return m, nil @@ -280,6 +297,9 @@ func (m Model) updateFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": m.filtering = false return m.enter() + case "tab": + m.completeFilter() + return m, nil case "backspace": if len(m.filter) > 0 { m.filter = m.filter[:len(m.filter)-1] @@ -590,6 +610,9 @@ func (m Model) selectedHost() hostEntry { func (m Model) sessionEntries() []probe.SessionInfo { sessions := append([]probe.SessionInfo(nil), m.selectedHost().Sessions...) + sort.SliceStable(sessions, func(i int, j int) bool { + return labelLess(sessions[i].Name, sessions[j].Name) + }) return sessions } @@ -622,6 +645,35 @@ func (m *Model) clampCursor() { m.setCursor(m.currentCursor()) } +func (m *Model) completeFilter() { + completion, ok := m.currentCompletion() + if !ok { + return + } + m.filter = completion + m.clampCursor() +} + +func (m Model) currentCompletion() (string, bool) { + if m.stage == stageHosts { + indexes := m.filteredHostIndexes() + if len(indexes) == 0 { + return "", false + } + return m.hosts[indexes[m.hostCursor]].Name, true + } + + rows := m.filteredSessionRows() + if len(rows) == 0 { + return "", false + } + row := rows[m.sessionCursor] + if row.New { + return "new session", true + } + return row.Session.Name, true +} + func (m Model) currentCursor() int { if m.stage == stageHosts { return m.hostCursor @@ -761,3 +813,16 @@ func maxInt(a int, b int) int { } return b } + +func labelLess(left string, right string) bool { + leftFolded := strings.ToLower(left) + rightFolded := strings.ToLower(right) + if leftFolded == rightFolded { + return left < right + } + return leftFolded < rightFolded +} + +func startsTypeahead(key string) bool { + return key != "/" && key != "?" && key != "q" +} diff --git a/internal/ui/root_test.go b/internal/ui/root_test.go index 4e69fd1..758a258 100644 --- a/internal/ui/root_test.go +++ b/internal/ui/root_test.go @@ -35,6 +35,20 @@ func TestTeatestHostSelectionAdvancesToSessionPicker(t *testing.T) { }, teatest.WithDuration(2*time.Second)) } +func TestHostsAreSortedByDisplayName(t *testing.T) { + model := NewModel(App{ + Config: config.Config{Hosts: []config.Host{ + {Name: "zeta", Hostname: "zeta.example.com"}, + {Name: "alpha", Hostname: "alpha.example.com"}, + }}, + ProbeResults: closedProbeResults(), + Hostname: "renova.local", + }) + + view := model.View() + assertOrder(t, view, "alpha", "localhost (renova)", "zeta") +} + func TestSelectExistingRemoteSessionProducesAttachPlan(t *testing.T) { model := NewModel(testApp()) model = updateModel(t, model, probeMsg(probe.ProbeResult{ @@ -56,6 +70,67 @@ func TestSelectExistingRemoteSessionProducesAttachPlan(t *testing.T) { } } +func TestSessionsAreSortedByName(t *testing.T) { + model := modelWithProdSessions(t, + probe.SessionInfo{Name: "zeta", Windows: 1}, + probe.SessionInfo{Name: "api", Windows: 3}, + probe.SessionInfo{Name: "ops", Windows: 1}, + ) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + + view := model.View() + assertOrder(t, view, "+ New session", "api", "ops", "zeta") +} + +func TestTypingAndTabCompleteHostSelection(t *testing.T) { + model := NewModel(App{ + Config: config.Config{Hosts: []config.Host{ + {Name: "prod", Hostname: "prod.example.com"}, + {Name: "builder", Hostname: "builder.example.com"}, + }}, + ProbeResults: closedProbeResults(), + Hostname: "renova.local", + }) + + for _, r := range "pr" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + model = press(t, model, tea.KeyMsg{Type: tea.KeyTab}) + + if !model.filtering || model.filter != "prod" { + t.Fatalf("filtering = %v, filter = %q; want completed prod filter", model.filtering, model.filter) + } + + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + if model.stage != stageSessions { + t.Fatalf("stage = %v, want sessions", model.stage) + } + if got := model.selectedHost().Name; got != "prod" { + t.Fatalf("selected host = %q, want prod", got) + } +} + +func TestTypingBoundNavigationLetterStartsHostFilter(t *testing.T) { + model := NewModel(App{ + Config: config.Config{Hosts: []config.Host{ + {Name: "gamma", Hostname: "gamma.example.com"}, + {Name: "prod", Hostname: "prod.example.com"}, + }}, + ProbeResults: closedProbeResults(), + Hostname: "renova.local", + }) + + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + + if !model.filtering || model.filter != "g" { + t.Fatalf("filtering = %v, filter = %q; want g filter", model.filtering, model.filter) + } + if view := model.View(); strings.Contains(view, "prod") { + t.Fatalf("View() = %q, want prod filtered out", view) + } +} + func TestSessionFilterSelectsMatchingSession(t *testing.T) { model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3}, @@ -78,6 +153,32 @@ func TestSessionFilterSelectsMatchingSession(t *testing.T) { } } +func TestTypingAndTabCompleteSessionSelection(t *testing.T) { + model := modelWithProdSessions(t, + probe.SessionInfo{Name: "api", Windows: 3}, + probe.SessionInfo{Name: "ops", Windows: 1}, + ) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + for _, r := range "op" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + model = press(t, model, tea.KeyMsg{Type: tea.KeyTab}) + + if !model.filtering || model.filter != "ops" { + t.Fatalf("filtering = %v, filter = %q; want completed ops filter", model.filtering, model.filter) + } + + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + want := limenexec.Plan{ + HostName: "prod", + Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "ops"}, + } + if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) { + t.Fatalf("result = %#v, want plan %#v", model.result, want) + } +} + func TestEscapeFromHostPickerProducesLocalNewPlan(t *testing.T) { model := NewModel(testApp()) model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc}) @@ -326,8 +427,6 @@ func TestWaitForProbeReturnsResultAndDoneMessages(t *testing.T) { } func testApp() App { - probes := make(chan probe.ProbeResult) - close(probes) return App{ Config: config.Config{Hosts: []config.Host{{ Name: "prod", @@ -338,12 +437,18 @@ func testApp() App { State: state.State{LastAttached: map[string]time.Time{ "prod": time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC), }}, - ProbeResults: probes, + ProbeResults: closedProbeResults(), Now: func() time.Time { return time.Date(2026, 5, 26, 14, 45, 0, 0, time.UTC) }, Hostname: "renova.local", } } +func closedProbeResults() <-chan probe.ProbeResult { + probes := make(chan probe.ProbeResult) + close(probes) + return probes +} + func modelWithProdSessions(t *testing.T, sessions ...probe.SessionInfo) Model { t.Helper() return updateModel(t, NewModel(testApp()), probeMsg(probe.ProbeResult{ @@ -353,6 +458,22 @@ func modelWithProdSessions(t *testing.T, sessions ...probe.SessionInfo) Model { })) } +func assertOrder(t *testing.T, text string, values ...string) { + t.Helper() + + last := -1 + for _, value := range values { + index := strings.Index(text, value) + if index == -1 { + t.Fatalf("text missing %q:\n%s", value, text) + } + if index < last { + t.Fatalf("%q appeared before prior value in:\n%s", value, text) + } + last = index + } +} + func press(t *testing.T, model Model, key tea.KeyMsg) Model { t.Helper() return updateModel(t, model, key) -- 2.54.0 From 60a7e26eacd6b53493076c9367d832497dc250b7 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 26 May 2026 16:19:19 -0500 Subject: [PATCH 3/4] fix(ui): use prefix picker filtering --- docs/DESIGN.md | 2 +- internal/ui/root.go | 22 ++++++++++++-- internal/ui/root_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3e95502..85263f2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -214,7 +214,7 @@ Same as stage 1, with the addition that when the highlighted entry is `+ New ses ### 5.4 Search / filter mode -In either picker stage, `/` opens an inline filter. Typing a letter while not already filtering also opens the inline filter and seeds it with that character, so the user can type directly toward the target host or session without first pressing `/`. The list narrows to entries matching the typed substring (case-insensitive, fuzzy match optional but not required for v1 — simple substring is fine). Tab completes the filter to the highlighted host or session name. Esc cancels filter. Enter selects the highlighted match. +In either picker stage, `/` opens an inline filter. Typing a letter while not already filtering also opens the inline filter and seeds it with that character, so the user can type directly toward the target host or session without first pressing `/`. The list narrows to entries whose visible host or session name starts with the typed prefix, case-insensitive. Tab completes the filter to the highlighted host or session name. Arrow keys continue to navigate the filtered matches. Esc cancels filter. Enter selects the highlighted match. While filter is active, the cheat-sheet footer changes to indicate `esc cancel filter`. diff --git a/internal/ui/root.go b/internal/ui/root.go index 6b74070..fea641e 100644 --- a/internal/ui/root.go +++ b/internal/ui/root.go @@ -300,6 +300,18 @@ func (m Model) updateFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "tab": m.completeFilter() return m, nil + case "up": + m.moveCursor(-1) + return m, nil + case "down": + m.moveCursor(1) + return m, nil + case "home": + m.setCursor(0) + return m, nil + case "end": + m.setCursor(m.entryCount() - 1) + return m, nil case "backspace": if len(m.filter) > 0 { m.filter = m.filter[:len(m.filter)-1] @@ -692,7 +704,7 @@ func (m Model) filteredHostIndexes() []int { indexes := make([]int, 0, len(m.hosts)) filter := strings.ToLower(m.filter) for i, host := range m.hosts { - if filter == "" || strings.Contains(strings.ToLower(host.DisplayName), filter) || strings.Contains(strings.ToLower(host.Connection), filter) { + if filter == "" || startsWithFold(host.DisplayName, filter) { indexes = append(indexes, i) } } @@ -703,11 +715,11 @@ func (m Model) filteredSessionRows() []sessionRow { filter := strings.ToLower(m.filter) rows := make([]sessionRow, 0, len(m.sessionEntries())+1) - if filter == "" || strings.Contains("new session", filter) { + if filter == "" || startsWithFold("new session", filter) { rows = append(rows, sessionRow{New: true}) } for _, session := range m.sessionEntries() { - if filter == "" || strings.Contains(strings.ToLower(session.Name), filter) { + if filter == "" || startsWithFold(session.Name, filter) { rows = append(rows, sessionRow{Session: session}) } } @@ -826,3 +838,7 @@ func labelLess(left string, right string) bool { func startsTypeahead(key string) bool { return key != "/" && key != "?" && key != "q" } + +func startsWithFold(value string, foldedPrefix string) bool { + return strings.HasPrefix(strings.ToLower(value), foldedPrefix) +} diff --git a/internal/ui/root_test.go b/internal/ui/root_test.go index 758a258..2b170c2 100644 --- a/internal/ui/root_test.go +++ b/internal/ui/root_test.go @@ -131,6 +131,29 @@ func TestTypingBoundNavigationLetterStartsHostFilter(t *testing.T) { } } +func TestHostFilterOnlyMatchesNamePrefixes(t *testing.T) { + model := NewModel(App{ + Config: config.Config{Hosts: []config.Host{ + {Name: "prod", Hostname: "prod.example.com"}, + {Name: "sandbox", Hostname: "box.example.com"}, + }}, + ProbeResults: closedProbeResults(), + Hostname: "renova.local", + }) + + for _, r := range "box" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + + view := model.View() + if strings.Contains(view, "sandbox") || strings.Contains(view, "box.example.com") { + t.Fatalf("View() = %q, want non-prefix host and connection filtered out", view) + } + if !strings.Contains(view, "no matching hosts") { + t.Fatalf("View() = %q, want no matching hosts", view) + } +} + func TestSessionFilterSelectsMatchingSession(t *testing.T) { model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3}, @@ -153,6 +176,26 @@ func TestSessionFilterSelectsMatchingSession(t *testing.T) { } } +func TestSessionFilterOnlyMatchesNamePrefixes(t *testing.T) { + model := modelWithProdSessions(t, + probe.SessionInfo{Name: "api", Windows: 3}, + probe.SessionInfo{Name: "ops", Windows: 1}, + ) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + for _, r := range "pi" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + + view := model.View() + if strings.Contains(view, "api") { + t.Fatalf("View() = %q, want substring session filtered out", view) + } + if !strings.Contains(view, "no matching sessions") { + t.Fatalf("View() = %q, want no matching sessions", view) + } +} + func TestTypingAndTabCompleteSessionSelection(t *testing.T) { model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3}, @@ -179,6 +222,29 @@ func TestTypingAndTabCompleteSessionSelection(t *testing.T) { } } +func TestSessionArrowKeysNavigateWhileFiltering(t *testing.T) { + model := modelWithProdSessions(t, + probe.SessionInfo{Name: "api", Windows: 3}, + probe.SessionInfo{Name: "app", Windows: 1}, + probe.SessionInfo{Name: "ops", Windows: 1}, + ) + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter}) + for _, r := range "a" { + model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + + model = press(t, model, tea.KeyMsg{Type: tea.KeyDown}) + if model.sessionCursor != 1 { + t.Fatalf("sessionCursor = %d, want 1 after arrow down in filter mode", model.sessionCursor) + } + + model = press(t, model, tea.KeyMsg{Type: tea.KeyUp}) + if model.sessionCursor != 0 { + t.Fatalf("sessionCursor = %d, want 0 after arrow up in filter mode", model.sessionCursor) + } +} + func TestEscapeFromHostPickerProducesLocalNewPlan(t *testing.T) { model := NewModel(testApp()) model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc}) -- 2.54.0 From a279c3e5def2bf61df67e685449adc3a11dbe920 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 26 May 2026 16:25:45 -0500 Subject: [PATCH 4/4] fix(exec): resolve handoff executable --- internal/exec/exec.go | 9 +++++++- internal/exec/exec_test.go | 45 +++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 3a4f1d9..ce5011f 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + osexec "os/exec" "strings" "syscall" "time" @@ -113,7 +114,13 @@ func Handoff(plan Plan, opts Options) int { st.LastAttached[plan.HostName] = deps.Now().UTC() _ = deps.SaveState(deps.StatePath, st) - if err := deps.Exec(plan.Argv[0], plan.Argv, deps.Environ()); err != nil { + execPath, err := osexec.LookPath(plan.Argv[0]) + if err != nil { + fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err) + return 127 + } + + if err := deps.Exec(execPath, plan.Argv, deps.Environ()); err != nil { fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err) return 127 } diff --git a/internal/exec/exec_test.go b/internal/exec/exec_test.go index 233d69d..85bf53e 100644 --- a/internal/exec/exec_test.go +++ b/internal/exec/exec_test.go @@ -3,6 +3,7 @@ package exec import ( "bytes" "errors" + osexec "os/exec" "reflect" "strings" "testing" @@ -144,8 +145,12 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) { var execArgv []string var execEnv []string var stderr bytes.Buffer + wantPath, err := osexec.LookPath("sh") + if err != nil { + t.Skipf("sh is unavailable: %v", err) + } - code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod", "tmux", "new"}}, Options{ + code := Handoff(Plan{HostName: "prod", Argv: []string{"sh", "-c", "true"}}, Options{ StatePath: "/tmp/state.json", Now: func() time.Time { return now }, Environ: func() []string { return []string{"TERM=xterm-256color"} }, @@ -181,7 +186,7 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) { if _, ok := saved.LastAttached["localhost"]; !ok { t.Fatal("existing localhost timestamp was not preserved") } - if execPath != "ssh" || !reflect.DeepEqual(execArgv, []string{"ssh", "-t", "prod", "tmux", "new"}) { + if execPath != wantPath || !reflect.DeepEqual(execArgv, []string{"sh", "-c", "true"}) { t.Fatalf("exec saw %q %#v", execPath, execArgv) } if !reflect.DeepEqual(execEnv, []string{"TERM=xterm-256color"}) { @@ -192,10 +197,44 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) { } } +func TestHandoffResolvesExecutableBeforeExec(t *testing.T) { + wantPath, err := osexec.LookPath("sh") + if err != nil { + t.Skipf("sh is unavailable: %v", err) + } + + var execPath string + var execArgv []string + + code := Handoff(Plan{HostName: "localhost", Argv: []string{"sh", "-c", "true"}}, Options{ + LoadState: func(path string) state.State { + return state.State{} + }, + SaveState: func(path string, st state.State) error { + return nil + }, + Exec: func(path string, argv []string, env []string) error { + execPath = path + execArgv = append([]string(nil), argv...) + return nil + }, + }) + + if code != 0 { + t.Fatalf("Handoff exit code = %d, want 0", code) + } + if execPath != wantPath { + t.Fatalf("exec path = %q, want %q", execPath, wantPath) + } + if !reflect.DeepEqual(execArgv, []string{"sh", "-c", "true"}) { + t.Fatalf("exec argv = %#v", execArgv) + } +} + func TestHandoffTreatsStateSaveFailureAsAdvisory(t *testing.T) { var calledExec bool - code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh"}}, Options{ + code := Handoff(Plan{HostName: "prod", Argv: []string{"sh"}}, Options{ LoadState: func(path string) state.State { return state.State{} }, -- 2.54.0