Skip to content

Commit 827113d

Browse files
Gheat1hamidi-dev
authored andcommitted
feat(tui): add page-scroll keys and / filter alias
- PgDn/PgUp and Ctrl-D/Ctrl-U move by half the visible pager height in the main lists, the session detail pager, the help pager, and the P overlay (model cursor and per-model session list) - / opens the live fuzzy filter, an alias for f, in the main views and the P overlay; help and footer advertise both - the mouse wheel over the open help overlay now scrolls it instead of closing it; a plain click still closes
1 parent 1aa37f8 commit 827113d

3 files changed

Lines changed: 160 additions & 17 deletions

File tree

src/opentab/tui/app.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2472,6 +2472,14 @@ def move(self, delta: int) -> None:
24722472
if n:
24732473
self.day_index = max(0, min(self.day_index + delta, n - 1))
24742474

2475+
def _page_step(self, stdscr: curses.window | None) -> int:
2476+
# Half the visible pager height (the window minus chrome, mirroring
2477+
# Renderer.max_scroll) -- the PgDn/PgUp and Ctrl-D/Ctrl-U stride.
2478+
if stdscr is None:
2479+
return 10
2480+
height, _width = stdscr.getmaxyx()
2481+
return max(1, (height - 9) // 2)
2482+
24752483
def jump(self, to_end: bool, stdscr: curses.window | None = None) -> None:
24762484
if self.view == "browse":
24772485
if self.browse_mode == "projects":
@@ -2741,12 +2749,16 @@ def handle_key(self, stdscr: curses.window, key: int) -> bool:
27412749
if self.price_prompt:
27422750
return self.handle_price_prompt_key(key)
27432751
if self.help:
2744-
# A pager like the price overlay: j/k/arrows and g/G scroll;
2752+
# A pager like the price overlay: j/k/arrows, page keys and g/G scroll;
27452753
# any other key closes it.
27462754
if key in (ord("j"), curses.KEY_DOWN):
27472755
self.help_scroll += 1
27482756
elif key in (ord("k"), curses.KEY_UP):
27492757
self.help_scroll = max(0, self.help_scroll - 1)
2758+
elif key in (curses.KEY_NPAGE, 4): # PgDn / Ctrl-D
2759+
self.help_scroll += self._page_step(stdscr) # clamped on draw
2760+
elif key in (curses.KEY_PPAGE, 21): # PgUp / Ctrl-U
2761+
self.help_scroll = max(0, self.help_scroll - self._page_step(stdscr))
27502762
elif key == ord("g"):
27512763
self.help_scroll = 0
27522764
elif key == ord("G"):
@@ -2760,8 +2772,8 @@ def handle_key(self, stdscr: curses.window, key: int) -> bool:
27602772
if self.filter_active:
27612773
return self.handle_filter_key(key)
27622774
if self.prices_model is not None:
2763-
return self._handle_price_sessions_key(key)
2764-
return self._handle_price_models_key(key)
2775+
return self._handle_price_sessions_key(key, stdscr)
2776+
return self._handle_price_models_key(key, stdscr)
27652777
if self.trends:
27662778
current = self.trend_tabs[self.trend_tab % len(self.trend_tabs)]
27672779
if current == "Calendar":
@@ -2890,7 +2902,7 @@ def handle_key(self, stdscr: curses.window, key: int) -> bool:
28902902
if key == ord("B"):
28912903
self.toggle_bookmarks_view()
28922904
return True
2893-
if key == ord("f"):
2905+
if key in (ord("f"), ord("/")):
28942906
if not self.can_filter_current_view():
28952907
self.notify(
28962908
"nothing to filter here — open a sessions, projects, or models list", "error"
@@ -2955,6 +2967,12 @@ def handle_key(self, stdscr: curses.window, key: int) -> bool:
29552967
if key in (ord("k"), curses.KEY_UP):
29562968
self.move(-1)
29572969
return True
2970+
if key in (curses.KEY_NPAGE, 4): # PgDn / Ctrl-D
2971+
self.move(self._page_step(stdscr))
2972+
return True
2973+
if key in (curses.KEY_PPAGE, 21): # PgUp / Ctrl-U
2974+
self.move(-self._page_step(stdscr))
2975+
return True
29582976
return True
29592977

29602978
def prices_view_label(self, view: str | None = None) -> str:
@@ -2971,11 +2989,11 @@ def cycle_prices_view(self) -> None:
29712989
self.prices_scroll = 0
29722990
self.notice = f"view: {self.prices_view_label()}"
29732991

2974-
def _handle_price_models_key(self, key: int) -> bool:
2975-
# The P overlay's model list: j/k/arrows move a cursor, g/G jump to ends,
2976-
# Enter drills into the selected model's sessions, s sorts by a column, p
2977-
# cycles the layout (by vendor / by provider / flat), f filters, r refreshes,
2978-
# e exports the table; any other key closes the overlay.
2992+
def _handle_price_models_key(self, key: int, stdscr: curses.window | None = None) -> bool:
2993+
# The P overlay's model list: j/k/arrows move a cursor, page keys stride,
2994+
# g/G jump to ends, Enter drills into the selected model's sessions, s sorts
2995+
# by a column, p cycles the layout (by vendor / by provider / flat), f
2996+
# filters, r refreshes, e exports the table; any other key closes the overlay.
29792997
n = len(self.priced_model_names())
29802998
if key in (ord("s"), ord("S")):
29812999
self.open_sort_menu()
@@ -2987,6 +3005,10 @@ def _handle_price_models_key(self, key: int) -> bool:
29873005
self.prices_index = min(self.prices_index + 1, max(0, n - 1))
29883006
elif key in (ord("k"), curses.KEY_UP):
29893007
self.prices_index = max(0, self.prices_index - 1)
3008+
elif key in (curses.KEY_NPAGE, 4): # PgDn / Ctrl-D
3009+
self.prices_index = min(self.prices_index + self._page_step(stdscr), max(0, n - 1))
3010+
elif key in (curses.KEY_PPAGE, 21): # PgUp / Ctrl-U
3011+
self.prices_index = max(0, self.prices_index - self._page_step(stdscr))
29903012
elif key == ord("g"):
29913013
self.prices_index = 0
29923014
elif key == ord("G"):
@@ -2998,7 +3020,7 @@ def _handle_price_models_key(self, key: int) -> bool:
29983020
self.prices_scroll = 0
29993021
elif key in (ord("r"), ord("R")):
30003022
self.refresh_prices_action() # keeps the overlay open
3001-
elif key == ord("f"):
3023+
elif key in (ord("f"), ord("/")):
30023024
self.filter_active = True
30033025
self._filter_before = self.query
30043026
self.prices_scroll = 0
@@ -3008,13 +3030,18 @@ def _handle_price_models_key(self, key: int) -> bool:
30083030
self.show_prices = False
30093031
return True
30103032

3011-
def _handle_price_sessions_key(self, key: int) -> bool:
3012-
# The P overlay's per-model drill-in: j/k/arrows and g/G scroll the session
3013-
# list; Esc/left/backspace backs out to the model list; any other key closes.
3033+
def _handle_price_sessions_key(self, key: int, stdscr: curses.window | None = None) -> bool:
3034+
# The P overlay's per-model drill-in: j/k/arrows, page keys and g/G scroll the
3035+
# session list; Esc/left/backspace backs out to the model list; any other key
3036+
# closes.
30143037
if key in (ord("j"), curses.KEY_DOWN):
30153038
self.prices_scroll += 1
30163039
elif key in (ord("k"), curses.KEY_UP):
30173040
self.prices_scroll = max(0, self.prices_scroll - 1)
3041+
elif key in (curses.KEY_NPAGE, 4): # PgDn / Ctrl-D
3042+
self.prices_scroll += self._page_step(stdscr) # clamped on draw
3043+
elif key in (curses.KEY_PPAGE, 21): # PgUp / Ctrl-U
3044+
self.prices_scroll = max(0, self.prices_scroll - self._page_step(stdscr))
30183045
elif key == ord("g"):
30193046
self.prices_scroll = 0
30203047
elif key == ord("G"):
@@ -3110,7 +3137,11 @@ def handle_mouse(self) -> bool:
31103137
self.launch_menu = None # click cancels the launch picker
31113138
return True
31123139
if self.help:
3113-
if up or down or click or double:
3140+
if up:
3141+
self.help_scroll = max(0, self.help_scroll - 3)
3142+
elif down:
3143+
self.help_scroll += 3 # clamped to the last page on draw
3144+
elif click or double:
31143145
self.help = False
31153146
return True
31163147
if self.show_prices:

src/opentab/tui/renderer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,7 @@ def draw_footer(self, stdscr: curses.window, height: int, width: int) -> None:
593593
# appears only where it does something (like "s/S sort").
594594
parts.append(("R range", self.range_label() != "all time"))
595595
if self.can_filter_current_view():
596-
parts.append(("f filter", bool(self.query)))
596+
parts.append(("f,/ filter", bool(self.query)))
597597
parts += [
598598
("T trends", self.trends),
599599
("P prices", self.show_prices),
@@ -1987,6 +1987,7 @@ def help_sections(self) -> list[tuple[str, list[tuple]]]:
19871987
"(per-tool / MCP spend, OpenCode); Sources joins in the merged 'all' view",
19881988
),
19891989
("j / k", "move in the list (↑/↓ too), or scroll the detail pane"),
1990+
("PgDn/PgUp", "move / scroll by half a page (Ctrl-D / Ctrl-U too)"),
19901991
("g / G", "jump to the top / bottom"),
19911992
(
19921993
"mouse",
@@ -2005,7 +2006,7 @@ def help_sections(self) -> list[tuple[str, list[tuple]]]:
20052006
("a", "show all time, keeping the current selection where possible"),
20062007
("s", "open the sort picker for the visible list (j/k move · Enter · Esc)"),
20072008
(
2008-
"f",
2009+
"f or /",
20092010
"live filter — fuzzy over sessions (title/project/id), projects and "
20102011
"Models; substring over Prices",
20112012
"while filtering: ↑/↓ select · Enter keep · Esc cancel · Ctrl-U clear",

test_opentab.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,81 @@ def test_jk_scrolls_the_help_overlay():
13961396
assert not app.help
13971397

13981398

1399+
def test_mouse_wheel_scrolls_the_help_overlay():
1400+
# The wheel over the open help pages it (like the P overlay) instead of
1401+
# closing it; a plain click still closes.
1402+
app = app_with([workflow("a", "2026-06-01 12:00:00")])
1403+
app.handle_key(None, ord("?"))
1404+
assert app.help and app.help_scroll == 0
1405+
app._wheel_down = getattr(ot.curses, "BUTTON5_PRESSED", 0) or ot.curses.REPORT_MOUSE_POSITION
1406+
orig = ot.curses.getmouse
1407+
try:
1408+
ot.curses.getmouse = lambda: (0, 0, 0, 0, app._wheel_down) # wheel down
1409+
app.handle_mouse()
1410+
assert app.help and app.help_scroll == 3
1411+
ot.curses.getmouse = lambda: (0, 0, 0, 0, ot.curses.BUTTON4_PRESSED) # wheel up
1412+
app.handle_mouse()
1413+
assert app.help and app.help_scroll == 0
1414+
app.handle_mouse() # floored at the top, still open
1415+
assert app.help and app.help_scroll == 0
1416+
ot.curses.getmouse = lambda: (0, 0, 0, 0, ot.curses.BUTTON1_CLICKED) # click closes
1417+
app.handle_mouse()
1418+
assert not app.help
1419+
finally:
1420+
ot.curses.getmouse = orig
1421+
1422+
1423+
def test_page_keys_stride_lists_by_half_a_screen():
1424+
# PgDn/PgUp and Ctrl-D/Ctrl-U move by half the visible pager height; headless
1425+
# (no screen to measure) the stride is a fixed 10 rows.
1426+
app = app_with([workflow(f"s{i:02d}", "2026-06-01 12:00:00") for i in range(25)])
1427+
app.view = "zoom"
1428+
app.tab = app.current_tabs().index("Sessions")
1429+
app.handle_key(None, ot.curses.KEY_NPAGE)
1430+
assert app.workflow_index == 10
1431+
app.handle_key(None, 4) # Ctrl-D
1432+
assert app.workflow_index == 20
1433+
app.handle_key(None, ot.curses.KEY_NPAGE) # clamped at the last row
1434+
assert app.workflow_index == 24
1435+
app.handle_key(None, ot.curses.KEY_PPAGE)
1436+
assert app.workflow_index == 14
1437+
app.handle_key(None, 21) # Ctrl-U
1438+
assert app.workflow_index == 4
1439+
app.handle_key(None, 21) # floored at the top
1440+
assert app.workflow_index == 0
1441+
# with a real screen the stride is half the pager height (height - 9)
1442+
assert app._page_step(FakeScreen(29, 80)) == 10
1443+
assert app._page_step(FakeScreen(5, 80)) == 1 # never 0 on a tiny window
1444+
1445+
1446+
def test_page_keys_scroll_the_detail_help_and_prices_pagers():
1447+
app = app_with([workflow("a", "2026-06-01 12:00:00", directory="/x")])
1448+
app.view = "session"
1449+
app.handle_key(None, ot.curses.KEY_NPAGE) # detail pager, via move()
1450+
assert app.scroll == 10
1451+
app.handle_key(None, 21) # Ctrl-U back up
1452+
assert app.scroll == 0
1453+
app.handle_key(None, ord("?")) # the help pager
1454+
app.handle_key(None, 4) # Ctrl-D
1455+
assert app.help and app.help_scroll == 10
1456+
app.handle_key(None, ot.curses.KEY_PPAGE)
1457+
assert app.help and app.help_scroll == 0
1458+
app.handle_key(None, ord("q")) # close help (any other key)
1459+
app.view = "browse"
1460+
app._model_by_root = {
1461+
"a": [
1462+
_model_row("claude-opus-4-8", 5.0, 10),
1463+
_model_row("gpt-5-codex", 2.0, 10),
1464+
_model_row("claude-haiku-4-5", 1.0, 10),
1465+
]
1466+
}
1467+
app.handle_key(None, ord("P")) # the P overlay's model cursor
1468+
app.handle_key(None, ot.curses.KEY_NPAGE)
1469+
assert app.show_prices and app.prices_index == 2 # clamped to the last of 3 rows
1470+
app.handle_key(None, 21)
1471+
assert app.show_prices and app.prices_index == 0
1472+
1473+
13991474
def test_help_sections_group_and_cover_the_keymap():
14001475
# The help overlay is grouped; help_sections() is the content source of truth
14011476
# (draw_help only wraps/colours it). Lock the sections and that the load-bearing
@@ -1415,7 +1490,21 @@ def test_help_sections_group_and_cover_the_keymap():
14151490
assert len(row) >= 2 and row[0] and row[1] # (key, summary, *notes)
14161491
assert all(isinstance(note, str) and note for note in row[2:])
14171492
keys.add(row[0])
1418-
for binding in ("p / t", "Enter / +", "R", "f", "b / B", "L", "T", "P", "$", "c", "C", "q"):
1493+
for binding in (
1494+
"p / t",
1495+
"Enter / +",
1496+
"PgDn/PgUp",
1497+
"R",
1498+
"f or /",
1499+
"b / B",
1500+
"L",
1501+
"T",
1502+
"P",
1503+
"$",
1504+
"c",
1505+
"C",
1506+
"q",
1507+
):
14191508
assert binding in keys, f"missing help entry for {binding}"
14201509

14211510

@@ -5395,6 +5484,28 @@ def test_f_enters_live_filter_mode():
53955484
assert app.handle_key(None, 3) is False
53965485

53975486

5487+
def test_slash_is_an_alias_for_the_filter_key():
5488+
app = app_with(
5489+
[
5490+
workflow("a", "2026-06-01 12:00:00", title="alpha"),
5491+
workflow("b", "2026-06-02 12:00:00", title="beta"),
5492+
]
5493+
)
5494+
app.view = "zoom"
5495+
app.tab = app.current_tabs().index("Sessions")
5496+
assert app.handle_key(None, ord("/")) and app.filter_active
5497+
for ch in "bet":
5498+
app.handle_key(None, ord(ch))
5499+
assert [w.title for w in app.current_sessions()] == ["beta"]
5500+
app.handle_key(None, 10) # Enter keeps the filter and leaves the mode
5501+
assert not app.filter_active and app.query == "bet"
5502+
# `/` also opens the P overlay's filter, like `f`
5503+
app.handle_key(None, ord("x")) # clear the committed filter first
5504+
app.handle_key(None, ord("P"))
5505+
assert app.show_prices and not app.filter_active
5506+
assert app.handle_key(None, ord("/")) and app.filter_active and app.show_prices
5507+
5508+
53985509
def test_f_is_a_noop_where_no_list_is_filtered():
53995510
# The time-browse main view shows Months/Days, not a session/project list, so the
54005511
# query would filter nothing -- "f" must not enter filter mode there, and the

0 commit comments

Comments
 (0)