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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ tools, one per forge.
|---|---|
| `prtop/cli.py` | The command: one fetch (`run_check`, one `check_forge` per forge), the choice between the live view and one table, `watch`, the arguments. |
| `prtop/live.py` | The live view: the frame, the fetch loop, the keys. Pure enough to test with a fake terminal and clock. |
| `prtop/terminal.py` | The real terminal: alternate screen, key reading on POSIX (`termios`) and Windows (`msvcrt`), key decoding. |
| `prtop/terminal.py` | The real terminal: alternate screen, mouse reporting on POSIX, key reading on POSIX (`termios`) and Windows (`msvcrt`), key decoding. |
| `prtop/report.py` | Marks, Status cells, row order and the table. The accessibility contract lives here. |
| `prtop/model.py` | The vocabulary a forge hands to the report: review states, deploy states, the state-file key and the `repo!number` the PR column shows, `ForgeError`. |
| `prtop/forges/` | One module per forge behind the `Forge` protocol: `gitlab.py` and `github.py` each own every call and field name of their service. `command.py` runs `glab` and `gh`; `pipelines.py` holds what both map production tracking onto: job statuses, the production jobs, the frontier. |
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ prtop's user-facing changes, newest first. The format follows

## [Unreleased]

### Changed

- On macOS and Linux, the live view keeps the mouse the way htop does: the wheel
does not scroll the terminal's history behind the table. Selecting text takes
Option in iTerm2, Shift in most other terminals.

## [0.2.0] - 2026-10-26

### Added
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ prtop

In a terminal, prtop takes the screen and fetches again every 30 seconds. The top
line names the forges it reads, the time of the last fetch and the countdown to
the next one; the bottom line lists the keys.
the next one; the bottom line lists the keys. On macOS and Linux it keeps the
mouse the way htop does: the wheel does not scroll the terminal's history, and
selecting text takes Option in iTerm2, Shift in most other terminals.

| Key | Does |
|---|---|
Expand Down
14 changes: 10 additions & 4 deletions prtop/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@

ENTER = "\033[?1049h\033[?25l" # the alternate screen, with the cursor hidden
LEAVE = "\033[?25h\033[?1049l"
# Mouse reporting, as `htop` asks for it: the terminal hands the wheel to prtop,
# which drops it, instead of scrolling its history behind the screen. SGR
# encoding makes each report one escape sequence.
MOUSE_ON, MOUSE_OFF = "\033[?1000h\033[?1006h", "\033[?1006l\033[?1000l"
HOME, CLEAR_TO_LINE_END, CLEAR_BELOW, CLEAR = "\033[H", "\033[K", "\033[J", "\033[2J"

# A function key reaches a POSIX terminal as an escape sequence.
Expand All @@ -31,15 +35,17 @@
"\x0c": "Ctrl+L",
"\x03": "Ctrl+C",
}
_UNNAMED_SEQUENCE = re.compile("\033(\\[[0-9;?]*[ -/]*[@-~]|O.)")
# A terminal without SGR encoding reports the mouse as `CSI M` and then three
# characters, the button and the position, that would otherwise read as keys.
_UNNAMED_SEQUENCE = re.compile("\033(\\[M...|\\[[0-?]*[ -/]*[@-~]|O.)")
# The Windows console reads it as a NUL, then the key's scan code.
SCAN_CODES = {";": "F1", "D": "F10"}


def decode_keys(text: str) -> list[str]:
"""Name each key in what the terminal sent: a printable key as itself, a
known sequence by its name, a lone escape as `Esc`. Sequences prtop has no
use for, the arrows among them, are dropped."""
use for, the arrows and the mouse among them, are dropped."""
keys: list[str] = []
i = 0
while i < len(text):
Expand Down Expand Up @@ -96,13 +102,13 @@ def __enter__(self) -> Self:
self._modes = termios.tcgetattr(self._fd)
tty.setcbreak(self._fd)
self._on_term = signal.signal(signal.SIGTERM, _exit_on)
self._write(ENTER)
self._write(ENTER + MOUSE_ON)
return self

def __exit__(self, *exc: object) -> None:
import termios

self._write(LEAVE)
self._write(MOUSE_OFF + LEAVE)
signal.signal(signal.SIGTERM, self._on_term)
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._modes)

Expand Down
16 changes: 12 additions & 4 deletions tests/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
ENTER,
HOME,
LEAVE,
MOUSE_OFF,
MOUSE_ON,
_exit_on,
_PosixTerminal,
_WindowsTerminal,
Expand All @@ -39,6 +41,13 @@ def test_control_keys_are_named(self):
def test_keys_prtop_has_no_use_for_are_dropped(self):
self.assertEqual(decode_keys("\033[A\033[1;5Cq"), ["q"])

def test_the_wheel_and_clicks_read_as_no_key(self):
self.assertEqual(decode_keys("\033[<64;81;5M\033[<65;81;5M\033[<0;10;3mq"), ["q"])

def test_a_mouse_report_without_sgr_encoding_reads_as_no_key(self):
# Button 64 at column 81, row 5: the column travels as a `q`.
self.assertEqual(decode_keys("\033[M`q%\033[Maq%"), [])

def test_a_lone_escape_reads_as_esc(self):
self.assertEqual(decode_keys("\033"), ["Esc"])

Expand Down Expand Up @@ -145,7 +154,7 @@ def test_the_screen_and_the_keyboard_modes_are_given_back(self):
self.assertEqual((cbreak, during), ([5], _exit_on))
self.assertEqual(termios.restored, [(5, FakeTermios.TCSADRAIN, ["the keyboard's modes"])])
self.assertEqual(signal.getsignal(signal.SIGTERM), before)
self.assertEqual(out.getvalue(), ENTER + LEAVE)
self.assertEqual(out.getvalue(), ENTER + MOUSE_ON + MOUSE_OFF + LEAVE)

def test_keys_are_read_as_they_are_pressed(self):
with redirect_stdout(io.StringIO()):
Expand Down Expand Up @@ -222,9 +231,8 @@ def read(self, fd, done, timeout=10.0):
def test_q_gives_the_screen_back(self):
code, output = self.run_demo(keys=b"q")
self.assertEqual(code, 0)
self.assertIn(ENTER, output)
self.assertTrue(output.rstrip().endswith(LEAVE), output[-200:])

self.assertIn(ENTER + MOUSE_ON, output)
self.assertTrue(output.rstrip().endswith(MOUSE_OFF + LEAVE), output[-200:])
def test_with_n_the_last_table_stays_on_the_screen(self):
code, output = self.run_demo("-n", "1")
self.assertEqual(code, 0)
Expand Down
Loading