mirror of
https://github.com/imcarlost/pidcat-repl.git
synced 2026-08-08 23:17:43 -04:00
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:
@@ -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)*
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,21 +467,36 @@ 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):
|
||||||
out.append(line + '\x1b[K\n')
|
# 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')
|
||||||
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))
|
||||||
else:
|
else:
|
||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user