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):
"""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)
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
# Read next bytes
b2 = os.read(fd, 1)
if b2 != b'[':
return 'ESC'
b3 = os.read(fd, 1)
# SGR mouse: \033[<btn;x;y;M or \033[<btn;x;y;m
if b3 == b'<':
# Read until M or m
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)
btn = mouse[0] - 32
if btn == 64: return 'SCROLLUP'
if btn == 65: return 'SCROLLDN'
if btn == 0:
col = mouse[1] - 32
row = mouse[2] - 32
return ('CLICK', row, col)
return ''
return 'ESC'
return ch.decode('utf-8', errors='replace')
@@ -578,6 +610,30 @@ def tui_viewer(range_days, client):
needs_redraw[0] = True
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):
return content.split('\n')
@@ -686,6 +742,15 @@ def tui_viewer(range_days, client):
input_buf += key
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':
break
elif key == 'r':