# Terminal Live View

## Overview

Add a live terminal view feature to ccgram. Users press a "Live" button on the screenshot keyboard or toolbar, and the terminal screenshot auto-refreshes every 5 seconds via `editMessageMedia`. Content-hash gating skips edits when the terminal is unchanged. PNG rendering is optimized for live mode (smaller font, 32-color palette quantization, max compression) to reduce upload size to ~15-40KB per frame. Auto-stops after 5 minutes, on window death, or topic close.

## Context (from discovery)

- **Screenshot renderer**: `src/ccgram/screenshot.py` — `text_to_image(text, font_size=28, with_ansi=True) -> bytes`, runs in `asyncio.to_thread`
- **Screenshot callbacks**: `src/ccgram/handlers/screenshot_callbacks.py` — `_handle_refresh` does single-shot `editMessageMedia` with `InputMediaDocument`; live view uses `InputMediaPhoto`
- **Polling loop**: `src/ccgram/handlers/polling_coordinator.py` — 1s `status_poll_loop`, time-gated tasks via `timers` dict in `periodic_tasks.py`
- **Cleanup registry**: `src/ccgram/topic_state_registry.py` — `@topic_state.register("topic")` for sync cleanup on topic close
- **Callback registry**: `src/ccgram/handlers/callback_registry.py` — `@register(*prefixes)` decorator, longest-prefix dispatch
- **Config**: `src/ccgram/config.py` — `_parse_int_env()` pattern, accessed as `config.live_view_interval`
- **Test patterns**: `AsyncMock(spec=Bot)`, `patch()` context managers, `autouse` fixtures for state cleanup

## Development Approach

- **Testing approach**: Regular (code first, then tests per task)
- Complete each task fully before moving to the next
- Make small, focused changes
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
- **CRITICAL: all tests must pass before starting next task** — `make test`
- **CRITICAL: update this plan file when scope changes during implementation**

## Testing Strategy

- **Unit tests**: LiveViewState hash/skip/timeout logic, keyboard builders, PNG optimization
- **Integration tests**: Not needed — `editMessageMedia` is already battle-tested in `_handle_refresh`
- **No E2E tests**: Feature is purely Telegram API interaction, no agent lifecycle involved

## Progress Tracking

- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with + prefix
- Document issues/blockers with warning prefix
- Update plan if implementation deviates from original scope

## Implementation Steps

### Task 1: Callback constants and config

**Files:**

- Modify: `src/ccgram/handlers/callback_data.py`
- Modify: `src/ccgram/config.py`

- [ ] Add `CB_LIVE_START = "lv:go:"` and `CB_LIVE_STOP = "lv:stop:"` constants to `callback_data.py`
- [ ] Add `live_view_interval: int` (env `CCGRAM_LIVE_VIEW_INTERVAL`, default `5`) to `Config.__init__`
- [ ] Add `live_view_timeout: int` (env `CCGRAM_LIVE_VIEW_TIMEOUT`, default `300`) to `Config.__init__`
- [ ] Write tests for config parsing (success + invalid env) in `tests/ccgram/test_config.py`
- [ ] Run tests — must pass before next task

### Task 2: PNG optimization in screenshot renderer

**Files:**

- Modify: `src/ccgram/screenshot.py`
- Create: `tests/ccgram/test_screenshot_optimization.py`

- [ ] Add `live_mode: bool = False` parameter to `text_to_image()`
- [ ] When `live_mode=True`: use `font_size=20`, `compress_level=9`, `optimize=True`, and `img.quantize(colors=32)` before save
- [ ] When `live_mode=False`: behavior unchanged (backward compatible)
- [ ] Write tests: `live_mode=True` produces valid PNG bytes (can be opened by PIL)
- [ ] Write tests: `live_mode=True` output is smaller than `live_mode=False` for same input
- [ ] Write tests: `live_mode=False` is unchanged (default behavior regression test)
- [ ] Run tests — must pass before next task

### Task 3: LiveViewState and core logic

**Files:**

- Create: `src/ccgram/handlers/live_view.py`
- Create: `tests/ccgram/handlers/test_live_view.py`

- [ ] Define `LiveViewState` dataclass: `chat_id: int`, `message_id: int`, `thread_id: int`, `user_id: int`, `window_id: str`, `pane_id: str | None`, `last_hash: str`, `last_edit_time: float`, `start_time: float`
- [ ] Module-level `_active_views: dict[tuple[int, int], LiveViewState]` keyed by `(user_id, thread_id)` — one live view per topic
- [ ] `start_live_view(state: LiveViewState) -> None` — stores in `_active_views`
- [ ] `stop_live_view(user_id: int, thread_id: int) -> LiveViewState | None` — pops and returns
- [ ] `get_live_view(user_id: int, thread_id: int) -> LiveViewState | None` — lookup
- [ ] `is_live(user_id: int, thread_id: int) -> bool` — quick check
- [ ] `content_hash(text: str) -> str` — `hashlib.md5(text.encode(), usedforsecurity=False).hexdigest()`
- [ ] Register `@topic_state.register("topic")` cleanup that calls `stop_live_view`
- [ ] Write tests: start/stop/get lifecycle
- [ ] Write tests: content_hash deterministic, different input produces different hash
- [ ] Write tests: is_live reflects active state correctly
- [ ] Write tests: cleanup registration removes active view
- [ ] Run tests — must pass before next task

### Task 4: Live view tick function

**Files:**

- Modify: `src/ccgram/handlers/live_view.py`
- Modify: `tests/ccgram/handlers/test_live_view.py`

- [ ] `async def tick_live_views(bot: Bot) -> None` — iterates `_active_views.copy()`, for each:
  - Capture pane via `tmux_manager.capture_pane` (or `capture_pane_by_id` if `pane_id` set)
  - Hash content, skip if unchanged (`last_hash` matches)
  - Check timeout: if `time.monotonic() - start_time > config.live_view_timeout`, stop and edit caption to "Live view ended (timeout)"
  - Check window alive: if `find_window_by_id` returns None, stop and edit caption to "Live view ended (window closed)"
  - Render via `text_to_image(text, with_ansi=True, live_mode=True)`
  - Call `bot.edit_message_media(chat_id=..., message_id=..., media=InputMediaPhoto(...), reply_markup=build_live_keyboard(...))`
  - Update `last_hash` and `last_edit_time`
  - On `TelegramError`: log and stop that view (message may have been deleted)
- [ ] Write tests: tick skips when hash unchanged
- [ ] Write tests: tick renders and edits when hash changed
- [ ] Write tests: tick auto-stops on timeout
- [ ] Write tests: tick auto-stops on dead window
- [ ] Write tests: tick handles TelegramError gracefully (stops view, no crash)
- [ ] Run tests — must pass before next task

### Task 5: Keyboard builders and callback handlers

**Files:**

- Modify: `src/ccgram/handlers/screenshot_callbacks.py`
- Modify: `tests/ccgram/handlers/test_live_view.py`

- [ ] `build_live_keyboard(window_id, pane_id=None) -> InlineKeyboardMarkup` — same quick keys as screenshot keyboard but bottom row is `[Stop Live]` instead of `[Live] [Refresh]`
- [ ] Modify `build_screenshot_keyboard()` — add `[Live]` button next to `[Refresh]` in bottom row: `[Live] [Refresh]`
- [ ] Modify `build_toolbar_keyboard()` — add `[Live]` button (row 1, after Screenshot)
- [ ] `_handle_live_start(query, user_id, data, update)` — extract window_id, auth check, capture+render first frame, edit message to live screenshot with `build_live_keyboard`, call `start_live_view()`
- [ ] `_handle_live_stop(query, user_id, data, update)` — call `stop_live_view()`, edit keyboard back to `build_screenshot_keyboard()`, answer "Stopped"
- [ ] Register `CB_LIVE_START` and `CB_LIVE_STOP` in the `@register(...)` decorator and dispatch in `handle_screenshot_callback()`
- [ ] Write tests: `build_live_keyboard` has Stop button, no Refresh/Live
- [ ] Write tests: `build_screenshot_keyboard` now has Live button
- [ ] Write tests: `build_toolbar_keyboard` now has Live button
- [ ] Write tests: `_handle_live_start` creates live view state and edits message
- [ ] Write tests: `_handle_live_stop` removes live view state and restores keyboard
- [ ] Write tests: auth check rejects non-owner
- [ ] Run tests — must pass before next task

### Task 6: Poll loop integration

**Files:**

- Modify: `src/ccgram/handlers/periodic_tasks.py`
- Modify: `src/ccgram/handlers/polling_coordinator.py`
- Modify: `tests/ccgram/handlers/test_live_view.py`

- [ ] Add `LIVE_VIEW_TICK_INTERVAL = 5.0` constant to `periodic_tasks.py`
- [ ] Add `"live_view": 0.0` to `timers` dict in `status_poll_loop()`
- [ ] Add time-gated call in `run_periodic_tasks()`: `tick_live_views(bot)` every `LIVE_VIEW_TICK_INTERVAL`
- [ ] Write test: `run_periodic_tasks` calls `tick_live_views` when interval elapsed
- [ ] Write test: `run_periodic_tasks` skips `tick_live_views` when interval not elapsed
- [ ] Run tests — must pass before next task

### Task 7: Verify acceptance criteria

- [ ] Verify: Live button appears on screenshot keyboard and toolbar
- [ ] Verify: Pressing Live starts auto-refresh, Stop stops it
- [ ] Verify: Content-hash gating skips edits on idle terminal
- [ ] Verify: Auto-stop fires after timeout
- [ ] Verify: Topic close stops live view (cleanup registry)
- [ ] Verify: `live_mode=True` PNG is smaller than regular
- [ ] Run full test suite: `make test`
- [ ] Run lint + typecheck: `make lint && make typecheck`

### Task 8: [Final] Documentation

- [ ] Update CLAUDE.md if new patterns discovered (e.g., live view config env vars)
- [ ] Move this plan to `docs/plans/completed/`

## Technical Details

### LiveViewState

```python
@dataclass
class LiveViewState:
    chat_id: int
    message_id: int
    thread_id: int
    user_id: int
    window_id: str
    pane_id: str | None
    last_hash: str
    last_edit_time: float
    start_time: float
```

### State key: `(user_id, thread_id)` — one live view per topic per user.

### Tick flow (every 5s)

```
for each active view:
  text = capture_pane(window_id)
  if text is None → stop (window dead)
  hash = md5(text)
  if hash == last_hash → skip
  if monotonic() - start_time > timeout → stop
  png = text_to_image(text, live_mode=True)
  bot.edit_message_media(chat_id, message_id, InputMediaPhoto(png))
  update last_hash, last_edit_time
```

### PNG optimization (live_mode only)

- `font_size=20` (vs 28 default)
- `img.quantize(colors=32)` before save
- `img.save(buf, format="PNG", optimize=True, compress_level=9)` (vs defaults)

### Callback data format

- `CB_LIVE_START = "lv:go:"` → `"lv:go:@0"` or `"lv:go:@0:%3"` (with pane)
- `CB_LIVE_STOP = "lv:stop:"` → `"lv:stop:@0"` or `"lv:stop:@0:%3"`

### Config env vars

- `CCGRAM_LIVE_VIEW_INTERVAL` — refresh interval in seconds (default: 5)
- `CCGRAM_LIVE_VIEW_TIMEOUT` — auto-stop timeout in seconds (default: 300)

## Post-Completion

**Manual verification:**

- Test on real phone: verify PNG readability at 20px font on both iOS and Android Telegram
- Test with active Claude Code session: verify 5s refresh captures terminal changes
- Test rate limits: run live view for several minutes, confirm no 429 errors
- Test concurrent: two topics with live view simultaneously
