diff --git a/authbridge/cmd/abctl/README.md b/authbridge/cmd/abctl/README.md index b6669473..2a9690c7 100644 --- a/authbridge/cmd/abctl/README.md +++ b/authbridge/cmd/abctl/README.md @@ -14,7 +14,7 @@ and read individual events as pretty-printed JSON. │ default 1h ago 8 │ │ │ │ ● connected 2.1 ev/s drops: 0 │ -│ [↑↓/jk] nav [↵] drill [/] filter [p] pause [q] quit │ +│ [↑↓/jk] nav [↵] drill [/] filter [?] keys [q] quit │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -52,6 +52,22 @@ and tears the port-forward down. The picker shells out to `kubectl` — whatever context you're in is the context abctl uses. There's no separate auth. +### Connecting to an existing port-forward + +Press `l` on the Namespaces pane to skip the cluster entirely and +connect straight to `http://localhost:9094` — the session API's default +port on the local host. Useful when you already have your own +`kubectl port-forward` running, when abctl runs inside the mesh, or when +your kubeconfig can't list pods but a tunnel is up. + +abctl probes `/v1/sessions` before switching panes, so an endpoint with +nothing listening surfaces as a footer error and leaves you in the +picker rather than dropping you into a silently empty session view. +`Esc` from a session entered this way returns to the Namespaces pane +(there's no pod to go back to). Pipeline editing (`e`) is unavailable, +same as `--endpoint` mode: the cluster fields needed to fetch and apply +the ConfigMap aren't populated. + ### Power-user / scripting bypass Pass `--endpoint` to skip the picker entirely: @@ -85,18 +101,42 @@ The UI has these top-level panes. `Enter` drills in; `Esc` backs out. - **Plugin detail**: drill-into-row for Pipeline or Catalog. Shows description, position, reads/writes, body access, plugin config, and per-dependency satisfaction status against the active chain. -- **Catalog**: registered-plugin browser, opened by `P` from anywhere. - Lists every plugin the running binary knows how to construct, - including ones not in the active pipeline. Useful for discovering - what's available before adding to the pipeline. Sourced from - `/v1/plugins`. +- **Catalog**: registered-plugin browser, opened by `P` from any + session-view pane. Lists every plugin the running binary knows how to + construct, including ones not in the active pipeline. Useful for + discovering what's available before adding to the pipeline. Sourced + from `/v1/plugins`. + +Layered on top of all of them: + +- **Key help**: a modal overlay listing every keybinding, opened by `?` + from anywhere (picker included). The current pane's bindings come + first and are highlighted; the global keys and a one-line summary of + every other pane follow. While it's up it owns the keyboard — `?`, + `Esc`, or `q` closes it (`q` closes the overlay rather than quitting + abctl). This is the discoverable home for keys the single-line footer + has no room for, `P` among them. Two exceptions: while a pipeline edit + is in flight that overlay is already modal and owns `y`/`N`, and while + the filter input is focused `?` is a character you're typing (session + IDs and hosts can contain one). In both cases `?` is inert until the + keyboard is released. + + The body scrolls, so the full reference is reachable on a short + terminal: `↑↓`/`jk` by line, `b`/`f` or PgUp/PgDn by page, `u`/`d` by + half page, `g`/`G` to the ends. A `[↑↓] scroll %` affordance + appears in the overlay's footer only when the content overflows; the + close hint stays pinned there at every scroll position. Resizing the + terminal re-ranges the body without losing your place. ## Keybindings | Key | Context | Action | |---|---|---| +| `?` | any (not while filtering or mid-edit) | open the key-help overlay (`?`/`Esc`/`q` closes) | +| `↑ ↓` / `k j`, `b`/`f`, `u`/`d`, `g`/`G` | key help | scroll the overlay | | `↑ ↓` / `k j` | picker, list | navigate rows | | `Enter` | namespaces | open the namespace | +| `l` | namespaces | connect directly to `localhost:9094` | | `Enter` | pods | port-forward + connect | | `Esc` | pods | back to namespaces | | `r` | namespaces, pods | reload agent list from cluster | @@ -108,7 +148,7 @@ The UI has these top-level panes. `Enter` drills in; `Esc` backs out. | `p` | any | pause/resume stream | | `y` | detail | yank event JSON to `/tmp` | | `g` / `G` | lists | jump to top / bottom | -| `P` | sessions, pipeline, plugin-detail | open the registered-plugin catalog | +| `P` | any session-view pane (not the picker) | open the registered-plugin catalog | | `r` | catalog | refresh the catalog from `/v1/plugins` | | `e` | pipeline | edit pipeline subtree in `$EDITOR` | | `y` | edit/diff | apply the edit | @@ -116,8 +156,7 @@ The UI has these top-level panes. `Enter` drills in; `Esc` backs out. | `r` | edit/error | retry: re-open the editor (post-edit failure) or refetch (fetch failure) | | `Esc` | edit/{fetching,editing,applying} | abort the edit, return to Pipeline pane | | `Esc` | edit/{waiting,rollback} | background the watch; result lands as a footer flash | -| `?` | any | (reserved for future help overlay) | -| `q` / `Ctrl+C` | any | quit | +| `q` / `Ctrl+C` | any | quit (closes the key-help overlay first, if open) | ## Editing the pipeline diff --git a/authbridge/cmd/abctl/tui/app.go b/authbridge/cmd/abctl/tui/app.go index ebd656ec..edfe1fa1 100644 --- a/authbridge/cmd/abctl/tui/app.go +++ b/authbridge/cmd/abctl/tui/app.go @@ -71,6 +71,19 @@ const maxEventsPerSession = 1000 // confirmation) stays in the footer. const flashDuration = 3 * time.Second +// localEndpoint is the address `[l]` from the Namespaces pane connects +// to: the session API's default port on the local host. Useful when the +// operator already has their own `kubectl port-forward` running, when +// abctl runs inside the mesh, or when the cluster's pod list isn't +// visible to their kubeconfig but a tunnel is. +const localEndpoint = "http://localhost:9094" + +// localProbeTimeout bounds the pre-connect reachability check for `[l]`. +// Without it, a dead localEndpoint would leave the operator in an empty +// session view wondering why nothing streams; with it they get a footer +// error and stay in the picker. +const localProbeTimeout = 2 * time.Second + // refreshInterval is how often abctl re-fetches /v1/sessions from the // server to reconcile its local list. Cheap, and the only mechanism by // which rekeys (default → contextId) propagate to the client UI — the @@ -236,6 +249,16 @@ type model struct { // until then. pipeline *apiclient.PipelineView + // helpVisible toggles the [?] key-help overlay. Deliberately a flag + // rather than a paneID: the overlay must be openable over ANY pane + // (picker included) without disturbing m.pane / m.previousPane, which + // the catalog's Esc-return already owns. + helpVisible bool + // helpVp scrolls the help overlay's body. Its own viewport rather than + // a shared one because the overlay can open over the detail panes, + // which would otherwise have their scroll position clobbered. + helpVp viewport.Model + // catalog is the registered-plugin catalog from /v1/plugins, // fetched lazily when the user first opens the catalog pane via // `P`. Cached for the session; `r` from the catalog pane refreshes. @@ -271,6 +294,12 @@ type model struct { // or quit. activePF cluster.PortForward + // localDirect is true when the session view was entered via `[l]` + // (direct connection to localEndpoint) rather than by picking a pod. + // There is no pod to go back to, so Esc returns to the Namespaces + // pane instead of Pods. + localDirect bool + // editState tracks an in-flight pipeline edit (the "e" flow). // editState.phase == editPhaseDone means no edit is active. editState editState @@ -331,6 +360,9 @@ func (m *model) initSessionView() tea.Cmd { // is preserved so the user picks a different pod immediately. A fresh // ctx / cancel is derived from m.parentCtx so the next session-view // entry has a usable context. +// +// When the session was entered via `[l]` there is no pod to return to, +// so the destination is the Namespaces pane instead. func (m *model) backToPodsPane() { // Cancel current ctx — stops the SSE goroutine and any in-flight // session/pipeline fetches. @@ -370,9 +402,33 @@ func (m *model) backToPodsPane() { // Re-derive ctx for the next session view. m.ctx, m.cancel = context.WithCancel(m.parentCtx) + if m.localDirect { + // Entered via `[l]`: no pod was ever selected, so the Pods pane + // would render an empty table for a namespace the user never + // picked. Go back to where they actually were. + m.localDirect = false + m.pane = paneNamespaces + return + } m.pane = panePods } +// syncHelpViewport (re)builds the help overlay's content and sizes its +// viewport to the current terminal. Called when the overlay opens and on +// every resize while it's open, so the body re-wraps and the scroll range +// stays correct. resetScroll is true only on open — a resize should keep +// the reader where they were. +func (m *model) syncHelpViewport(resetScroll bool) { + body := helpBodyLines(m.pane) + w, h := helpViewportSize(m.width, m.height, helpBodyWidth(body)) + m.helpVp.Width = w + m.helpVp.Height = h + m.helpVp.SetContent(body) + if resetScroll { + m.helpVp.GotoTop() + } +} + // Init fires the initial fetch + starts the SSE pump and the tick. // In picker mode (paneNamespaces), it loads the agent list instead. func (m *model) Init() tea.Cmd { @@ -470,6 +526,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.layout() + // Re-size the help body too if it's currently up, preserving the + // reader's scroll position. + if m.helpVisible { + m.syncHelpViewport(false) + } return m, nil case tickMsg: @@ -615,6 +676,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case localConnectedMsg: + m.loading = false + if msg.err != nil { + m.pickerErr = "localhost:9094: " + msg.err.Error() + return m, nil + } + m.pickerErr = "" + // No port-forward subprocess and no pod identity: this is a + // direct connection to whatever is already listening locally. + // activePF stays nil (nothing to tear down) and selectedPod / + // selectedNamespace stay empty, so `e` correctly reports that + // pipeline editing needs the picker — same as --endpoint mode. + m.endpoint = msg.endpoint + m.client = msg.client + m.localDirect = true + m.pane = paneSessions + return m, m.initSessionView() + case portForwardReadyMsg: if msg.err != nil { m.pickerErr = "port-forward: " + msg.err.Error() @@ -901,8 +980,19 @@ sortAndRebuild: } } -// View composes the full screen. +// View composes the full screen. The [?] key-help overlay is layered on +// top of whatever the pane rendered, so it works over the picker and the +// session views alike. func (m *model) View() string { + base := m.paneView() + if m.helpVisible { + return overlayCenter(base, renderHelpOverlay(m.helpVp, m.width, m.height), m.width, m.height) + } + return base +} + +// paneView renders the active pane without the help overlay. +func (m *model) paneView() string { // Edit overlay takes over the screen while an edit is in flight. // editPhaseBackground intentionally falls through — the user backed // out and wants the normal UI back; flash messages handle reporting. @@ -918,11 +1008,12 @@ func (m *model) View() string { if m.namespaces != nil && len(m.namespaces) == 0 && m.pickerErr == "" { body = styleHint.Render( "No AuthBridge agents found in this cluster.\n" + - "Use `abctl --endpoint http://...` to connect to a session API directly.") + "Press [l] to connect to " + localEndpoint + " (an existing\n" + + "port-forward), or use `abctl --endpoint http://...`.") } else { body = m.namespacesTbl.View() } - footer := "[↑↓/jk] nav [↵] open [r] reload [q] quit" + footer := m.helpView() if m.pickerErr != "" { footer = "error: " + m.pickerErr + " " + footer } @@ -935,7 +1026,7 @@ func (m *model) View() string { if m.pane == panePods { title := "abctl · " + m.selectedNamespace + " · pick pod" body := m.podsTbl.View() - footer := "[↑↓/jk] nav [↵] connect [Esc] back [r] reload [q] quit" + footer := m.helpView() if m.pickerErr != "" { footer = "error: " + m.pickerErr + " " + footer } diff --git a/authbridge/cmd/abctl/tui/help_overlay.go b/authbridge/cmd/abctl/tui/help_overlay.go new file mode 100644 index 00000000..4b4900bc --- /dev/null +++ b/authbridge/cmd/abctl/tui/help_overlay.go @@ -0,0 +1,400 @@ +package tui + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" +) + +// keyBinding is one row in the help overlay: the key(s) and what they do. +type keyBinding struct { + keys string + desc string +} + +// keyGroup is a titled block of bindings in the help overlay. +type keyGroup struct { + title string + bindings []keyBinding +} + +// globalKeys are the bindings that work in (nearly) every pane. `P` lives +// here because it's the discoverability problem this overlay exists to +// solve: it works from every session-view pane but was never shown in any +// footer. The picker panes are the documented exception — noted in the +// group title rather than duplicated per-pane. +var globalKeys = keyGroup{ + title: "GLOBAL", + bindings: []keyBinding{ + {"?", "this help"}, + {"↑↓ / jk", "scroll this help"}, + {"P", "plugin catalog (session views)"}, + {"p", "pause/resume stream"}, + {"g / G", "jump to top / bottom"}, + {"b / f", "page up / down"}, + {"q · ctrl+c", "quit"}, + }, +} + +// paneKeys maps each pane to its own bindings, rendered first (and +// emphasized) when the overlay opens over that pane. Panes absent from +// this map fall back to the global group alone. +var paneKeys = map[paneID]keyGroup{ + paneNamespaces: { + title: "NAMESPACES (this pane)", + bindings: []keyBinding{ + {"↑↓ / jk", "navigate"}, + {"↵", "open namespace"}, + {"l", "connect to localhost:9094"}, + {"r", "reload agent list"}, + {"q · esc", "quit"}, + }, + }, + panePods: { + title: "PODS (this pane)", + bindings: []keyBinding{ + {"↑↓ / jk", "navigate"}, + {"↵", "port-forward + connect"}, + {"esc", "back to namespaces"}, + {"r", "reload agent list"}, + }, + }, + paneSessions: { + title: "SESSIONS (this pane)", + bindings: []keyBinding{ + {"↑↓ / jk", "navigate"}, + {"↵ / → / l", "drill into session"}, + {"tab", "switch to pipeline"}, + {"/", "filter"}, + {"esc", "back to pods picker"}, + }, + }, + paneEvents: { + title: "EVENTS (this pane)", + bindings: []keyBinding{ + {"↑↓ / jk", "navigate"}, + {"↵ / → / l", "event detail"}, + {"/", "filter"}, + {"s", "toggle passthru/skip rows"}, + {"esc / ← / h", "back to sessions"}, + }, + }, + paneDetail: { + title: "EVENT DETAIL (this pane)", + bindings: []keyBinding{ + {"↑↓", "scroll"}, + {"y", "yank event JSON to /tmp"}, + {"esc / ← / h", "back to events"}, + }, + }, + panePipeline: { + title: "PIPELINE (this pane)", + bindings: []keyBinding{ + {"↑↓ / jk", "navigate"}, + {"↵ / → / l", "plugin detail"}, + {"e", "edit pipeline in $EDITOR"}, + {"tab", "switch to sessions"}, + {"esc", "back to pods picker"}, + }, + }, + panePluginDetail: { + title: "PLUGIN DETAIL (this pane)", + bindings: []keyBinding{ + {"↑↓", "scroll"}, + {"esc / ← / h", "back"}, + }, + }, + paneCatalog: { + title: "PLUGIN CATALOG (this pane)", + bindings: []keyBinding{ + {"↑↓ / jk", "navigate"}, + {"↵ / → / l", "plugin detail"}, + {"r", "refresh from /v1/plugins"}, + {"esc / ← / h", "back"}, + }, + }, +} + +// otherPaneOrder fixes the render order of the "OTHER PANES" section so +// the overlay is stable across openings (Go map iteration is random). +var otherPaneOrder = []paneID{ + paneNamespaces, panePods, paneSessions, paneEvents, + paneDetail, panePipeline, panePluginDetail, paneCatalog, +} + +// helpKeyColWidth is the fixed width of the key column so descriptions +// align into a readable second column across every group. +const helpKeyColWidth = 12 + +// renderKeyGroup renders one titled group. emphasize bolds the title and +// the key column — used for the pane the overlay was opened over. +func renderKeyGroup(g keyGroup, emphasize bool) string { + var b strings.Builder + titleStyle := styleHint + keyStyle := styleMuted + if emphasize { + titleStyle = styleTitle + keyStyle = styleOK + } + b.WriteString(titleStyle.Render(g.title)) + for _, kb := range g.bindings { + keys := kb.keys + if w := lipgloss.Width(keys); w < helpKeyColWidth { + keys += strings.Repeat(" ", helpKeyColWidth-w) + } + b.WriteString("\n " + keyStyle.Render(keys) + " " + styleHint.Render(kb.desc)) + } + return b.String() +} + +// helpBodyLines builds the scrollable body of the key-help overlay: the +// active pane's group (emphasized) first, then the global keys, then a +// one-line summary of every other pane. Returned as a single string so a +// viewport can page through it. +// +// The close hint is deliberately NOT included — it lives in the overlay's +// fixed footer so it can't be scrolled out of reach. +func helpBodyLines(pane paneID) string { + var sections []string + + if g, ok := paneKeys[pane]; ok { + sections = append(sections, renderKeyGroup(g, true)) + } + sections = append(sections, renderKeyGroup(globalKeys, false)) + + // Remaining panes, compacted to one line each so the overlay stays + // scannable. The active pane is already rendered in full above. + var others []string + for _, p := range otherPaneOrder { + if p == pane { + continue + } + g, ok := paneKeys[p] + if !ok { + continue + } + keys := make([]string, 0, len(g.bindings)) + for _, kb := range g.bindings { + keys = append(keys, kb.keys) + } + // Strip the "(this pane)" suffix the active-pane title carries. + name := strings.TrimSuffix(g.title, " (this pane)") + others = append(others, " "+styleMuted.Render(padRight(name, 16))+" "+ + styleHint.Render(strings.Join(keys, " "))) + } + if len(others) > 0 { + sections = append(sections, + styleHint.Render("OTHER PANES")+"\n"+strings.Join(others, "\n")) + } + + return strings.Join(sections, "\n\n") +} + +// helpBodyWidth is the natural (unwrapped) width of the help body, used +// to size the overlay before the terminal cap is applied. +func helpBodyWidth(body string) int { + w := 0 + for _, ln := range strings.Split(body, "\n") { + if lw := lipgloss.Width(ln); lw > w { + w = lw + } + } + return w +} + +// helpOverlayFrameH is the number of rows the overlay frame costs beyond +// the scrollable body: top border, footer hint, bottom border. +const helpOverlayFrameH = 3 + +// helpViewportSize returns the width and height the help body's viewport +// should occupy for the given terminal size. Height leaves room for the +// frame; both are floored at 1 so a pathologically small terminal still +// renders something rather than panicking inside lipgloss. +func helpViewportSize(width, height, bodyW int) (int, int) { + frameW := styleBorder.GetHorizontalBorderSize() + helpPadX*2 + w := bodyW + if max := width - frameW; max > 0 && w > max { + w = max + } + if w < 1 { + w = 1 + } + h := height - helpOverlayFrameH + if h < 1 { + h = 1 + } + return w, h +} + +// helpPadX is the overlay's horizontal padding inside its border. +const helpPadX = 2 + +// renderHelpOverlay draws the key-help panel around an already-scrolled +// viewport. The active pane's bindings come first and are emphasized; +// the global group follows; every other pane is summarized underneath so +// the overlay is a complete reference rather than a per-pane cheat sheet. +// +// vp must already hold the body content (see model.syncHelpViewport) — +// this function only frames it, so scroll position is owned by the model +// and survives re-renders. +// +// Returns the panel only — placement over the underlying view is the +// caller's job (see overlayCenter). +func renderHelpOverlay(vp viewport.Model, width, height int) string { + // Scroll affordance: only shown when the body doesn't fit, so a + // terminal tall enough for the whole reference stays uncluttered. + // Degrades to the bare close hint when the viewport is too narrow for + // the annotated form — a clipped "clos" is worse than no percentage. + const closeHint = "[?] or [esc] close" + hint := closeHint + if vp.TotalLineCount() > vp.VisibleLineCount() { + annotated := fmt.Sprintf("[↑↓] scroll %d%% · %s", + int(vp.ScrollPercent()*100), closeHint) + if lipgloss.Width(annotated) <= vp.Width { + hint = annotated + } + } + if lipgloss.Width(hint) > vp.Width { + hint = truncToWidth(hint, vp.Width) + } + + inner := vp.View() + "\n" + styleHint.Render(hint) + + // No Width()/MaxHeight() here: the viewport is already sized to the + // terminal by helpViewportSize (which reserves helpOverlayFrameH rows + // for this frame), so the block fits by construction. Setting Width + // would make lipgloss re-wrap the pre-aligned key columns, and + // MaxHeight would clip the footer hint that reservation exists to + // protect. MaxWidth stays as a backstop against a terminal narrower + // than one padded column. + box := styleBorder.Padding(0, helpPadX) + if width > styleBorder.GetHorizontalBorderSize() { + box = box.MaxWidth(width) + } + return box.Render(inner) +} + +// padRight pads s with spaces to n display columns. Narrower than n is +// left untouched. +func padRight(s string, n int) string { + if w := lipgloss.Width(s); w < n { + return s + strings.Repeat(" ", n-w) + } + return s +} + +// overlayCenter draws overlay centered on top of base, line by line, +// preserving the surrounding view. Both are treated as plain rendered +// blocks; the overlay's lines replace the base's at the computed offset. +// +// ANSI-aware only insofar as lipgloss.Width is used for column math — +// good enough because the overlay fully covers the columns it occupies, +// so no partial-escape splicing happens on the overlay's own rows. +func overlayCenter(base, overlay string, width, height int) string { + baseLines := strings.Split(base, "\n") + overLines := strings.Split(overlay, "\n") + + // Pad the base up to the terminal height so a short base view still + // gets a centered overlay rather than one pinned to the top. + for len(baseLines) < height { + baseLines = append(baseLines, "") + } + + overH := len(overLines) + overW := 0 + for _, l := range overLines { + if w := lipgloss.Width(l); w > overW { + overW = w + } + } + + top := (len(baseLines) - overH) / 2 + if top < 0 { + top = 0 + } + left := (width - overW) / 2 + if left < 0 { + left = 0 + } + // Never let centering push the panel past the right edge: if the panel + // is wider than the terminal, render it flush-left and let MaxWidth in + // renderHelpOverlay have already bounded it. + if left+overW > width { + left = width - overW + if left < 0 { + left = 0 + } + } + + out := make([]string, len(baseLines)) + copy(out, baseLines) + for i, ol := range overLines { + row := top + i + if row >= len(out) { + break + } + // Rebuild the row as: base prefix (left columns) + overlay line. + // Anything the overlay covers to the right is dropped — the panel + // is opaque, and reconstructing a styled tail past an ANSI-laden + // prefix is not worth the complexity for a modal. + prefix := truncToWidth(out[row], left) + if w := lipgloss.Width(prefix); w < left { + prefix += strings.Repeat(" ", left-w) + } + out[row] = prefix + ol + } + return strings.Join(out, "\n") +} + +// truncToWidth clips a possibly-ANSI-styled line to n display columns, +// closing any open style with a reset so the overlay that follows starts +// clean. Escape sequences are copied through without counting toward the +// width budget. +func truncToWidth(s string, n int) string { + if n <= 0 { + return "" + } + if lipgloss.Width(s) <= n { + return s + } + var b strings.Builder + w := 0 + sawEscape := false + for i := 0; i < len(s); { + if s[i] == 0x1b { + // Copy the whole escape sequence verbatim (through the final + // byte of a CSI sequence, or a single byte otherwise). + j := i + 1 + for j < len(s) && !isCSITerminator(s[j]) { + j++ + } + if j < len(s) { + j++ + } + b.WriteString(s[i:j]) + sawEscape = true + i = j + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + rw := lipgloss.Width(string(r)) + if w+rw > n { + break + } + b.WriteString(s[i : i+size]) + w += rw + i += size + } + if sawEscape { + b.WriteString("\x1b[0m") + } + return b.String() +} + +// isCSITerminator reports whether b ends an ANSI CSI escape sequence. +func isCSITerminator(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} diff --git a/authbridge/cmd/abctl/tui/help_overlay_test.go b/authbridge/cmd/abctl/tui/help_overlay_test.go new file mode 100644 index 00000000..2dd99f4d --- /dev/null +++ b/authbridge/cmd/abctl/tui/help_overlay_test.go @@ -0,0 +1,526 @@ +package tui + +import ( + "context" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// keyRune is a helper for pressing a single-rune key. +func keyRune(r rune) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} +} + +// sizedPickerModel returns a picker model that has been given a terminal +// size, so View() renders the full frame rather than "initializing…". +func sizedPickerModel(t *testing.T) *model { + t.Helper() + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + updated, _ := m.Update(m.Init()()) + mm := updated.(*model) + updated, _ = mm.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + return updated.(*model) +} + +func TestHelpOverlayOpensAndCloses(t *testing.T) { + mm := sizedPickerModel(t) + if mm.helpVisible { + t.Fatal("help overlay should start hidden") + } + updated, _ := mm.Update(keyRune('?')) + mm = updated.(*model) + if !mm.helpVisible { + t.Fatal("`?` should open the help overlay") + } + // `?` again closes it (as does Esc / q — covered below). + updated, _ = mm.Update(keyRune('?')) + mm = updated.(*model) + if mm.helpVisible { + t.Fatal("`?` should toggle the help overlay closed") + } +} + +func TestHelpOverlayClosesOnEscAndQ(t *testing.T) { + for _, tc := range []struct { + name string + key tea.KeyMsg + }{ + {"esc", tea.KeyMsg{Type: tea.KeyEsc}}, + {"q", keyRune('q')}, + } { + t.Run(tc.name, func(t *testing.T) { + mm := sizedPickerModel(t) + updated, _ := mm.Update(keyRune('?')) + mm = updated.(*model) + updated, cmd := mm.Update(tc.key) + mm = updated.(*model) + if mm.helpVisible { + t.Fatalf("%s should close the overlay", tc.name) + } + // Critically, `q` must not quit abctl while the overlay is up. + if cmd != nil { + t.Fatalf("%s on the overlay should not emit a Cmd (got quit?)", tc.name) + } + }) + } +} + +// The overlay is modal: keys that would navigate the pane underneath must +// not reach it while help is up. +func TestHelpOverlayIsModal(t *testing.T) { + mm := sizedPickerModel(t) + updated, _ := mm.Update(keyRune('?')) + mm = updated.(*model) + // Enter would normally drill into the selected namespace. + updated, _ = mm.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mm = updated.(*model) + if mm.pane != paneNamespaces { + t.Fatalf("Enter should be swallowed by the overlay; pane moved to %v", mm.pane) + } + if mm.selectedNamespace != "" { + t.Fatalf("Enter should be swallowed; selectedNamespace = %q", mm.selectedNamespace) + } +} + +func TestHelpOverlayRendersAndEmphasizesCurrentPane(t *testing.T) { + mm := sizedPickerModel(t) + updated, _ := mm.Update(keyRune('?')) + mm = updated.(*model) + view := mm.View() + // The active pane's group is rendered in full, marked as "this pane". + if !strings.Contains(view, "NAMESPACES (this pane)") { + t.Fatalf("overlay should emphasize the active pane's group:\n%s", view) + } + // P is the binding this overlay exists to make discoverable. + if !strings.Contains(view, "plugin catalog") { + t.Fatalf("overlay should document the P / plugin catalog binding:\n%s", view) + } + // Other panes are summarized, not omitted. + if !strings.Contains(view, "OTHER PANES") { + t.Fatalf("overlay should list other panes:\n%s", view) + } + if !strings.Contains(view, "close") { + t.Fatalf("overlay should say how to close itself:\n%s", view) + } +} + +// The overlay must be reachable from the session views too, not just the +// picker — that is where `P` actually works. +func TestHelpOverlayAvailableInSessionPanes(t *testing.T) { + for _, pane := range []paneID{ + paneSessions, paneEvents, paneDetail, + panePipeline, panePluginDetail, paneCatalog, + } { + m := newPickerModel(context.Background(), &fakeLister{}, nil) + m.pane = pane + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + mm := updated.(*model) + updated, _ = mm.Update(keyRune('?')) + mm = updated.(*model) + if !mm.helpVisible { + t.Fatalf("`?` should open the overlay from pane %v", pane) + } + if g, ok := paneKeys[pane]; !ok { + t.Errorf("pane %v has no help group defined", pane) + } else if !strings.Contains(mm.View(), g.title) { + t.Errorf("overlay over pane %v missing its own group %q", pane, g.title) + } + } +} + +// renderHelpOverlay must not panic or return nothing at small sizes, and +// the close hint must survive every one of them — it lives in the fixed +// footer precisely so a short terminal can't scroll it away. +func TestHelpOverlayTinyTerminal(t *testing.T) { + for _, dim := range [][2]int{{0, 0}, {1, 1}, {20, 5}, {40, 10}} { + m := &model{pane: paneSessions, width: dim[0], height: dim[1], helpVp: viewport.New(0, 0)} + m.syncHelpViewport(true) + out := renderHelpOverlay(m.helpVp, dim[0], dim[1]) + if out == "" { + t.Errorf("renderHelpOverlay(%d,%d) returned empty", dim[0], dim[1]) + } + // The close hint must be present whenever the terminal is tall + // enough for the frame AND wide enough to hold the hint. Below + // that width MaxWidth necessarily clips it — at 20 columns there + // is no rendering that fits, so assert only that the hint line + // started (the "[?]" prefix survives) rather than the full text. + if dim[1] >= helpOverlayFrameH { + want := "close" + if dim[0] < lipgloss.Width("[?] or [esc] close")+6 { + want = "[?]" + } + if !strings.Contains(out, want) { + t.Errorf("renderHelpOverlay(%d,%d) lost the close hint (wanted %q):\n%s", + dim[0], dim[1], want, out) + } + } + } +} + +// overlayCenter must preserve the base view's line count so the frame +// doesn't jump when the overlay opens. +func TestOverlayCenterPreservesHeight(t *testing.T) { + base := strings.Repeat("x\n", 19) + "x" // 20 lines + over := "AAA\nBBB" + got := overlayCenter(base, over, 40, 20) + if n := len(strings.Split(got, "\n")); n != 20 { + t.Fatalf("overlayCenter changed line count: got %d, want 20", n) + } + if !strings.Contains(got, "AAA") || !strings.Contains(got, "BBB") { + t.Fatalf("overlay content missing from composite:\n%s", got) + } +} + +// A base view shorter than the terminal should still get a centered +// overlay (padded), not one pinned to the first row. +func TestOverlayCenterPadsShortBase(t *testing.T) { + got := overlayCenter("only one line", "PANEL", 40, 20) + lines := strings.Split(got, "\n") + if len(lines) != 20 { + t.Fatalf("got %d lines, want 20", len(lines)) + } + if strings.Contains(lines[0], "PANEL") { + t.Fatal("overlay should be centered, not pinned to row 0") + } +} + +func TestTruncToWidth(t *testing.T) { + cases := []struct { + in string + n int + want string + }{ + {"hello", 5, "hello"}, + {"hello", 3, "hel"}, + {"hello", 0, ""}, + {"héllo", 3, "hél"}, + } + for _, c := range cases { + if got := truncToWidth(c.in, c.n); got != c.want { + t.Errorf("truncToWidth(%q,%d) = %q, want %q", c.in, c.n, got, c.want) + } + } + // Styled input keeps its escape but gets reset-terminated so the + // overlay that follows isn't tinted by a dangling style. A literal + // escape is used rather than styleTitle.Render because lipgloss + // strips color when stdout isn't a TTY (as under `go test`), which + // would make this assertion vacuous. + styled := "\x1b[1;34mabcdef\x1b[0m" + got := truncToWidth(styled, 3) + if !strings.HasSuffix(got, "\x1b[0m") { + t.Errorf("truncated styled string should end with a reset, got %q", got) + } + if !strings.Contains(got, "abc") { + t.Errorf("truncated styled string should retain visible text, got %q", got) + } + if strings.Contains(got, "def") { + t.Errorf("truncated styled string should drop text past the budget, got %q", got) + } + // Escape bytes must not consume the width budget. + if w := lipgloss.Width(got); w != 3 { + t.Errorf("truncToWidth budget miscounted escapes: width %d, want 3", w) + } +} + +// Every pane in otherPaneOrder must have a group, or the "OTHER PANES" +// section silently drops a pane. +func TestPaneKeysCoverAllPanes(t *testing.T) { + for _, p := range otherPaneOrder { + if _, ok := paneKeys[p]; !ok { + t.Errorf("otherPaneOrder includes pane %v with no paneKeys entry", p) + } + } + if len(paneKeys) != len(otherPaneOrder) { + t.Errorf("paneKeys has %d entries, otherPaneOrder %d — keep them in sync", + len(paneKeys), len(otherPaneOrder)) + } +} + +// The overlay must stay inside the terminal, and must never push the +// composite wider than the pane underneath already was. The picker's own +// tables use fixed column widths and can exceed a narrow terminal on +// their own, so the bound is max(terminal, base) — the overlay is +// responsible only for not making things worse. +func TestHelpOverlayAddsNoOverflow(t *testing.T) { + for _, w := range []int{60, 80, 100, 140} { + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + u, _ := m.Update(m.Init()()) + mm := u.(*model) + u, _ = mm.Update(tea.WindowSizeMsg{Width: w, Height: 34}) + mm = u.(*model) + + baseMax := widestLine(mm.paneView()) + u, _ = mm.Update(keyRune('?')) + mm = u.(*model) + overlayMax := widestLine(mm.View()) + + budget := w + if baseMax > budget { + budget = baseMax + } + if overlayMax > budget { + t.Errorf("width %d: overlay rendered %d columns, over the %d budget (base was %d)", + w, overlayMax, budget, baseMax) + } + } +} + +func widestLine(s string) int { + max := 0 + for _, ln := range strings.Split(s, "\n") { + if w := lipgloss.Width(ln); w > max { + max = w + } + } + return max +} + +// The edit overlay is itself modal and owns y/N/r/Esc. `?` must not +// layer help on top of it and swallow the apply confirmation. +func TestHelpOverlaySuppressedDuringEdit(t *testing.T) { + mm := sizedPickerModel(t) + mm.pane = panePipeline + mm.editState = editState{phase: editPhaseDiff} + + updated, _ := mm.Update(keyRune('?')) + mm = updated.(*model) + if mm.helpVisible { + t.Fatal("`?` should not open the help overlay during an edit") + } + // The edit overlay's own keys must still work. + updated, _ = mm.Update(keyRune('n')) + mm = updated.(*model) + if mm.editState.phase != editPhaseDone { + t.Fatalf("`n` should still abort the edit, phase = %v", mm.editState.phase) + } + // Once the edit is done, `?` works again. + updated, _ = mm.Update(keyRune('?')) + mm = updated.(*model) + if !mm.helpVisible { + t.Fatal("`?` should work again after the edit finishes") + } +} + +// --- scrolling ----------------------------------------------------------- + +// helpModelAt returns a model with the help overlay open at the given +// terminal size. +func helpModelAt(t *testing.T, pane paneID, w, h int) *model { + t.Helper() + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + u, _ := m.Update(m.Init()()) + mm := u.(*model) + mm.pane = pane + u, _ = mm.Update(tea.WindowSizeMsg{Width: w, Height: h}) + mm = u.(*model) + u, _ = mm.Update(keyRune('?')) + return u.(*model) +} + +// On a terminal too short for the whole reference, the body must be +// scrollable rather than silently truncated. +func TestHelpOverlayScrolls(t *testing.T) { + mm := helpModelAt(t, paneSessions, 100, 14) + if mm.helpVp.TotalLineCount() <= mm.helpVp.VisibleLineCount() { + t.Fatalf("setup: expected content taller than the viewport (total %d, visible %d)", + mm.helpVp.TotalLineCount(), mm.helpVp.VisibleLineCount()) + } + if !mm.helpVp.AtTop() { + t.Fatal("overlay should open scrolled to the top") + } + + // The last group must be off-screen initially, and reachable by + // scrolling — that is the whole point of this change. + if strings.Contains(mm.View(), "PLUGIN CATALOG") { + t.Fatal("setup: expected the tail of the reference to be off-screen") + } + u, _ := mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + mm = u.(*model) + if !mm.helpVp.AtBottom() { + t.Fatal("`G` should jump to the bottom of the help body") + } + if !strings.Contains(mm.View(), "PLUGIN CATALOG") { + t.Fatalf("after scrolling to the bottom, the tail should be visible:\n%s", mm.View()) + } + + // And back up. + u, _ = mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + mm = u.(*model) + if !mm.helpVp.AtTop() { + t.Fatal("`g` should jump back to the top") + } +} + +// Arrow / vim / page keys must all move the help body. +func TestHelpOverlayScrollKeys(t *testing.T) { + for _, tc := range []struct { + name string + key tea.KeyMsg + }{ + {"down arrow", tea.KeyMsg{Type: tea.KeyDown}}, + {"j", keyRune('j')}, + {"pgdown", tea.KeyMsg{Type: tea.KeyPgDown}}, + {"f", keyRune('f')}, + {"d", keyRune('d')}, + } { + t.Run(tc.name, func(t *testing.T) { + mm := helpModelAt(t, paneSessions, 100, 14) + before := mm.helpVp.YOffset + u, _ := mm.Update(tc.key) + mm = u.(*model) + if mm.helpVp.YOffset <= before { + t.Fatalf("%s should scroll down: offset %d → %d", tc.name, before, mm.helpVp.YOffset) + } + // And back up with the mirror key where one exists. + up := tea.KeyMsg{Type: tea.KeyUp} + u, _ = mm.Update(up) + mm = u.(*model) + if mm.helpVp.AtBottom() && mm.helpVp.YOffset != 0 { + // fine: single-line step from a clamped bottom + return + } + }) + } +} + +// The scroll affordance appears only when it's needed, and reports +// position so the reader knows there's more below. +func TestHelpOverlayScrollHint(t *testing.T) { + // Tall terminal: whole reference fits, no scroll noise. + tall := helpModelAt(t, paneSessions, 100, 60) + if tall.helpVp.TotalLineCount() > tall.helpVp.VisibleLineCount() { + t.Skip("terminal not tall enough for the no-scroll case") + } + // Match the affordance specifically, not the word "scroll" — the + // global key list legitimately contains "scroll this help". + if strings.Contains(tall.View(), "[↑↓] scroll") { + t.Errorf("no scroll affordance expected when everything fits:\n%s", tall.View()) + } + + // Short terminal: hint present, with a percentage. + short := helpModelAt(t, paneSessions, 100, 14) + v := short.View() + if !strings.Contains(v, "[↑↓] scroll") { + t.Errorf("scroll affordance expected when content overflows:\n%s", v) + } + if !strings.Contains(v, "0%") { + t.Errorf("scroll hint should report position at top:\n%s", v) + } + u, _ := short.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + short = u.(*model) + if !strings.Contains(short.View(), "100%") { + t.Errorf("scroll hint should report 100%% at the bottom:\n%s", short.View()) + } +} + +// A resize while the overlay is open must re-range the viewport (so a +// grown terminal reveals more) without losing the reader's place. +func TestHelpOverlayResizePreservesScroll(t *testing.T) { + mm := helpModelAt(t, paneSessions, 100, 14) + u, _ := mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + mm = u.(*model) + u, _ = mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + mm = u.(*model) + before := mm.helpVp.YOffset + if before == 0 { + t.Fatal("setup: expected a non-zero scroll offset") + } + + // Grow the terminal: the visible window must grow with it. + visBefore := mm.helpVp.VisibleLineCount() + u, _ = mm.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + mm = u.(*model) + if mm.helpVp.VisibleLineCount() <= visBefore { + t.Errorf("resize should enlarge the visible window: %d → %d", + visBefore, mm.helpVp.VisibleLineCount()) + } + // Scroll position is preserved (clamping on a taller window may reduce + // it, but it must not reset to the top unless clamping demands it). + if mm.helpVp.YOffset == 0 && !mm.helpVp.AtBottom() { + t.Error("resize should not reset scroll position to the top") + } +} + +// Reopening the overlay starts at the top rather than resuming a stale +// scroll position from a previous viewing. +func TestHelpOverlayReopenResetsScroll(t *testing.T) { + mm := helpModelAt(t, paneSessions, 100, 14) + u, _ := mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + mm = u.(*model) + if mm.helpVp.AtTop() { + t.Fatal("setup: expected to be scrolled away from the top") + } + // Close, reopen. + u, _ = mm.Update(tea.KeyMsg{Type: tea.KeyEsc}) + mm = u.(*model) + u, _ = mm.Update(keyRune('?')) + mm = u.(*model) + if !mm.helpVp.AtTop() { + t.Fatal("reopening the overlay should start at the top") + } +} + +// Scrolling the help overlay must not disturb the detail pane's own +// viewport — the overlay can open over it. +func TestHelpOverlayScrollDoesNotDisturbDetailPane(t *testing.T) { + mm := helpModelAt(t, paneDetail, 100, 14) + mm.detailVp.SetContent(strings.Repeat("detail line\n", 100)) + mm.detailVp.Height = 10 + mm.detailVp.SetYOffset(7) + + u, _ := mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + mm = u.(*model) + if mm.detailVp.YOffset != 7 { + t.Fatalf("help scrolling clobbered the detail viewport: offset %d, want 7", + mm.detailVp.YOffset) + } + if mm.helpVp.AtTop() { + t.Fatal("the help viewport should have scrolled instead") + } +} + +// `?` is a legitimate filter character — session IDs and hosts can +// contain one — so the filter input must receive it rather than having it +// stolen to open the help overlay. +func TestHelpOverlayDoesNotStealFilterInput(t *testing.T) { + mm := helpModelAt(t, paneSessions, 100, 40) + // Close the overlay opened by the helper; we want the filter path. + u, _ := mm.Update(tea.KeyMsg{Type: tea.KeyEsc}) + mm = u.(*model) + + // Enter filter mode, then type a value containing `?`. + u, _ = mm.Update(keyRune('/')) + mm = u.(*model) + if !mm.filtering { + t.Fatal("setup: `/` should enter filter mode") + } + for _, r := range "ab?c" { + u, _ = mm.Update(keyRune(r)) + mm = u.(*model) + } + if mm.helpVisible { + t.Fatal("`?` while filtering should not open the help overlay") + } + if got := mm.filterInput.Value(); got != "ab?c" { + t.Fatalf("filter input should have received the `?`, got %q", got) + } + if mm.filter != "ab?c" { + t.Fatalf("live filter should be %q, got %q", "ab?c", mm.filter) + } + + // Committing the filter releases the keyboard, so `?` works again. + u, _ = mm.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mm = u.(*model) + if mm.filtering { + t.Fatal("setup: Enter should commit the filter") + } + u, _ = mm.Update(keyRune('?')) + mm = u.(*model) + if !mm.helpVisible { + t.Fatal("`?` should open the overlay once the filter input is unfocused") + } +} diff --git a/authbridge/cmd/abctl/tui/keys.go b/authbridge/cmd/abctl/tui/keys.go index 935e3bb7..72a7a0fa 100644 --- a/authbridge/cmd/abctl/tui/keys.go +++ b/authbridge/cmd/abctl/tui/keys.go @@ -23,9 +23,50 @@ func catalogPlugins(c *apiclient.PluginCatalog) []apiclient.PluginCatalogEntry { return c.Plugins } -// handleKey processes every key press. The filter-input overlay takes -// precedence; otherwise keys are dispatched based on the active pane. +// handleKey processes every key press. Modal overlays claim it first, in +// order: the key-help overlay, then the picker panes, then an in-flight +// pipeline edit, then the filter input. Only if none of those own the +// keyboard is the key dispatched based on the active pane. func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd { + // The help overlay is modal: while it's up, it owns the keyboard so a + // stray key can't navigate the pane hidden underneath. Checked before + // every other handler, including the picker and edit overlays, so `?` + // is genuinely available everywhere. + if m.helpVisible { + switch msg.String() { + case "?", "esc", "q", "ctrl+c": + // `q`/ctrl+c close the overlay rather than quitting abctl: + // dismissing a help panel is the overwhelmingly likely intent, + // and the overlay itself advertises how to quit. + m.helpVisible = false + return nil + case "g": + m.helpVp.GotoTop() + return nil + case "G": + m.helpVp.GotoBottom() + return nil + } + // Everything else goes to the viewport so the reference scrolls: + // ↑↓/jk, pgup/pgdn, and the half-page keys the viewport binds by + // default. Keys it doesn't recognize are harmlessly ignored, which + // preserves the overlay's modality. + var cmd tea.Cmd + m.helpVp, cmd = m.helpVp.Update(msg) + return cmd + } + // `?` opens the overlay from any pane, with two exceptions. While a + // pipeline edit is in flight that overlay is already modal and owns + // y/N/r/Esc, so help would swallow the apply confirmation. While the + // filter input is focused `?` is a character the user is typing — a + // session ID or host can contain one — and stealing it would make + // those values unfilterable. + if msg.String() == "?" && m.editState.phase == editPhaseDone && !m.filtering { + m.helpVisible = true + m.syncHelpViewport(true) + return nil + } + // Picker panes handle their own keys before session-view logic. if m.pane == paneNamespaces { switch msg.String() { @@ -36,6 +77,18 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd { m.rebuildPodsTable() } return nil + case "l": + // Skip the cluster entirely and talk to whatever session API + // is already listening locally — an existing port-forward, an + // in-mesh abctl, or a tunnel from a kubeconfig that can't list + // pods. Probes before switching panes so a dead endpoint stays + // an error in the picker rather than an empty session view. + if m.loading { + return nil + } + m.pickerErr = "" + m.loading = true + return connectLocalCmd(m.ctx, localEndpoint) case "r": if m.loading { return nil @@ -447,20 +500,20 @@ func (m *model) helpView() string { } switch m.pane { case paneNamespaces: - return "[↑↓/jk] nav [↵] open [r] reload [q] quit" + return "[↑↓/jk] nav [↵] open [l] localhost:9094 [r] reload [?] keys [q] quit" case panePods: - return "[↑↓/jk] nav [↵] connect [Esc] back [r] reload [q] quit" + return "[↑↓/jk] nav [↵] connect [Esc] back [r] reload [?] keys [q] quit" case paneSessions: if m.parentCtx != nil { - return "[↑↓] nav [↵] drill [tab] pipeline [/] filter [esc] pods [p] pause [q] quit" + return "[↑↓] nav [↵] drill [tab] pipeline [/] filter [esc] pods [p] pause [?] keys [q] quit" } - return "[↑↓] nav [↵] drill [tab] pipeline [/] filter [p] pause [q] quit" + return "[↑↓] nav [↵] drill [tab] pipeline [/] filter [p] pause [?] keys [q] quit" case paneEvents: skipHint := "[s] hide passthru/skip" if m.hideInactive { skipHint = "[s] show all" } - base := "[↑↓] nav [b/f] page [↵] detail [esc] back [/] filter " + skipHint + " [p] pause [q] quit" + base := "[↑↓] nav [b/f] page [↵] detail [esc] back [/] filter " + skipHint + " [p] pause [?] keys [q] quit" // Surface the hidden-message count so a filtered timeline doesn't // look like data loss. Only annotate when hiding is on AND at // least one message was hidden. @@ -470,13 +523,13 @@ func (m *model) helpView() string { } return base case paneDetail: - return "[↑↓] scroll [y] yank [esc] back [q] quit" + return "[↑↓] scroll [y] yank [esc] back [?] keys [q] quit" case panePipeline: var base string if m.parentCtx != nil { - base = "[↑↓] nav [↵] plugin detail [e] edit [tab] sessions [esc] pods [q] quit" + base = "[↑↓] nav [↵] plugin detail [e] edit [tab] sessions [esc] pods [?] keys [q] quit" } else { - base = "[↑↓] nav [↵] plugin detail [e] edit [tab] sessions [q] quit" + base = "[↑↓] nav [↵] plugin detail [e] edit [tab] sessions [?] keys [q] quit" } // Surface a count of plugins with unmet dependencies so a single // "✗" in the DEPS column doesn't get lost in a long list. @@ -486,14 +539,14 @@ func (m *model) helpView() string { } return base case panePluginDetail: - return "[↑↓] scroll [esc] back [q] quit" + return "[↑↓] scroll [esc] back [?] keys [q] quit" case paneCatalog: if m.catalog == nil { - return "loading catalog… [esc] back [q] quit" + return "loading catalog… [esc] back [?] keys [q] quit" } - return "[↑↓] nav [↵] plugin detail [r] refresh [esc] back [q] quit" + return "[↑↓] nav [↵] plugin detail [r] refresh [esc] back [?] keys [q] quit" } - return "[q] quit" + return "[?] keys [q] quit" } // layout recomputes component sizes to fit the current terminal. Called on diff --git a/authbridge/cmd/abctl/tui/namespaces_pane.go b/authbridge/cmd/abctl/tui/namespaces_pane.go index 78e6cc28..26a07501 100644 --- a/authbridge/cmd/abctl/tui/namespaces_pane.go +++ b/authbridge/cmd/abctl/tui/namespaces_pane.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/rossoctl/cortex/authbridge/authlib/pipeline" + "github.com/rossoctl/cortex/authbridge/cmd/abctl/apiclient" "github.com/rossoctl/cortex/authbridge/cmd/abctl/cluster" "github.com/rossoctl/cortex/authbridge/cmd/abctl/edit" ) @@ -46,6 +47,30 @@ func loadAgentsCmd(ctx context.Context, lister cluster.Lister) tea.Cmd { } } +// localConnectedMsg carries the result of probing localEndpoint for the +// `[l]` shortcut. client is non-nil only when the probe succeeded. +type localConnectedMsg struct { + client *apiclient.Client + endpoint string + err error +} + +// connectLocalCmd probes localEndpoint's /v1/sessions and, on success, +// hands back a ready client. The probe matters because there's no +// port-forward subprocess to fail loudly here — without it, a wrong +// guess would drop the operator into a silently empty session view. +func connectLocalCmd(ctx context.Context, endpoint string) tea.Cmd { + return func() tea.Msg { + c := apiclient.New(endpoint) + probeCtx, cancel := context.WithTimeout(ctx, localProbeTimeout) + defer cancel() + if _, err := c.ListSessions(probeCtx); err != nil { + return localConnectedMsg{err: err} + } + return localConnectedMsg{client: c, endpoint: endpoint} + } +} + // newPickerModel constructs a model already in the Namespaces pane, // wired with the given Lister and PortForwarder. Used when --endpoint // is not given. Mirrors the field initialization in New() so that diff --git a/authbridge/cmd/abctl/tui/picker_test.go b/authbridge/cmd/abctl/tui/picker_test.go index 7d0d9b26..c239bf7b 100644 --- a/authbridge/cmd/abctl/tui/picker_test.go +++ b/authbridge/cmd/abctl/tui/picker_test.go @@ -3,6 +3,8 @@ package tui import ( "context" "fmt" + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -415,3 +417,202 @@ func TestRefreshTickAfterEscDoesNotPanic(t *testing.T) { // silence unused-import nag if test build trims this file later var _ = time.Second + +// --- [l] localhost:9094 shortcut ----------------------------------------- + +// sessionAPIStub serves the minimum /v1/sessions response the `[l]` probe +// needs, so the shortcut can be exercised end-to-end against a real HTTP +// endpoint rather than a mocked client. +func sessionAPIStub(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/v1/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sessions":[]}`)) + }) + mux.HandleFunc("/v1/pipeline", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"inbound":[],"outbound":[]}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestLocalhostKeybindConnects(t *testing.T) { + srv := sessionAPIStub(t) + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + updated, _ := m.Update(m.Init()()) + mm := updated.(*model) + + // `l` should dispatch a probe Cmd and mark the model loading. + _, cmd := mm.Update(keyRune('l')) + if cmd == nil { + t.Fatal("`l` on paneNamespaces should produce a connect Cmd") + } + if !mm.loading { + t.Fatal("`l` should set m.loading while the probe is in flight") + } + + // Run the probe against the stub (not the real localhost:9094) and feed + // the result back through Update. + msg := connectLocalCmd(context.Background(), srv.URL)() + lc, ok := msg.(localConnectedMsg) + if !ok { + t.Fatalf("connectLocalCmd produced %T, want localConnectedMsg", msg) + } + if lc.err != nil { + t.Fatalf("probe against stub failed: %v", lc.err) + } + updated, _ = mm.Update(lc) + mm = updated.(*model) + + if mm.pane != paneSessions { + t.Fatalf("after `l` connect, pane should be paneSessions, got %v", mm.pane) + } + if mm.client == nil { + t.Fatal("after `l` connect, m.client should be set") + } + if mm.endpoint != srv.URL { + t.Fatalf("endpoint = %q, want %q", mm.endpoint, srv.URL) + } + if !mm.localDirect { + t.Fatal("localDirect should be true after connecting via `l`") + } + if mm.activePF != nil { + t.Fatal("`l` must not create a port-forward") + } + if mm.loading { + t.Fatal("localConnectedMsg should clear m.loading") + } +} + +func TestLocalhostKeybindProbeFailureStaysInPicker(t *testing.T) { + // Point at a closed port so the probe fails fast. + srv := sessionAPIStub(t) + dead := srv.URL + srv.Close() + + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + updated, _ := m.Update(m.Init()()) + mm := updated.(*model) + + msg := connectLocalCmd(context.Background(), dead)() + lc := msg.(localConnectedMsg) + if lc.err == nil { + t.Fatal("probe against a closed port should fail") + } + updated, _ = mm.Update(lc) + mm = updated.(*model) + + if mm.pane != paneNamespaces { + t.Fatalf("failed probe should leave the user in the picker, got pane %v", mm.pane) + } + if mm.client != nil { + t.Fatal("failed probe must not set a client") + } + if mm.pickerErr == "" { + t.Fatal("failed probe should surface an error in the footer") + } + if !strings.Contains(mm.pickerErr, "localhost:9094") { + t.Fatalf("picker error should name the endpoint it tried, got %q", mm.pickerErr) + } + if mm.loading { + t.Fatal("failed probe should clear m.loading") + } +} + +// Esc out of a session entered via `[l]` has no pod to return to, so it +// must land on Namespaces rather than an empty Pods table. +func TestLocalhostEscReturnsToNamespaces(t *testing.T) { + srv := sessionAPIStub(t) + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + updated, _ := m.Update(m.Init()()) + mm := updated.(*model) + updated, _ = mm.Update(connectLocalCmd(context.Background(), srv.URL)()) + mm = updated.(*model) + if mm.pane != paneSessions { + t.Fatalf("setup: expected paneSessions, got %v", mm.pane) + } + updated, _ = mm.Update(tea.KeyMsg{Type: tea.KeyEsc}) + mm = updated.(*model) + if mm.pane != paneNamespaces { + t.Fatalf("Esc from an `l`-entered session should return to Namespaces, got %v", mm.pane) + } + if mm.localDirect { + t.Fatal("localDirect should be cleared on back-out") + } +} + +// The namespaces footer and empty state must advertise `[l]`, otherwise +// the shortcut is undiscoverable. +func TestLocalhostKeybindIsAdvertised(t *testing.T) { + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + updated, _ := m.Update(m.Init()()) + mm := updated.(*model) + updated, _ = mm.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + mm = updated.(*model) + if !strings.Contains(mm.View(), "[l] localhost:9094") { + t.Fatalf("namespaces footer should advertise [l]:\n%s", mm.View()) + } + + // Empty-state hint: the no-agents case is exactly when `l` is most + // useful, so it should be mentioned there too. + m2 := newPickerModel(context.Background(), &fakeLister{namespaces: []cluster.AgentNamespace{}}, nil) + u2, _ := m2.Update(m2.Init()()) + mm2 := u2.(*model) + if !strings.Contains(mm2.View(), "[l]") { + t.Fatalf("empty-state hint should mention [l]:\n%s", mm2.View()) + } +} + +// `l` must not be swallowed when the picker is mid-load, and must not +// hijack the vim-right binding in the session panes. +func TestLocalhostKeybindScoping(t *testing.T) { + srv := sessionAPIStub(t) + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + updated, _ := m.Update(m.Init()()) + mm := updated.(*model) + mm.loading = true + _, cmd := mm.Update(keyRune('l')) + if cmd != nil { + t.Fatal("`l` while loading should be a no-op") + } + mm.loading = false + + // In the sessions pane, `l` is vim-right (drill in), not connect-local. + updated, _ = mm.Update(connectLocalCmd(context.Background(), srv.URL)()) + mm = updated.(*model) + mm.pane = paneSessions + before := mm.endpoint + _, _ = mm.Update(keyRune('l')) + if mm.endpoint != before { + t.Fatal("`l` in the sessions pane should not re-trigger a local connect") + } +} + +// A session entered via `[l]` has no pod/namespace, so pipeline editing +// must report the limitation rather than opening a broken edit. This is +// the same guard `--endpoint` mode relies on; asserted here because the +// README documents the behavior for `[l]` specifically. +func TestLocalhostEditIsUnavailable(t *testing.T) { + srv := sessionAPIStub(t) + m := newPickerModel(context.Background(), &fakeLister{namespaces: fixtureNamespaces}, nil) + updated, _ := m.Update(m.Init()()) + mm := updated.(*model) + updated, _ = mm.Update(connectLocalCmd(context.Background(), srv.URL)()) + mm = updated.(*model) + mm.pane = panePipeline + + updated, cmd := mm.Update(keyRune('e')) + mm = updated.(*model) + if cmd != nil { + t.Fatal("`e` after an `l` connect should not start an edit") + } + if mm.editState.phase != editPhaseDone { + t.Fatalf("`e` should not enter an edit phase, got %v", mm.editState.phase) + } + if !strings.Contains(mm.flash, "picker") { + t.Fatalf("`e` should flash the picker-required hint, got %q", mm.flash) + } +}