From 86e9dfd81b9a13e49ffb619201504f33cb4fab55 Mon Sep 17 00:00:00 2001 From: jp2195 <24376525+jp2195@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:49:03 -0400 Subject: [PATCH 1/2] fix(tui): align dashboard scroll keys with table navigation Dashboard scrolling matched its keys as literal strings and picked Ctrl+F/Ctrl+B for paging with no `g` for top, while table navigation uses Ctrl+D/Ctrl+U and g/G. Two different sets of scroll keys in the same app is not something worth documenting, so match the tables instead. Bindings now come from the shared KeyMap rather than string literals, which keeps the two in step automatically. --- internal/tui/dashboard_scroll.go | 19 +++++++---- internal/tui/dashboard_scroll_test.go | 48 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 internal/tui/dashboard_scroll_test.go diff --git a/internal/tui/dashboard_scroll.go b/internal/tui/dashboard_scroll.go index c020c91..c95c430 100644 --- a/internal/tui/dashboard_scroll.go +++ b/internal/tui/dashboard_scroll.go @@ -1,6 +1,7 @@ package tui import ( + "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "github.com/jp2195/pyre/internal/tui/views" @@ -51,22 +52,26 @@ func (m Model) resetDashboardScroll() Model { // dashboardScrollDelta maps a key press to a scroll distance in lines, // reporting false when the key is not a scroll key. +// +// Bindings come from the shared KeyMap rather than literal strings so +// dashboard scrolling stays identical to table navigation — j/k, g/G, +// Ctrl+D/Ctrl+U and the page keys all behave the same in both places. func (m Model) dashboardScrollDelta(msg tea.KeyPressMsg) (int, bool) { // One screenful minus a line of overlap for context. page := max(m.height-5, 1) - switch msg.String() { - case "j", "down": + switch { + case key.Matches(msg, m.keys.Down): return 1, true - case "k", "up": + case key.Matches(msg, m.keys.Up): return -1, true - case "pgdown", "ctrl+f": + case key.Matches(msg, m.keys.PageDown): return page, true - case "pgup", "ctrl+b": + case key.Matches(msg, m.keys.PageUp): return -page, true - case "G", "end": + case key.Matches(msg, m.keys.End): return scrollJump, true - case "home": + case key.Matches(msg, m.keys.Home): return -scrollJump, true } return 0, false diff --git a/internal/tui/dashboard_scroll_test.go b/internal/tui/dashboard_scroll_test.go new file mode 100644 index 0000000..07224d9 --- /dev/null +++ b/internal/tui/dashboard_scroll_test.go @@ -0,0 +1,48 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +// TestDashboardScrollKeysMatchTableNavigation pins that scrolling a dashboard +// uses the same bindings as scrolling a table. They were briefly divergent +// (Ctrl+F/Ctrl+B, and no `g`), which would have been an odd thing to have to +// document. +func TestDashboardScrollKeysMatchTableNavigation(t *testing.T) { + m := newTestModel(t, ViewDashboard) + m.height = 40 + + cases := []struct { + name string + key tea.KeyPressMsg + want int + }{ + {"j", tea.KeyPressMsg{Code: 'j', Text: "j"}, 1}, + {"down", tea.KeyPressMsg{Code: tea.KeyDown}, 1}, + {"k", tea.KeyPressMsg{Code: 'k', Text: "k"}, -1}, + {"up", tea.KeyPressMsg{Code: tea.KeyUp}, -1}, + {"ctrl+d", tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}, 35}, + {"ctrl+u", tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl}, -35}, + {"g", tea.KeyPressMsg{Code: 'g', Text: "g"}, -scrollJump}, + {"G", tea.KeyPressMsg{Code: 'G', Text: "G"}, scrollJump}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := m.dashboardScrollDelta(tc.key) + if !ok { + t.Fatalf("%s is not recognised as a scroll key", tc.name) + } + if got != tc.want { + t.Errorf("%s delta = %d, want %d", tc.name, got, tc.want) + } + }) + } + + // A non-scroll key must fall through to the view. + if _, ok := m.dashboardScrollDelta(tea.KeyPressMsg{Code: 'x', Text: "x"}); ok { + t.Error("'x' should not be treated as a scroll key") + } +} From b1854d8754196eca2e9ae3ad350e08e746ba131e Mon Sep 17 00:00:00 2001 From: jp2195 <24376525+jp2195@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:49:45 -0400 Subject: [PATCH 2/2] docs: refresh for navigation levels, scrolling, paste, and macOS install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the docs back in line with the behaviour changes from the live firewall review, and add the macOS Gatekeeper guidance that was missing. Navigation is now described as three levels with three key sets — numbers for groups, Tab for views, [ / ] for sub-tabs within a view. Objects switched from Tab to [ / ], so keybindings.md, views/objects.md, getting-started.md and the README navigation section all said the wrong thing. Dashboards scroll when the panel stack is taller than the terminal, and that was undocumented; keybindings.md and views/dashboard.md now cover the keys and the scroll indicator. The login screen has an in-flight state worth documenting: Enter is deliberately ignored while authenticating, because PAN-OS counts every keygen as a login attempt and repeat presses during an MFA push lock the account out. Esc cancels. Bracketed paste now works in text inputs, so it gets a short section rather than users assuming it is broken. README gains a macOS section: installing with curl avoids Gatekeeper entirely, because the quarantine flag is set by the downloading program and curl does not set it. Verified by installing v1.5.3 from the release URL and confirming the extracted binary carries no com.apple.quarantine and runs without a prompt. `xattr -d` is documented for anyone who already downloaded through a browser. Notarization is the real fix but needs a paid Apple Developer account, so it is deliberately not claimed here. Also corrects the Go pin in CLAUDE.md (1.26.4 -> 1.26.5, matching go.mod and CI), notes that CI runs golangci-lint and that go vet alone will not predict it, and records the invariants this work established: CDATA op output must go through api.InnerText, HasData gates the shared spinner, tables share one column grid, detail panels size to contentWidth, and tea.PasteMsg needs explicit routing. --- CLAUDE.md | 21 ++++++++++-- README.md | 41 +++++++++++++++++++----- docs/getting-started.md | 18 ++++++++--- docs/keybindings.md | 69 ++++++++++++++++++++++++++++++++++------ docs/views/dashboard.md | 21 +++++++++++- docs/views/interfaces.md | 6 +++- docs/views/objects.md | 9 ++++-- 7 files changed, 157 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ba8db7d..966070e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,13 @@ go fix ./... # Apply modernizers (safe, behavior-preserving) - **Bubble Tea v2 View composition**: only the top-level `tui.Model.View()` returns `tea.View`; every sub-view model returns `string`. The top-level composes sub-view strings and sets program options (alt-screen, mouse mode, window title, cursor) on the returned `tea.View` rather than on `tea.NewProgram`. - Use `tea.KeyPressMsg` in key handler type switches (not `tea.KeyMsg`, which in v2 is the union interface of press and release). Construct test messages as `tea.KeyPressMsg{Code: tea.KeyDown}` or `tea.KeyPressMsg{Code: 'j', Text: "j"}` — `Runes`/`Type` from v1 no longer exist. - Theme palette fields are `image/color.Color`, not a string alias. Construct concrete values via `lipgloss.Color("#RRGGBB")`. +- **Three navigation levels, three key sets**: `1`/`2`/`3` switch groups, `Tab`/`Shift+Tab` move between views in a group, `[`/`]` switch sub-tabs *within* a view (Objects, Routes, Logs). A view must never consume `Tab` — Objects used to, and became the one view you could not `Tab` out of. +- **Free-form op output is CDATA-wrapped on real hardware.** `Result.Inner` is `xml:",innerxml"`, so it keeps the literal `__.tar.gz +# macOS / Linux — set VERSION, OS (darwin|linux), ARCH (arm64|amd64) +VERSION=1.5.3 OS=darwin ARCH=arm64 +curl -sSL "https://github.com/jp2195/pyre/releases/download/v${VERSION}/pyre_${VERSION}_${OS}_${ARCH}.tar.gz" | tar xz chmod +x pyre sudo mv pyre /usr/local/bin/pyre ``` @@ -65,6 +66,26 @@ Or build from source (Go 1.26+): go install github.com/jp2195/pyre/cmd/pyre@latest ``` +### macOS: "cannot be opened because the developer cannot be verified" + +pyre isn't notarized with Apple, so macOS may refuse to run it and send +you to System Settings → Privacy & Security to click "Open Anyway". + +**Installing with `curl` (above) avoids this entirely.** The quarantine +flag that triggers Gatekeeper is applied by the *downloading program* — +browsers set it, `curl` doesn't — so a curl-installed binary runs with +no prompt. + +If you already downloaded the archive in a browser, clear the flag +instead of digging through System Settings: + +```bash +xattr -d com.apple.quarantine ./pyre +``` + +`go install` is likewise unaffected, since nothing is downloaded through +a browser. + ## Verifying releases Every release is checksummed, signed, and attested at build time: @@ -143,13 +164,17 @@ connection config instead of `--insecure`. ## Navigation -Three numbered groups: `1` Monitor (dashboards), `2` Analyze (list -views), `3` Tools (config). Same number again — or `Tab` — cycles -sub-views in the group. `Ctrl+P` opens a fuzzy command palette that -jumps anywhere. +Three levels, three sets of keys: + +- `1` Monitor (dashboards), `2` Analyze (list views), `3` Tools (config) +- `Tab` / `Shift+Tab` move between views in the active group +- `[` / `]` switch sub-tabs *within* a view (Objects, Routes, Logs) + +`Ctrl+P` opens a fuzzy command palette that jumps anywhere. Inside a list view: `/` filter, `s` cycle sort, `Enter` open detail, -`r` refresh, `?` help, `q` quit. +`r` refresh, `?` help, `q` quit. Dashboards taller than your terminal +scroll with `j`/`k`. New to pyre? The **"first 60 seconds"** section of [Getting Started](docs/getting-started.md) walks the model in one diff --git a/docs/getting-started.md b/docs/getting-started.md index 921dcc2..32ba16a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -84,7 +84,13 @@ Footer shows the keys that apply right now — when in doubt, look down. Press the same number again, or `Tab`, to cycle through sub-views in that group. Try `2`, `2`, `2` to walk through Policies → NAT → Objects. -**3. Inside a list view, four keys do almost everything:** +**3. Some views have sub-tabs — those are `[` and `]`.** Objects splits +into Address / Service, Routes into Routes / Neighbors, Logs into +System / Traffic / Threat. `[` and `]` move between them and never +leave the view; `Tab` always leaves. Three levels, three sets of keys: +numbers for groups, `Tab` for views, brackets for sub-tabs. + +**4. Inside a list view, four keys do almost everything:** - `/` filter (substring match, case-insensitive) - `s` cycle sort field @@ -94,18 +100,22 @@ that group. Try `2`, `2`, `2` to walk through Policies → NAT → Objects. Try it: press `2` to land on Policies, `/web` to filter for "web", `s` to flip sort fields, `Enter` to see the full rule, `Esc` to close. -**4. `Ctrl+P` jumps anywhere.** Don't memorize keybindings. Type the +Dashboards work the same way for scrolling: if the panel stack is +taller than your terminal, `j`/`k` scroll it and an indicator at the +bottom tells you how much is left. + +**5. `Ctrl+P` jumps anywhere.** Don't memorize keybindings. Type the view name (or any partial — `obj`, `sess`, `logs`) and Enter. Same muscle memory as VS Code's command palette. -**5. Two modal pickers.** +**6. Two modal pickers.** - `:` opens the connection picker — switch between saved firewalls. - `d` opens the device picker — switch between managed devices when you're connected to a Panorama. (On a standalone firewall, `d` falls through to the view's own handlers.) -**6. `?` toggles help.** `q` or `Ctrl+C` quits. +**7. `?` toggles help.** `q` or `Ctrl+C` quits. That's the whole navigation model. Everything else is a refinement. diff --git a/docs/keybindings.md b/docs/keybindings.md index 75c9c3f..756fdf5 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -2,9 +2,16 @@ ## Navigation model -Views are organized into three numbered groups. Press a number to switch -groups; press it again (or `Tab`) to cycle through views within the -current group. +Navigation has three levels, and each level has its own keys: + +| Level | Keys | Moves between | +|-------|-------------------|----------------------------------------| +| 1 | `1` / `2` / `3` | Groups — Monitor, Analyze, Tools | +| 2 | `Tab` / `Shift+Tab` | Views within the active group | +| 3 | `[` / `]` | Sub-tabs *within* a view | + +Level 1 — press a number to switch groups; press it again to cycle +through views in that group. | Key | Group | Views | |-----|---------|-------------------------------------------------------------------------------------| @@ -12,9 +19,24 @@ current group. | `2` | Analyze | Policies · NAT · Objects · Sessions · Interfaces · Routes · IPSec · GP Users · Logs | | `3` | Tools | Config | +Level 3 applies only to the views that have sub-tabs — Objects +(Address / Service), Routes (Routes / Neighbors) and Logs (System / +Traffic / Threat). `[` and `]` never leave the current view, and `Tab` +always does, in every view. + The header shows the group tabs on top and the sub-tabs for the active group underneath. +## Pasting + +Bracketed paste is supported wherever text is entered: the login form, +the connection form, the command palette, and any view's `/` filter +input while it is focused. Use your terminal's normal paste shortcut +(`Cmd+V` on macOS, `Ctrl+Shift+V` on most Linux terminals). + +Outside a text input, paste is ignored rather than being interpreted as +a burst of navigation keys. + ## Filter-mode guard (M8) When any view's `/` filter input is focused, **all keys except `Ctrl+C` @@ -31,6 +53,7 @@ Active only when a main view is displayed and no filter input is focused. | `1` / `2` / `3` | Switch (or cycle within) navigation group | | `Tab` | Next view in the current group | | `Shift+Tab` | Previous view in the current group | +| `[` / `]` | Previous / next sub-tab (views that have them) | | `Ctrl+P` | Command palette — fuzzy jump anywhere | | `:` | Connection picker (switch between firewalls) | | `d` | Device picker (Panorama only; falls through to view on standalone firewall) | @@ -53,6 +76,18 @@ Logs, Interfaces, …). | `Ctrl+U` / `PgUp` | Page up | | `Enter` | Toggle detail panel | +## Dashboard scrolling + +Dashboards render a stack of panels that is often taller than the +terminal. When it doesn't fit, the visible portion is windowed and a +scroll indicator appears at the bottom (`↓ 15 more j/k scroll`). + +The same keys as table navigation apply — `j`/`k`, `g`/`G`, +`Ctrl+D`/`Ctrl+U`, `PgDn`/`PgUp` — and they scroll whichever dashboard +is on screen. Switching dashboards resets the scroll position to the +top. A dashboard that already fits is never trimmed and shows no +indicator. + ## Filter | Key | Action | @@ -88,11 +123,11 @@ GP Users) and Logs. ### Objects (group 2) -| Key | Action | -|---------|---------------------------------------------------------------------| -| `Tab` | Cycle Address ↔ Service tab | -| `a` | Jump to Address tab | -| `s` | Jump to Service tab | +| Key | Action | +|-------------|-----------------------------------------------------------------| +| `[` / `]` | Cycle Address ↔ Service tab | +| `a` | Jump to Address tab | +| `s` | Jump to Service tab | | `S` | Cycle sort field for the active tab (always resets to ascending) | | `/` | Enter filter mode for the active tab | | `Enter` | Toggle detail panel for the selected object | @@ -221,5 +256,21 @@ While the delete confirmation is shown: | `Shift+Tab` | Previous field | | `Space` | Toggle insecure-skip-verify checkbox | | `Enter` | Submit (when all required fields are filled) | -| `Esc` | Return to Connection Hub; form buffers are cleared | +| `Esc` | Cancel; return to Connection Hub, clearing buffers | | `Ctrl+C` | Quit | + +While authenticating, the form shows a spinner and +`Authenticating…`, and the fields are frozen: + +| Key | Action | +|----------|---------------------------------------------------------------| +| `Enter` | **Ignored** — see below | +| `Esc` | Cancel the in-flight login and return to the Connection Hub | +| `Ctrl+C` | Quit | + +> [!IMPORTANT] +> Enter is deliberately ignored while a login is in flight. PAN-OS +> counts every keygen request as a login attempt, so repeatedly pressing +> Enter while waiting on an MFA push burns through the failed-attempt +> budget and locks the account out. Wait for the MFA prompt, or press +> `Esc` to cancel. diff --git a/docs/views/dashboard.md b/docs/views/dashboard.md index 44b34b2..bcc93d3 100644 --- a/docs/views/dashboard.md +++ b/docs/views/dashboard.md @@ -101,4 +101,23 @@ columns (left: statistics and pending changes; right: rule analysis). ## Keys Standard navigation applies — see [keybindings.md](../keybindings.md). -View-specific: none beyond cycling sub-views with `1` / `Tab`. +Cycle sub-views with `1` (or `3` for Config) and `Tab`. + +### Scrolling + +A dashboard's panel stack is frequently taller than the terminal. When +it doesn't fit, the visible portion is windowed and a scroll indicator +appears on the last line (`↓ 15 more j/k scroll`). + +| Key | Action | +|---------------------|----------------| +| `j` / `Down` | Scroll down | +| `k` / `Up` | Scroll up | +| `Ctrl+D` / `PgDn` | Page down | +| `Ctrl+U` / `PgUp` | Page up | +| `g` / `Home` | Jump to top | +| `G` / `End` | Jump to bottom | + +Scrolling applies to whichever dashboard is on screen, and switching +dashboards resets to the top. A dashboard that already fits its terminal +is never trimmed and shows no indicator. diff --git a/docs/views/interfaces.md b/docs/views/interfaces.md index e676e0b..aec7c17 100644 --- a/docs/views/interfaces.md +++ b/docs/views/interfaces.md @@ -37,7 +37,11 @@ type, virtual router. ## Detail panel (`enter`) -Two-column layout at ≥ 100 wide; single column otherwise. Sections: +Two-column layout at ≥ 100 wide; single column otherwise. Sections are +distributed across the two columns whole — a heading always stays with +its own fields — and the split is chosen to keep the columns close in +height, so which section lands in which column varies with the data. +Sections: - **Basic Information** — State (● UP / ○ DOWN), Type, Zone, Mode, Vsys (if set). diff --git a/docs/views/objects.md b/docs/views/objects.md index 097f9b7..700ce29 100644 --- a/docs/views/objects.md +++ b/docs/views/objects.md @@ -8,12 +8,17 @@ Two sub-tabs with independent filter, sort, cursor, and detail state: | Key | Action | |-----|--------| -| `Tab` | Cycle Address ↔ Service | +| `[` / `]` | Cycle Address ↔ Service | | `a` | Jump to Address tab | | `s` | Jump to Service tab | The header shows `[Address] Service` or `Address [Service]` with a -`(a/s/Tab to switch)` hint. +`([/] or a/s to switch)` hint. + +`[` and `]` are the sub-tab keys across every view that has sub-tabs +(see [Navigation model](../keybindings.md#navigation-model)). `Tab` is +reserved for moving to the next view in the Analyze group, so it leaves +Objects rather than switching tabs within it. ## Address tab