@@ -956,6 +956,8 @@ def _build_sections(
956956 if caps or usage :
957957 bc_lines : list [tuple [str , int ]] = []
958958 bc_section_worst = C ["RUN" ]
959+ budget = data .get ("budget" , {})
960+ global_cap = {"max_per_hour" : budget .get ("hourly_cap" ), "max_per_day" : budget .get ("daily_cap" )}
959961 for backend in sorted (set (caps ) | set (usage )):
960962 bc = caps .get (backend , {})
961963 bu = usage .get (backend , {})
@@ -965,7 +967,7 @@ def _build_sections(
965967 ("Hourly" , "hourly" , "max_per_hour" ),
966968 ("Daily" , "daily" , "max_per_day" ),
967969 ):
968- limit = bc .get (cap_key )
970+ limit = bc .get (cap_key ) or global_cap . get ( cap_key )
969971 used = bu .get (used_key , 0 )
970972 if limit is not None :
971973 ratio = (used / limit ) if limit else 0.0
@@ -1254,7 +1256,8 @@ def _draw_main(
12541256 hints_collapsed : bool = True ,
12551257 top_scroll_offset : int = 0 ,
12561258 next_banner : tuple [str , str ] | None = None ,
1257- ) -> tuple [dict [str , tuple [int , int ]], dict [str , int ], int ]:
1259+ cursor_vrow : int = - 1 ,
1260+ ) -> tuple [dict [str , tuple [int , int ]], dict [str , int ], int , int , list , int ]:
12581261 """Render the main view with per-section scroll/collapse/size state.
12591262
12601263 Each top-level section (roles / active / recent / board / campaigns /
@@ -1264,11 +1267,15 @@ def _draw_main(
12641267 the cursor is over; click-on-header toggles the section's collapsed
12651268 state; ``+``/``-`` keys grow/shrink the focused section's allocation.
12661269
1267- Returns ``(section_rows, header_rows)``:
1270+ Returns ``(section_rows, header_rows, top_scroll_offset, total_buf_h, vbuf_meta, middle_h )``:
12681271 - ``section_rows[sid] = (start, end_exclusive)`` for hit-testing
12691272 wheel scrolls anywhere in the section
12701273 - ``header_rows[sid] = row`` of the section's header line for
12711274 hit-testing collapse-toggle clicks
1275+ - ``top_scroll_offset`` clamped scroll offset
1276+ - ``total_buf_h`` total virtual buffer height
1277+ - ``vbuf_meta`` list of ``(section_id, line_idx_in_section)`` per vbuf row
1278+ - ``middle_h`` height of the middle visible area
12721279 """
12731280 collapsed = collapsed or {}
12741281 size_mult = size_mult or {}
@@ -1405,17 +1412,20 @@ def _sec_height(sec: dict) -> int:
14051412 # scrolls it); content below the window flows under the bottom-
14061413 # anchored block, which renders on top afterward.
14071414 vbuf : list [tuple [str , int ]] = []
1415+ vbuf_meta : list [tuple [str , int ]] = [] # (section_id, line_idx_in_section)
14081416 section_buf_ranges : dict [str , tuple [int , int ]] = {}
14091417 for i , sec in enumerate (sections ):
14101418 if i > 0 :
14111419 vbuf .append ((_SEP_MARKER , C ["DIM" ]))
1420+ vbuf_meta .append (("" , - 1 ))
14121421 start_idx = len (vbuf )
14131422 sec_h = rows_per [i ]
14141423 for j in range (sec_h ):
14151424 if j < len (sec ["lines" ]):
14161425 vbuf .append (sec ["lines" ][j ])
14171426 else :
14181427 vbuf .append (("" , 0 ))
1428+ vbuf_meta .append ((sec ["id" ], j ))
14191429 section_buf_ranges [sec ["id" ]] = (start_idx , len (vbuf ))
14201430
14211431 middle_h = middle_bottom - middle_top
@@ -1440,6 +1450,8 @@ def _sec_height(sec: dict) -> int:
14401450 if text == _SEP_MARKER :
14411451 _put (stdscr , screen_row , h , w , "─" * (w - 1 ), attr )
14421452 else :
1453+ if buf_idx == cursor_vrow and buf_idx < len (vbuf_meta ) and vbuf_meta [buf_idx ][1 ] != - 1 :
1454+ attr = C ["SEL" ] | curses .A_BOLD
14431455 put (screen_row , text , attr )
14441456
14451457 # Map buffer indices to screen rows so click + wheel hit-testing works.
@@ -1515,7 +1527,7 @@ def _put_right(row: int, ch: str, attr: int) -> None:
15151527 if flash :
15161528 put (h - 3 - hint_h , f" { flash } " , C ["HEAD" ])
15171529 stdscr .refresh ()
1518- return section_rows , header_rows , top_scroll_offset
1530+ return section_rows , header_rows , top_scroll_offset , total_buf_h , vbuf_meta , middle_h
15191531
15201532
15211533# ── submenu view ──────────────────────────────────────────────────────────────
@@ -1671,7 +1683,12 @@ def _refresh_loop() -> None:
16711683 except curses .error :
16721684 pass # Terminal without mouse support — keyboard still works.
16731685
1674- role_sel = 0
1686+ cursor_vrow = 0
1687+ prev_cursor_vrow = - 1
1688+ total_buf_h = 0
1689+ vbuf_meta : list = []
1690+ middle_h = 1
1691+ action_role = ""
16751692 mode = "roles"
16761693 action_sel = 0
16771694 log_lines : list [str ] = []
@@ -1739,17 +1756,26 @@ def _refresh_loop() -> None:
17391756 current_banner = conditions [banner_index ]
17401757
17411758 if mode == "log" :
1742- _draw_log_view (stdscr , _ROLES [ role_sel ] , log_lines , C )
1759+ _draw_log_view (stdscr , action_role , log_lines , C )
17431760 elif mode == "action" :
1744- _draw_submenu (stdscr , _ROLES [ role_sel ] ,
1745- snap ["roles" ].get (_ROLES [ role_sel ] , {}), action_sel , C )
1761+ _draw_submenu (stdscr , action_role ,
1762+ snap ["roles" ].get (action_role , {}), action_sel , C )
17461763 else :
17471764 next_banner = (
17481765 conditions [(banner_index + 1 ) % len (conditions )]
17491766 if len (conditions ) > 1 else None
17501767 )
1751- section_rows , header_rows , top_scroll_offset = _draw_main (
1752- stdscr , snap , role_sel , refreshing , flash , C , section_offsets ,
1768+ # Auto-scroll to keep cursor_vrow visible.
1769+ if cursor_vrow != prev_cursor_vrow and middle_h > 0 :
1770+ if cursor_vrow - 1 < top_scroll_offset :
1771+ top_scroll_offset = max (0 , cursor_vrow - 1 )
1772+ elif cursor_vrow + 1 >= top_scroll_offset + middle_h :
1773+ top_scroll_offset = cursor_vrow + 1 - middle_h + 1
1774+ top_scroll_offset = max (0 , min (top_scroll_offset , max (0 , total_buf_h - middle_h )))
1775+ prev_cursor_vrow = cursor_vrow
1776+ (section_rows , header_rows , top_scroll_offset ,
1777+ total_buf_h , vbuf_meta , middle_h ) = _draw_main (
1778+ stdscr , snap , - 1 , refreshing , flash , C , section_offsets ,
17531779 collapsed = collapsed_sections ,
17541780 size_mult = size_mult ,
17551781 focused_section = focused_section ,
@@ -1760,7 +1786,9 @@ def _refresh_loop() -> None:
17601786 hints_collapsed = hints_collapsed ,
17611787 top_scroll_offset = top_scroll_offset ,
17621788 next_banner = next_banner ,
1789+ cursor_vrow = cursor_vrow ,
17631790 )
1791+ cursor_vrow = max (0 , min (cursor_vrow , total_buf_h - 1 ))
17641792
17651793 # Marquee + cycle bookkeeping. Banner always animates. Cycle to
17661794 # the next condition only when the current condition's full unit
@@ -1799,7 +1827,7 @@ def _refresh_loop() -> None:
17991827 mode = "roles"
18001828 elif key in (curses .KEY_ENTER , 10 , 13 ):
18011829 action = _ACTIONS [action_sel ]
1802- role = _ROLES [ role_sel ]
1830+ role = action_role
18031831 if action == "tail logs" :
18041832 msg = _do_tail (role )
18051833 flash = msg
@@ -1817,20 +1845,32 @@ def _refresh_loop() -> None:
18171845
18181846 else :
18191847 if key == curses .KEY_UP :
1820- role_sel = (role_sel - 1 ) % len (_ROLES )
1821- top_scroll_offset = max (0 , top_scroll_offset - 3 )
1848+ cursor_vrow = max (0 , cursor_vrow - 1 )
1849+ while cursor_vrow > 0 and vbuf_meta and vbuf_meta [cursor_vrow ][1 ] == - 1 :
1850+ cursor_vrow -= 1
18221851 elif key == curses .KEY_DOWN :
1823- role_sel = (role_sel + 1 ) % len (_ROLES )
1824- top_scroll_offset += 3 # clamped on next render
1852+ cursor_vrow = min (max (0 , total_buf_h - 1 ), cursor_vrow + 1 )
1853+ while cursor_vrow < total_buf_h - 1 and vbuf_meta and vbuf_meta [cursor_vrow ][1 ] == - 1 :
1854+ cursor_vrow += 1
18251855 elif key == curses .KEY_PPAGE :
18261856 # Scroll the top block up by ~10 lines.
1827- top_scroll_offset = max (0 , top_scroll_offset - 10 )
1857+ amount = max (1 , middle_h - 1 )
1858+ top_scroll_offset = max (0 , top_scroll_offset - amount )
1859+ cursor_vrow = max (0 , cursor_vrow - amount )
1860+ while cursor_vrow > 0 and vbuf_meta and vbuf_meta [cursor_vrow ][1 ] == - 1 :
1861+ cursor_vrow -= 1
18281862 elif key == curses .KEY_NPAGE :
1829- top_scroll_offset += 10 # clamped on next render
1863+ amount = max (1 , middle_h - 1 )
1864+ top_scroll_offset += amount # clamped on next render
1865+ cursor_vrow = min (max (0 , total_buf_h - 1 ), cursor_vrow + amount )
1866+ while cursor_vrow < total_buf_h - 1 and vbuf_meta and vbuf_meta [cursor_vrow ][1 ] == - 1 :
1867+ cursor_vrow += 1
18301868 elif key == curses .KEY_HOME :
18311869 top_scroll_offset = 0
1870+ cursor_vrow = 0
18321871 elif key == curses .KEY_END :
18331872 top_scroll_offset = 10_000 # clamped on next render
1873+ cursor_vrow = max (0 , total_buf_h - 1 )
18341874 elif key == ord ("+" ):
18351875 cur = size_mult .get (focused_section , 1.0 )
18361876 size_mult [focused_section ] = min (
@@ -1865,12 +1905,16 @@ def _refresh_loop() -> None:
18651905 _BOTTOM_IDS = {"system_resources" , "global_gate" , "global_rate" }
18661906 if bstate & (curses .BUTTON4_PRESSED | curses .BUTTON5_PRESSED ):
18671907 # Wheel anywhere in the top area scrolls the virtual
1868- # buffer; over a bottom-anchored section it's a no-op .
1908+ # buffer; over a bottom-anchored section moves cursor .
18691909 if target_section is None or target_section not in _BOTTOM_IDS :
18701910 if bstate & curses .BUTTON4_PRESSED :
1871- top_scroll_offset = max (0 , top_scroll_offset - 3 )
1911+ cursor_vrow = max (0 , cursor_vrow - 1 )
1912+ while cursor_vrow > 0 and vbuf_meta and vbuf_meta [cursor_vrow ][1 ] == - 1 :
1913+ cursor_vrow -= 1
18721914 elif bstate & curses .BUTTON5_PRESSED :
1873- top_scroll_offset += 3 # clamped next render
1915+ cursor_vrow = min (max (0 , total_buf_h - 1 ), cursor_vrow + 1 )
1916+ while cursor_vrow < total_buf_h - 1 and vbuf_meta and vbuf_meta [cursor_vrow ][1 ] == - 1 :
1917+ cursor_vrow += 1
18741918 elif target_section is not None :
18751919 focused_section = target_section
18761920 if bstate & curses .BUTTON1_PRESSED :
@@ -1880,8 +1924,16 @@ def _refresh_loop() -> None:
18801924 collapsed_sections .get (target_section , False )
18811925 )
18821926 elif key in (curses .KEY_ENTER , 10 , 13 ):
1883- mode = "action"
1884- action_sel = 0
1927+ if 0 <= cursor_vrow < len (vbuf_meta ):
1928+ sec_id , line_idx = vbuf_meta [cursor_vrow ]
1929+ if line_idx == 0 and sec_id : # header row
1930+ collapsed_sections [sec_id ] = not collapsed_sections .get (sec_id , False )
1931+ elif sec_id == "roles" and line_idx > 0 :
1932+ role_idx = line_idx - 1
1933+ if 0 <= role_idx < len (_ROLES ):
1934+ action_role = _ROLES [role_idx ]
1935+ mode = "action"
1936+ action_sel = 0
18851937 elif key in (ord ("?" ), ord ("/" )):
18861938 hints_collapsed = not hints_collapsed
18871939 elif key == ord ("x" ):
0 commit comments