diff --git a/internal/tui/app.go b/internal/tui/app.go index 7eb02cb..7db7347 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -680,7 +680,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = nil 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.fullHelp = newFullHelpModel(m.width, m.height) m.screen = screenFullHelp @@ -1549,41 +1549,41 @@ func (m *tuiModel) viewConfirm() string { if m.confirm == nil { return "" } - width := m.width - if width <= 0 { - width = 80 - } - innerWidth := max(1, width-2) - lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(m.confirm.title, width)), ""} - for _, line := range wrapCells(m.confirm.target, innerWidth) { - lines = append(lines, " "+line) - } - if m.confirm.consequence != "" { - lines = append(lines, "") - for _, line := range wrapCells(m.confirm.consequence, innerWidth) { - lines = append(lines, " "+line) + body := func(width, height int) string { + innerWidth := max(1, width-4) + lines := []string{dashboardSection(m.confirm.title), ""} + lines = append(lines, wrapCells(m.confirm.target, innerWidth)...) + if m.confirm.consequence != "" { + lines = append(lines, "") + lines = append(lines, wrapCells(m.confirm.consequence, innerWidth)...) } + 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, "") - 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, fitLine(" "+m.confirm.verb+" in progress…", width), "") - } 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") + return renderScreenShell(screenShell{ + breadcrumb: "Confirm", + status: shellStatus(m.vaultUnlocked, "Action required"), + width: m.width, + height: m.height, + body: body, + footer: []helpItem{ + {Key: "Tab", Action: "choose"}, + {Key: "Enter", Action: "activate"}, + {Key: "Esc", Action: "cancel"}, + }, + }) } 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: "Ins", Action: insAction}, helpItem{Key: "?", Action: "hotkeys"}, - helpItem{Key: "F1", Action: "help"}, + helpItem{Key: "Ctrl+H", Action: "help"}, helpItem{Key: "Ctrl+Q", Action: "quit"}, ) return items diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index 3e78c67..f190cc8 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "io" - "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbletea" @@ -12,8 +11,9 @@ import ( // --- Help screen (?) --- type helpScreenModel struct { - list list.Model - width int + list list.Model + width int + height int } 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: "Ctrl+W", action: "Manage port forwards", section: "Forwards"}, 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"}, } @@ -40,7 +40,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel { l.SetFilteringEnabled(false) l.Styles.Title = titleStyle - return &helpScreenModel{list: l, width: w} + return &helpScreenModel{list: l, width: w, height: h} } type helpScreenItem struct { @@ -86,6 +86,7 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.WindowSizeMsg: m.width = msg.Width + m.height = msg.Height m.list.SetSize(msg.Width, msg.Height-4) return m, nil } @@ -95,10 +96,39 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 { width int @@ -149,11 +179,6 @@ func (m *fullHelpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *fullHelpModel) View() string { - var b strings.Builder - - b.WriteString(titleStyle.Render("sshkeeper — Full Help")) - b.WriteString("\n\n") - sections := []struct { title string rows [][2]string @@ -174,7 +199,7 @@ func (m *fullHelpModel) View() string { {"Enter", "Select / Confirm / Open"}, {"Esc", "Back / Cancel / Close"}, {"?", "Quick help (hotkeys)"}, - {"F1", "Full documentation"}, + {"Ctrl+H", "Full documentation"}, {"Ctrl+Q", "Quit"}, }}, {"Server list", [][2]string{ @@ -224,47 +249,45 @@ func (m *fullHelpModel) View() string { }}, } + var lines []string for _, sec := range sections { - b.WriteString(sectionStyle.Render(sec.title)) - b.WriteString("\n") + lines = append(lines, sectionStyle.Copy().MarginTop(0).Render(sec.title)) for _, row := range sec.rows { if row[0] == "" { - b.WriteString(fmt.Sprintf(" %s\n", row[1])) + lines = append(lines, " "+row[1]) } 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")) - - // Simple scroll - lines := strings.Split(b.String(), "\n") - maxLines := m.height - 1 - if maxLines < 5 { - maxLines = 5 + body := func(width, height int) string { + capacity := max(1, height-2) + start := min(m.offset, max(0, len(lines)-capacity)) + end := min(len(lines), start+capacity) + return renderPaddedPanel(width, height, lines[start:end]) } - start := m.offset - if start > len(lines)-maxLines { - start = len(lines) - maxLines - } - if start < 0 { - start = 0 - } - end := start + maxLines - if end > len(lines) { - end = len(lines) - } - - return strings.Join(lines[start:end], "\n") + return renderScreenShell(screenShell{ + breadcrumb: "Full Help", + status: fmt.Sprintf("line %d/%d", min(m.offset+1, len(lines)), len(lines)), + width: m.width, + height: m.height, + body: body, + footer: []helpItem{ + {Key: "↑/↓", Action: "scroll"}, + {Key: "Ctrl+H", Action: "full help"}, + {Key: "Esc/Enter", Action: "close"}, + }, + }) } // --- Action menu --- type actionMenuItem struct { - label string - action string + label string + action string + description string } func (i actionMenuItem) Title() string { return i.label } @@ -279,20 +302,20 @@ type actionMenuModel struct { func newActionMenuModel(w, h int) *actionMenuModel { items := []list.Item{ - actionMenuItem{label: "Connect", action: "connect"}, - actionMenuItem{label: "Connect with tunnels", action: "tunnel"}, - actionMenuItem{label: "Start tunnels only", action: "tunnel_n"}, - actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"}, - actionMenuItem{label: "Manage port forwards", action: "forwards"}, - actionMenuItem{label: "Manage tunnels", action: "tunnels"}, - actionMenuItem{label: "Manage route", action: "route"}, - actionMenuItem{label: "Test connection", action: "test"}, - actionMenuItem{label: "Edit", action: "edit"}, - actionMenuItem{label: "Delete", action: "delete"}, - actionMenuItem{label: "Import", action: "import"}, - actionMenuItem{label: "Export", action: "export"}, - actionMenuItem{label: "Vault: lock", action: "vault_lock"}, - actionMenuItem{label: "Vault: change password", action: "vault_change_pw"}, + actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."}, + actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."}, + actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."}, + actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."}, + actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."}, + actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."}, + actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."}, + actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."}, + actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."}, + actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."}, + actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."}, + actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."}, + actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."}, + 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) @@ -324,10 +347,44 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) { } func (m *actionMenuModel) View() string { - footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width) - lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")} - capacity := max(1, m.height-displayLineCount(footer)-1) + body := func(width, height int) string { + listLines := m.actionLines(max(1, height-2)) + 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) + lines := make([]string, 0, capacity) for index := start; index < end; index++ { item, ok := m.list.Items()[index].(actionMenuItem) if !ok { @@ -337,11 +394,7 @@ func (m *actionMenuModel) View() string { if index == m.list.Index() { marker = "> " } - lines = append(lines, fitLine(marker+item.label, m.width)) + lines = append(lines, marker+item.label) } - lines = append(lines, strings.Split(footer, "\n")...) - if len(lines) > m.height && m.height > 0 { - lines = lines[:m.height] - } - return strings.Join(lines, "\n") + return lines } diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index 56e0e3c..75acfb3 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -133,6 +133,7 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) { menu := newActionMenuModel(size.width, size.height) view := menu.View() assertViewFits(t, view, size.width, size.height) + assertUnifiedScreen(t, view, size.width, size.height) for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} { if !strings.Contains(view, want) { 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() assertViewFits(t, view, size.width, size.height) + assertUnifiedScreen(t, view, size.width, size.height) for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} { if !strings.Contains(view, want) { 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) { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { 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 func (e errText) Error() string { return string(e) } diff --git a/internal/tui/shell.go b/internal/tui/shell.go new file mode 100644 index 0000000..a250605 --- /dev/null +++ b/internal/tui/shell.go @@ -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) +} diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go new file mode 100644 index 0000000..7365fb4 --- /dev/null +++ b/internal/tui/shell_test.go @@ -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) + } +} diff --git a/internal/tui/status_help_test.go b/internal/tui/status_help_test.go index 72f0991..ea5055b 100644 --- a/internal/tui/status_help_test.go +++ b/internal/tui/status_help_test.go @@ -45,15 +45,15 @@ func TestNotificationSurvivesRepeatedView(t *testing.T) { } } -func TestFullHelpReturnsToOriginatingScreen(t *testing.T) { +func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) { m := New(nil) m.screen = screenForwardList 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) 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}) 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) { m := New(nil) m.screen = screenForwardList