From de98efde3c189a1e3df1b8abe9c8b02e5ef5dcf8 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 13 Apr 2026 13:32:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20full=20TUI=20viewer=20=E2=80=94=20scrol?= =?UTF-8?q?l,=20refresh,=20task=20lookup=20without=20leaving?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- briefing | 258 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 199 insertions(+), 59 deletions(-) diff --git a/briefing b/briefing index e7472f6..0aa831d 100755 --- a/briefing +++ b/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()) - 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__":