diff --git a/README.md b/README.md index 3bf1ec06..18851b28 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,12 @@ The Projects tab (`2`) has three regions: a left **rail**, a middle **coordinato | `Cmd+↑` / `Cmd+↓` | Move the rail cursor up / down without changing the focused pane (the mod-7 escape sequence is consumed — the pane's PTY never sees it) | | `ctrl+q` | Return focus to the rail | -When a **worker** is selected the details region shows its live agent terminal. When a **coordinator** is selected it stacks a read-only roster of that orchestrator's roles — a compact table (status, name, diligence archetype, resolved model; ready-to-close/PR fold into the status cell), scrollable via `↑`/`↓`/`j`/`k` when it has more agents than the panel can show — over the embedded **plan DAG** — the planned + live worker roles laid out by their `hera_blocks` dependency order (the plan the coordinator authored over the `hera_plan*` MCP tools), with same-stage siblings auto-collapsed into parallel groups and a master-detail header above the diagram. Both render at once, no toggle; the plan graph is the interactive surface (a coordinator with no authored plan shows its live roles flat with a "no plan" hint): +When a **worker** is selected the details region shows its live agent terminal. When a **coordinator** is selected it stacks a read-only roster of that orchestrator's roles — a compact table (status, name, diligence archetype, resolved model; ready-to-close/PR fold into the status cell), scrollable via `PgUp`/`PgDn` when it has more agents than the panel can show — over the embedded **plan DAG** — the planned + live worker roles laid out by their `hera_blocks` dependency order (the plan the coordinator authored over the `hera_plan*` MCP tools), with same-stage siblings auto-collapsed into parallel groups and a master-detail header above the diagram. Both render at once, no toggle; the plan graph is the interactive surface (a coordinator with no authored plan shows its live roles flat with a "no plan" hint): | Key (plan DAG) | Action | | -------------------- | --------------------------------------------------------------------------------- | -| `↑` / `↓` / `j` / `k` | Scroll the Agents roster first when it has more agents than fit, then move between plan stages once the roster can't scroll further in that direction (collapses any fanned-out group on the way) | +| `↑` / `↓` / `j` / `k` | Move between plan stages (collapses any fanned-out group on the way) — always the plan graph's, never the Agents roster above it | +| `PgUp` / `PgDn` | Scroll the Agents roster when it has more agents than fit — its own dedicated keys, so they never compete with the plan graph's stage nav | | `←` / `→` / `h` / `l` | Move between slots; inside a fanned-out group, walk its members | | `Space` | Fan out / collapse a parallel group — a pure toggle that never opens a node (on a lone leaf it is a no-op; opening is `Enter`'s job) | | `Enter` | Fan out a collapsed group; on a fanned-out group **member**, a sub-coordinator node, or a plain leaf, open that node: drill into a sub-coordinator's child orchestrator's plan, else jump to that node's role within the Projects view (selects it in the rail + focuses its agent pane — no tab switch), reviving a dead/suspended session just like the rail's `Enter`. On a member it does **not** collapse the group — that's `Space` / `Esc` | diff --git a/context/knowledge/gotchas/hera-view.md b/context/knowledge/gotchas/hera-view.md index 3d88332f..1621c3f1 100644 --- a/context/knowledge/gotchas/hera-view.md +++ b/context/knowledge/gotchas/hera-view.md @@ -61,6 +61,7 @@ M6a scaffolds the native Hera view: a `HeraPage` (rail | coordinator pane | agen - **planview↔hera import direction is one-way: `hera` imports `planview`, NEVER the reverse.** The projection (`heraPlanNodes*`) and the drill-in resolution (`HeraPage.drillIntoChild` → `bridgeIndex()` → child `OrchView`) live in `hera` because they need the rail Model; `planview` is a generic widget that knows nothing about hera. The seam is the `OnDrillIn(id)` callback: planview fires it with the node id, the page resolves the child + reprojects + `PushOrch`. This is why the App wires `OnEnter` (jump-to-agent-view, App's concern) but the PAGE wires `OnDrillIn` in `NewHeraPage` (it needs hera internals planview can't reach). - **The widget is exposed via `HeraPage.Plan()`; the App wires only `OnEnter`** (jump to a leaf node's agent view) + `OnBranchChange`→`forceRedraw` (log-only, never Sync). Drill-in (`OnDrillIn`) is page-owned (above). Retitled `" Plan "` via `SetTitle`, full-rect coverage via the widget's own `DrawBorderedPanel`/header strip. `TestDetailsPlan_NoSyncOnDraw` pins no-Sync; `TestDetailsPlan_DrawStacksBothPanels` pins both `" Details "` and `" Plan "` render at once (and asserts no stray `" DAG "`/`"Orchestration Tree"`). Node colour comes from `RoleView.TaskStatus`/`TaskResult` (a `{"failed":true}` result → red `✕`, winning over the workflow status). - **A status step (`s`/`S`) / `ready_to_close` clear re-projects the plan in lock-step with the rail: `doRefresh`→`applySelection`→`rebuildPlan` runs the SAME refresh, but `planview.UpdateData` short-circuits on an unchanged `projectionSig` — so the sig MUST fold the node's resolved status ICON (glyph + Animated), not just `State.Glyph()` (BUG-012).** The plan node's `State` is task-derived (`TaskStatus`/`TaskResult`), but its ICON also carries the rail-parity glyph (`ready_to_close` ✓, hera role-status mark). A role-status step / `ready_to_close` clear changes the ICON while the task-derived State is unchanged (e.g. still `working`), so without the icon in the sig `UpdateData` no-ops and the DAG node renders a STALE ✓ while the rail already moved. The projected `Icon.Glyph` is a stable frame-0 placeholder (spinner frames re-resolve at Draw), so folding it never spams a reproject. Pinned by `planview.TestUpdateData_IconChangeReinstalls` + page-level `TestRefresh_StatusStepReprojectsPlanNode`. +- **The Agents roster's scroll keys were split OFF the plan widget's j/k/Up/Down/h/l nav onto PgUp/PgDn (`dag-arrow-key-focus-fix`).** The roster used to share the identical physical keys the plan widget binds for stage nav — "layered, not duplicated" — via `HeraPage.handleDetailsKey`/`rosterScrollDelta` claiming j/k/Up/Down FIRST whenever the roster had unseen rows, falling through to the plan widget only once the roster hit its scroll bound. That meant an operator navigating the embedded DAG with arrow keys instead silently scrolled the Agents roster above it — reported live as "arrow keys used to navigate the plan graph instead scroll the agent list" — for as long as the roster had any room left to scroll in that direction. Fix: `rosterScrollDelta` now maps ONLY `PgDn`/`PgUp`; every j/k/Up/Down/h/l keystroke reaches `planview.Widget.InputHandler` unconditionally, and the roster is scrollable only via its own dedicated keys, so the two surfaces can never contend for the same keypress. See `TestHandleDetailsKey_ArrowsAlwaysReachPlanNeverRoster` + `TestHandleDetailsKey_PgDnPgUpScrollRoster` + `TestFocusRouting_ArrowKeysStayWithinFocusedRegion`. ## Rail parity — nesting, coordinator fold, spinner, PR cell, cascade delete diff --git a/internal/tui/hera/dag_test.go b/internal/tui/hera/dag_test.go index acf4233e..cdc96c79 100644 --- a/internal/tui/hera/dag_test.go +++ b/internal/tui/hera/dag_test.go @@ -265,19 +265,81 @@ func TestDetailsPlan_KeyForwardsToWidget(t *testing.T) { testutil.Equal(t, p.Plan().CursorPos().Stage, 0) } -// TestHandleDetailsKey_ScrollsRosterBeforePlan: a coordinator with far more -// agents than a short pane can show must scroll the roster FIRST on -// j/k/Up/Down — the plan widget's own stage cursor must NOT move while the -// roster still has room to scroll. Only once the roster is fully scrolled -// does the SAME keystroke reach the plan widget again (ScrollRoster's false -// return falls through), so the two never fight over a keypress. -func TestHandleDetailsKey_ScrollsRosterBeforePlan(t *testing.T) { +// TestFocusRouting_ArrowKeysStayWithinFocusedRegion is the end-to-end +// regression guard for the arrow-key focus-routing fix, driven through the +// real SimulationScreen + full page InputHandler dispatch (planPage/ +// toAgentFocus): while the RAIL is focused, arrow-driven cursor nav and other +// rail keys (s/S status-step, etc.) behave exactly as before and never touch +// the plan widget; once focus moves to the Details/plan region (a coordinator +// selection), arrow keys drive the plan widget's stage cursor exclusively and +// never move the rail's cursor or the roster's scroll offset — closing the +// reported "arrow keys meant for the DAG instead move the rail/agent list" gap +// in both directions. +func TestFocusRouting_ArrowKeysStayWithinFocusedRegion(t *testing.T) { + p := planPage(t) + testutil.Equal(t, selectOrchByName(p, "orch"), true) + h := p.InputHandler() + + // -- Rail focus: existing nav + mutation keys are unaffected by the fix. -- + testutil.Equal(t, p.Machine().State(), FocusRail) + railCursorBefore := p.Rail().CursorIndex() + advanced := false + p.OnStatusAdvance = func(Selection) { advanced = true } + h(tcell.NewEventKey(tcell.KeyRune, 's', tcell.ModNone), noFocus) + testutil.Equal(t, advanced, true) // rail mutation key still fires + testutil.Equal(t, p.Plan().CursorPos().Stage, 0) // plan untouched by a rail-focused key + // Down still drives ordinary rail cursor nav (moves onto the orchestrator's + // planned-role rows) — unaffected by the fix, which only touches the + // Details/plan region's own key routing. + h(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), noFocus) + testutil.Equal(t, p.Rail().CursorIndex() != railCursorBefore, true) + testutil.Equal(t, p.Plan().CursorPos().Stage, 0) // still untouched + // Return the cursor to the coordinator header so the Details/plan region + // (below) is exercised against the coordinator selection. + h(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), noFocus) + testutil.Equal(t, p.Rail().CursorIndex(), railCursorBefore) + testutil.Equal(t, p.detailsMode, true) + + // -- Move focus onto the Details/plan region (Tab, then Ctrl+Alt+Right). -- + toAgentFocus(p) + testutil.Equal(t, p.Machine().State(), FocusAgent) + testutil.Equal(t, p.detailsMode, true) + + // Arrow keys move the plan's stage cursor and leave the rail's cursor + // (still parked on the coordinator header) and the roster's scroll offset + // untouched. + rosterBefore := p.details.rosterScroll + h(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), noFocus) + testutil.Equal(t, p.Plan().CursorPos().Stage, 1) + testutil.Equal(t, p.Rail().CursorIndex(), railCursorBefore) + testutil.Equal(t, p.details.rosterScroll, rosterBefore) + + h(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), noFocus) + testutil.Equal(t, p.Plan().CursorPos().Stage, 0) + testutil.Equal(t, p.Rail().CursorIndex(), railCursorBefore) + testutil.Equal(t, p.details.rosterScroll, rosterBefore) + + // Focus itself never moved off the Details/plan region during any of this. + testutil.Equal(t, p.Machine().State(), FocusAgent) +} + +// TestHandleDetailsKey_ArrowsAlwaysReachPlanNeverRoster: a coordinator with far +// more agents than a short pane can show must NOT have its roster steal +// j/k/Up/Down from the embedded plan widget (BUG: reported "arrow keys used to +// navigate the DAG instead scroll the agent roster"). Those keys are the plan +// widget's own stage-nav keys, so they must reach it unconditionally and the +// roster's scroll offset must never move in response to them, regardless of +// how many agents overflow the roster panel. +func TestHandleDetailsKey_ArrowsAlwaysReachPlanNeverRoster(t *testing.T) { d := memDB(t) orch := seedOrch(t, d, "big-orch") seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord") for i := 1; i <= 20; i++ { seedBoundRole(t, d, orch, fmt.Sprintf("agent-%02d", i), db.HeraKindWorker, fmt.Sprintf("t-w%02d", i)) } + a := seedPlannedRole(t, d, orch, "1a-research") + b := seedPlannedRole(t, d, orch, "2a-write") + testutil.NoError(t, d.AddHeraBlock(b.ID, a.ID)) // 2a←1a, so the plan has 2 stages to move between p := NewHeraPage(d) p.SetSessionResolver(resolverFor(map[string]*fakeSession{"t-coord": {id: "t-coord", alive: true}})) p.Refresh() @@ -297,13 +359,60 @@ func TestHandleDetailsKey_ScrollsRosterBeforePlan(t *testing.T) { toAgentFocus(p) p.Draw(sim) // populate DetailsView.rosterVisibleRows for this selection/size + h := p.InputHandler() + planStage := func() int { return p.Plan().CursorPos().Stage } + startStage := planStage() + testutil.Equal(t, startStage, 0) + for _, key := range []tcell.Key{tcell.KeyDown, tcell.KeyDown, tcell.KeyUp} { + before := p.details.rosterScroll + h(tcell.NewEventKey(key, 0, tcell.ModNone), noFocus) + testutil.Equal(t, p.details.rosterScroll, before) // roster never moves on an arrow key + p.Draw(sim) + } + // j moved the plan cursor down a stage, k moved it back — the roster's + // scroll offset stayed put throughout. + rosterBefore := p.details.rosterScroll + h(tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone), noFocus) + testutil.Equal(t, planStage(), 1) + testutil.Equal(t, p.details.rosterScroll, rosterBefore) + h(tcell.NewEventKey(tcell.KeyRune, 'k', tcell.ModNone), noFocus) + testutil.Equal(t, planStage(), startStage) + testutil.Equal(t, p.details.rosterScroll, rosterBefore) +} + +// TestHandleDetailsKey_PgDnPgUpScrollRoster: PgDn/PgUp are the roster's +// dedicated scroll keys (moved off j/k/Up/Down, which now belong exclusively +// to the plan widget — see TestHandleDetailsKey_ArrowsAlwaysReachPlanNeverRoster). +// The plan widget's stage cursor must not move in response to them. +func TestHandleDetailsKey_PgDnPgUpScrollRoster(t *testing.T) { + d := memDB(t) + orch := seedOrch(t, d, "big-orch") + seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord") + for i := 1; i <= 20; i++ { + seedBoundRole(t, d, orch, fmt.Sprintf("agent-%02d", i), db.HeraKindWorker, fmt.Sprintf("t-w%02d", i)) + } + p := NewHeraPage(d) + p.SetSessionResolver(resolverFor(map[string]*fakeSession{"t-coord": {id: "t-coord", alive: true}})) + p.Refresh() + + sim := tcell.NewSimulationScreen("UTF-8") + testutil.NoError(t, sim.Init()) + t.Cleanup(sim.Fini) + sim.SetSize(80, 50) + p.SetRect(0, 0, 80, 50) + p.Draw(sim) + + testutil.Equal(t, selectOrchByName(p, "big-orch"), true) + toAgentFocus(p) + p.Draw(sim) + h := p.InputHandler() planStage := func() int { return p.Plan().CursorPos().Stage } startStage := planStage() scrolledRoster := false for i := 0; i < 30; i++ { // far more than needed; ScrollRoster clamps at the bound before := p.details.rosterScroll - h(tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone), noFocus) + h(tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone), noFocus) if p.details.rosterScroll != before { scrolledRoster = true } @@ -311,10 +420,13 @@ func TestHandleDetailsKey_ScrollsRosterBeforePlan(t *testing.T) { } testutil.Equal(t, scrolledRoster, true) testutil.Equal(t, p.details.rosterScroll, p.details.rosterMaxScroll(len(p.details.workers()))) - // This fixture has no authored plan (no planned nodes, no edges) — the - // plan widget's degenerate empty-plan stage never moves regardless, so - // the assertion that matters is that scrolling the roster didn't need the - // plan widget's cursor to change AT ALL. + testutil.Equal(t, planStage(), startStage) // the plan cursor never moved + + for i := 0; i < 30; i++ { + h(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone), noFocus) + p.Draw(sim) + } + testutil.Equal(t, p.details.rosterScroll, 0) testutil.Equal(t, planStage(), startStage) } diff --git a/internal/tui/hera/details.go b/internal/tui/hera/details.go index cd72b474..69d4828c 100644 --- a/internal/tui/hera/details.go +++ b/internal/tui/hera/details.go @@ -245,9 +245,9 @@ func (d *DetailsView) Draw(screen tcell.Screen, x, y, w, h int, focused bool) { // rendered as an aligned, SCROLLABLE table: status (icon + label), name, // diligence archetype, resolved model. When there are more agents than // the remaining row budget affords, only a window starting at - // d.rosterScroll renders — j/k/Up/Down (routed by HeraPage.handleDetailsKey - // before the embedded plan widget's own nav) move the window so every - // agent stays reachable instead of the tail being silently cut off. + // d.rosterScroll renders — PgUp/PgDn (routed by HeraPage.handleDetailsKey, + // its OWN dedicated keys — see that function's doc) move the window so + // every agent stays reachable instead of the tail being silently cut off. workers := d.workers() draw(inner.X, fmt.Sprintf("Agents (%d):", len(workers)), theme.StyleDimmed) if len(workers) == 0 { @@ -361,11 +361,11 @@ func (d *DetailsView) clampRosterScroll(total int) { // ScrollRoster moves the roster's scroll offset by delta rows (+1 down, -1 // up), clamped to [0, maxScroll]. Returns false — a no-op — when the roster -// is already at the requested bound, or hasn't been drawn yet (rosterVisibleRows -// still zero): the caller (HeraPage.handleDetailsKey) falls through to the -// embedded plan widget's own navigation in that case, so j/k/Up/Down scroll -// the roster first and only reach the plan once the roster can't move -// further in that direction — the two never fight over the same keystroke. +// is already at the requested bound, or hasn't been drawn yet +// (rosterVisibleRows still zero). Driven exclusively by PgUp/PgDn +// (HeraPage.rosterScrollDelta) — disjoint from the plan widget's j/k/Up/Down/ +// h/l stage-and-slot nav keys, so the roster and the plan graph never contend +// for the same keystroke (see HeraPage.handleDetailsKey). func (d *DetailsView) ScrollRoster(delta int) bool { if d.orch == nil || d.rosterVisibleRows <= 0 { return false diff --git a/internal/tui/hera/details_test.go b/internal/tui/hera/details_test.go index 13ce3af7..4966fb70 100644 --- a/internal/tui/hera/details_test.go +++ b/internal/tui/hera/details_test.go @@ -820,25 +820,28 @@ func TestDetails_ClampRosterScrollAboveMax(t *testing.T) { testutil.Equal(t, d.rosterScroll < 100, true) } +// TestRosterScrollDelta pins the roster's OWN scroll keyset — PgDn/PgUp — +// deliberately disjoint from the plan widget's j/k/Up/Down/h/l stage-and-slot +// nav keys, so the two surfaces never contend for the same keystroke (a +// reported bug: arrow keys aimed at the DAG graph were being consumed by the +// roster instead). See HeraPage.handleDetailsKey. func TestRosterScrollDelta(t *testing.T) { - down, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) + down, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone)) testutil.Equal(t, ok, true) testutil.Equal(t, down, 1) - up, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) + up, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone)) testutil.Equal(t, ok, true) testutil.Equal(t, up, -1) - jDown, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone)) - testutil.Equal(t, ok, true) - testutil.Equal(t, jDown, 1) - - kUp, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyRune, 'k', tcell.ModNone)) - testutil.Equal(t, ok, true) - testutil.Equal(t, kUp, -1) - - // Any other key (h/l, Enter, Esc, an unrelated rune) is not a scroll key. + // Every plan-widget nav key (arrows, j/k/h/l, Enter, Esc) and any other + // rune is NOT a roster scroll key — they must reach the plan widget + // unconditionally, never the roster. for _, ev := range []*tcell.EventKey{ + tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), + tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), + tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone), + tcell.NewEventKey(tcell.KeyRune, 'k', tcell.ModNone), tcell.NewEventKey(tcell.KeyRune, 'h', tcell.ModNone), tcell.NewEventKey(tcell.KeyRune, 'l', tcell.ModNone), tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone), diff --git a/internal/tui/hera/page.go b/internal/tui/hera/page.go index a42df938..80329da0 100644 --- a/internal/tui/hera/page.go +++ b/internal/tui/hera/page.go @@ -879,17 +879,17 @@ func (p *HeraPage) terminalPaneFocused() bool { // handleDetailsKey routes keys for a focused Details region (coordinator // selected). The region stacks the read-only roster over the embedded plan -// graph. j/k/Up/Down scroll the roster FIRST when it has more agents than fit -// (DetailsView.ScrollRoster) — the SAME physical keys the plan widget already -// binds for stage nav, layered rather than duplicated: once the roster can't -// move further in the requested direction (or never needed to scroll), -// ScrollRoster returns false and the key falls through to the plan widget -// unchanged, so the two never fight over a keystroke. Every other key (h/l, -// Enter/Space, Esc) always goes straight to the plan widget, the only OTHER -// interactive surface: nav, Enter/Space (fan-out/collapse a group, drill into -// a sub-coordinator, or jump to a leaf's agent view via the wired OnEnter -// callback), and Esc ("back out one level": un-fan a fanned group → drill out -// → root no-op). +// graph, and the plan graph is the ONLY interactive surface here — so j/k/ +// Up/Down/h/l/Enter/Space/Esc all go straight to it, unconditionally. The +// roster scrolls on its OWN dedicated keys (PgUp/PgDn, rosterScrollDelta) +// instead of sharing the plan widget's stage-nav keys — a prior "layered, not +// duplicated" design let the roster claim j/k/Up/Down FIRST whenever it had +// unseen rows, so arrow keys aimed at the DAG graph silently scrolled the +// agent roster instead until the roster ran out of room to scroll (reported: +// "pressing Up/Down while trying to navigate the DAG instead scrolls the +// agent list"). Splitting the keysets removes the contention entirely: arrows +// always drive the DAG, PgUp/PgDn always drive the roster, and neither can +// ever steal the other's keystroke. See gotchas/hera-view.md. // // Esc is ALWAYS forwarded to the widget, which CONSUMES it in every case (see // Widget.EscBack); it never jumps to the rail. The operator leaves the pane via @@ -898,29 +898,24 @@ func (p *HeraPage) terminalPaneFocused() bool { // swallow Esc at the root.) The global handler reserves only 1/2/3/q/? — see // gotchas/keybindings.md. func (p *HeraPage) handleDetailsKey(event *tcell.EventKey, setFocus func(tview.Primitive)) { - if delta, ok := rosterScrollDelta(event); ok && p.details.ScrollRoster(delta) { + if delta, ok := rosterScrollDelta(event); ok { + p.details.ScrollRoster(delta) return } p.plan.InputHandler()(event, setFocus) } // rosterScrollDelta maps a key event to a roster-scroll direction (+1 down, -// -1 up) using the same j/k/Up/Down keys already bound for plan-stage nav in -// this region. Any other key (h/l, Enter, Space, Esc, …) is not a scroll key -// and always falls through to the plan widget. +// -1 up). PgDn/PgUp are the roster's OWN keys, deliberately disjoint from the +// plan widget's j/k/Up/Down/h/l stage-and-slot nav (see handleDetailsKey) so +// the two surfaces never contend for the same keystroke. Any other key is not +// a scroll key and always falls through to the plan widget. func rosterScrollDelta(event *tcell.EventKey) (int, bool) { switch event.Key() { - case tcell.KeyDown: + case tcell.KeyPgDn: return 1, true - case tcell.KeyUp: + case tcell.KeyPgUp: return -1, true - case tcell.KeyRune: - switch event.Rune() { - case 'j': - return 1, true - case 'k': - return -1, true - } } return 0, false }