feat: click task numbers to open detail view

Click on any #NNNN task number in the briefing to load its
full detail view. Uses SGR mouse protocol for accurate click
position, strips ANSI to find task IDs in visible text.
Tolerates clicks within 5 chars of the # symbol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 13:40:19 +02:00
parent 365c9118bf
commit 730f572806

View File

@@ -528,25 +528,57 @@ def get_terminal_size():
def read_key(fd): def read_key(fd):
"""Read a keypress from raw terminal. Returns key string.""" """Read a keypress from raw terminal. Returns key string or ('CLICK', row, col) tuple."""
ch = os.read(fd, 1) ch = os.read(fd, 1)
if not ch: if not ch:
return '' return ''
if ch == b'\x1b': if ch == b'\x1b':
# Escape sequence # Read next bytes
seq = os.read(fd, 2) b2 = os.read(fd, 1)
if seq == b'[A': return 'UP' if b2 != b'[':
if seq == b'[B': return 'DOWN' return 'ESC'
if seq == b'[5': os.read(fd, 1); return 'PGUP' b3 = os.read(fd, 1)
if seq == b'[6': os.read(fd, 1); return 'PGDN'
if seq == b'[H': return 'HOME' # SGR mouse: \033[<btn;x;y;M or \033[<btn;x;y;m
if seq == b'[F': return 'END' if b3 == b'<':
if seq == b'[M': # Read until M or m
# Mouse event: read 3 more bytes buf = b''
while True:
c = os.read(fd, 1)
if c in (b'M', b'm'):
break
buf += c
parts = buf.decode().split(';')
if len(parts) == 3:
btn = int(parts[0])
col = int(parts[1])
row = int(parts[2])
# btn 64/65 = scroll up/down
if btn == 64: return 'SCROLLUP'
if btn == 65: return 'SCROLLDN'
# btn 0 = left click (on release: c == b'm')
if btn == 0 and c == b'M':
return ('CLICK', row, col)
return ''
# Arrow keys and other sequences
if b3 == b'A': return 'UP'
if b3 == b'B': return 'DOWN'
if b3 == b'5': os.read(fd, 1); return 'PGUP'
if b3 == b'6': os.read(fd, 1); return 'PGDN'
if b3 == b'H': return 'HOME'
if b3 == b'F': return 'END'
# Old-style X10 mouse: \033[M + 3 bytes
if b3 == b'M':
mouse = os.read(fd, 3) mouse = os.read(fd, 3)
btn = mouse[0] - 32 btn = mouse[0] - 32
if btn == 64: return 'SCROLLUP' if btn == 64: return 'SCROLLUP'
if btn == 65: return 'SCROLLDN' if btn == 65: return 'SCROLLDN'
if btn == 0:
col = mouse[1] - 32
row = mouse[2] - 32
return ('CLICK', row, col)
return '' return ''
return 'ESC' return 'ESC'
return ch.decode('utf-8', errors='replace') return ch.decode('utf-8', errors='replace')
@@ -578,6 +610,30 @@ def tui_viewer(range_days, client):
needs_redraw[0] = True needs_redraw[0] = True
signal.signal(signal.SIGWINCH, on_resize) signal.signal(signal.SIGWINCH, on_resize)
TASK_ID_RE = re.compile(r'#(\d{3,5})')
def find_task_at_click(row, col):
"""Find a #NNNN task number on the clicked line, closest to click col."""
line_idx = scroll + row - 1 # row is 1-based
if line_idx < 0 or line_idx >= len(lines):
return None
# Strip ANSI to get visible text and map positions
raw = lines[line_idx]
clean = ANSI_ESC.sub('', raw)
# Find all #NNNN in the visible text
best = None
best_dist = 999
for m in TASK_ID_RE.finditer(clean):
start, end = m.start(), m.end()
# Distance from click to the match
if start <= col <= end:
return int(m.group(1))
dist = min(abs(col - start), abs(col - end))
if dist < best_dist and dist <= 5:
best_dist = dist
best = int(m.group(1))
return best
def split_lines(content): def split_lines(content):
return content.split('\n') return content.split('\n')
@@ -686,6 +742,15 @@ def tui_viewer(range_days, client):
input_buf += key input_buf += key
continue continue
# Mouse click — find task number
if isinstance(key, tuple) and key[0] == 'CLICK':
_, click_row, click_col = key
task_id = find_task_at_click(click_row, click_col)
if task_id:
viewing_task = True
load_task(task_id)
continue
if key == 'q': if key == 'q':
break break
elif key == 'r': elif key == 'r':