Bump version to 2.4.0

Fix click-to-line behavior in the interactive UI (no-op without a filter,
correct row math when the log hasn't filled the screen) and add a hover
highlight, a persistent pin on the clicked line, and always-visible process
create/death separators so filtered runs stay distinguishable.
This commit is contained in:
2026-07-20 11:44:13 -04:00
parent 0cb6331afc
commit 80fd6ba760
3 changed files with 105 additions and 38 deletions
+14
View File
@@ -1,6 +1,20 @@
Change Log Change Log
========== ==========
Version 2.4.0 *(2026-07-20)*
----------------------------
* Fix: Clicking a line with no filter active was recentering the view instead of doing
nothing; it's now a no-op until you actually have something to unfilter.
* Fix: Click-to-line row math was off whenever the log hadn't filled the screen yet
(e.g. right after startup), so clicks could land on the wrong entry or miss entirely.
* New: Hovering over a line highlights it while a filter is active, hinting that it's
clickable.
* New: The line you click stays highlighted after the filter clears, so you can find
it in the full scrollback; the highlight clears once you start another search.
* New: Process create/death separators now stay visible even while a filter is active,
so you can tell separate app runs apart in filtered results.
Version 2.3.0 *(2026-07-20)* Version 2.3.0 *(2026-07-20)*
---------------------------- ----------------------------
+17 -12
View File
@@ -7,6 +7,20 @@ entries for a specific application package; this fork adds an interactive,
full-screen filter UI on top, turning `pidcat` into more of a REPL than a full-screen filter UI on top, turning `pidcat` into more of a REPL than a
one-shot stream. one-shot stream.
Install
-------
Use [Homebrew][2]:
```shell
brew tap imcarlost/pidcat-repl
brew trust imcarlost/pidcat-repl
brew install pidcat-repl
```
Background
----------
During application development you often want to only display log messages During application development you often want to only display log messages
coming from your app. Unfortunately, because the process ID changes every time coming from your app. Unfortunately, because the process ID changes every time
you deploy to the phone it becomes a challenge to grep for the right thing. you deploy to the phone it becomes a challenge to grep for the right thing.
@@ -37,7 +51,7 @@ case-insensitively.
* `Backspace` edits the query, `Ctrl-U` clears it. * `Backspace` edits the query, `Ctrl-U` clears it.
* `Ctrl-L` forces a redraw; the view also tracks terminal resizes. * `Ctrl-L` forces a redraw; the view also tracks terminal resizes.
* `Ctrl-C` or `Ctrl-D` quits and restores your scrollback. * `Esc` or `Ctrl-D` quits and restores your scrollback.
Pass `--plain` to get the original streaming output instead, e.g. for Pass `--plain` to get the original streaming output instead, e.g. for
`pidcat --plain com.oprah.bees.android | grep Foo`. Piped input or output `pidcat --plain com.oprah.bees.android | grep Foo`. Piped input or output
@@ -45,17 +59,8 @@ Pass `--plain` to get the original streaming output instead, e.g. for
uses plain streaming, since there is no terminal to draw the UI on. uses plain streaming, since there is no terminal to draw the UI on.
Install Requirements
------- ------------
Use [Homebrew][2]:
```shell
brew tap imcarlost/pidcat-repl
brew trust imcarlost/pidcat-repl
brew install pidcat-repl
```
Make sure that `adb` from the [Android SDK][3] is on your PATH. This script will Make sure that `adb` from the [Android SDK][3] is on your PATH. This script will
not work unless this is that case. That means, when you type `adb` and press not work unless this is that case. That means, when you type `adb` and press
+73 -25
View File
@@ -33,7 +33,7 @@ import subprocess
import threading import threading
from subprocess import PIPE from subprocess import PIPE
__version__ = '2.3.0' __version__ = '2.4.0'
LOG_LEVELS = 'VDIWEF' LOG_LEVELS = 'VDIWEF'
LOG_LEVELS_MAP = dict([(LOG_LEVELS[i], i) for i in range(len(LOG_LEVELS))]) LOG_LEVELS_MAP = dict([(LOG_LEVELS[i], i) for i in range(len(LOG_LEVELS))])
@@ -321,7 +321,7 @@ def stream(emit):
linebuf += colorize(' ' * (header_size - 1), bg=WHITE) linebuf += colorize(' ' * (header_size - 1), bg=WHITE)
linebuf += ' PID: %s UID: %s GIDs: %s' % (line_pid, line_uid, line_gids) linebuf += ' PID: %s UID: %s GIDs: %s' % (line_pid, line_uid, line_gids)
linebuf += '\n' linebuf += '\n'
emit('Process %s created for %s PID: %s' % (line_package, target, line_pid), linebuf) emit('Process %s created for %s PID: %s' % (line_package, target, line_pid), linebuf, is_separator=True)
last_tag = None # Ensure next log gets a tag printed last_tag = None # Ensure next log gets a tag printed
dead_pid, dead_pname = parse_death(tag, message) dead_pid, dead_pname = parse_death(tag, message)
@@ -331,7 +331,7 @@ def stream(emit):
linebuf += colorize(' ' * (header_size - 1), bg=RED) linebuf += colorize(' ' * (header_size - 1), bg=RED)
linebuf += ' Process %s (PID: %s) ended' % (dead_pname, dead_pid) linebuf += ' Process %s (PID: %s) ended' % (dead_pname, dead_pid)
linebuf += '\n' linebuf += '\n'
emit('Process %s (PID: %s) ended' % (dead_pname, dead_pid), linebuf) emit('Process %s (PID: %s) ended' % (dead_pname, dead_pid), linebuf, is_separator=True)
last_tag = None # Ensure next log gets a tag printed last_tag = None # Ensure next log gets a tag printed
# Make sure the backtrace is printed after a native crash # Make sure the backtrace is printed after a native crash
@@ -385,21 +385,24 @@ def stream(emit):
class InteractiveUI: class InteractiveUI:
'''Full-screen filter UI: log lines render above a bottom prompt line, and the '''Full-screen filter UI: log lines render above a bottom prompt line, and the
typed query live-filters the scrollback. Every whitespace-separated word must typed query live-filters the scrollback. Every whitespace-separated word must
appear in a block's plain text (case-insensitive) for it to be shown.''' appear in a block's plain text (case-insensitive) for it to be shown, except
process create/death separators, which always stay visible so filtered runs
can still be told apart.'''
MAX_ENTRIES = 10000 # (search_text, block) pairs kept for re-filtering MAX_ENTRIES = 10000 # (search_text, block) pairs kept for re-filtering
MAX_VISIBLE = 5000 # rendered lines kept for the current query MAX_VISIBLE = 5000 # rendered lines kept for the current query
def __init__(self): def __init__(self):
self.lock = threading.Lock() self.lock = threading.Lock()
self.entries = collections.deque(maxlen=self.MAX_ENTRIES) # (entry_id, search_lower, block) self.entries = collections.deque(maxlen=self.MAX_ENTRIES) # (entry_id, search_lower, block, is_separator)
self.visible = [] # (entry_id, line_text) self.visible = [] # (entry_id, line_text)
self.next_entry_id = 0 self.next_entry_id = 0
self.query = '' self.query = ''
self.status = '' self.status = ''
self.resized = False self.resized = False
self.scroll_offset = 0 # lines scrolled up from the tail; 0 == following the live tail self.scroll_offset = 0 # lines scrolled up from the tail; 0 == following the live tail
self.render_start = 0 # index into self.visible of the top rendered line self.hover_row = None # 0-based screen row the pointer is over, or None
self.selected_index = None # index into self.visible pinned by the last click, or None
self.rows, self.cols = self._term_size() self.rows, self.cols = self._term_size()
def _term_size(self): def _term_size(self):
@@ -415,6 +418,11 @@ class InteractiveUI:
def _matches(self, search_lower): def _matches(self, search_lower):
return all(token in search_lower for token in self.query.lower().split()) return all(token in search_lower for token in self.query.lower().split())
def _included(self, search_lower, is_separator):
# Process create/death separators ignore the filter so a run boundary is
# never hidden by an unrelated search.
return is_separator or self._matches(search_lower)
def _append_visible(self, entry_id, block): def _append_visible(self, entry_id, block):
new_lines = [(entry_id, line) for line in block.split('\n')] new_lines = [(entry_id, line) for line in block.split('\n')]
self.visible.extend(new_lines) self.visible.extend(new_lines)
@@ -426,14 +434,18 @@ class InteractiveUI:
if overflow > 0: if overflow > 0:
del self.visible[:overflow] del self.visible[:overflow]
self.scroll_offset = max(0, self.scroll_offset - overflow) self.scroll_offset = max(0, self.scroll_offset - overflow)
if self.selected_index is not None:
self.selected_index -= overflow
if self.selected_index < 0:
self.selected_index = None
def emit(self, search_text, block): def emit(self, search_text, block, is_separator=False):
with self.lock: with self.lock:
entry_id = self.next_entry_id entry_id = self.next_entry_id
self.next_entry_id += 1 self.next_entry_id += 1
search_lower = search_text.lower() search_lower = search_text.lower()
self.entries.append((entry_id, search_lower, block)) self.entries.append((entry_id, search_lower, block, is_separator))
if self._matches(search_lower): if self._included(search_lower, is_separator):
self._append_visible(entry_id, block) self._append_visible(entry_id, block)
self._render() self._render()
@@ -442,8 +454,9 @@ class InteractiveUI:
self.query = query self.query = query
self.visible = [] self.visible = []
self.scroll_offset = 0 self.scroll_offset = 0
for entry_id, search_lower, block in self.entries: self.selected_index = None # a new search replaces whatever was pinned
if self._matches(search_lower): for entry_id, search_lower, block, is_separator in self.entries:
if self._included(search_lower, is_separator):
self._append_visible(entry_id, block) self._append_visible(entry_id, block)
self._render() self._render()
@@ -454,20 +467,35 @@ class InteractiveUI:
width = self.cols # future indent_wrap calls track the new size width = self.cols # future indent_wrap calls track the new size
self._render() self._render()
def _render(self): def _window_bounds(self):
'''Returns (start, end, pad): the self.visible slice currently on screen and
how many blank padding rows precede it (when there isn't enough content yet
to fill the log area).'''
log_rows = max(1, self.rows - 2) log_rows = max(1, self.rows - 2)
max_offset = max(0, len(self.visible) - log_rows) max_offset = max(0, len(self.visible) - log_rows)
if self.scroll_offset > max_offset: if self.scroll_offset > max_offset:
self.scroll_offset = max_offset self.scroll_offset = max_offset
end = len(self.visible) - self.scroll_offset end = len(self.visible) - self.scroll_offset
start = max(0, end - log_rows) start = max(0, end - log_rows)
self.render_start = start pad = log_rows - (end - start)
return start, end, pad
def _render(self):
log_rows = max(1, self.rows - 2)
start, end, pad = self._window_bounds()
window = self.visible[start:end] window = self.visible[start:end]
out = ['\x1b[H'] out = ['\x1b[H']
# Pad above so the log content hugs the prompt, like a terminal. # Pad above so the log content hugs the prompt, like a terminal.
for _ in range(log_rows - len(window)): for _ in range(pad):
out.append('\x1b[K\n') out.append('\x1b[K\n')
for _, line in window: for i, (_, line) in enumerate(window):
# Only hint clickability when a filter is active; with no filter a
# click is a no-op, so there's nothing to invite the user to click.
hovered = self.query and self.hover_row == pad + i
selected = self.selected_index == start + i
if hovered or selected:
out.append('\x1b[7m' + line.replace(RESET, RESET + '\x1b[7m') + RESET + '\x1b[K\n')
else:
out.append(line + '\x1b[K\n') out.append(line + '\x1b[K\n')
if self.query: if self.query:
state = '%d matching lines of %d blocks' % (len(self.visible), len(self.entries)) state = '%d matching lines of %d blocks' % (len(self.visible), len(self.entries))
@@ -477,7 +505,8 @@ class InteractiveUI:
state += ' \xb7 scrolled (End to jump to latest)' state += ' \xb7 scrolled (End to jump to latest)'
if self.status: if self.status:
state += ' \xb7 ' + self.status state += ' \xb7 ' + self.status
separator = ' %s \xb7 type to filter \xb7 ctrl-u clear \xb7 click a line to unfilter \xb7 esc/ctrl-c quit' % state hint = ' \xb7 click a line to unfilter' if self.query else ''
separator = ' %s \xb7 type to filter \xb7 ctrl-u clear%s \xb7 esc/ctrl-c quit' % (state, hint)
out.append('\x1b[2m' + separator[:max(0, self.cols - 1)] + '\x1b[0m\x1b[K\n') out.append('\x1b[2m' + separator[:max(0, self.cols - 1)] + '\x1b[0m\x1b[K\n')
out.append('\x1b[36m\x1b[0m ' + self.query + '\x1b[K') out.append('\x1b[36m\x1b[0m ' + self.query + '\x1b[K')
sys.stdout.write(''.join(out)) sys.stdout.write(''.join(out))
@@ -494,24 +523,38 @@ class InteractiveUI:
def _handle_click(self, row): def _handle_click(self, row):
with self.lock: with self.lock:
log_rows = max(1, self.rows - 2) if not self.query: # nothing to unfilter, so clicking is a no-op
if row < 1 or row > log_rows:
return return
idx = self.render_start + (row - 1) log_rows = max(1, self.rows - 2)
start, _end, pad = self._window_bounds()
if row < 1 or row > log_rows or row <= pad:
return
idx = start + (row - 1 - pad)
if idx < 0 or idx >= len(self.visible): if idx < 0 or idx >= len(self.visible):
return return
entry_id = self.visible[idx][0] entry_id = self.visible[idx][0]
self._jump_to_entry(entry_id) self._jump_to_entry(entry_id)
def _handle_hover(self, row):
with self.lock:
log_rows = max(1, self.rows - 2)
_start, _end, pad = self._window_bounds()
new_hover = (row - 1) if (1 <= row <= log_rows and row > pad) else None
if new_hover == self.hover_row:
return
self.hover_row = new_hover
self._render()
def _jump_to_entry(self, entry_id): def _jump_to_entry(self, entry_id):
'''Clears the filter, rebuilds the full unfiltered scrollback, and scrolls '''Clears the filter, rebuilds the full unfiltered scrollback, and scrolls
so the clicked entry is in view with a bit of context above it.''' so the clicked entry is in view with a bit of context above it. The entry's
line stays pinned/highlighted until another search starts.'''
with self.lock: with self.lock:
self.query = '' self.query = ''
self.visible = [] self.visible = []
self.scroll_offset = 0 self.scroll_offset = 0
target_index = None target_index = None
for eid, _search_lower, block in self.entries: for eid, _search_lower, block, _is_separator in self.entries:
for line in block.split('\n'): for line in block.split('\n'):
if target_index is None and eid == entry_id: if target_index is None and eid == entry_id:
target_index = len(self.visible) target_index = len(self.visible)
@@ -522,10 +565,13 @@ class InteractiveUI:
if target_index is not None: if target_index is not None:
target_index -= overflow target_index -= overflow
if target_index is not None and target_index >= 0: if target_index is not None and target_index >= 0:
self.selected_index = target_index
log_rows = max(1, self.rows - 2) log_rows = max(1, self.rows - 2)
max_offset = max(0, len(self.visible) - log_rows) max_offset = max(0, len(self.visible) - log_rows)
desired_end = target_index + max(1, log_rows // 3) desired_end = target_index + max(1, log_rows // 3)
self.scroll_offset = max(0, min(max_offset, len(self.visible) - desired_end)) self.scroll_offset = max(0, min(max_offset, len(self.visible) - desired_end))
else:
self.selected_index = None
self._render() self._render()
def _handle_escape(self, seq): def _handle_escape(self, seq):
@@ -537,6 +583,8 @@ class InteractiveUI:
button = int(button) button = int(button)
if button == 0: # left click if button == 0: # left click
self._handle_click(int(row)) self._handle_click(int(row))
elif button == 35: # pointer moved, no button held
self._handle_hover(int(row))
elif button == 64: # wheel up elif button == 64: # wheel up
self._scroll(3) self._scroll(3)
elif button == 65: # wheel down elif button == 65: # wheel down
@@ -593,8 +641,8 @@ class InteractiveUI:
old_attrs = termios.tcgetattr(fd) old_attrs = termios.tcgetattr(fd)
decoder = codecs.getincrementaldecoder('utf-8')('replace') decoder = codecs.getincrementaldecoder('utf-8')('replace')
signal.signal(signal.SIGWINCH, lambda *_: setattr(self, 'resized', True)) signal.signal(signal.SIGWINCH, lambda *_: setattr(self, 'resized', True))
# Alt screen, no autowrap, mouse click/wheel reporting (SGR encoding). # Alt screen, no autowrap, mouse click/wheel/motion reporting (SGR encoding).
sys.stdout.write('\x1b[?1049h\x1b[?7l\x1b[2J\x1b[H\x1b[?1000h\x1b[?1006h') sys.stdout.write('\x1b[?1049h\x1b[?7l\x1b[2J\x1b[H\x1b[?1000h\x1b[?1003h\x1b[?1006h')
sys.stdout.flush() sys.stdout.flush()
tty.setcbreak(fd) tty.setcbreak(fd)
reader_thread.start() reader_thread.start()
@@ -644,7 +692,7 @@ class InteractiveUI:
pass pass
finally: finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs) termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
sys.stdout.write('\x1b[?1006l\x1b[?1000l\x1b[?7h\x1b[?1049l') sys.stdout.write('\x1b[?1006l\x1b[?1003l\x1b[?1000l\x1b[?7h\x1b[?1049l')
sys.stdout.flush() sys.stdout.flush()
@@ -665,4 +713,4 @@ else:
# Die quietly like other unix filters when the downstream reader closes, # Die quietly like other unix filters when the downstream reader closes,
# e.g. `pidcat --plain <pkg> | head`. # e.g. `pidcat --plain <pkg> | head`.
signal.signal(signal.SIGPIPE, signal.SIG_DFL) signal.signal(signal.SIGPIPE, signal.SIG_DFL)
stream(lambda search_text, block: print(block)) stream(lambda search_text, block, is_separator=False: print(block))