# PRD: TUI Session Manager

**Module:** `tui-sm/` | **Namespace:** `ajet.chat.tui-sm.*`
**Status:** v1 | **Last updated:** 2026-02-17

---

## 1. Overview

The TUI Session Manager serves terminal clients via SSE. It holds live connections from TUI clients, subscribes to NATS for real-time events, and calls the API for data. It acts as an intermediary — translating NATS events into structured SSE events that the TUI client renders.

Unlike Web SM (which sends HTML fragments), TUI SM sends **JSON-structured events** over SSE. The TUI client handles all rendering.

## 2. Architecture

```
TUI Client ←─ SSE (JSON events) ──→ TUI SM ──→ NATS (subscribe)
           ←─ HTTP POST (signals) ──→       ──→ API (HTTP, data)
```

## 3. SSE Event Protocol

### 3.1 Connection

**Endpoint:** `GET /tui/sse/events`
**Headers:**
- `Accept: text/event-stream`
- `X-User-Id`, `X-Trace-Id` (injected by Auth GW)

**Query params:**
- `community_id=<uuid>` — active community (optional, can start with DMs)
- `last_event_id=<id>` — for reconnection replay

### 3.2 SSE Event Format

```
event: <event-type>
id: <sequential-id>
data: <json-payload>
```

### 3.3 Event Types

| Event Type | Payload | Description |
|------------|---------|-------------|
| `init` | `{communities, channels, dms, user}` | Initial state on connect |
| `message.new` | `{message}` | New message in subscribed channel |
| `message.edit` | `{message}` | Message edited |
| `message.delete` | `{message_id, channel_id}` | Message deleted |
| `reaction.add` | `{message_id, emoji, user_id}` | Reaction added |
| `reaction.remove` | `{message_id, emoji, user_id}` | Reaction removed |
| `typing.start` | `{channel_id, user_id, username}` | User started typing |
| `typing.stop` | `{channel_id, user_id}` | User stopped typing |
| `presence.update` | `{user_id, status}` | Online/offline change |
| `channel.new` | `{channel}` | New channel in community |
| `channel.update` | `{channel}` | Channel updated (topic, name) |
| `channel.delete` | `{channel_id}` | Channel deleted |
| `member.join` | `{channel_id, user}` | User joined channel |
| `member.leave` | `{channel_id, user_id}` | User left channel |
| `notification` | `{notification}` | New notification for user |
| `unread.update` | `{channel_id, count, mentions}` | Unread count changed |
| `ping` | `{}` | Keepalive (every 30s) |

### 3.4 Init Event

Sent immediately after SSE connection established:

```json
{
  "communities": [
    {"id": "uuid", "name": "My Team", "slug": "my-team", "role": "owner"}
  ],
  "channels": {
    "community-uuid": [
      {"id": "uuid", "name": "general", "type": "text", "category": "General",
       "unread_count": 3, "mention_count": 1}
    ]
  },
  "dms": [
    {"id": "uuid", "type": "dm", "members": [...], "unread_count": 0}
  ],
  "user": {"id": "uuid", "username": "alice", "display_name": "Alice"}
}
```

## 4. Client → Server Signals (HTTP POST)

| Action | Endpoint | Payload |
|--------|----------|---------|
| Send message | `POST /tui/messages` | `{channel_id, body_md, parent_id?}` |
| Edit message | `POST /tui/messages/:id/edit` | `{body_md}` |
| Delete message | `POST /tui/messages/:id/delete` | — |
| Add reaction | `POST /tui/reactions` | `{message_id, emoji}` |
| Remove reaction | `POST /tui/reactions/remove` | `{message_id, emoji}` |
| Switch channel | `POST /tui/navigate` | `{channel_id, community_id?}` |
| Mark read | `POST /tui/read` | `{channel_id, message_id}` |
| Typing | `POST /tui/typing` | `{channel_id}` |
| Heartbeat | `POST /tui/heartbeat` | — |
| Fetch messages | `GET /tui/messages` | `?channel_id=<uuid>&before=<uuid>&limit=50` |
| Search | `GET /tui/search` | `?q=<query>&type=<type>` |
| Slash command | `POST /tui/command` | `{command, channel_id, community_id}` |

All responses are JSON.

## 5. Connection Tracking

```clojure
{user-id {:sse-channel       <http-kit-channel>
          :active-community  uuid-or-nil
          :active-channel    uuid-or-nil
          :nats-subs         [sub-handles...]
          :last-event-id     int
          :connected-at      instant}}
```

- On connect: send `init` event, subscribe to user's NATS subjects
- On disconnect: unsubscribe from NATS, clean up state
- On community switch: update NATS subscriptions
- Keepalive pings every 30 seconds to detect dead connections

## 6. Presence Batching

TUI SM buffers presence events to avoid flooding clients:
- Collect heartbeat events over 60-second windows
- On flush: diff with previous state, send only changes
- Typing indicators sent immediately (latency-sensitive)

## 7. Service Configuration

### 7.1 Config Shape

```clojure
{:server {:host "0.0.0.0" :port 3003}
 :api    {:base-url "http://localhost:3001"}
 :nats   {:url "nats://localhost:4222"
          :stream-name "ajet-events"}
 :session {:max-connections 5000          ;; max concurrent SSE connections
           :ping-interval-sec 30}
 :presence {:batch-interval-sec 60}}
```

### 7.2 Startup / Shutdown Sequence

**Startup:**
```
1. Load config
2. Connect to NATS
3. Initialize connection tracker (atom)
4. Start http-kit server
5. Start ping scheduler (30s interval)
6. Log "TUI SM started on port {port}"
```

**Shutdown (graceful):**
```
1. Stop accepting new SSE connections
2. Send close event to all connected TUI clients
3. Stop ping scheduler
4. Unsubscribe all NATS subscriptions
5. Close NATS connection
6. Stop http-kit server
7. Log "TUI SM stopped"
```

### 7.3 Health Check

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/tui/health` | None | Service health |

```json
{"status": "ok", "connections": 23, "checks": {"api": "ok", "nats": "ok"}}
```

### 7.4 Error Handling

| Scenario | Behavior |
|----------|----------|
| API unavailable | Client signals (POST) return 502 JSON error. SSE stays open. |
| NATS unavailable | SSE stays open, no real-time events. Events resume when NATS reconnects. Status event sent to client. |
| Client sends invalid JSON | Return 400 with error description |
| Client sends to unauthorized channel | Return 403 |
| SSE write fails (dead connection) | Clean up connection state, unsubscribe NATS |
| Max connections reached | Return 503 `{"error": "max connections reached, retry later"}` |

### 7.5 Backpressure

- If a client's SSE buffer exceeds 1000 events, disconnect the client (forces reconnect with replay)
- Events are dropped (not queued) if write buffer is full — JetStream replay handles recovery
- Connection tracking atom is bounded by `max-connections` config

---

## 8. Test Cases

### 8.1 SSE Connection

| ID | Test | Description |
|----|------|-------------|
| TSM-T1 | SSE connect | GET /tui/sse/events returns text/event-stream |
| TSM-T2 | Init event sent | First event is `init` with communities, channels, DMs, user |
| TSM-T3 | NATS subscribed on connect | Server subscribes to user's community subjects |
| TSM-T4 | Ping keepalive | Ping event sent every 30 seconds |
| TSM-T5 | Disconnect cleanup | SSE disconnect unsubscribes from NATS |
| TSM-T6 | Reconnect with last_event_id | Missed events replayed via JetStream |

### 8.2 Real-Time Events

| ID | Test | Description |
|----|------|-------------|
| TSM-T7 | New message event | Message from NATS → SSE `message.new` event to client |
| TSM-T8 | Message edit event | Edit from NATS → SSE `message.edit` event |
| TSM-T9 | Message delete event | Delete from NATS → SSE `message.delete` event |
| TSM-T10 | Reaction events | Add/remove from NATS → SSE reaction events |
| TSM-T11 | Typing indicator | Typing from NATS → SSE `typing.start` event |
| TSM-T12 | Typing auto-expire | `typing.stop` sent after 15 seconds of silence |
| TSM-T13 | Presence update | Batched presence diffs sent every 60 seconds |
| TSM-T14 | Channel events | New/update/delete channel events forwarded |
| TSM-T15 | Notification event | User-targeted notification forwarded |
| TSM-T16 | Unread count update | New message in other channel → unread count SSE event |

### 8.3 Client Signals

| ID | Test | Description |
|----|------|-------------|
| TSM-T17 | Send message | POST /tui/messages → API call → success response |
| TSM-T18 | Edit message | POST /tui/messages/:id/edit → API call → success |
| TSM-T19 | Delete message | POST /tui/messages/:id/delete → API call → success |
| TSM-T20 | Switch channel | POST /tui/navigate → NATS sub update → returns channel messages |
| TSM-T21 | Fetch older messages | GET /tui/messages with before cursor → paginated results |
| TSM-T22 | Search | GET /tui/search → API search → JSON results |
| TSM-T23 | Slash command | POST /tui/command → API command → result |
| TSM-T24 | Heartbeat | POST /tui/heartbeat → proxied to API |

### 8.4 Edge Cases

| ID | Test | Description |
|----|------|-------------|
| TSM-T25 | Multiple TUI clients | Same user, two connections — both receive events |
| TSM-T26 | API unavailable | Returns 502, SSE stays open |
| TSM-T27 | NATS unavailable | SSE stays open, events resume when NATS recovers |
| TSM-T28 | Event filtering | Events for non-subscribed channels not forwarded |
| TSM-T29 | DM events | DM messages forwarded via `chat.dm.{channel-id}` subscription |
| TSM-T30 | Large init payload | Init event with many channels/DMs serializes correctly |
