feat: add unified tui shell and ctrl-h help

This commit is contained in:
mirivlad 2026-08-14 07:50:13 +08:00
parent 4ba2bfce34
commit 762d14bb9b
6 changed files with 399 additions and 101 deletions

View File

@ -680,7 +680,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil m.err = nil
m.success = "" m.success = ""
} }
if msg.Type == tea.KeyF1 && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm { if msg.Type == tea.KeyCtrlH && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
m.helpParent = m.screen m.helpParent = m.screen
m.fullHelp = newFullHelpModel(m.width, m.height) m.fullHelp = newFullHelpModel(m.width, m.height)
m.screen = screenFullHelp m.screen = screenFullHelp
@ -1549,41 +1549,41 @@ func (m *tuiModel) viewConfirm() string {
if m.confirm == nil { if m.confirm == nil {
return "" return ""
} }
width := m.width body := func(width, height int) string {
if width <= 0 { innerWidth := max(1, width-4)
width = 80 lines := []string{dashboardSection(m.confirm.title), ""}
} lines = append(lines, wrapCells(m.confirm.target, innerWidth)...)
innerWidth := max(1, width-2) if m.confirm.consequence != "" {
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(m.confirm.title, width)), ""} lines = append(lines, "")
for _, line := range wrapCells(m.confirm.target, innerWidth) { lines = append(lines, wrapCells(m.confirm.consequence, innerWidth)...)
lines = append(lines, " "+line)
}
if m.confirm.consequence != "" {
lines = append(lines, "")
for _, line := range wrapCells(m.confirm.consequence, innerWidth) {
lines = append(lines, " "+line)
} }
lines = append(lines, "")
cancel := "[ Cancel ]"
accept := "[ " + m.confirm.verb + " ]"
if m.confirm.focus == confirmCancel {
cancel = selectedStyle.Render("> " + cancel)
} else {
accept = errorStyle.Render("> " + accept)
}
if m.confirm.pending {
lines = append(lines, m.confirm.verb+" in progress…")
} else {
lines = append(lines, cancel+" "+accept)
}
return renderPaddedPanel(width, height, lines)
} }
lines = append(lines, "") return renderScreenShell(screenShell{
cancel := "[ Cancel ]" breadcrumb: "Confirm",
accept := "[ " + m.confirm.verb + " ]" status: shellStatus(m.vaultUnlocked, "Action required"),
if m.confirm.focus == confirmCancel { width: m.width,
cancel = selectedStyle.Render("> " + cancel) height: m.height,
} else { body: body,
accept = errorStyle.Render("> " + accept) footer: []helpItem{
} {Key: "Tab", Action: "choose"},
if m.confirm.pending { {Key: "Enter", Action: "activate"},
lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "") {Key: "Esc", Action: "cancel"},
} else { },
lines = append(lines, fitLine(" "+cancel+" "+accept, width), "") })
}
footer := renderHelp([]helpItem{
{Key: "Tab", Action: "choose"},
{Key: "Enter", Action: "activate"},
{Key: "Esc", Action: "cancel"},
}, width)
lines = append(lines, strings.Split(footer, "\n")...)
return strings.Join(lines, "\n")
} }
func (m *tuiModel) beginConfirm(state confirmState) { func (m *tuiModel) beginConfirm(state confirmState) {
@ -2163,7 +2163,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
helpItem{Key: "Ctrl+F", Action: "search"}, helpItem{Key: "Ctrl+F", Action: "search"},
helpItem{Key: "Ins", Action: insAction}, helpItem{Key: "Ins", Action: insAction},
helpItem{Key: "?", Action: "hotkeys"}, helpItem{Key: "?", Action: "hotkeys"},
helpItem{Key: "F1", Action: "help"}, helpItem{Key: "Ctrl+H", Action: "help"},
helpItem{Key: "Ctrl+Q", Action: "quit"}, helpItem{Key: "Ctrl+Q", Action: "quit"},
) )
return items return items

View File

@ -3,7 +3,6 @@ package tui
import ( import (
"fmt" "fmt"
"io" "io"
"strings"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
@ -12,8 +11,9 @@ import (
// --- Help screen (?) --- // --- Help screen (?) ---
type helpScreenModel struct { type helpScreenModel struct {
list list.Model list list.Model
width int width int
height int
} }
func newHelpScreenModel(w, h int) *helpScreenModel { func newHelpScreenModel(w, h int) *helpScreenModel {
@ -30,7 +30,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"}, helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"}, helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
helpScreenItem{key: "?", action: "This quick help", section: "Other"}, helpScreenItem{key: "?", action: "This quick help", section: "Other"},
helpScreenItem{key: "F1", action: "Full documentation", section: "Other"}, helpScreenItem{key: "Ctrl+H", action: "Full documentation", section: "Other"},
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"}, helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
} }
@ -40,7 +40,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
l.SetFilteringEnabled(false) l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle l.Styles.Title = titleStyle
return &helpScreenModel{list: l, width: w} return &helpScreenModel{list: l, width: w, height: h}
} }
type helpScreenItem struct { type helpScreenItem struct {
@ -86,6 +86,7 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height
m.list.SetSize(msg.Width, msg.Height-4) m.list.SetSize(msg.Width, msg.Height-4)
return m, nil return m, nil
} }
@ -95,10 +96,39 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *helpScreenModel) View() string { func (m *helpScreenModel) View() string {
return m.list.View() items := m.list.Items()
body := func(width, height int) string {
innerRows := max(1, height-2)
start, end := visibleServerRange(len(items), m.list.Index(), innerRows)
lines := make([]string, 0, innerRows)
for index := start; index < end; index++ {
item, ok := items[index].(helpScreenItem)
if !ok {
continue
}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+padCells(item.key, 12)+" "+item.action)
}
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Quick Help",
status: "Keyboard reference",
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "↑/↓", Action: "move"},
{Key: "Ctrl+H", Action: "full help"},
{Key: "Esc", Action: "back"},
},
})
} }
// --- Full help (F1) --- // --- Full help (Ctrl+H) ---
type fullHelpModel struct { type fullHelpModel struct {
width int width int
@ -149,11 +179,6 @@ func (m *fullHelpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *fullHelpModel) View() string { func (m *fullHelpModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("sshkeeper — Full Help"))
b.WriteString("\n\n")
sections := []struct { sections := []struct {
title string title string
rows [][2]string rows [][2]string
@ -174,7 +199,7 @@ func (m *fullHelpModel) View() string {
{"Enter", "Select / Confirm / Open"}, {"Enter", "Select / Confirm / Open"},
{"Esc", "Back / Cancel / Close"}, {"Esc", "Back / Cancel / Close"},
{"?", "Quick help (hotkeys)"}, {"?", "Quick help (hotkeys)"},
{"F1", "Full documentation"}, {"Ctrl+H", "Full documentation"},
{"Ctrl+Q", "Quit"}, {"Ctrl+Q", "Quit"},
}}, }},
{"Server list", [][2]string{ {"Server list", [][2]string{
@ -224,47 +249,45 @@ func (m *fullHelpModel) View() string {
}}, }},
} }
var lines []string
for _, sec := range sections { for _, sec := range sections {
b.WriteString(sectionStyle.Render(sec.title)) lines = append(lines, sectionStyle.Copy().MarginTop(0).Render(sec.title))
b.WriteString("\n")
for _, row := range sec.rows { for _, row := range sec.rows {
if row[0] == "" { if row[0] == "" {
b.WriteString(fmt.Sprintf(" %s\n", row[1])) lines = append(lines, " "+row[1])
} else { } else {
b.WriteString(fmt.Sprintf(" %-16s %s\n", row[0], row[1])) lines = append(lines, fmt.Sprintf(" %-16s %s", row[0], row[1]))
} }
} }
b.WriteString("\n") lines = append(lines, "")
} }
b.WriteString(helpStyle.Render(" ↑/↓ scroll — q/Esc/Enter close")) body := func(width, height int) string {
capacity := max(1, height-2)
// Simple scroll start := min(m.offset, max(0, len(lines)-capacity))
lines := strings.Split(b.String(), "\n") end := min(len(lines), start+capacity)
maxLines := m.height - 1 return renderPaddedPanel(width, height, lines[start:end])
if maxLines < 5 {
maxLines = 5
} }
start := m.offset return renderScreenShell(screenShell{
if start > len(lines)-maxLines { breadcrumb: "Full Help",
start = len(lines) - maxLines status: fmt.Sprintf("line %d/%d", min(m.offset+1, len(lines)), len(lines)),
} width: m.width,
if start < 0 { height: m.height,
start = 0 body: body,
} footer: []helpItem{
end := start + maxLines {Key: "↑/↓", Action: "scroll"},
if end > len(lines) { {Key: "Ctrl+H", Action: "full help"},
end = len(lines) {Key: "Esc/Enter", Action: "close"},
} },
})
return strings.Join(lines[start:end], "\n")
} }
// --- Action menu --- // --- Action menu ---
type actionMenuItem struct { type actionMenuItem struct {
label string label string
action string action string
description string
} }
func (i actionMenuItem) Title() string { return i.label } func (i actionMenuItem) Title() string { return i.label }
@ -279,20 +302,20 @@ type actionMenuModel struct {
func newActionMenuModel(w, h int) *actionMenuModel { func newActionMenuModel(w, h int) *actionMenuModel {
items := []list.Item{ items := []list.Item{
actionMenuItem{label: "Connect", action: "connect"}, actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."},
actionMenuItem{label: "Connect with tunnels", action: "tunnel"}, actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."},
actionMenuItem{label: "Start tunnels only", action: "tunnel_n"}, actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."},
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"}, actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."},
actionMenuItem{label: "Manage port forwards", action: "forwards"}, actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."},
actionMenuItem{label: "Manage tunnels", action: "tunnels"}, actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."},
actionMenuItem{label: "Manage route", action: "route"}, actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."},
actionMenuItem{label: "Test connection", action: "test"}, actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."},
actionMenuItem{label: "Edit", action: "edit"}, actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."},
actionMenuItem{label: "Delete", action: "delete"}, actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."},
actionMenuItem{label: "Import", action: "import"}, actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."},
actionMenuItem{label: "Export", action: "export"}, actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."},
actionMenuItem{label: "Vault: lock", action: "vault_lock"}, actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."},
actionMenuItem{label: "Vault: change password", action: "vault_change_pw"}, actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."},
} }
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2) l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
@ -324,10 +347,44 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
} }
func (m *actionMenuModel) View() string { func (m *actionMenuModel) View() string {
footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width) body := func(width, height int) string {
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")} listLines := m.actionLines(max(1, height-2))
capacity := max(1, m.height-displayLineCount(footer)-1) if classifyTerminal(width, height) == sizeWide {
leftWidth := width * 48 / 100
rightWidth := width - leftWidth - 1
selected, _ := m.list.SelectedItem().(actionMenuItem)
detail := []string{dashboardSection("Selected action"), "", selected.label, ""}
detail = append(detail, wrapCells(selected.description, max(1, rightWidth-4))...)
return joinPanelColumns(
renderPaddedPanel(leftWidth, height, listLines), leftWidth,
renderPaddedPanel(rightWidth, height, detail), rightWidth,
)
}
if classifyTerminal(width, height) == sizeMedium {
if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 {
listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
}
}
return renderPaddedPanel(width, height, listLines)
}
return renderScreenShell(screenShell{
breadcrumb: "Actions",
status: fmt.Sprintf("%d actions", len(m.list.Items())),
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "↑/↓", Action: "move"},
{Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}
func (m *actionMenuModel) actionLines(capacity int) []string {
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity) start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
lines := make([]string, 0, capacity)
for index := start; index < end; index++ { for index := start; index < end; index++ {
item, ok := m.list.Items()[index].(actionMenuItem) item, ok := m.list.Items()[index].(actionMenuItem)
if !ok { if !ok {
@ -337,11 +394,7 @@ func (m *actionMenuModel) View() string {
if index == m.list.Index() { if index == m.list.Index() {
marker = "> " marker = "> "
} }
lines = append(lines, fitLine(marker+item.label, m.width)) lines = append(lines, marker+item.label)
} }
lines = append(lines, strings.Split(footer, "\n")...) return lines
if len(lines) > m.height && m.height > 0 {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
} }

View File

@ -133,6 +133,7 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
menu := newActionMenuModel(size.width, size.height) menu := newActionMenuModel(size.width, size.height)
view := menu.View() view := menu.View()
assertViewFits(t, view, size.width, size.height) assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} { for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -154,6 +155,7 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
}) })
view := m.View() view := m.View()
assertViewFits(t, view, size.width, size.height) assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} { for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -162,6 +164,22 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
} }
} }
func TestHelpScreensUseUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
for name, view := range map[string]string{
"quick": newHelpScreenModel(size.width, size.height).View(),
"full": newFullHelpModel(size.width, size.height).View(),
} {
t.Run(name+itoa(size.width), func(t *testing.T) {
assertUnifiedScreen(t, view, size.width, size.height)
if strings.Contains(view, "F1") || !strings.Contains(view, "Ctrl+H") {
t.Fatalf("help exposes the wrong global binding:\n%s", view)
}
})
}
}
}
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) { func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newTemplateFormModel(nil, size.width, size.height) form := newTemplateFormModel(nil, size.width, size.height)
@ -190,6 +208,28 @@ func assertViewFits(t *testing.T, view string, width, height int) {
} }
} }
func assertUnifiedScreen(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(view, "\n")
if len(lines) != height {
t.Fatalf("unified screen has %d lines, want %d:\n%s", len(lines), height, view)
}
if !strings.HasPrefix(ansi.Strip(lines[0]), "sshkeeper / ") {
t.Fatalf("unified screen has no breadcrumb header: %q", ansi.Strip(lines[0]))
}
if !strings.Contains(ansi.Strip(view), "┌") || !strings.Contains(ansi.Strip(view), "┘") {
t.Fatalf("unified screen has no framed content:\n%s", view)
}
if strings.TrimSpace(ansi.Strip(lines[height-1])) == "" {
t.Fatalf("unified screen footer is not on last row:\n%s", view)
}
for index, line := range lines {
if got := ansi.StringWidth(line); got > width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
}
}
}
type errText string type errText string
func (e errText) Error() string { return string(e) } func (e errText) Error() string { return string(e) }

122
internal/tui/shell.go Normal file
View File

@ -0,0 +1,122 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type screenShell struct {
breadcrumb string
status string
notification string
width int
height int
body func(width, height int) string
footer []helpItem
}
func renderScreenShell(shell screenShell) string {
width, height := shell.width, shell.height
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
canvasWidth := max(1, width-1)
headerLeft := "sshkeeper"
if shell.breadcrumb != "" {
headerLeft += " / " + shell.breadcrumb
}
header := headerLeft
if shell.status != "" {
if gap := canvasWidth - lipgloss.Width(headerLeft) - lipgloss.Width(shell.status); gap > 0 {
header = headerLeft + strings.Repeat(" ", gap) + shell.status
} else {
header = headerLeft + " " + shell.status
}
}
footer := renderHelp(shell.footer, canvasWidth)
footerLines := splitBlock(footer)
if len(footerLines) == 0 {
footerLines = []string{""}
}
fixedRows := 2 + len(footerLines)
notificationLines := []string(nil)
if shell.notification != "" {
notificationLines = []string{fitLine(shell.notification, canvasWidth)}
fixedRows++
}
bodyHeight := max(1, height-fixedRows)
body := ""
if shell.body != nil {
body = shell.body(canvasWidth, bodyHeight)
}
bodyLines := fitBlock(body, canvasWidth, bodyHeight)
lines := make([]string, 0, height)
lines = append(lines,
titleStyle.Copy().MarginLeft(0).Render(fitLine(header, canvasWidth)),
helpStyle.Copy().MarginLeft(0).Render(strings.Repeat("─", canvasWidth)),
)
lines = append(lines, notificationLines...)
lines = append(lines, bodyLines...)
for _, line := range footerLines {
lines = append(lines, fitLine(line, canvasWidth))
}
if len(lines) > height {
lines = lines[:height]
}
for len(lines) < height {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
func renderPaddedPanel(width, height int, lines []string) string {
if width < 4 || height < 2 {
return ""
}
contentWidth := width - 4
padded := make([]string, 0, len(lines))
for _, line := range lines {
padded = append(padded, " "+padCells(line, contentWidth)+" ")
}
return renderPanel(width, height, padded)
}
func fitBlock(block string, width, height int) []string {
lines := splitBlock(block)
if len(lines) > height {
lines = lines[:height]
}
for index := range lines {
lines[index] = fitLine(lines[index], width)
}
for len(lines) < height {
lines = append(lines, strings.Repeat(" ", width))
}
return lines
}
func splitBlock(block string) []string {
if block == "" {
return nil
}
return strings.Split(strings.TrimRight(block, "\n"), "\n")
}
func shellStatus(vaultUnlocked bool, detail string) string {
vault := "Vault locked"
if vaultUnlocked {
vault = "Vault unlocked"
}
if detail == "" {
return vault
}
return fmt.Sprintf("%s · %s", vault, detail)
}

View File

@ -0,0 +1,62 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestScreenShellFitsAndAnchorsFooter(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
t.Run(itoa(size.width)+"x"+itoa(size.height), func(t *testing.T) {
view := renderScreenShell(screenShell{
breadcrumb: "Actions / production-数据库",
status: "Vault unlocked",
width: size.width,
height: size.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{"Actions", "> Connect", " Manage port forwards"})
},
footer: []helpItem{{Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
lines := strings.Split(view, "\n")
if len(lines) != size.height {
t.Fatalf("shell has %d lines, want %d:\n%s", len(lines), size.height, view)
}
if !strings.Contains(ansi.Strip(lines[0]), "sshkeeper / Actions") {
t.Fatalf("missing breadcrumb header: %q", ansi.Strip(lines[0]))
}
if !strings.HasPrefix(ansi.Strip(lines[2]), "┌") || !strings.HasSuffix(ansi.Strip(lines[size.height-2]), "┘") {
t.Fatalf("body panel does not fill shell:\n%s", view)
}
if !strings.Contains(ansi.Strip(lines[size.height-1]), "Ctrl+H") {
t.Fatalf("footer is not anchored to last row: %q", ansi.Strip(lines[size.height-1]))
}
for index, line := range lines {
if got := ansi.StringWidth(line); got > size.width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d, terminal=%d", index+1, got, size.width)
}
}
})
}
}
func TestScreenShellShowsNotificationWithoutMovingFooter(t *testing.T) {
view := renderScreenShell(screenShell{
breadcrumb: "Port Forwards / prod",
status: "2 rules",
notification: "Forward saved",
width: 60,
height: 16,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{"Local PostgreSQL"})
},
footer: []helpItem{{Key: "Esc", Action: "back"}},
})
lines := strings.Split(view, "\n")
if len(lines) != 16 || !strings.Contains(view, "Forward saved") || !strings.Contains(ansi.Strip(lines[15]), "Esc") {
t.Fatalf("notification broke shell geometry:\n%s", view)
}
}

View File

@ -45,15 +45,15 @@ func TestNotificationSurvivesRepeatedView(t *testing.T) {
} }
} }
func TestFullHelpReturnsToOriginatingScreen(t *testing.T) { func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) {
m := New(nil) m := New(nil)
m.screen = screenForwardList m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24) m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1}) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH})
m = updated.(*tuiModel) m = updated.(*tuiModel)
if m.screen != screenFullHelp || m.fullHelp == nil { if m.screen != screenFullHelp || m.fullHelp == nil {
t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen) t.Fatalf("Ctrl+H did not open full help from forward list: screen=%v", m.screen)
} }
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(*tuiModel) m = updated.(*tuiModel)
@ -62,6 +62,27 @@ func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
} }
} }
func TestF1NoLongerOpensFullHelp(t *testing.T) {
m := New(nil)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
m = updated.(*tuiModel)
if m.screen == screenFullHelp || m.fullHelp != nil {
t.Fatalf("F1 still opens full help: screen=%v", m.screen)
}
}
func TestBackspaceStillEditsSearchAfterCtrlHBinding(t *testing.T) {
m := New(nil)
m.screen = screenSearch
m.searchInput.SetValue("prod")
m.searchInput.Focus()
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
m = updated.(*tuiModel)
if m.screen != screenSearch || m.searchInput.Value() != "pro" {
t.Fatalf("Backspace did not edit search: screen=%v value=%q", m.screen, m.searchInput.Value())
}
}
func TestContextHelpReturnsToOriginatingManager(t *testing.T) { func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
m := New(nil) m := New(nil)
m.screen = screenForwardList m.screen = screenForwardList