Compare commits

...

8 Commits

Author SHA1 Message Date
mirivlad 44156a11af docs: refresh unified tui screenshots
Complete the final task of the unified TUI shell plan, which had been left
undone: the code dropped F1 in favour of Ctrl+H, but README and the guide
still documented F1 as the full-help key in nine places, and the repository
screenshots still showed the pre-shell UI.

- Replace every F1 reference in README.md and docs/guide.md with Ctrl+H,
  including the two ASCII help blocks that mirror runtime output.
- Document the BS/DEL terminal constraint: Ctrl+H is BS (0x08) while xterm
  sends DEL (0x7F) for Backspace, so help and text editing do not collide.
- Recapture all five screenshots from the current binary in a real xterm at
  120x40, 80x24 and 60x16 using an isolated XDG profile.

Runtime verification in xterm confirms Ctrl+H opens full help while seven
Backspace presses correctly trim typed text, and that every captured screen
carries the header, framed content and bottom-anchored footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:33:14 +08:00
mirivlad 84070d2721 fix: harden responsive tui layouts 2026-08-14 08:11:27 +08:00
mirivlad 5e83300ea8 feat: unify tui editor screens 2026-08-14 08:02:23 +08:00
mirivlad 18d7a7c07a feat: unify tui manager screens 2026-08-14 07:58:47 +08:00
mirivlad d8ae2d4236 feat: redesign port forward screens 2026-08-14 07:54:51 +08:00
mirivlad 762d14bb9b feat: add unified tui shell and ctrl-h help 2026-08-14 07:50:13 +08:00
mirivlad 4ba2bfce34 docs: plan unified tui shell 2026-08-14 07:44:35 +08:00
mirivlad 7b5919b6c1 docs: specify unified tui shell 2026-08-14 07:43:27 +08:00
21 changed files with 1327 additions and 448 deletions

View File

@ -111,11 +111,16 @@ sshkeeper / Servers Vault unlocked · 1 profile
Press `?` outside text editors for a compact hotkey reference. Inside forms and
search, `?` remains normal text input.
### Full Help (F1)
### Full Help (Ctrl+H)
Press `F1` on any screen for full documentation including routes, port
Press `Ctrl+H` on any screen for full documentation including routes, port
forwarding, tunnels, and vault.
`Ctrl+H` is the BS control character (0x08). xterm and most modern emulators
send DEL (0x7F) for Backspace, so help and text editing never collide. A
terminal configured to send BS for Backspace cannot distinguish the two; switch
it to DEL (in xterm, `backarrowKey: false`).
### Screenshots
| Wide dashboard | Dashboard 80x24 | Server form 60x16 |
@ -138,7 +143,7 @@ forwarding, tunnels, and vault.
| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete, import/export, vault actions) |
| Ins | Select / deselect a server |
| ? | Quick help (hotkeys) |
| F1 | Full documentation |
| Ctrl+H | Full documentation |
| Ctrl+Q / Ctrl+C | Quit |
Templates are global entities and can run on any server. Foreground template

View File

@ -170,7 +170,7 @@ Auth: agent Group: - Status: ?
| `Ctrl+F` | Поиск |
| `Ins` | Выбрать/снять выбор |
| `?` | Краткая справка по клавишам |
| `F1` | Полная справка по приложению |
| `Ctrl+H` | Полная справка по приложению |
| `Ctrl+Q` | Выход |
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
@ -179,7 +179,7 @@ Auth: agent Group: - Status: ?
### Быстрая справка по клавишам
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
остаётся обычным вводом. `F1` открывает полную справку также из форм.
остаётся обычным вводом. `Ctrl+H` открывает полную справку также из форм.
```
sshkeeper — Quick Help
@ -211,14 +211,20 @@ sshkeeper — Quick Help
Other
? This quick help
F1 Full documentation
Ctrl+H Full documentation
Esc / Enter / ? / q — close
```
### Полная справка по приложению
Нажмите `F1` на любом экране. Это полная документация по sshkeeper:
Нажмите `Ctrl+H` на любом экране. Это полная документация по sshkeeper:
> **Про терминалы.** `Ctrl+H` — это управляющий символ BS (0x08), а Backspace в
> xterm и большинстве современных эмуляторов шлёт DEL (0x7F), поэтому справка и
> редактирование текста не конфликтуют. Если ваш терминал настроен отправлять BS
> по Backspace, различить их невозможно: Backspace начнёт открывать справку.
> В этом случае переключите терминал на DEL (в xterm — `backarrowKey: false`).
```
sshkeeper — Full Help
@ -238,7 +244,7 @@ sshkeeper — Full Help
Enter Select / Confirm / Open
Esc Back / Cancel / Close
? Quick help (hotkeys)
F1 Full documentation
Ctrl+H Full documentation
Ctrl+Q Quit
Server list
@ -743,7 +749,7 @@ sshkeeper connect secure
| `Ctrl+X` | Меню действий |
| `Ins` | Выбрать/снять выбор |
| `?` | Краткая справка по клавишам |
| `F1` | Полная справка по приложению |
| `Ctrl+H` | Полная справка по приложению |
| `Ctrl+Q` | Выход |
### Меню действий (Ctrl+X)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,141 @@
# Unified sshkeeper TUI Shell Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Apply the approved dashboard-style full-screen shell to every sshkeeper TUI screen and replace F1 help with Ctrl+H.
**Architecture:** Add one pure screen-shell renderer that owns header, notification, bordered content height, inner width, and bottom footer. Refactor each child screen to supply bounded body panels and contextual help rather than free-form terminal strings; retain existing Bubble Tea state and callback boundaries.
**Tech Stack:** Go 1.25, Bubble Tea v1.3.10, Bubbles v1.0.0, Lip Gloss v1.1.0, charmbracelet/x/ansi v0.11.6, tmux/xterm/Xvfb runtime capture.
## Global Constraints
- The existing server dashboard is the visual reference.
- Supported terminal floor is exactly `60x16`.
- Breakpoints are narrow 60-69, medium 70-99, and wide 100+ columns.
- Every screen has a header, framed bounded content, and footer anchored to the bottom.
- No rendered row directly consumes the last terminal column.
- `Ctrl+H` is global full help; remove `F1` from runtime and documentation.
- Printable input ownership, destructive safety, validation, dirty state, and callback boundaries remain intact.
- No release publication or release-metadata changes.
- Commit and push after every task.
---
### Task 1: Shared shell and help binding
**Files:**
- Create: `internal/tui/shell.go`
- Create: `internal/tui/shell_test.go`
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/help.go`
- Modify: `internal/tui/help_screen.go`
- Modify: `internal/tui/status_help_test.go`
**Interfaces:**
- Produces: `screenShell`, `renderScreenShell(screenShell) string`, `renderBodyPanel(width, height int, lines []string) string`, and `footerAtBottom(body, footer string, height int) string`.
- Consumes: existing `renderPanel`, display-cell helpers, root vault/notification state, and child body strings.
- [ ] Add failing tests that render representative action/help/confirm states at 120x40, 80x24, and 60x16 and assert header, border, exact height, last-row footer, one-cell right safety margin, and no `F1` text.
- [ ] Add failing root-event tests proving `tea.KeyCtrlH` opens full help from list, manager, and form; `tea.KeyBackspace` still reaches a focused text input; and closing help restores its parent.
- [ ] Run `go test ./internal/tui -run 'Test(ScreenShell|CtrlH|Backspace|NoF1)' -count=1` and confirm failures identify missing shell/binding behavior.
- [ ] Implement the shell with a spec carrying `breadcrumb`, `status`, `notification`, `body`, `footer`, `width`, and `height`; calculate body height after wrapped footer rows and render content inside a bounded panel.
- [ ] Replace the root F1 branch with `tea.KeyCtrlH`, remove F1 entries from quick/full help content, and preserve active confirmation ownership.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `feat: add unified tui screen shell` and push.
### Task 2: Actions, help, search, and confirmations
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/help_screen.go`
- Modify: `internal/tui/layout_test.go`
- Modify: `internal/tui/status_help_test.go`
**Interfaces:**
- Consumes: `renderScreenShell` and `renderBodyPanel` from Task 1.
- Produces: shell-backed action menu, quick/full help, search, tag input, and confirmation views.
- [ ] Add failing render tests for action selection at every breakpoint, long help rows, search input, tag input, and long Unicode confirmation content; assert selection/focus markers and bottom footer.
- [ ] Run `go test ./internal/tui -run 'Test(ActionShell|HelpShell|InputShell|ConfirmationShell)' -count=1` and confirm current free-form views fail.
- [ ] Render actions as a bounded list with a wide description panel and stacked medium description; render help as a scrolling framed body; render search/tag input inside a form panel; render confirmation as a bounded dialog within the shell.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture action, help, and confirmation in real xterm at 120x40, 80x24, and 60x16; inspect header, border, focus, footer, and right margin.
- [ ] Commit as `feat: unify tui actions and help screens` and push.
### Task 3: Port forward manager and form
**Files:**
- Modify: `internal/tui/forward.go`
- Modify: `internal/tui/forward_test.go`
- Modify: `internal/tui/layout_test.go`
**Interfaces:**
- Consumes: shared shell and panel primitives.
- Produces: responsive framed forward table/details and framed forward editor.
- [ ] Add failing tests with long ASCII/Cyrillic/CJK/emoji names and endpoints at all three sizes. Assert every ANSI-stripped line is at most `width-1`, both table and footer stay within height, and footer occupies the final rows.
- [ ] Add table-driven tests for wide two-panel, medium stacked, and narrow compact column sets.
- [ ] Run `go test ./internal/tui -run 'Test(ForwardManagerShell|ForwardFormShell|ForwardColumns)' -count=1` and confirm overflow/frame/footer failures.
- [ ] Derive every table width from panel inner width with a one-cell safety margin; add framed table/details layouts per breakpoint and move form content into the shared framed shell.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture forward list and form at all three sizes in xterm, inspect for wrapping/overflow, and add a failing regression test before correcting any observed defect.
- [ ] Commit as `feat: redesign tui port forward screens` and push.
### Task 4: Tags, templates, results, and tunnels
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/tunnel.go`
- Modify: `internal/tui/template_form.go`
- Modify: `internal/tui/layout_test.go`
**Interfaces:**
- Consumes: shared shell and bounded list/panel primitives.
- Produces: shell-backed tag manager, template manager/form/picker/mode/results, and tunnel manager.
- [ ] Add a screen-inventory render test covering normal, empty, error, and selected states for every manager at 120x40, 80x24, and 60x16.
- [ ] Run `go test ./internal/tui -run 'Test(ManagerScreenInventory|TunnelShell|TemplateShell|TagShell)' -count=1` and confirm missing frames/footer anchoring.
- [ ] Replace default Bubbles list rendering and free-form strings with bounded viewport rows inside framed panels. Keep exact selection, scroll index, and contextual actions.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture every manager family at 80x24 and its densest/longest state at 60x16; inspect borders, focus, footer, empty/error copy, and truncation.
- [ ] Commit as `feat: unify tui manager screens` and push.
### Task 5: Server form and complete screen matrix
**Files:**
- Modify: `internal/tui/form.go`
- Modify: `internal/tui/template_form.go`
- Modify: `internal/tui/layout_test.go`
- Modify: `internal/tui/form_validation_test.go`
**Interfaces:**
- Consumes: shared shell and existing focus-centered form viewport.
- Produces: framed server/template forms plus a complete enum-state layout matrix.
- [ ] Add failing tests proving server/template form breadcrumbs, panel borders, focused field visibility, validation visibility, action row, and bottom footer at all sizes.
- [ ] Add one exhaustive table listing every `screen` enum value with a representative model builder; assert all non-below-floor screens satisfy shell invariants.
- [ ] Run `go test ./internal/tui -run 'Test(FormShell|AllScreensUseShell)' -count=1` and confirm remaining non-shell states fail.
- [ ] Move form viewport content into the shared shell without changing navigation or save behavior; close all inventory gaps.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `feat: finish unified tui screen coverage` and push.
### Task 6: Runtime verification, documentation, and binary
**Files:**
- Modify: `README.md`
- Modify: `docs/guide.md`
- Replace after runtime verification: `docs/screenshots/screen_1.png` through `docs/screenshots/screen_5.png`
**Interfaces:**
- Consumes: completed TUI and isolated XDG audit profile.
- Produces: verified screenshots, synchronized documentation, and `bin/sshkeeper`.
- [ ] Build `/tmp/sshkeeper-unified-audit` and run it in tmux/xterm with the isolated audit profile.
- [ ] Capture dashboard, actions, forwards, forward form, tunnels, server form, tags/templates, confirmation, quick help, and full help at 120x40, 80x24, and 60x16.
- [ ] Inspect every capture for border continuity, right-column overflow, focus, notification truth, bottom footer, and correct Ctrl+H copy. For any defect, add a failing automated test before editing code.
- [ ] Verify real xterm sends Backspace as `KeyBackspace` and Ctrl+H as `KeyCtrlH`; document the terminal mapping constraint without restoring F1.
- [ ] Update README and guide only after runtime behavior is verified; refresh repository screenshots from the verified binary.
- [ ] Run `gofmt`, `git diff --check`, `go vet ./...`, `go test ./... -count=1`, and `go build ./...`.
- [ ] Commit as `docs: refresh unified tui screenshots` and push.
- [ ] Run `./build.sh`, report the absolute binary path, version, SHA-256, feature-branch HEAD, remote synchronization, and clean worktree. Do not publish a release.

View File

@ -0,0 +1,138 @@
# Unified sshkeeper TUI Shell
## Goal
Bring every sshkeeper TUI screen to the same visual and behavioral standard as
the approved server dashboard. Every screen must have a stable header, bounded
framed content, and a contextual footer anchored to the bottom of the terminal.
## User-approved direction
The existing server dashboard is the visual reference. Do not introduce a new
visual language or browser mockups. Apply its hierarchy, border treatment,
selection markers, spacing, colors, and responsive degradation to all child
screens.
`Ctrl+H` replaces `F1` as the global full-help binding. `?` remains contextual
quick help outside text editors. Remove `F1` from runtime help, footers, README,
and the user guide.
## Root cause
The dashboard owns a height-aware renderer with a header, panels, and footer.
Most child screens still render independent free-form strings or a default
Bubbles list. They therefore do not share inner-width budgeting, borders,
viewport height, or bottom-footer placement. The correction is a shared shell,
not per-screen blank-line padding.
## Shared screen shell
Every full-screen state uses this vertical contract:
1. Header: `sshkeeper / <breadcrumb>` on the left and truthful vault/context
status on the right.
2. Separator: one display-cell-bounded horizontal line.
3. Content: one or two bordered panels filling all available rows.
4. Footer: only the current screen's primary shortcuts, wrapped by display
width and anchored to the last terminal row.
The shell computes content height as terminal height minus header, separator,
notification, and wrapped-footer rows. A panel owns a one-cell border and at
least one-cell inner horizontal padding. No content row may consume the
terminal's last column directly.
Errors, success messages, pending state, and partial success appear in a
dedicated notification row below the separator. Rendering remains pure.
## Screen families
### Actions
Actions use a framed selectable list. At 100 columns and wider, a second panel
describes the selected action and its target. At 70-99 columns, the description
appears below the list when height permits. At 60-69 columns, only the framed
list remains. The footer is always at the bottom.
### Port forwards
At 100 columns and wider, forwards use a framed table and a framed selected-rule
panel. At 70-99 columns, the selected-rule panel is stacked below the table. At
60-69 columns, the table contains Name, Type, and On; details stay available
through the selected-rule panel only when vertical space permits.
All column widths are derived from the panel's inner width. The table must
leave an inner right margin, so an 80-column terminal never renders a row at 80
display cells. Long names, endpoints, explanations, and SSH arguments truncate
by display cells with an ellipsis.
The forward form uses the shared shell, a framed form panel, radio markers for
type, and an action row. Focused fields and validation remain visible.
### Managers and pickers
Tags, command templates, template picker/mode/results, and tunnel manager use a
framed list or result panel. Selection uses `>` as well as color. Empty and
error states remain inside the panel. Lists viewport around the selected item
and never rely on the default Bubbles frame or footer.
### Forms and text entry
Server, template, tag, search, and forward editors use a framed form panel.
Their title moves into the common breadcrumb. Required markers, validation,
dirty confirmation, and input ownership do not change. The focused control,
status/error, and action row remain in the panel's visible window.
### Confirmations
Confirmations use the same application header and a centered or width-bounded
framed dialog panel. Exact target, consequence, Cancel-first action row, and
footer remain visible at 60x16. Long Unicode text wraps by display cells.
### Help
`Ctrl+H` opens full help from every state except a confirmation overlay. `?`
opens contextual quick help only when a text editor does not own printable
input. `F1` has no documented or runtime binding.
Bubble Tea v1 distinguishes `KeyCtrlH` (ASCII BS) from `KeyBackspace` (DEL).
Automated and PTY checks must prove that xterm Backspace edits text while
`Ctrl+H` opens help. Terminals configured to emit BS for Backspace cannot
distinguish the two; the supported runtime check uses xterm's default DEL
Backspace mapping.
Both help screens use the shared shell and a framed, scrolling body. Their
footer stays at the bottom and states how to close/scroll.
## Responsive contract
- Supported floor: `60x16`.
- Wide: `width >= 100`, two panels where useful.
- Medium: `70 <= width < 100`, stacked panels.
- Narrow: `60 <= width < 70`, compact single panel.
- Below the floor: render only the minimum-size message.
Every screen family is tested at 120x40, 80x24, and 60x16 with long ASCII,
Cyrillic, CJK, combining, and emoji content. Assertions measure ANSI-aware
display cells and exact maximum height.
## Verification
Automated coverage must inventory every `screen` enum value and prove that its
normal representative state:
- fits terminal width and height;
- contains a top header and at least one border;
- keeps the contextual footer on the final rendered row(s);
- retains a non-color selection/focus marker where applicable;
- does not expose `F1` and does expose `Ctrl+H` help where applicable.
Runtime verification uses the freshly built binary in an isolated XDG profile.
Capture and inspect dashboard, actions, forwards, forward form, tunnel manager,
server form, template/tag managers, confirmation, and help at all three sizes.
## Delivery boundary
Commit and push each implementation stage. Finish with a fresh binary under
`bin/sshkeeper` and report its absolute path and checksum. Do not publish a
release or update release metadata until the user explicitly approves the
binary.

View File

@ -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
@ -1213,8 +1213,7 @@ func (m *tuiModel) View() string {
b.WriteString(m.viewServerList())
case screenSearch:
b.WriteString("Search: " + m.searchInput.View() + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Esc", Action: "cancel"}}, m.width))
b.WriteString(m.viewSearch())
case screenForm:
b.WriteString(m.form.View())
@ -1274,13 +1273,6 @@ func (m *tuiModel) View() string {
b.WriteString(m.viewConfirm())
}
if m.screen != screenList && m.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
}
if m.screen != screenList && m.success != "" {
b.WriteString("\n" + successStyle.Render(m.success))
}
return b.String()
}
@ -1549,41 +1541,52 @@ 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)
innerHeight := max(1, height-2)
message := wrapCells(m.confirm.target, innerWidth)
if m.confirm.consequence != "" {
message = append(message, "")
message = append(message, wrapCells(m.confirm.consequence, innerWidth)...)
}
cancel := "[ Cancel ]"
accept := "[ " + m.confirm.verb + " ]"
if m.confirm.focus == confirmCancel {
cancel = selectedStyle.Render("> " + cancel)
} else {
accept = errorStyle.Render("> " + accept)
}
action := cancel + " " + accept
if m.confirm.pending {
action = m.confirm.verb + " in progress…"
}
messageRows := max(0, innerHeight-2)
if len(message) > messageRows {
message = message[:messageRows]
if len(message) > 0 {
message[len(message)-1] = truncateCells(strings.TrimSpace(message[len(message)-1])+" …", innerWidth)
}
}
lines := []string{dashboardSection(m.confirm.title)}
lines = append(lines, message...)
for len(lines) < innerHeight-1 {
lines = append(lines, "")
}
lines = append(lines, action)
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) {
@ -1771,6 +1774,36 @@ func (m *tuiModel) viewServerList() string {
return m.renderServerDashboard()
}
func (m *tuiModel) rootNotification() string {
if m.err != nil {
return errorStyle.Render("Error: " + m.err.Error())
}
if m.success != "" {
return successStyle.Render(m.success)
}
return ""
}
func (m *tuiModel) viewSearch() string {
return renderScreenShell(screenShell{
breadcrumb: "Search",
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d profiles", len(m.servers))),
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{
dashboardSection("Find server"),
"",
m.searchInput.View(),
"",
dashboardHelp("Search alias, host, display name, group, tags, notes, and route."),
})
},
footer: []helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
}
func (m *tuiModel) viewInlineBackgroundResults() string {
var b strings.Builder
b.WriteString(sectionStyle.Render("Last Background Run"))
@ -1891,37 +1924,34 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string {
}
func (m *tuiModel) viewTags() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Tags"))
b.WriteString("\n\n")
if len(m.tags) == 0 {
b.WriteString(helpStyle.Render(" No tags yet. Press Ctrl+A to add one to the selected servers."))
b.WriteString("\n")
} else {
for i, item := range m.tagList.Items() {
tag, ok := item.(groupItem)
if !ok {
continue
return renderScreenShell(screenShell{
breadcrumb: "Tags",
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d tags", len(m.tags))),
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
if len(m.tags) == 0 {
return renderPaddedPanel(width, height, []string{dashboardHelp("No tags yet. Ctrl+A adds one to the selected servers.")})
}
marker := " "
style := normalStyle
if i == m.tagList.Index() {
marker = "> "
style = selectedRowStyle
capacity := max(1, height-2)
start, end := visibleServerRange(len(m.tagList.Items()), m.tagList.Index(), capacity)
lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
tag, ok := m.tagList.Items()[index].(groupItem)
if !ok {
continue
}
marker := " "
if index == m.tagList.Index() {
marker = "> "
}
lines = append(lines, marker+tag.name)
}
b.WriteString(style.Render(marker + tag.name))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Enter", Action: "toggle for selected/current"},
{Key: "Ctrl+A", Action: "add"},
{Key: "Ctrl+E", Action: "rename"},
{Key: "Ctrl+D", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{{Key: "Enter", Action: "toggle"}, {Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "rename"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
}
func (m *tuiModel) viewTagInput() string {
@ -1929,74 +1959,83 @@ func (m *tuiModel) viewTagInput() string {
if m.tagMode == "rename" {
title = "Rename Tag"
}
return titleStyle.Render(title) + "\n\n" + m.tagInput.View() + "\n\n" + renderHelp([]helpItem{{Key: "Enter", Action: "save"}, {Key: "Esc", Action: "cancel"}}, m.width)
return renderScreenShell(screenShell{
breadcrumb: title,
status: shellStatus(m.vaultUnlocked, "Tag editor"),
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{dashboardSection(title), "", m.tagInput.View()})
},
footer: []helpItem{{Key: "Enter", Action: "save"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
}
func (m *tuiModel) viewTemplates() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Command Templates"))
b.WriteString("\n\n")
if len(m.templates) == 0 {
b.WriteString(helpStyle.Render(" No command templates yet. Press Ctrl+A to add one."))
b.WriteString("\n")
} else {
for i, item := range m.templateList.Items() {
tpl, ok := item.(templateItem)
if !ok {
continue
return renderScreenShell(screenShell{
breadcrumb: "Command Templates",
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d templates", len(m.templates))),
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
if len(m.templates) == 0 {
return renderPaddedPanel(width, height, []string{dashboardHelp("No command templates yet. Ctrl+A adds one.")})
}
marker := " "
style := normalStyle
if i == m.templateList.Index() {
marker = "> "
style = selectedRowStyle
capacity := max(1, height-2)
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
tpl, ok := m.templateList.Items()[index].(templateItem)
if !ok {
continue
}
marker := " "
if index == m.templateList.Index() {
marker = "> "
}
line := marker + tpl.template.Name + " " + tpl.template.Command
if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow {
line += " — " + tpl.template.Description
}
lines = append(lines, line)
}
line := fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)
b.WriteString(style.Render(line))
b.WriteString("\n")
if tpl.template.Description != "" {
b.WriteString(helpStyle.Render(" " + tpl.template.Description))
b.WriteString("\n")
}
}
}
b.WriteString("\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+A", Action: "add"},
{Key: "Ctrl+E", Action: "edit"},
{Key: "Ctrl+D", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{{Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "edit"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
}
func (m *tuiModel) viewTemplatePicker() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Run Template"))
b.WriteString("\n")
b.WriteString(helpStyle.Render(fmt.Sprintf("Targets: %s", strings.Join(serverAliases(m.targetServers()), ", "))))
b.WriteString("\n\n")
if len(m.templates) == 0 {
b.WriteString(helpStyle.Render(" No command templates. Press Esc, then Ctrl+P to add one."))
} else {
for i, item := range m.templateList.Items() {
tpl, ok := item.(templateItem)
if !ok {
continue
targets := strings.Join(serverAliases(m.targetServers()), ", ")
return renderScreenShell(screenShell{
breadcrumb: "Run Template",
status: "Targets: " + targets,
width: m.width,
height: m.height,
body: func(width, height int) string {
lines := []string{dashboardSection("Choose template"), dashboardHelp("Targets: " + targets), ""}
if len(m.templates) == 0 {
lines = append(lines, dashboardHelp("No command templates. Press Esc, then Ctrl+P to add one."))
} else {
capacity := max(1, height-len(lines)-2)
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
for index := start; index < end; index++ {
tpl, ok := m.templateList.Items()[index].(templateItem)
if !ok {
continue
}
marker := " "
if index == m.templateList.Index() {
marker = "> "
}
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
}
}
marker := " "
style := normalStyle
if i == m.templateList.Index() {
marker = "> "
style = selectedRowStyle
}
b.WriteString(style.Render(fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "choose"}, {Key: "Esc", Action: "back"}}, m.width))
return b.String()
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{{Key: "Enter", Action: "choose"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
}
func (m *tuiModel) viewTemplateMode() string {
@ -2006,30 +2045,44 @@ func (m *tuiModel) viewTemplateMode() string {
name = m.pendingTemplate.Name
command = m.pendingTemplate.Command
}
return titleStyle.Render("Run Mode") + "\n\n" +
fmt.Sprintf("Template: %s\nCommand: %s\nTargets: %s\n\n", name, command, strings.Join(serverAliases(m.targetServers()), ", ")) +
renderHelp([]helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Esc", Action: "back"}}, m.width)
targets := strings.Join(serverAliases(m.targetServers()), ", ")
return renderScreenShell(screenShell{
breadcrumb: "Run Template / Mode",
status: "Targets: " + targets,
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{dashboardSection("Execution"), "", "Template: " + name, "Command: " + command, "Targets: " + targets, "", "Choose foreground for an interactive run or background to keep using sshkeeper."})
},
footer: []helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
}
func (m *tuiModel) viewBackgroundResults() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Background Results"))
b.WriteString("\n\n")
for _, result := range m.bgResults {
status := "OK"
if result.Err != "" {
status = "FAIL: " + result.Err
}
b.WriteString(sectionStyle.Render(result.Alias + " " + status))
b.WriteString("\n")
if result.Output != "" {
b.WriteString(result.Output)
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter/Esc", Action: "back"}}, m.width))
return b.String()
return renderScreenShell(screenShell{
breadcrumb: "Background Results",
status: fmt.Sprintf("%d results", len(m.bgResults)),
width: m.width,
height: m.height,
body: func(width, height int) string {
lines := make([]string, 0)
for _, result := range m.bgResults {
status := "OK"
if result.Err != "" {
status = "FAIL: " + result.Err
}
lines = append(lines, dashboardSection(result.Alias+" "+status))
if result.Output != "" {
for _, line := range strings.Split(result.Output, "\n") {
lines = append(lines, strings.ReplaceAll(line, "\t", " "))
}
}
lines = append(lines, "")
}
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{{Key: "Enter/Esc", Action: "back"}, {Key: "Ctrl+H", Action: "help"}},
})
}
func (m *tuiModel) selectedServers() []*model.Server {
@ -2134,7 +2187,7 @@ func (m *tuiModel) removeTag(name string) {
// --- Server list footer ---
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
width := m.width - 2
width := m.width - 3
if width <= 0 {
width = 80
}
@ -2163,7 +2216,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

View File

@ -342,8 +342,8 @@ func TestAuthMethodListViewShowsAllOptions(t *testing.T) {
if between := view[authPos:listPos]; strings.Contains(between, "Identity File") {
t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view)
}
if strings.Contains(view, "│") {
t.Fatalf("expected compact auth method dropdown without default list border\nview:\n%s", view)
if !strings.Contains(view, "│") {
t.Fatalf("expected auth method dropdown inside the unified frame\nview:\n%s", view)
}
for _, method := range []model.AuthMethod{
model.AuthPassword,
@ -383,8 +383,8 @@ func TestGroupListViewRendersDirectlyUnderGroupField(t *testing.T) {
if between := view[groupPos:listPos]; strings.Contains(between, "Password") {
t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view)
}
if strings.Contains(view, "│") {
t.Fatalf("expected compact group dropdown without default list border\nview:\n%s", view)
if !strings.Contains(view, "│") {
t.Fatalf("expected group dropdown inside the unified frame\nview:\n%s", view)
}
}

View File

@ -16,6 +16,8 @@ func (m *tuiModel) renderServerDashboard() string {
if height <= 0 {
height = 40
}
sizeClass := classifyTerminal(width, height)
width = max(1, width-1)
header := m.renderDashboardHeader(width)
notification := m.renderDashboardNotification(width)
@ -29,7 +31,7 @@ func (m *tuiModel) renderServerDashboard() string {
}
var body string
switch classifyTerminal(width, height) {
switch sizeClass {
case sizeWide:
leftWidth := width * 62 / 100
rightWidth := width - leftWidth - 1

View File

@ -589,14 +589,6 @@ func (fm *formModel) View() string {
if fm.edit {
title = "Edit Server: " + fm.server.Alias
}
footer := renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "/", Action: "pick list"},
{Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"},
}, fm.width)
if fm.showAuthList || fm.showGroupList {
var dropdown list.Model
fieldIndex := 8
@ -606,10 +598,18 @@ func (fm *formModel) View() string {
} else {
dropdown = fm.groupList
}
return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" +
fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" +
fitLine(renderDropdown(dropdown), fm.width) + "\n" +
renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)
return renderScreenShell(screenShell{
breadcrumb: title + " / Picker",
status: "Choose a value",
width: fm.width,
height: fm.height,
body: func(width, height int) string {
lines := []string{fm.inputs[fieldIndex].View(), ""}
lines = append(lines, splitBlock(renderDropdown(dropdown))...)
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
}
status := fm.formStatusLine()
@ -620,60 +620,68 @@ func (fm *formModel) View() string {
if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render("> [ Save ]")
}
actions := fitLine(testBtn+" "+saveBtn, fm.width)
actions := testBtn + " " + saveBtn
reserved := 1 + displayLineCount(footer) + 1
if status != "" {
reserved++
}
fieldRows := max(4, fm.height-reserved)
richLayout := fm.width >= 90 && fm.height >= 24
allFields := make([]string, 0, len(fm.inputs)+5)
focusRows := make([]int, len(fm.inputs)+1)
for i := range fm.inputs {
if richLayout {
if section := formSectionTitle(i); section != "" {
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
body := func(width, height int) string {
richLayout := width >= 90 && height >= 20
allFields := make([]string, 0, len(fm.inputs)+5)
focusRows := make([]int, len(fm.inputs)+1)
for i := range fm.inputs {
if richLayout {
if section := formSectionTitle(i); section != "" {
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
}
}
if i == 5 {
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
}
if i == 8 && len(fm.groups) > 0 {
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
}
focusRows[i] = len(allFields)
allFields = append(allFields, fm.inputs[i].View())
}
if i == 5 {
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
focusRows[len(fm.inputs)] = len(allFields)
allFields = append(allFields, fm.password.View())
focusField := len(allFields) - 1
if fm.focusIdx <= len(fm.inputs) {
focusField = focusRows[fm.focusIdx]
}
if i == 8 && len(fm.groups) > 0 {
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
actionRows := 1
if richLayout {
actionRows = 2
}
focusRows[i] = len(allFields)
allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width))
fieldRows := max(1, height-2-actionRows)
start, end := visibleServerRange(len(allFields), focusField, fieldRows)
visible := append([]string(nil), allFields[start:end]...)
if start > 0 && len(visible) > 0 {
visible[0] = "↑ more fields · " + visible[0]
}
if end < len(allFields) && len(visible) > 0 {
visible[len(visible)-1] += " · more ↓"
}
if richLayout {
visible = append(visible, sectionStyle.Copy().MarginTop(0).Render("Actions"))
}
visible = append(visible, actions)
return renderPaddedPanel(width, height, visible)
}
focusRows[len(fm.inputs)] = len(allFields)
allFields = append(allFields, fitLine(fm.password.View(), fm.width))
focusField := len(allFields) - 1
if fm.focusIdx <= len(fm.inputs) {
focusField = focusRows[fm.focusIdx]
}
start, end := visibleServerRange(len(allFields), focusField, fieldRows)
visible := append([]string(nil), allFields[start:end]...)
if start > 0 && len(visible) > 0 {
visible[0] = fitLine("↑ more fields · "+visible[0], fm.width)
}
if end < len(allFields) && len(visible) > 0 {
visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width)
}
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
lines = append(lines, visible...)
if status != "" {
lines = append(lines, fitLine(status, fm.width))
}
if richLayout {
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
}
lines = append(lines, actions)
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
}
return strings.Join(lines, "\n")
return renderScreenShell(screenShell{
breadcrumb: title,
status: "Server profile",
notification: status,
width: fm.width,
height: fm.height,
body: body,
footer: []helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "/", Action: "pick list"},
{Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}
func (fm *formModel) formStatusLine() string {
@ -701,7 +709,7 @@ func (fm *formModel) formStatusLine() string {
func renderDropdown(l list.Model) string {
var b strings.Builder
b.WriteString(sectionStyle.Render(l.Title))
b.WriteString(dashboardSection(l.Title))
b.WriteString("\n")
for i, item := range l.Items() {
group, ok := item.(groupItem)

View File

@ -79,52 +79,90 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
}
func (m *forwardScreenModel) View() string {
footer := renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width)
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine("Port Forwards — "+m.serverAlias, m.width))}
notification := ""
if m.err != nil {
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)), m.width))
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
}
footerRows := displayLineCount(footer)
detailRows := 0
if len(m.list) > 0 && m.height-footerRows >= 7 {
detailRows = 3
}
rowCapacity := max(1, m.height-len(lines)-footerRows-detailRows-1)
if len(m.list) == 0 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
} else {
lines = append(lines, m.renderForwardRow(nil, false))
rowCapacity--
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
}
if end < len(m.list) || start > 0 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
forward := m.list[m.selected]
lines = append(lines,
sectionStyle.Copy().MarginTop(0).Render("Selected"),
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
body := func(width, height int) string {
switch classifyShellContent(width) {
case sizeWide:
leftWidth := width * 70 / 100
rightWidth := width - leftWidth - 1
return joinPanelColumns(
renderPaddedPanel(leftWidth, height, m.forwardListLines(leftWidth-4, height-2, false)), leftWidth,
renderPaddedPanel(rightWidth, height, m.forwardDetailLines(rightWidth-4, false)), rightWidth,
)
case sizeMedium:
detailHeight := min(7, max(4, height/3))
listHeight := max(3, height-detailHeight-1)
listPanel := renderPaddedPanel(width, listHeight, m.forwardListLines(width-4, listHeight-2, false))
detailPanel := renderPaddedPanel(width, detailHeight, m.forwardDetailLines(width-4, true))
return listPanel + "\n" + detailPanel
default:
return renderPaddedPanel(width, height, m.forwardListLines(width-4, height-2, true))
}
}
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 renderScreenShell(screenShell{
breadcrumb: "Port Forwards / " + m.serverAlias,
status: fmt.Sprintf("%d rules", len(m.list)),
notification: notification,
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
func (m *forwardScreenModel) forwardListLines(width, capacity int, compact bool) []string {
if len(m.list) == 0 {
return []string{helpStyle.Copy().MarginLeft(0).Render("No port forwards configured. Ctrl+A adds one.")}
}
lines := []string{m.renderForwardRow(nil, false, width, compact)}
rowCapacity := max(1, capacity-1)
showRange := len(m.list) > rowCapacity
if showRange {
rowCapacity = max(1, rowCapacity-1)
}
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected, width, compact))
}
if showRange {
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
return lines
}
func (m *forwardScreenModel) forwardDetailLines(width int, compact bool) []string {
lines := []string{dashboardSection("Selected rule")}
if m.selected < 0 || m.selected >= len(m.list) {
return append(lines, "", dashboardHelp("No rule selected."))
}
forward := m.list[m.selected]
name := forward.Name
if name == "" {
name = forward.ForwardListen()
}
if compact {
lines = append(lines, name+" · "+string(forward.Type))
} else {
lines = append(lines, "", name, string(forward.Type))
}
lines = append(lines, wrapCells(forward.ForwardHumanExplanation(m.serverAlias), max(1, width))...)
if !compact {
lines = append(lines, "")
}
lines = append(lines, dashboardHelp("ssh "+strings.Join(forward.ForwardSSHArgs(), " ")))
return lines
}
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool, width int, compact bool) string {
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
if forward != nil {
if selected {
@ -142,30 +180,30 @@ func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected b
enabled = "no"
}
}
wide := m.width >= 70
typeWidth, enabledWidth := 8, 3
if wide {
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
if !compact && width >= 58 {
flexible := max(3, width-typeWidth-enabledWidth-7)
nameWidth := max(1, flexible*30/100)
listenWidth := max(1, flexible*32/100)
targetWidth := max(1, flexible-nameWidth-listenWidth)
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil {
return listHeaderStyle.Render(fitLine(line, m.width))
return listHeaderStyle.Render(fitLine(line, width))
}
if selected {
return selectedRowStyle.Render(fitLine(line, m.width))
return selectedRowStyle.Render(fitLine(line, width))
}
return fitLine(line, m.width)
return fitLine(line, width)
}
nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
nameWidth := max(1, width-typeWidth-enabledWidth-5)
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil {
return listHeaderStyle.Render(fitLine(line, m.width))
return listHeaderStyle.Render(fitLine(line, width))
}
if selected {
return selectedRowStyle.Render(fitLine(line, m.width))
return selectedRowStyle.Render(fitLine(line, width))
}
return fitLine(line, m.width)
return fitLine(line, width)
}
// --- Forward form screen model ---
@ -595,76 +633,69 @@ func (fm *forwardFormModel) View() string {
if fm.editMode {
title = "Edit Port Forward"
}
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
lines = append(lines,
fitLine(fm.nameInput.View(), fm.width),
fitLine(fm.descInput.View(), fm.width),
)
typeParts := make([]string, len(forwardTypes))
for i, forwardType := range forwardTypes {
selected := "○"
if i == fm.typeIdx {
selected = "●"
}
focus := " "
if fm.focusIdx == 2+i {
focus = ">"
}
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
}
lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
if fm.width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
}
visible := fm.visibleFields()
for _, idx := range visible {
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
}
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
}
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
fwd := &model.Forward{
Type: fm.currentType,
LocalAddr: fm.inputs[0].Value(),
LocalPort: 0,
RemoteAddr: fm.inputs[2].Value(),
RemotePort: 0,
}
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
}
total := 2 + 3 + len(visible) + 1
button := " [ Save ]"
if fm.focusIdx == total-1 {
button = selectedStyle.Render("> [ Save ]")
}
notification := ""
if fm.err != nil {
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
notification = errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
} else if fm.saved {
notification = successStyle.Render("✓ Saved.")
}
if fm.saved {
lines = append(lines, successStyle.Render("✓ Saved."))
body := func(width, height int) string {
contentWidth := max(1, width-4)
lines := []string{fm.nameInput.View(), fm.descInput.View()}
typeParts := make([]string, len(forwardTypes))
for i, forwardType := range forwardTypes {
selected := "○"
if i == fm.typeIdx {
selected = "●"
}
focus := " "
if fm.focusIdx == 2+i {
focus = ">"
}
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
}
lines = append(lines, "Type "+strings.Join(typeParts, " "))
if width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(forwardTypes[fm.typeIdx].description))
}
visible := fm.visibleFields()
for _, idx := range visible {
lines = append(lines, fm.inputs[idx].View())
}
if strings.TrimSpace(fm.inputs[0].Value()) == "0.0.0.0" {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render("⚠ This port will be accessible from the network."))
}
if width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
fwd := &model.Forward{Type: fm.currentType, LocalAddr: fm.inputs[0].Value(), RemoteAddr: fm.inputs[2].Value()}
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
preview := "Preview ssh " + strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
lines = append(lines, wrapCells(preview, contentWidth)...)
}
total := 2 + 3 + len(visible) + 1
button := " [ Save ]"
if fm.focusIdx == total-1 {
button = selectedStyle.Render("> [ Save ]")
}
lines = append(lines, "", button)
return renderPaddedPanel(width, height, lines)
}
lines = append(lines, button)
footer := renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "1/2/3", Action: "select type"},
{Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"},
}, fm.width)
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
}
return strings.Join(lines, "\n")
return renderScreenShell(screenShell{
breadcrumb: "Port Forwards / " + title,
status: string(fm.currentType),
notification: notification,
width: fm.width,
height: fm.height,
body: body,
footer: []helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "1/2/3", Action: "select type"},
{Key: "Enter", Action: "save"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}
// forwardEditSignal is sent when user wants to edit a forward

View File

@ -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 classifyShellContent(width) == 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 classifyShellContent(width) == 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
}

View File

@ -1,6 +1,7 @@
package tui
import (
"fmt"
"strings"
"testing"
@ -46,6 +47,7 @@ func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
m := New(servers)
m.width, m.height = size.width, size.height
assertViewFits(t, m.View(), size.width, size.height)
assertRightMargin(t, m.View(), size.width)
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
if !strings.Contains(m.View(), want) {
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
@ -90,6 +92,7 @@ func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
fm.updateFocus()
view := fm.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -107,7 +110,9 @@ func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
fm.inputs[1].SetValue("15432")
fm.inputs[2].SetValue("database.internal.example")
fm.inputs[3].SetValue("5432")
assertViewFits(t, fm.View(), size.width, size.height)
view := fm.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
}
}
@ -120,6 +125,7 @@ func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
}
view := fm.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -133,6 +139,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 +161,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 +170,143 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
}
}
func TestConfirmationKeepsActionsVisibleWithLongContent(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New(nil)
m.width, m.height = size.width, size.height
m.beginConfirm(confirmState{
title: "Delete port forward?",
target: strings.Repeat("非常に長い-очень-длинный-🔐 ", 20),
consequence: strings.Repeat("Active connections can be interrupted. ", 20),
verb: "Delete",
parent: screenForwardList,
})
view := m.View()
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"[ Cancel ]", "[ Delete ]"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d clipped %q:\n%s", size.width, size.height, want, view)
}
}
}
}
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 TestManagerScreensUseUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New([]*model.Server{{Alias: "prod", Host: "prod.example", Port: 22, User: "ops"}})
m.width, m.height = size.width, size.height
template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"}
m.setTemplates([]*model.CommandTemplate{template})
m.setTags([]string{"production"})
m.pendingTemplate = template
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
screens := []struct {
name string
screen screen
}{
{"search", screenSearch},
{"tags", screenTags},
{"tag-input", screenTagInput},
{"templates", screenTemplates},
{"template-picker", screenTemplatePicker},
{"template-mode", screenTemplateMode},
{"background-results", screenBackgroundResults},
}
for _, tt := range screens {
m.screen = tt.screen
t.Run(tt.name+itoa(size.width), func(t *testing.T) {
assertUnifiedScreen(t, m.View(), size.width, size.height)
})
}
tunnelScreen := newTunnelScreenModel(size.width, size.height)
assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height)
}
}
func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
covered := map[screen]string{
screenList: "dashboard",
screenForm: "server form",
screenSearch: "manager matrix",
screenTags: "manager matrix",
screenTagInput: "manager matrix",
screenTemplates: "manager matrix",
screenTemplateForm: "template form",
screenTemplatePicker: "manager matrix",
screenTemplateMode: "manager matrix",
screenBackgroundResults: "manager matrix",
screenHelp: "help matrix",
screenActionMenu: "action matrix",
screenForwardList: "forward matrix",
screenForwardForm: "forward form matrix",
screenTunnelManager: "manager matrix",
screenConfirm: "confirmation matrix",
screenFullHelp: "help matrix",
}
for value := screenList; value <= screenFullHelp; value++ {
if _, ok := covered[value]; !ok {
t.Fatalf("screen %d is missing from the layout matrix", value)
}
}
}
func TestShellBreakpointsUseTerminalWidth(t *testing.T) {
for _, tt := range []struct {
contentWidth int
want terminalSizeClass
}{{68, sizeNarrow}, {69, sizeMedium}, {98, sizeMedium}, {99, sizeWide}} {
if got := classifyShellContent(tt.contentWidth); got != tt.want {
t.Fatalf("content width %d classified as %v, want %v", tt.contentWidth, got, tt.want)
}
}
}
func TestTunnelErrorUsesUnifiedShellRows(t *testing.T) {
tunnelModel := newTunnelScreenModel(60, 16)
tunnelModel.tunnels = []*model.TunnelState{{Name: "prod tunnel", ServerAlias: "prod", LastError: "connection lost\nretry failed"}}
tunnelModel.rebuildList()
view := tunnelModel.View()
assertUnifiedScreen(t, view, 60, 16)
if !strings.Contains(view, "connection lost") || !strings.Contains(view, "retry failed") {
t.Fatalf("tunnel error was lost:\n%s", view)
}
}
func TestTemplateViewportKeepsSelectedDescribedItemVisible(t *testing.T) {
m := New(nil)
m.width, m.height = 60, 16
templates := make([]*model.CommandTemplate, 20)
for index := range templates {
templates[index] = &model.CommandTemplate{Name: fmt.Sprintf("template-%02d", index), Command: "echo ok", Description: "description"}
}
m.setTemplates(templates)
m.templateList.Select(len(templates) - 1)
m.screen = screenTemplates
view := m.View()
assertUnifiedScreen(t, view, 60, 16)
if !strings.Contains(view, "> template-19") {
t.Fatalf("selected template is outside viewport:\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)
@ -169,6 +314,7 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
form.inputs[1].SetValue("printf 'a very long command that remains editable'")
view := form.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -177,6 +323,21 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
}
}
func TestServerFormDropdownUsesUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newFormModel(size.width, size.height)
form.focusIdx = 5
form.showAuthList = true
view := form.View()
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Select auth method", "password", "agent", "Enter", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("dropdown at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func assertViewFits(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
@ -190,6 +351,37 @@ 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)
}
}
}
func assertRightMargin(t *testing.T, view string, width int) {
t.Helper()
for index, line := range strings.Split(view, "\n") {
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) }

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

@ -0,0 +1,133 @@
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 classifyShellContent(contentWidth int) terminalSizeClass {
terminalWidth := contentWidth + 1
if terminalWidth >= 100 {
return sizeWide
}
if terminalWidth >= 70 {
return sizeMedium
}
return sizeNarrow
}
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.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

View File

@ -151,31 +151,40 @@ func (tf *templateFormModel) save() tea.Cmd {
}
func (tf *templateFormModel) View() string {
var b strings.Builder
title := "Add Template"
if tf.edit {
title = "Edit Template"
}
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
b.WriteString("\n\n")
for i := range tf.inputs {
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
b.WriteString("\n")
}
button := " [ Save ]"
if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render("> [ Save ]")
}
b.WriteString("\n" + button + "\n\n")
notification := ""
if tf.err != nil {
b.WriteString(errorStyle.Render(tf.err.Error()))
b.WriteString("\n")
notification = errorStyle.Render(tf.err.Error())
} else if tf.saved {
notification = successStyle.Render("✓ Saved.")
}
b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"},
}, tf.width))
return b.String()
return renderScreenShell(screenShell{
breadcrumb: "Command Templates / " + title,
status: "Template editor",
notification: notification,
width: tf.width,
height: tf.height,
body: func(width, height int) string {
lines := make([]string, 0, len(tf.inputs)+3)
for i := range tf.inputs {
lines = append(lines, tf.inputs[i].View())
}
button := " [ Save ]"
if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render("> [ Save ]")
}
lines = append(lines, "", button)
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}

View File

@ -94,19 +94,44 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
}
func (m *tunnelScreenModel) View() string {
var b strings.Builder
b.WriteString(m.list.View())
b.WriteString("\n\n")
notification := ""
if m.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
b.WriteString("\n\n")
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
}
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
body := func(width, height int) string {
if len(m.tunnels) == 0 {
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
}
capacity := max(1, height-2)
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3))
lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
item := tunnelItem{state: m.tunnels[index]}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+item.Title())
for _, description := range strings.Split(item.Description(), "\n") {
lines = append(lines, " "+strings.TrimSpace(description))
}
}
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Tunnel Manager",
status: fmt.Sprintf("%d running", len(m.tunnels)),
notification: notification,
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}
type tunnelsLoadedMsg struct {