From 39ec13d6c2153d4f9d8a3772c17bb80e46615e51 Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 9 Oct 2026 15:51:18 -0400 Subject: [PATCH 1/2] Scroll a dashboard taller than the pane, with a scrollbar Watch mode draws on the full-screen view, which has no scrollback, so rows past the pane were cut off. up/down (keys.scroll_up, keys.scroll_down) scroll one row; the mouse wheel sends those keys on the full-screen view, and devdash turns on alternate scroll mode (DEC 1007) while it runs. A scrollbar in the rightmost column shows the visible part; content lays out one column narrower beside it. --- CHANGELOG.md | 3 ++ README.md | 22 +++++++++++---- config.example.toml | 2 ++ devdash.py | 66 +++++++++++++++++++++++++++++++++++-------- tests/test_devdash.py | 37 ++++++++++++++++++++++-- 5 files changed, 109 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7befa3b..067b1eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All notable changes to this project are documented here. The format follows both with and without a scheduled interval. - Configurable, case-sensitive built-in keys and per-integration actions, with private state files in watch mode, per-section key hints, and fetch countdowns on section headings. +- A dashboard taller than the pane scrolls with `up`/`down` (config: `keys.scroll_up`, + `keys.scroll_down`) and with the mouse wheel through alternate scroll mode, and shows a + scrollbar, instead of cutting the bottom off. - `--profile NAME` selects the matching isolated omp profile for usage and cached readings; only that profile's authenticated providers appear. Provider headings include that provider's account email in white, for example `Codex (user@example.com)`. diff --git a/README.md b/README.md index 896d45f..10dff9c 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ interval = 60 # seconds between refreshes refresh = "r" # "" disables a built-in; Ctrl-C always quits quit = "q" focus = "tab" +scroll_up = "up" # scroll when the dashboard is taller than the pane +scroll_down = "down" [github] exclude = ["acme/monorepo", "some-org"] # "owner/repo", or "owner" for all of its repositories @@ -203,10 +205,10 @@ Integrations that share a position appear in the order they are listed in the fi ### Keys, actions, focus, and state -In watch mode, `[keys]` configures the built-in `refresh`, `quit`, and `focus` keys. An empty -string disables a built-in. Key names are case-sensitive: one printable ASCII character other -than space, or `up`, `down`, `left`, `right`, `enter`, or `tab`. Arrow keys and Enter/Tab are -decoded by devdash; Ctrl-C always quits. +In watch mode, `[keys]` configures the built-in `refresh`, `quit`, `focus`, `scroll_up`, and +`scroll_down` keys. An empty string disables a built-in. Key names are case-sensitive: one +printable ASCII character other than space, or `up`, `down`, `left`, `right`, `enter`, or `tab`. +Arrow keys and Enter/Tab are decoded by devdash; Ctrl-C always quits. An integration can declare `keys = { j = "newer", k = "older" }`, mapping keys to action names that the integration itself understands. Bindings are case-sensitive; they cannot use a key @@ -215,8 +217,16 @@ integrations can use the same binding; only the focused one receives it. Focus s first integration with bindings in screen order, and the configured focus key cycles through those integrations. Each section lists its own keys on its last row: an integration's bindings in config order under its output, and the refresh key under the last PR section. The bottom row lists -the keys for the whole dashboard: quit, and the focus key when two or more integrations are -focusable. The focused section's heading starts with `▸ `. +the keys for the whole dashboard: quit, the focus key when two or more integrations are +focusable, and the scroll keys when the dashboard is taller than the pane. The focused section's +heading starts with `▸ `. + +Watch mode draws on the terminal's full-screen view, which has no scrollback, so a dashboard taller +than the pane scrolls inside devdash: each scroll key press moves it one row, and a scrollbar in the +rightmost column shows which part is on screen. On the full-screen view, most +terminals turn mouse-wheel scrolls into arrow keys (devdash also turns on the alternate scroll mode, +DEC mode 1007, while it runs), so the wheel scrolls the dashboard with the default bindings. +`--once` prints every row. Section headings show how long ago the last result was fetched and count down to the next fetch, updating every second. Integrations use their own intervals; MY PRS, REVIEW REQUESTED, and USAGE diff --git a/config.example.toml b/config.example.toml index 4041454..bf6112d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,6 +10,8 @@ interval = 60 refresh = "r" quit = "q" focus = "tab" +scroll_up = "up" # scroll a dashboard taller than the pane; the mouse wheel sends these too +scroll_down = "down" [github] # Repositories to hide from both PR sections. An entry is "owner/repo" for one repository, diff --git a/devdash.py b/devdash.py index 74341e3..c58dd75 100755 --- a/devdash.py +++ b/devdash.py @@ -123,7 +123,7 @@ def line(*parts): "interval": 60, "github": {"exclude": [], "review_requested": True, "account": ""}, "usage": {"enabled": "auto", "refetch_after": 180, "pace": True, "order": [], "names": {}, "colors": {}}, - "keys": {"refresh": "r", "quit": "q", "focus": "tab"}, + "keys": {"refresh": "r", "quit": "q", "focus": "tab", "scroll_up": "up", "scroll_down": "down"}, "integrations": [], } REPO_RULE = re.compile(r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$") @@ -137,7 +137,7 @@ def valid_key_name(key): def check_builtin_keys(keys): enabled = {} - for name in ("refresh", "quit", "focus"): + for name in DEFAULTS["keys"]: key = keys[name] if not isinstance(key, str): raise SystemExit(f"devdash: config key 'keys.{name}' must be a key name or an empty string") @@ -1350,6 +1350,10 @@ def dispatch_key(st, key): result = "refresh" elif key == st.keys["focus"] and st.keys["focus"]: focus_next(st) + elif key == st.keys["scroll_up"] and st.keys["scroll_up"]: + st.scroll = max(0, st.scroll - 1) + elif key == st.keys["scroll_down"] and st.keys["scroll_down"]: + st.scroll += 1 # Dashboard clamps it to the content at the next render elif st.focus is not None and key in st.focus.keys: st.focus.queue_action(st.focus.keys[key]) if st.live is not None: @@ -1394,6 +1398,7 @@ class State: width = 80 # the pane width at the last render, passed to integrations as COLUMNS keys = DEFAULTS["keys"] focus = None + scroll = 0 # rows hidden above the window when the dashboard is taller than the pane live = None global_next_fetch = None prs_fetching = False @@ -1410,15 +1415,39 @@ def __rich_console__(self, console, options): now = time.time() monotonic_now = time.monotonic() st = self.st + keys = st.keys + room = max(1, (options.height or console.height) - 2) width = options.max_width + rows = self.rows(width, now, monotonic_now) + overflow = len(rows) > room + if overflow: # lay out one column narrower to make room for the scrollbar + width -= 1 + rows = self.rows(width, now, monotonic_now) if width != st.width: # integrations lay out for the new width now, not at their next run st.width = width for ig in st.integrations: ig.request_refresh(after_current=True) + hints = [f"{keys['quit']} quit"] if keys["quit"] else [] + if len(focusable_integrations(st)) >= 2 and keys["focus"]: + hints.append(f"{keys['focus']} focus") + scroll_keys = "/".join(k for k in (keys["scroll_up"], keys["scroll_down"]) if k) + if overflow and scroll_keys: + hints.append(f"{scroll_keys} scroll") + if overflow: + st.scroll = max(0, min(st.scroll, len(rows) - room)) + rows = [with_bar(row, width, bar) + for row, bar in zip(rows[st.scroll:st.scroll + room], + scrollbar(room, len(rows), st.scroll), strict=True)] + else: + st.scroll = 0 + yield Group(*rows, Text(), line((" · ".join(hints), META))) + + def rows(self, width, now, monotonic_now): + st = self.st + keys = st.keys slot = {p: [render_integration(ig, now, ig is st.focus, monotonic_now) for ig in st.integrations if ig.position == p] for p in POSITIONS} - keys = st.keys mine = render_mine(st, width, now, monotonic_now) review = render_review(st, width, now, monotonic_now) if st.show_review else None if keys["refresh"]: # the hint sits under the last PR section @@ -1429,15 +1458,22 @@ def __rich_console__(self, console, options): rows = list(sections[0]) for rows_of in sections[1:]: rows += [Text(), rule(width), *rows_of] - hints = [f"{keys['quit']} quit"] if keys["quit"] else [] - if len(focusable_integrations(st)) >= 2 and keys["focus"]: - hints.append(f"{keys['focus']} focus") - foot = " · ".join(hints) - height = options.height or console.height - if len(rows) > height - 2: - hidden = len(rows) - (height - 3) - rows = rows[: height - 3] + [line((f"… {hidden} more rows", META))] - yield Group(*rows, Text(), line((foot, META))) + return rows + + +def scrollbar(room, total, scroll): + """One character per visible row: a thumb sized and placed by the visible share of `total`.""" + thumb = max(1, room * room // total) + start = round(scroll * (room - thumb) / (total - room)) + return ["█" if start <= i < start + thumb else "│" for i in range(room)] + + +def with_bar(row, width, bar): + """`row` cut or padded to `width` cells, then the scrollbar character.""" + row = row.copy() + row.truncate(width, overflow="ellipsis", pad=True) + row.append(bar, RULE if bar == "│" else META) + return row def refresh(st, usage_every, force): @@ -1607,6 +1643,10 @@ def watch(st, usage_every): fd = sys.stdin.fileno() saved = termios.tcgetattr(fd) tty.setcbreak(fd) + # Alternate scroll mode: the mouse wheel sends arrow keys on the full-screen view, which + # has no scrollback, so the wheel scrolls the dashboard. Save the mode and restore it on exit. + CONSOLE.file.write("\x1b[?1007s\x1b[?1007h") + CONSOLE.file.flush() try: with integration_state_files(st): focusable = focusable_integrations(st) @@ -1670,6 +1710,8 @@ def watch(st, usage_every): ig.stop() finally: termios.tcsetattr(fd, termios.TCSADRAIN, saved) + CONSOLE.file.write("\x1b[?1007r") + CONSOLE.file.flush() if __name__ == "__main__": diff --git a/tests/test_devdash.py b/tests/test_devdash.py index c3c7e95..f0bcaed 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -79,7 +79,7 @@ def test_config_rejects_mistakes(tmp_path, text): def test_key_config_accepts_case_sensitive_names_and_records_an_empty_table(tmp_path): cfg = load(tmp_path, '[keys]\nrefresh = "R"\nquit = ""\nfocus = "tab"\n' '[[integrations]]\nname = "t"\ncommand = ["date"]\nkeys = {}\n') - assert cfg["keys"] == {"refresh": "R", "quit": "", "focus": "tab"} + assert cfg["keys"] == {"refresh": "R", "quit": "", "focus": "tab", "scroll_up": "up", "scroll_down": "down"} assert cfg["integrations"][0]["keys"] == {} assert cfg["integrations"][0]["has_keys"] is True @@ -766,7 +766,7 @@ def test_focus_cycles_in_screen_order_and_dispatches_only_to_focused_binding(): bottom = integration("pass", name="bottom", position="bottom", keys={"j": "older"}) top = integration("pass", name="top", position="top", keys={"k": "newer"}) st = devdash.State() - st.keys = {"refresh": "r", "quit": "q", "focus": "tab"} + st.keys = {**devdash.DEFAULTS["keys"], "refresh": "r", "quit": "q", "focus": "tab"} st.integrations = [bottom, top] # config order differs from screen order focusable = devdash.focusable_integrations(st) assert focusable == [top, bottom] @@ -786,7 +786,7 @@ def test_each_section_lists_its_own_keys_and_footer_holds_global_keys(): bottom = integration("pass", name="bottom", position="bottom", keys={"x": "other"}) st = devdash.State() st.show_usage = False - st.keys = {"refresh": "R", "quit": "q", "focus": "tab"} + st.keys = {**devdash.DEFAULTS["keys"], "refresh": "R", "quit": "q", "focus": "tab"} st.integrations = [bottom, top] st.focus = top console = Console(record=True, width=120) @@ -801,6 +801,37 @@ def test_each_section_lists_its_own_keys_and_footer_holds_global_keys(): assert lines[-1] == "q quit · tab focus" +def test_a_dashboard_taller_than_the_pane_scrolls_with_its_keys_within_bounds(): + st = devdash.State() + st.show_usage = st.show_review = False + st.integrations = [integration("pass")] + st.integrations[0].result = ([devdash.line(f"row {i}") for i in range(30)], 0, None) + + def frame(): + console = Console(record=True, width=80, height=12) + console.print(devdash.Dashboard(st), height=12) + return console.export_text().splitlines() + + def bar(rows): + return "".join(row[79] for row in rows[:10]) + + top = frame() + assert len(top) == 12 and top[-1].rstrip() == "q quit · up/down scroll" + assert top[0].startswith("MY PRS") and bar(top) == "██││││││││" # 10 of 35 rows visible + assert st.width == 79 # content and integrations lay out beside the bar + devdash.dispatch_key(st, "down") + assert frame()[0].startswith("r refresh") + for _ in range(100): + devdash.dispatch_key(st, "down") + bottom = frame() + assert bottom[9].rstrip()[:-1].rstrip() == "row 29" and bar(bottom) == "││││││││██" + devdash.dispatch_key(st, "up") # clamped at the end, so one step up moves at once + assert frame()[9].startswith("row 28") + for _ in range(100): + devdash.dispatch_key(st, "up") + assert frame() == top + + def test_refresh_hint_follows_my_prs_when_review_is_hidden_and_focus_needs_two(): ig = integration("pass", keys={"j": "newer"}) st = devdash.State() From e0d047b878eae32fe8d733422024f4b678404777 Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 9 Oct 2026 16:07:50 -0400 Subject: [PATCH 2/2] Harden scrolling: one scroll snapshot per frame, uncropped --once, guarded mode 1007 Render reads st.scroll once and re-checks overflow after the narrow layout, so a key press or data change between steps cannot misalign the scrollbar. --once renders without a viewport instead of a 10,000-row height. Mode 1007 is written only to a terminal, inside the cleanup scope, and restored even if restoring stdin fails. --- devdash.py | 38 +++++++++++++++++++++++--------------- tests/test_devdash.py | 4 ++++ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/devdash.py b/devdash.py index c58dd75..ba0b0d2 100755 --- a/devdash.py +++ b/devdash.py @@ -1406,10 +1406,13 @@ class State: class Dashboard: - """Rebuilt on every Live refresh, so resizes and countdowns stay current.""" + """Rebuilt on every Live refresh, so resizes and countdowns stay current. - def __init__(self, st): + With `window`, a dashboard taller than the pane shows the rows at `st.scroll` beside a scrollbar.""" + + def __init__(self, st, window=True): self.st = st + self.window = window def __rich_console__(self, console, options): now = time.time() @@ -1419,10 +1422,10 @@ def __rich_console__(self, console, options): room = max(1, (options.height or console.height) - 2) width = options.max_width rows = self.rows(width, now, monotonic_now) - overflow = len(rows) > room - if overflow: # lay out one column narrower to make room for the scrollbar + if self.window and len(rows) > room: # lay out one column narrower to make room for the scrollbar width -= 1 rows = self.rows(width, now, monotonic_now) + overflow = self.window and len(rows) > room # data can change between the two layouts if width != st.width: # integrations lay out for the new width now, not at their next run st.width = width for ig in st.integrations: @@ -1433,13 +1436,13 @@ def __rich_console__(self, console, options): scroll_keys = "/".join(k for k in (keys["scroll_up"], keys["scroll_down"]) if k) if overflow and scroll_keys: hints.append(f"{scroll_keys} scroll") + # The key loop changes st.scroll on another thread: read it once, clamp it, and use only that. + scroll = max(0, min(st.scroll, len(rows) - room)) if overflow else 0 + st.scroll = scroll if overflow: - st.scroll = max(0, min(st.scroll, len(rows) - room)) rows = [with_bar(row, width, bar) - for row, bar in zip(rows[st.scroll:st.scroll + room], - scrollbar(room, len(rows), st.scroll), strict=True)] - else: - st.scroll = 0 + for row, bar in zip(rows[scroll:scroll + room], + scrollbar(room, len(rows), scroll), strict=True)] yield Group(*rows, Text(), line((" · ".join(hints), META))) def rows(self, width, now, monotonic_now): @@ -1636,7 +1639,7 @@ def print_once(st, usage_every): job.result() finally: runner.shutdown(wait=False) # on an early exit, main stops the commands these workers wait on - CONSOLE.print(Dashboard(st), height=10_000) + CONSOLE.print(Dashboard(st, window=False)) def watch(st, usage_every): @@ -1645,9 +1648,11 @@ def watch(st, usage_every): tty.setcbreak(fd) # Alternate scroll mode: the mouse wheel sends arrow keys on the full-screen view, which # has no scrollback, so the wheel scrolls the dashboard. Save the mode and restore it on exit. - CONSOLE.file.write("\x1b[?1007s\x1b[?1007h") - CONSOLE.file.flush() + scroll_mode = CONSOLE.is_terminal try: + if scroll_mode: + CONSOLE.file.write("\x1b[?1007s\x1b[?1007h") + CONSOLE.file.flush() with integration_state_files(st): focusable = focusable_integrations(st) st.focus = focusable[0] if focusable else None @@ -1709,9 +1714,12 @@ def watch(st, usage_every): for ig in st.integrations: ig.stop() finally: - termios.tcsetattr(fd, termios.TCSADRAIN, saved) - CONSOLE.file.write("\x1b[?1007r") - CONSOLE.file.flush() + try: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + finally: + if scroll_mode: + CONSOLE.file.write("\x1b[?1007r") + CONSOLE.file.flush() if __name__ == "__main__": diff --git a/tests/test_devdash.py b/tests/test_devdash.py index f0bcaed..dcee816 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -830,6 +830,10 @@ def bar(rows): for _ in range(100): devdash.dispatch_key(st, "up") assert frame() == top + once = Console(record=True, width=80, height=12) + once.print(devdash.Dashboard(st, window=False)) # --once prints every row, with no scrollbar + printed = [row.rstrip() for row in once.export_text().splitlines()] + assert "row 29" in printed and printed[-1] == "q quit" def test_refresh_hint_follows_my_prs_when_review_is_hidden_and_focus_needs_two():