Skip to content

Commit 159d16d

Browse files
feat(ui): unified cursor, header nav, backend limits fallback
status pane: - Unified cursor (cursor_vrow) across all scrollable sections — arrow keys and mouse wheel move the highlight instead of raw-scrolling - Cursor can land on any line including section headers; Enter on a header collapses/expands it - Enter on a Workers section role opens the action menu for that role - Auto-scroll follows cursor with 1-row context - Backend Limits: daily/hourly falls back to global budget caps when no per-backend cap is configured (was showing 8/∞) git watcher: - Navigation (↑↓ and mouse wheel) now includes group headers - Enter on a header collapses/expands that group (was Enter-only-lazygit) - Header highlighted with SEL when selected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a634499 commit 159d16d

2 files changed

Lines changed: 107 additions & 33 deletions

File tree

src/operator_console/git_watcher.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ def _build_vbuf(
229229

230230
if vbuf: # no leading sep before the very first group
231231
vbuf.append(SEP)
232+
if item_idx == sel_item:
233+
sel_vrow = len(vbuf)
234+
attr = C["SEL"] | curses.A_BOLD
232235
vbuf.append((hdr_text[: w - 1], attr))
233236

234237
else:
@@ -370,6 +373,16 @@ def _watcher(stdscr, repos: list[str]) -> None:
370373
def nav_idxs() -> list[int]:
371374
return _navigable(items, collapsed_groups)
372375

376+
def all_nav_idxs() -> list[int]:
377+
"""All navigable indices: headers plus uncollapsed repos."""
378+
result = []
379+
for i, it in enumerate(items):
380+
if it["kind"] == "header":
381+
result.append(i)
382+
elif it["kind"] == "repo" and it["group"] not in collapsed_groups:
383+
result.append(i)
384+
return result
385+
373386
# start selection on first navigable repo
374387
sel_item = nav_idxs()[0] if nav_idxs() else 0
375388

@@ -378,9 +391,9 @@ def _current_group() -> str | None:
378391
return it["group"] if it and it["kind"] == "repo" else None
379392

380393
def _clamp_sel() -> None:
381-
"""After collapsing, move selection to nearest navigable repo."""
394+
"""After collapsing, move selection to nearest navigable item (header or repo)."""
382395
nonlocal sel_item
383-
nav = nav_idxs()
396+
nav = all_nav_idxs()
384397
if not nav:
385398
return
386399
if sel_item in nav:
@@ -447,13 +460,13 @@ def refresh_all() -> None:
447460
break
448461

449462
elif key == curses.KEY_UP:
450-
nav = nav_idxs()
463+
nav = all_nav_idxs()
451464
if nav:
452465
cur_pos = nav.index(sel_item) if sel_item in nav else 0
453466
sel_item = nav[max(0, cur_pos - 1)]
454467

455468
elif key == curses.KEY_DOWN:
456-
nav = nav_idxs()
469+
nav = all_nav_idxs()
457470
if nav:
458471
cur_pos = nav.index(sel_item) if sel_item in nav else -1
459472
sel_item = nav[min(len(nav) - 1, cur_pos + 1)]
@@ -507,11 +520,11 @@ def refresh_all() -> None:
507520
elif key == curses.KEY_MOUSE:
508521
try:
509522
_, _mx, _my, _mz, bstate = curses.getmouse()
510-
nav = nav_idxs()
511-
if nav and bstate & curses.BUTTON4_PRESSED: # wheel up → prev repo
523+
nav = all_nav_idxs()
524+
if nav and bstate & curses.BUTTON4_PRESSED: # wheel up → prev item
512525
cur_pos = nav.index(sel_item) if sel_item in nav else 0
513526
sel_item = nav[max(0, cur_pos - 1)]
514-
elif nav and bstate & curses.BUTTON5_PRESSED: # wheel down → next repo
527+
elif nav and bstate & curses.BUTTON5_PRESSED: # wheel down → next item
515528
cur_pos = nav.index(sel_item) if sel_item in nav else -1
516529
sel_item = nav[min(len(nav) - 1, cur_pos + 1)]
517530
except curses.error:
@@ -526,10 +539,19 @@ def refresh_all() -> None:
526539
hints_collapsed = not hints_collapsed
527540

528541
elif key in (curses.KEY_ENTER, 10, 13):
529-
if sel_item < len(items) and items[sel_item]["kind"] == "repo":
530-
repo = items[sel_item]["path"]
531-
curses.endwin()
532-
os.execvp("lazygit", ["lazygit", "-p", repo])
542+
if sel_item < len(items):
543+
it = items[sel_item]
544+
if it["kind"] == "header":
545+
label = it["label"]
546+
if label in collapsed_groups:
547+
collapsed_groups.discard(label)
548+
else:
549+
collapsed_groups.add(label)
550+
_clamp_sel()
551+
elif it["kind"] == "repo":
552+
repo = it["path"]
553+
curses.endwin()
554+
os.execvp("lazygit", ["lazygit", "-p", repo])
533555

534556

535557
def main() -> None:

src/operator_console/watcher_status_pane.py

Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)