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:
2026-04-13 13:32:40 +02:00
parent a344a9032d
commit de98efde3c

258
briefing
View File

@@ -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())
else:
print(buf.getvalue())
return raw.get('tasks', [])
return buf.getvalue(), raw.get('tasks', [])
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}")
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()
while True:
try:
inp = input(f"\n{BOLD}{CYAN}#{RESET} ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not inp or inp.lower() in ('q', 'quit', 'exit'):
break
# ── Terminal helpers ────────────────────────────────────────────────
# 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}")
continue
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
# Strip # prefix if present
inp = inp.lstrip('#')
if inp.isdigit():
show_task_detail(int(inp))
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:
out += f'\033[{i+1};1H\033[K'
# Status bar (bottom row, reverse video)
if input_mode:
bar = f" Task #: {input_buf}\u2588"
elif status_msg:
bar = f" {status_msg}"
else:
print(f" {DIM}Commands: task number | r = refresh | q = quit{RESET}")
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 = ""
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:
render()
key = read_key(fd)
rows, cols = get_terminal_size()
view_height = rows - 1
max_scroll = max(0, len(lines) - view_height)
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
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)
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__":