Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
82 changes: 66 additions & 16 deletions devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_.-]+)?$")
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1394,31 +1398,59 @@ 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
usage_fetching = False


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()
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)
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:
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")
# 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:
rows = [with_bar(row, width, bar)
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):
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
Expand All @@ -1429,15 +1461,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):
Expand Down Expand Up @@ -1600,14 +1639,20 @@ 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):
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.
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
Expand Down Expand Up @@ -1669,7 +1714,12 @@ def watch(st, usage_every):
for ig in st.integrations:
ig.stop()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
try:
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
finally:
if scroll_mode:
CONSOLE.file.write("\x1b[?1007r")
CONSOLE.file.flush()


if __name__ == "__main__":
Expand Down
41 changes: 38 additions & 3 deletions tests/test_devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand All @@ -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)
Expand All @@ -801,6 +801,41 @@ 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
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():
ig = integration("pass", keys={"j": "newer"})
st = devdash.State()
Expand Down
Loading