add resizing

This commit is contained in:
2026-01-20 15:31:41 -05:00
parent 66b4acaf42
commit b6f772f901
22 changed files with 1727 additions and 420 deletions
+87
View File
@@ -0,0 +1,87 @@
# Server CLAUDE.md
Clojure backend for Spiceflow.
## Commands
```bash
clj -M:dev # REPL with dev tools
clj -M:run # Production mode
clj -M:test # Unit tests
clj -M:test --focus ns # Specific namespace
```
**REPL commands** (in `dev/user.clj`):
```clojure
(go) ; Start server + auto-reload
(reset) ; Reload code + restart
(stop) ; Stop server
(reload) ; Reload code only
(reload-all) ; Force reload all namespaces
```
## Directory Structure
```
server/
├── src/spiceflow/
│ ├── core.clj # Entry point, Mount states
│ ├── config.clj # Configuration (aero)
│ ├── db/ # Database layer
│ ├── adapters/ # CLI integrations
│ ├── api/ # HTTP & WebSocket
│ ├── session/ # Session lifecycle
│ ├── push/ # Push notifications
│ └── terminal/ # Terminal diff caching
├── dev/user.clj # REPL helpers
├── test/ # Unit tests
├── resources/config.edn # Configuration
└── deps.edn # Dependencies
```
## Mount States (start order)
1. `store` - SQLite database
2. `push` - Push notification store
3. `server` - Jetty HTTP server
## Namespaces
| Namespace | Purpose |
|-----------|---------|
| `spiceflow.core` | Entry point, Mount states |
| `spiceflow.config` | Configuration |
| `spiceflow.db.protocol` | DataStore protocol |
| `spiceflow.db.sqlite` | SQLite implementation |
| `spiceflow.db.memory` | In-memory (tests) |
| `spiceflow.adapters.protocol` | AgentAdapter protocol |
| `spiceflow.adapters.claude` | Claude Code CLI |
| `spiceflow.adapters.opencode` | OpenCode CLI |
| `spiceflow.adapters.tmux` | Tmux terminal |
| `spiceflow.api.routes` | HTTP handlers |
| `spiceflow.api.websocket` | WebSocket management |
| `spiceflow.session.manager` | Session lifecycle |
## Configuration (aero)
```edn
{:server {:port #long #or [#env SPICEFLOW_PORT 3000]
:host #or [#env SPICEFLOW_HOST "0.0.0.0"]}
:database {:type :sqlite
:dbname #or [#env SPICEFLOW_DB "spiceflow.db"]}}
```
Access: `(get-in config/config [:server :port])`
## Conventions
| Thing | Convention |
|-------|------------|
| Files | kebab-case |
| Namespaces | kebab-case |
| Functions | kebab-case |
| Protocols | PascalCase |
| Records | PascalCase |
| Private vars | `^:private` |
Thread safety: `ConcurrentHashMap` for processes, `atom` for simpler state.
+50
View File
@@ -0,0 +1,50 @@
# Spiceflow Core CLAUDE.md
## Files
| File | Purpose |
|------|---------|
| `core.clj` | Entry point, Mount states, server wiring |
| `config.clj` | Configuration loading |
## core.clj
Mount states (start order):
1. `store` - SQLite via `sqlite/create-store`
2. `push` - Push store using same DB connection
3. `server` - Jetty with WebSocket support
Server wiring:
```clojure
(ws/set-pending-permission-fn! (partial manager/get-pending-permission store))
(manager/set-push-store! push)
(routes/create-app store ws/broadcast-to-session push)
```
## Subdirectories
| Directory | Purpose |
|-----------|---------|
| `db/` | DataStore protocol + implementations |
| `adapters/` | AgentAdapter protocol + CLI integrations |
| `api/` | HTTP routes + WebSocket |
| `session/` | Session state machine |
| `push/` | Push notifications |
| `terminal/` | Terminal diff caching |
## Patterns
**Dependency injection via currying:**
```clojure
(defn create-app [store broadcast-fn push-store]
(let [handlers (make-handlers store broadcast-fn)]
(ring/ring-handler (router handlers))))
```
**Async processing:**
```clojure
(defn handler [store broadcast-fn]
(fn [request]
(future (manager/stream-session-response ...))
{:status 200 :body {:status "sent"}}))
```
+71
View File
@@ -0,0 +1,71 @@
# Adapters CLAUDE.md
CLI integrations for Claude Code, OpenCode, and tmux.
## Files
| File | Purpose |
|------|---------|
| `protocol.clj` | AgentAdapter protocol |
| `claude.clj` | Claude Code CLI |
| `opencode.clj` | OpenCode CLI |
| `tmux.clj` | Tmux terminal |
## AgentAdapter Protocol
```clojure
(defprotocol AgentAdapter
(provider-name [this]) ; :claude, :opencode, :tmux
(discover-sessions [this]) ; Find existing sessions
(spawn-session [this session-id opts]) ; Start CLI process
(send-message [this handle message]) ; Write to stdin
(read-stream [this handle callback]) ; Read JSONL, call callback
(kill-process [this handle]) ; Terminate process
(parse-output [this line])) ; Parse JSONL line to event
```
## Process Handle
All adapters return:
```clojure
{:process java.lang.Process
:stdin java.io.Writer
:stdout java.io.BufferedReader}
```
## Event Types
| Event | Description |
|-------|-------------|
| `:init` | Process started, includes `:cwd` |
| `:content-delta` | Streaming text chunk |
| `:message-stop` | Message complete |
| `:permission-request` | Permission needed |
| `:working-dir-update` | CWD changed |
| `:error` | Error occurred |
## Claude Adapter
**Spawn command:**
```bash
claude --print --output-format stream-json \
--resume <external-id> \
--allowedTools '["Write","Edit"]' \
-- <working-dir>
```
**Session discovery:** Reads `~/.claude/projects/*/sessions/*.json`
**Permission detection:** Looks for `tool_denied` in result event.
## Tmux Adapter
- No JSONL (raw terminal)
- Session names: `spiceflow-{adjective}-{noun}-{4digits}`
- Diff-based terminal updates
## Adding an Adapter
1. Implement `AgentAdapter` protocol in new file
2. Register in `api/routes.clj` `get-adapter` function
3. Add provider to `db/protocol.clj` `valid-session?`
+76 -5
View File
@@ -252,16 +252,35 @@
[]
(->TmuxAdapter))
;; Pattern to match clear screen escape sequences
;; \x1b[H moves cursor home, \x1b[2J clears screen, \x1b[3J clears scrollback
(def ^:private clear-screen-pattern #"\u001b\[H\u001b\[2J|\u001b\[2J\u001b\[H|\u001b\[2J|\u001b\[3J")
(defn- content-after-last-clear
"Return content after the last clear screen sequence, or full content if no clear found."
[content]
(if (str/blank? content)
content
(let [matcher (re-matcher clear-screen-pattern content)]
(loop [last-end nil]
(if (.find matcher)
(recur (.end matcher))
(if last-end
(subs content last-end)
content))))))
(defn capture-pane
"Capture the current content of a tmux pane.
Returns the visible terminal content as a string, or nil if session doesn't exist."
Returns the visible terminal content as a string, or nil if session doesn't exist.
Preserves ANSI escape sequences for color rendering in the client.
Content before the last clear screen sequence is stripped."
[session-name]
(when session-name
;; Use capture-pane with -p to print to stdout, -e to include escape sequences (then strip them)
;; -S - and -E - captures the entire scrollback history
(let [result (shell/sh "tmux" "capture-pane" "-t" session-name "-p" "-S" "-1000")]
;; Use capture-pane with -p to print to stdout, -e to include escape sequences
;; -S -1000 captures scrollback history
(let [result (shell/sh "tmux" "capture-pane" "-t" session-name "-p" "-e" "-S" "-1000")]
(when (zero? (:exit result))
(strip-ansi (:out result))))))
(content-after-last-clear (:out result))))))
(defn get-session-name
"Get the tmux session name for a spiceflow session.
@@ -317,3 +336,55 @@
(let [result (shell/sh "tmux" "rename-session" "-t" old-name new-name)]
(when (zero? (:exit result))
new-name))))
;; Screen size presets for different device orientations
(def ^:private screen-sizes
{:desktop {:width 180 :height 50}
:landscape {:width 100 :height 30}
:portrait {:width 40 :height 35}})
(defn resize-session
"Resize a tmux session window to a preset size.
mode should be :desktop, :landscape, or :portrait.
Returns true on success, nil on failure."
[session-name mode]
(when (and session-name mode)
(let [{:keys [width height]} (get screen-sizes mode)]
(when (and width height)
(log/debug "[Tmux] Resizing session" session-name "to" mode "(" width "x" height ")")
;; Resize the window - need to use resize-window on the session's window
(let [result (shell/sh "tmux" "resize-window" "-t" session-name "-x" (str width) "-y" (str height))]
(when (zero? (:exit result))
true))))))
(defn get-window-size
"Get the current window size of a tmux session.
Returns {:width N :height M} or nil if session doesn't exist."
[session-name]
(when session-name
(let [result (shell/sh "tmux" "display-message" "-t" session-name "-p" "#{window_width}x#{window_height}")]
(when (zero? (:exit result))
(let [output (str/trim (:out result))
[width height] (str/split output #"x")]
(when (and width height)
{:width (parse-long width)
:height (parse-long height)}))))))
(defn detect-layout-mode
"Detect the current layout mode based on tmux window size.
Returns :desktop, :landscape, :portrait, or nil if unknown."
[session-name]
(when-let [{:keys [width height]} (get-window-size session-name)]
;; Find the closest matching preset
(let [presets (vec screen-sizes)
distances (map (fn [[mode {:keys [width w height h] :as preset-size}]]
(let [pw (:width preset-size)
ph (:height preset-size)]
{:mode mode
:distance (+ (Math/abs (- width pw))
(Math/abs (- height ph)))}))
presets)
closest (first (sort-by :distance distances))]
;; Only return the mode if it's an exact match or very close
(when (and closest (<= (:distance closest) 5))
(:mode closest)))))
+68
View File
@@ -0,0 +1,68 @@
# API CLAUDE.md
HTTP and WebSocket handlers.
## Files
| File | Purpose |
|------|---------|
| `routes.clj` | HTTP endpoints |
| `websocket.clj` | WebSocket management |
## Endpoints
| Method | Path | Handler |
|--------|------|---------|
| GET | `/api/health` | `health-handler` |
| GET | `/api/sessions` | `get-sessions-handler` |
| POST | `/api/sessions` | `create-session-handler` |
| GET | `/api/sessions/:id` | `get-session-handler` |
| PATCH | `/api/sessions/:id` | `update-session-handler` |
| DELETE | `/api/sessions/:id` | `delete-session-handler` |
| POST | `/api/sessions/:id/send` | `send-message-handler` |
| POST | `/api/sessions/:id/permission` | `permission-handler` |
| GET | `/api/sessions/:id/terminal` | `get-terminal-handler` |
| POST | `/api/sessions/:id/terminal/input` | `send-terminal-input` |
## Handler Pattern
```clojure
(defn my-handler [store broadcast-fn]
(fn [request]
(let [id (get-in request [:path-params :id])
body (:body request)]
{:status 200 :body {:result "ok"}})))
```
## WebSocket
**State:**
```clojure
(defonce ^:private all-connections (atom #{}))
(defonce ^:private connections (ConcurrentHashMap.))
;; {session-id -> #{socket1 socket2}}
```
**Client sends:**
```json
{"type": "subscribe", "session-id": "uuid"}
{"type": "unsubscribe", "session-id": "uuid"}
```
**Server sends:**
```json
{"event": "subscribed", "session-id": "uuid"}
{"event": "content-delta", "text": "..."}
{"event": "message-stop"}
{"event": "permission-request", "permission-request": {...}}
{"event": "terminal-update", "content": "...", "diff": {...}}
{"event": "error", "message": "..."}
```
**Key functions:**
```clojure
(subscribe-to-session socket session-id)
(broadcast-to-session session-id message)
```
On subscribe, checks for pending permission and sends if exists.
+67 -14
View File
@@ -9,6 +9,7 @@
[spiceflow.session.manager :as manager]
[spiceflow.adapters.protocol :as adapter]
[spiceflow.adapters.tmux :as tmux]
[spiceflow.terminal.diff :as terminal-diff]
[spiceflow.push.protocol :as push-proto]
[clojure.tools.logging :as log]))
@@ -18,6 +19,15 @@
(-> (response/response body)
(response/content-type "application/json")))
(defn- json-response-no-cache
"Create a JSON response with no-cache headers (for polling endpoints)"
[body]
(-> (response/response body)
(response/content-type "application/json")
(response/header "Cache-Control" "no-store, no-cache, must-revalidate")
(response/header "Pragma" "no-cache")
(response/header "Expires" "0")))
(defn- error-response
"Create an error response"
[status message]
@@ -104,13 +114,15 @@
(let [id (get-in request [:path-params :id])]
(log/debug "API request: delete-session" {:session-id id})
(if (tmux-session-id? id)
;; Tmux session - just kill the tmux session
;; Tmux session - kill the session and clean up cache
(if (tmux/session-alive? id)
(do
(log/debug "Killing tmux session:" id)
(let [tmux-adapter (manager/get-adapter :tmux)]
(adapter/kill-process tmux-adapter {:session-name id
:output-file (str "/tmp/spiceflow-tmux-" id ".log")}))
;; Clean up terminal diff cache
(terminal-diff/invalidate-cache id)
(response/status (response/response nil) 204))
(error-response 404 "Session not found"))
;; Regular DB session
@@ -213,22 +225,36 @@
;; Tmux terminal handlers
(defn terminal-capture-handler
"Get the current terminal content for a tmux session.
For ephemeral tmux sessions, the session ID IS the tmux session name."
For ephemeral tmux sessions, the session ID IS the tmux session name.
Returns diff information for efficient updates.
Pass ?fresh=true to force a fresh capture (invalidates cache first)."
[_store]
(fn [request]
(let [id (get-in request [:path-params :id])]
(let [id (get-in request [:path-params :id])
fresh? (= "true" (get-in request [:query-params "fresh"]))]
(if (tmux-session-id? id)
;; Ephemeral tmux session - ID is the session name
(if (tmux/session-alive? id)
(let [content (tmux/capture-pane id)]
(json-response {:content (or content "")
:alive true
:session-name id}))
(error-response 404 "Session not found"))
(do
;; If fresh=true, invalidate cache to ensure full content is returned
(when fresh?
(terminal-diff/invalidate-cache id))
(let [{:keys [content diff]} (terminal-diff/capture-with-diff id tmux/capture-pane)
layout (tmux/detect-layout-mode id)]
(json-response-no-cache {:content (or content "")
:alive true
:session-name id
:diff diff
:layout (when layout (name layout))})))
(do
;; Session died - invalidate cache
(terminal-diff/invalidate-cache id)
(error-response 404 "Session not found")))
(error-response 400 "Not a tmux session")))))
(defn terminal-input-handler
"Send raw input to a tmux session (stdin-style)"
"Send raw input to a tmux session (stdin-style).
Broadcasts diff-based terminal updates after input."
[_store broadcast-fn]
(fn [request]
(let [id (get-in request [:path-params :id])
@@ -238,21 +264,45 @@
(if (tmux/session-alive? id)
(do
(tmux/send-keys-raw id input)
;; Broadcast terminal update after input
;; Broadcast terminal update with diff after input
(future
(Thread/sleep 100) ;; Small delay to let terminal update
(let [content (tmux/capture-pane id)]
(broadcast-fn id {:event :terminal-update
:content (or content "")})))
(let [{:keys [content diff changed]} (terminal-diff/capture-with-diff id tmux/capture-pane)]
(when changed
(broadcast-fn id {:event :terminal-update
:content (or content "")
:diff diff}))))
(json-response {:status "sent"}))
(error-response 400 "Tmux session not alive"))
(error-response 400 "Not a tmux session")))))
(defn terminal-resize-handler
"Resize a tmux session to a preset screen size.
Mode can be: desktop, landscape, or portrait."
[_store]
(fn [request]
(let [id (get-in request [:path-params :id])
mode (keyword (get-in request [:body :mode]))]
(if (tmux-session-id? id)
(if (tmux/session-alive? id)
(if (#{:desktop :landscape :portrait} mode)
(if (tmux/resize-session id mode)
(json-response {:status "resized" :mode (name mode)})
(error-response 500 "Failed to resize tmux session"))
(error-response 400 "Invalid mode. Must be: desktop, landscape, or portrait"))
(error-response 400 "Tmux session not alive"))
(error-response 400 "Not a tmux session")))))
;; Health check
(defn health-handler
[_request]
(json-response {:status "ok" :service "spiceflow"}))
;; Test endpoint for verifying hot reload
(defn ping-handler
[_request]
(json-response {:pong true :time (str (java.time.Instant/now))}))
;; Push notification handlers
(defn vapid-key-handler
"Return the public VAPID key for push subscriptions"
@@ -295,6 +345,7 @@
[store broadcast-fn push-store]
[["/api"
["/health" {:get health-handler}]
["/ping" {:get ping-handler}]
["/sessions" {:get (list-sessions-handler store)
:post (create-session-handler store)}]
["/sessions/:id" {:get (get-session-handler store)
@@ -304,6 +355,7 @@
["/sessions/:id/permission" {:post (permission-response-handler store broadcast-fn)}]
["/sessions/:id/terminal" {:get (terminal-capture-handler store)}]
["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}]
["/sessions/:id/terminal/resize" {:post (terminal-resize-handler store)}]
["/push/vapid-key" {:get (vapid-key-handler push-store)}]
["/push/subscribe" {:post (subscribe-handler push-store)}]
["/push/unsubscribe" {:post (unsubscribe-handler push-store)}]]])
@@ -312,7 +364,8 @@
"Create the Ring application"
[store broadcast-fn push-store]
(-> (ring/ring-handler
(ring/router (create-routes store broadcast-fn push-store))
(ring/router (create-routes store broadcast-fn push-store)
{:data {:middleware [parameters/parameters-middleware]}})
(ring/create-default-handler))
(wrap-json-body {:keywords? true})
wrap-json-response
+92
View File
@@ -0,0 +1,92 @@
# Database CLAUDE.md
## Files
| File | Purpose |
|------|---------|
| `protocol.clj` | DataStore protocol definition |
| `sqlite.clj` | SQLite implementation |
| `memory.clj` | In-memory (tests) |
## DataStore Protocol
```clojure
(defprotocol DataStore
(get-sessions [this])
(get-session [this id])
(save-session [this session])
(update-session [this id data])
(delete-session [this id])
(get-messages [this session-id])
(save-message [this message])
(get-message [this id])
(update-message [this id data]))
```
## Schema
```sql
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL, -- 'claude', 'opencode', 'tmux'
external_id TEXT,
title TEXT,
working_dir TEXT,
spawn_dir TEXT,
status_id INTEGER DEFAULT 1, -- FK to session_statuses
pending_permission TEXT, -- JSON
auto_accept_edits INTEGER DEFAULT 0,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
role TEXT NOT NULL, -- 'user', 'assistant', 'system'
content TEXT,
metadata TEXT, -- JSON
created_at TEXT
);
CREATE TABLE session_statuses (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE -- 'idle', 'processing', 'awaiting-permission'
);
```
## Data Shapes
**Session:**
```clojure
{:id "uuid"
:provider :claude ; :opencode, :tmux
:external-id "cli-session-id"
:title "Fixing bug"
:working-dir "/path"
:spawn-dir "/path"
:status :idle ; :processing, :awaiting-permission
:pending-permission {...}
:auto-accept-edits true}
```
**Message:**
```clojure
{:id "uuid"
:session-id "session-uuid"
:role :user ; :assistant, :system
:content "text"
:metadata {:tools ["Write"] :status "accept"}}
```
## Naming Conversion
Clojure kebab-case <-> DB snake_case handled by `row->session` and `session->row`.
## Migrations
Add to `migrations` vector in `sqlite.clj`:
```clojure
(def migrations
["ALTER TABLE sessions ADD COLUMN new_field TEXT"])
```
+75
View File
@@ -0,0 +1,75 @@
# Session CLAUDE.md
Session lifecycle and state machine.
## Files
| File | Purpose |
|------|---------|
| `manager.clj` | Session state machine, streaming, permissions |
## Session States
```
idle -> processing -> awaiting-permission -> idle
^ |
+--------------------+
```
| State | Description |
|-------|-------------|
| `idle` | No active process |
| `processing` | CLI running, streaming |
| `awaiting-permission` | Paused for permission |
## Active Processes
```clojure
(defonce ^:private active-processes (ConcurrentHashMap.))
;; {session-id -> {:process ... :stdin ... :stdout ...}}
```
## Key Functions
**`start-session [store session-id opts]`** - Spawn CLI process, add to active-processes.
**`send-message-to-session [store session-id message]`** - Save message, send to stdin.
**`stream-session-response [store session-id broadcast-fn]`** - Read JSONL, broadcast events, handle permissions.
**`respond-to-permission [store session-id response message broadcast-fn]`** - Handle accept/deny/steer.
## Permission Flow
1. CLI outputs permission request in result event
2. Save permission message with status "pending"
3. Set `pending-permission` on session
4. Status -> `:awaiting-permission`
5. Broadcast `:permission-request` event
6. User responds via `/api/sessions/:id/permission`
7. Spawn new process with `--resume --allowedTools`
8. Send response message ("continue" or steer text)
9. Continue streaming
## Auto-Accept
```clojure
(defn should-auto-accept? [session permission]
(and (:auto-accept-edits session)
(every? #{"Write" "Edit"} (:tools permission))))
```
## REPL Debugging
```clojure
@#'m/active-processes ; See running sessions
(.get @#'m/active-processes "id") ; Specific session
(db/get-session store "id") ; Check status/pending
```
Force cleanup:
```clojure
(.destroyForcibly (:process (.get @#'m/active-processes "id")))
(.remove @#'m/active-processes "id")
(db/update-session store "id" {:status :idle :pending-permission :clear})
```
+172
View File
@@ -0,0 +1,172 @@
(ns spiceflow.terminal.diff
"Terminal content diffing with caching.
Maintains a ConcurrentHashMap of terminal state per session and computes
line-based diffs to minimize data transfer. Supports TUI applications
where any line can change on screen refresh.
Includes auto-incrementing frame IDs to prevent out-of-order frame issues
when reducing batch timers."
(:require [clojure.string :as str]
[clojure.tools.logging :as log])
(:import [java.util.concurrent ConcurrentHashMap]
[java.util.concurrent.atomic AtomicLong]))
;; Cache storing terminal state per session
;; Key: session-name (string)
;; Value: {:lines [vector of strings] :hash int :frame-id long}
(defonce ^:private terminal-cache (ConcurrentHashMap.))
;; Global frame counter - auto-incrementing sequence for ordering frames
;; Uses AtomicLong for thread safety. Max value is 2^53-1 (JS MAX_SAFE_INTEGER)
;; to ensure safe handling on the client side
(def ^:private max-frame-id 9007199254740991) ;; 2^53-1 (JS MAX_SAFE_INTEGER)
(defonce ^:private frame-counter (AtomicLong. 0))
(defn- next-frame-id
"Get the next frame ID, handling overflow by wrapping to 0.
Returns a monotonically increasing value (except on wrap)."
[]
(loop []
(let [current (.get frame-counter)
next-val (if (>= current max-frame-id) 0 (inc current))]
(if (.compareAndSet frame-counter current next-val)
next-val
(recur)))))
(defn- content->lines
"Split terminal content into lines, preserving empty lines."
[content]
(if (str/blank? content)
[]
(str/split-lines content)))
(defn- lines->content
"Join lines back into content string."
[lines]
(str/join "\n" lines))
(defn- compute-line-diff
"Compute line-by-line diff between old and new line vectors.
Returns a map of {:line-num content} for changed lines.
For efficiency, if more than 50% of lines changed, returns nil
to signal that a full refresh is more efficient."
[old-lines new-lines]
(let [old-count (count old-lines)
new-count (count new-lines)
max-count (max old-count new-count)
changes (volatile! (transient {}))]
(when (pos? max-count)
;; Compare existing lines
(dotimes [i max-count]
(let [old-line (get old-lines i)
new-line (get new-lines i)]
(when (not= old-line new-line)
(vswap! changes assoc! i new-line))))
(let [diff (persistent! @changes)
change-count (count diff)]
;; If more than 50% changed, full refresh is more efficient
(when (and (pos? change-count)
(< (/ change-count max-count) 0.5))
diff)))))
(defn- compute-diff
"Compute diff between cached state and new content.
Returns one of:
- {:type :unchanged :frame-id n} - no changes
- {:type :diff :changes {line-num content} :total-lines n :frame-id n} - partial update
- {:type :full :lines [lines] :total-lines n :frame-id n} - full refresh needed
All responses include :frame-id for ordering. Unchanged responses use
the cached frame-id since content hasn't changed."
[cached new-content]
(let [new-lines (content->lines new-content)
new-count (count new-lines)
new-hash (hash new-content)]
(cond
;; No cached state - full refresh
(nil? cached)
(let [frame-id (next-frame-id)]
{:type :full
:lines new-lines
:total-lines new-count
:hash new-hash
:frame-id frame-id})
;; Content unchanged (fast path via hash)
(= (:hash cached) new-hash)
{:type :unchanged
:total-lines new-count
:hash new-hash
:frame-id (:frame-id cached)} ;; Reuse cached frame-id for unchanged
;; Compute line diff
:else
(let [old-lines (:lines cached)
line-diff (compute-line-diff old-lines new-lines)
frame-id (next-frame-id)]
(if line-diff
;; Partial diff is efficient
{:type :diff
:changes line-diff
:total-lines new-count
:hash new-hash
:frame-id frame-id}
;; Too many changes - send full
{:type :full
:lines new-lines
:total-lines new-count
:hash new-hash
:frame-id frame-id})))))
(defn capture-with-diff
"Capture terminal content and compute diff from cached state.
Arguments:
- session-name: tmux session identifier
- capture-fn: function that takes session-name and returns content string
Returns map with:
- :content - full content string (always included for GET requests)
- :diff - diff payload for WebSocket updates (includes :frame-id)
- :changed - boolean indicating if content changed"
[session-name capture-fn]
(let [new-content (capture-fn session-name)
cached (.get terminal-cache session-name)
diff-result (compute-diff cached new-content)]
;; Update cache if changed (store frame-id for unchanged responses)
(when (not= :unchanged (:type diff-result))
(.put terminal-cache session-name
{:lines (content->lines new-content)
:hash (:hash diff-result)
:frame-id (:frame-id diff-result)}))
{:content new-content
:diff diff-result
:changed (not= :unchanged (:type diff-result))}))
(defn get-cached-content
"Get cached content for a session without capturing.
Returns nil if not cached."
[session-name]
(when-let [cached (.get terminal-cache session-name)]
(lines->content (:lines cached))))
(defn invalidate-cache
"Remove a session from the cache."
[session-name]
(.remove terminal-cache session-name)
(log/debug "[TerminalDiff] Invalidated cache for" session-name))
(defn clear-cache
"Clear all cached terminal state."
[]
(.clear terminal-cache)
(log/debug "[TerminalDiff] Cleared all terminal cache"))
(defn cache-stats
"Get cache statistics for debugging."
[]
{:session-count (.size terminal-cache)
:sessions (vec (.keySet terminal-cache))})