feat: full TUI viewer — scroll, refresh, task lookup without leaving
Replaced curses/less pager with raw terminal TUI: - Alternate screen buffer with full ANSI color support - Arrow keys / j/k / PgUp/PgDn / mouse scroll to navigate - r = refresh in-place (re-fetches, resets to top) - : = type a task number to view details inline - b = back to briefing from task view - q = quit - Space = page down - Status bar with scroll percentage No need to exit and re-enter — everything happens in one view. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
254
briefing
254
briefing
@@ -7,7 +7,7 @@ Usage: briefing [7|30] [client]
|
||||
briefing 7 OHS — 7-day, OHS only
|
||||
"""
|
||||
|
||||
import json, sys, subprocess, re, readline, os, io
|
||||
import json, sys, subprocess, re, os, io, tty, termios, struct, fcntl, signal
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
@@ -450,18 +450,8 @@ def show_task_detail(nid):
|
||||
print(f"\n {DIM}URL: https://teacup.nodie.co.za/?sn={node.get('nodesid')}{RESET}")
|
||||
|
||||
|
||||
def show_in_pager(content):
|
||||
"""Show content in less, starting at the top. User scrolls down, q to exit."""
|
||||
try:
|
||||
proc = subprocess.Popen(['less', '-R', '-S', '--mouse'], stdin=subprocess.PIPE)
|
||||
proc.communicate(input=content.encode())
|
||||
except (BrokenPipeError, FileNotFoundError):
|
||||
# Fallback: just print if less isn't available
|
||||
print(content)
|
||||
|
||||
|
||||
def fetch_and_render(range_days, client):
|
||||
"""Fetch briefing data from API, render to pager. Returns tasks list."""
|
||||
def fetch_briefing_content(range_days, client):
|
||||
"""Fetch briefing data from API and render to string. Returns (content, tasks)."""
|
||||
url = f"{BASE_URL}/briefing?range={range_days}"
|
||||
if client:
|
||||
url += f"&client={client}"
|
||||
@@ -472,23 +462,18 @@ def fetch_and_render(range_days, client):
|
||||
)
|
||||
|
||||
if not result.stdout.strip():
|
||||
print(f"{RED}Empty response from API{RESET}")
|
||||
return []
|
||||
return f"{RED}Empty response from API{RESET}\n", []
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
print(f"{RED}Invalid JSON response{RESET}")
|
||||
print(result.stdout[:500])
|
||||
return []
|
||||
return f"{RED}Invalid JSON response{RESET}\n", []
|
||||
|
||||
if not data.get("success"):
|
||||
print(f"{RED}API error: {data.get('message', 'Unknown')}{RESET}")
|
||||
return []
|
||||
return f"{RED}API error: {data.get('message', 'Unknown')}{RESET}\n", []
|
||||
|
||||
raw = data["data"]
|
||||
|
||||
# Render to buffer
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
render_summary(raw.get('summary', {}), int(range_days))
|
||||
@@ -496,47 +481,201 @@ def fetch_and_render(range_days, client):
|
||||
render_tasks(raw.get('tasks', []))
|
||||
render_commits(raw.get('git_commits', {}))
|
||||
|
||||
# Show in pager (starts at top, scroll down for more)
|
||||
if sys.stdout.isatty():
|
||||
show_in_pager(buf.getvalue())
|
||||
return buf.getvalue(), raw.get('tasks', [])
|
||||
|
||||
|
||||
def fetch_task_content(nid):
|
||||
"""Fetch task detail and render to string."""
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
show_task_detail(nid)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── Terminal helpers ────────────────────────────────────────────────
|
||||
|
||||
def get_terminal_size():
|
||||
try:
|
||||
result = struct.unpack('hh', fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, b'\0' * 4))
|
||||
return result[0], result[1] # rows, cols
|
||||
except Exception:
|
||||
return 40, 80
|
||||
|
||||
|
||||
def read_key(fd):
|
||||
"""Read a keypress from raw terminal. Returns key string."""
|
||||
ch = os.read(fd, 1)
|
||||
if not ch:
|
||||
return ''
|
||||
if ch == b'\x1b':
|
||||
# Escape sequence
|
||||
seq = os.read(fd, 2)
|
||||
if seq == b'[A': return 'UP'
|
||||
if seq == b'[B': return 'DOWN'
|
||||
if seq == b'[5': os.read(fd, 1); return 'PGUP'
|
||||
if seq == b'[6': os.read(fd, 1); return 'PGDN'
|
||||
if seq == b'[H': return 'HOME'
|
||||
if seq == b'[F': return 'END'
|
||||
if seq == b'[M':
|
||||
# Mouse event: read 3 more bytes
|
||||
mouse = os.read(fd, 3)
|
||||
btn = mouse[0] - 32
|
||||
if btn == 64: return 'SCROLLUP'
|
||||
if btn == 65: return 'SCROLLDN'
|
||||
return ''
|
||||
return 'ESC'
|
||||
return ch.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
def tui_viewer(range_days, client):
|
||||
"""Raw-terminal scrollable viewer with ANSI color support."""
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
|
||||
# Switch to alternate screen buffer, hide cursor
|
||||
sys.stdout.write('\033[?1049h\033[?25l')
|
||||
# Enable mouse reporting (SGR mode for scroll)
|
||||
sys.stdout.write('\033[?1000h\033[?1006h')
|
||||
sys.stdout.flush()
|
||||
|
||||
scroll = 0
|
||||
lines = []
|
||||
tasks = []
|
||||
briefing_lines = []
|
||||
viewing_task = False
|
||||
input_mode = False
|
||||
input_buf = ""
|
||||
status_msg = ""
|
||||
|
||||
def split_lines(content):
|
||||
return content.split('\n')
|
||||
|
||||
def render():
|
||||
rows, cols = get_terminal_size()
|
||||
view_height = rows - 1 # bottom row = status bar
|
||||
|
||||
# Move cursor to top-left, clear screen
|
||||
out = '\033[H\033[2J'
|
||||
|
||||
for i in range(view_height):
|
||||
line_idx = scroll + i
|
||||
if line_idx < len(lines):
|
||||
line = lines[line_idx]
|
||||
out += f'\033[{i+1};1H{line}\033[K'
|
||||
else:
|
||||
print(buf.getvalue())
|
||||
out += f'\033[{i+1};1H\033[K'
|
||||
|
||||
return raw.get('tasks', [])
|
||||
# Status bar (bottom row, reverse video)
|
||||
if input_mode:
|
||||
bar = f" Task #: {input_buf}\u2588"
|
||||
elif status_msg:
|
||||
bar = f" {status_msg}"
|
||||
else:
|
||||
bar = " \u2191\u2193/jk scroll | r refresh | : task# | b back | q quit"
|
||||
|
||||
# Scroll percentage
|
||||
if len(lines) > view_height:
|
||||
pct = min(100, int((scroll + view_height) / max(1, len(lines)) * 100))
|
||||
bar_right = f" {pct}% "
|
||||
else:
|
||||
bar_right = ""
|
||||
|
||||
def interactive_mode(tasks, range_days, client):
|
||||
"""Interactive prompt — type a task number for details, 'r' to refresh."""
|
||||
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
print(f"{DIM}Commands: task number | r = refresh | q = quit{RESET}")
|
||||
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
bar_pad = cols - len(bar) - len(bar_right)
|
||||
if bar_pad < 0:
|
||||
bar_pad = 0
|
||||
out += f'\033[{rows};1H\033[7m{bar}{" " * bar_pad}{bar_right}\033[0m'
|
||||
|
||||
sys.stdout.write(out)
|
||||
sys.stdout.flush()
|
||||
|
||||
def load_briefing():
|
||||
nonlocal lines, tasks, scroll, status_msg, briefing_lines
|
||||
status_msg = "Loading..."
|
||||
render()
|
||||
content, tasks = fetch_briefing_content(range_days, client)
|
||||
lines = split_lines(content)
|
||||
briefing_lines = lines[:]
|
||||
scroll = 0
|
||||
status_msg = ""
|
||||
|
||||
def load_task(nid):
|
||||
nonlocal lines, scroll, status_msg
|
||||
status_msg = f"Loading #{nid}..."
|
||||
render()
|
||||
content = fetch_task_content(nid)
|
||||
lines = split_lines(content)
|
||||
scroll = 0
|
||||
status_msg = ""
|
||||
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
load_briefing()
|
||||
|
||||
while True:
|
||||
try:
|
||||
inp = input(f"\n{BOLD}{CYAN}#{RESET} ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
render()
|
||||
key = read_key(fd)
|
||||
rows, cols = get_terminal_size()
|
||||
view_height = rows - 1
|
||||
max_scroll = max(0, len(lines) - view_height)
|
||||
|
||||
if not inp or inp.lower() in ('q', 'quit', 'exit'):
|
||||
break
|
||||
|
||||
# Refresh
|
||||
if inp.lower() in ('r', 'refresh'):
|
||||
print(f"\n{DIM}Refreshing...{RESET}\n")
|
||||
tasks = fetch_and_render(range_days, client)
|
||||
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
print(f"{DIM}Commands: task number | r = refresh | q = quit{RESET}")
|
||||
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
if input_mode:
|
||||
if key == 'ESC':
|
||||
input_mode = False
|
||||
input_buf = ""
|
||||
elif key in ('\r', '\n'):
|
||||
input_mode = False
|
||||
task_num = input_buf.strip().lstrip('#')
|
||||
input_buf = ""
|
||||
if task_num.isdigit():
|
||||
viewing_task = True
|
||||
load_task(int(task_num))
|
||||
elif key in ('\x7f', '\x08'): # Backspace
|
||||
input_buf = input_buf[:-1]
|
||||
elif len(key) == 1 and ord(key) >= 32:
|
||||
input_buf += key
|
||||
continue
|
||||
|
||||
# Strip # prefix if present
|
||||
inp = inp.lstrip('#')
|
||||
if key == 'q':
|
||||
break
|
||||
elif key == 'r':
|
||||
viewing_task = False
|
||||
load_briefing()
|
||||
elif key == ':':
|
||||
input_mode = True
|
||||
input_buf = ""
|
||||
elif key == 'b':
|
||||
if viewing_task:
|
||||
viewing_task = False
|
||||
lines = briefing_lines[:]
|
||||
scroll = 0
|
||||
elif key in ('UP', 'k'):
|
||||
scroll = max(0, scroll - 1)
|
||||
elif key in ('DOWN', 'j'):
|
||||
scroll = min(max_scroll, scroll + 1)
|
||||
elif key == 'PGUP':
|
||||
scroll = max(0, scroll - view_height)
|
||||
elif key == 'PGDN':
|
||||
scroll = min(max_scroll, scroll + view_height)
|
||||
elif key in ('HOME', 'g'):
|
||||
scroll = 0
|
||||
elif key in ('END', 'G'):
|
||||
scroll = max_scroll
|
||||
elif key == 'SCROLLUP':
|
||||
scroll = max(0, scroll - 3)
|
||||
elif key == 'SCROLLDN':
|
||||
scroll = min(max_scroll, scroll + 3)
|
||||
elif key == ' ':
|
||||
scroll = min(max_scroll, scroll + view_height)
|
||||
|
||||
if inp.isdigit():
|
||||
show_task_detail(int(inp))
|
||||
else:
|
||||
print(f" {DIM}Commands: task number | r = refresh | q = quit{RESET}")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
pass
|
||||
finally:
|
||||
# Restore terminal
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
sys.stdout.write('\033[?1000l\033[?1006l') # disable mouse
|
||||
sys.stdout.write('\033[?25h') # show cursor
|
||||
sys.stdout.write('\033[?1049l') # restore main screen
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
@@ -549,11 +688,12 @@ def main():
|
||||
if args:
|
||||
client = args[0]
|
||||
|
||||
tasks = fetch_and_render(range_days, client)
|
||||
|
||||
# Interactive mode
|
||||
if sys.stdin.isatty():
|
||||
interactive_mode(tasks, range_days, client)
|
||||
if sys.stdout.isatty():
|
||||
tui_viewer(range_days, client)
|
||||
else:
|
||||
# Non-interactive: just print
|
||||
content, _ = fetch_briefing_content(range_days, client)
|
||||
print(content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user