Author SHA1 Message Date
james.maes a279c3e5de fix(exec): resolve handoff executable 2026-05-26 16:25:45 -05:00
james.maes 60a7e26eac fix(ui): use prefix picker filtering 2026-05-26 16:19:19 -05:00
james.maes 5376dc561e feat(ui): add picker typeahead 2026-05-26 16:09:46 -05:00
james.maes b7880fffab feat(ui): add terminal picker
Closes #4
2026-05-26 15:52:57 -05:00
11 changed files with 1733 additions and 95 deletions

No files matched your search

+6 -4
View File
@@ -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 (<short-hostname>)` 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 (<short-hostname>)` 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 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`.
+1 -1
View File
@@ -16,7 +16,7 @@
pname = "limen";
version = "0.1.0";
src = ./.;
vendorHash = null;
vendorHash = "sha256-zYQ6hMFBFDPNlViB7EmnxbXozardyYeHiUSiG4uS/9Y=";
ldflags = [
"-s"
+29 -1
View File
@@ -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
)
+49
View File
@@ -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=
+8 -1
View File
@@ -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
}
+42 -3
View File
@@ -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{}
},
+844
View File
@@ -0,0 +1,844 @@
// Package ui contains limen's Bubble Tea models.
package ui
import (
"context"
"fmt"
"os"
"sort"
"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],
})
}
sort.SliceStable(hosts, func(i int, j int) bool {
return labelLess(hosts[i].DisplayName, hosts[j].DisplayName)
})
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)
}
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
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 "tab":
if m.stage != stageNewSession {
m.filtering = true
m.completeFilter()
}
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 "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]
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...)
sort.SliceStable(sessions, func(i int, j int) bool {
return labelLess(sessions[i].Name, sessions[j].Name)
})
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) 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
}
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 == "" || startsWithFold(host.DisplayName, 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 == "" || startsWithFold("new session", filter) {
rows = append(rows, sessionRow{New: true})
}
for _, session := range m.sessionEntries() {
if filter == "" || startsWithFold(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
}
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"
}
func startsWithFold(value string, foldedPrefix string) bool {
return strings.HasPrefix(strings.ToLower(value), foldedPrefix)
}
+556
View File
@@ -0,0 +1,556 @@
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 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{
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 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 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},
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 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},
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 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})
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 {
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: 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{
HostName: "prod",
Reachable: true,
Sessions: sessions,
}))
}
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)
}
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
}
+37
View File
@@ -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)
)
+41 -10
View File
@@ -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,
})
}
+120 -75
View File
@@ -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")
}