init codebase

This commit is contained in:
2026-02-17 17:30:45 -05:00
parent a3b28549b4
commit f7e2755a91
175 changed files with 21600 additions and 232 deletions
+126
View File
@@ -0,0 +1,126 @@
# CLI & TUI Client
Terminal client for ajet-chat. Provides two modes:
- **CLI Mode** — stateless one-shot commands for scripting and quick interactions
- **TUI Mode** — full interactive terminal application with split panes, markdown rendering, and real-time updates
## Usage
```bash
# CLI commands
ajet login # OAuth login
ajet communities # List communities
ajet channels # List channels
ajet read general # Read messages
ajet send general "hello" # Send a message
ajet tui # Launch interactive TUI
# TUI with options
ajet tui --community my-team # Open to specific community
ajet tui --channel general # Open to specific channel
```
## Dependencies
- `clojure-tui` — local dep at `../../clojure-tui` (Elm architecture TUI framework)
- `babashka.http-client` — HTTP client for API calls
- `clojure.data.json` — JSON parsing
- `clojure.tools.cli` — CLI argument parsing
- Shared modules: `api-client`, `markdown`, `mentions`
## TODO: clojure-tui Gaps
The TUI (`tui.clj`) uses clojure-tui's Elm architecture (init/update/view) for state management and rendering. However, several PRD features require capabilities that clojure-tui does not yet provide. SSE integration is worked around via a shared `LinkedBlockingQueue` polled every 100ms with `delayed-event`. Below is the complete list of gaps between the PRD requirements and clojure-tui's current capabilities.
### 1. Mouse Support (PRD 4.4)
**PRD requires:** Mouse click to select channel/message/button, mouse scroll in message list.
**Gap:** clojure-tui has no mouse tracking escape sequences in `terminal.clj` and no mouse event parsing in `input.clj`. The library only handles keyboard input.
**Workaround:** Keyboard-only navigation (Ctrl+N/P for channels, Tab for focus, arrow keys for scrolling).
**To resolve:** Add SGR mouse tracking (`\033[?1000h\033[?1006h`) to `terminal.clj` and mouse event parsing (button, position, scroll) to `input.clj`.
### 2. Inline Image Rendering (PRD 4.5)
**PRD requires:** Render images inline in message list via timg, sixel, or kitty graphics protocol.
**Gap:** clojure-tui has no image rendering support. Its render pipeline outputs ANSI text only.
**Workaround:** Images display as `[image: filename.png]` text placeholder.
**To resolve:** Add a `:image` render primitive that shells out to `timg` or emits sixel/kitty escape sequences. Requires terminal capability detection.
### 3. Multiline Text Input (PRD 4.4)
**PRD requires:** Shift+Enter or Alt+Enter inserts a newline in the input field.
**Gap:** clojure-tui's `:input` widget is single-line only. It handles backspace and character insertion but has no concept of line breaks within the input buffer.
**Workaround:** Messages are single-line only. No multiline composition.
**To resolve:** Extend `:input` widget to support a multi-line buffer with cursor movement across lines, or create a new `:textarea` widget.
### 4. Autocomplete Dropdowns (PRD 4.4)
**PRD requires:** Typing `@` shows user mention dropdown, `#` shows channel dropdown, `/` shows slash command list. Tab to select.
**Gap:** clojure-tui has no autocomplete or dropdown widget. It has `:modal` and `:scroll` primitives but no composition for filtered-list-as-you-type behavior.
**Workaround:** @mentions, #channels, and /commands are typed manually without autocomplete.
**To resolve:** Build an autocomplete widget by composing `:modal` + `:scroll` + filtered list, with keyboard navigation. This is application-level code that could be contributed back to clojure-tui.
### 5. SSE Client Integration (PRD 4.7)
**PRD requires:** Real-time event stream from TUI session manager via Server-Sent Events.
**Gap:** clojure-tui's event loop (`core.clj`) only processes keyboard input events. It has no mechanism to inject external events (HTTP responses, SSE data) into the Elm update cycle.
**Workaround:** A background thread reads SSE via `HttpURLConnection` and writes parsed events to a shared `LinkedBlockingQueue`. The Elm loop polls this queue every 100ms via `delayed-event`, draining events and processing them in the `:update` function. This works but adds up to 100ms latency.
**To resolve:** Add an external event channel to clojure-tui's `run` function (e.g., accept a `core.async` channel that the event loop merges with stdin input via `alt!`). This would eliminate polling and allow SSE events to flow through `:update` with zero latency.
### 6. Terminal Bell (PRD 4.8)
**PRD requires:** Terminal bell (`\a`) on new @mention or DM.
**Gap:** clojure-tui's render pipeline doesn't include bell output. Trivial to implement but not part of the library's event/render model.
**Workaround:** Not yet implemented. Can be added as `(print "\u0007") (flush)` in the message event handler.
**To resolve:** Either add a `:bell` event type to clojure-tui, or just emit the bell character directly in application code (outside the render cycle).
### 7. OSC 8 Hyperlinks (PRD 4.6)
**PRD requires:** URLs in messages render as clickable hyperlinks using OSC 8 escape sequences (`\033]8;;URL\033\\text\033]8;;\033\\`).
**Gap:** clojure-tui's `ansi.clj` has ANSI color/style codes but no OSC 8 hyperlink support.
**Workaround:** URLs render as plain underlined text without click behavior.
**To resolve:** Add OSC 8 hyperlink escape sequences to `ansi.clj` and integrate into the `:text` render primitive when a `:href` attribute is present.
### 8. Spoiler Text Reveal (PRD 4.6)
**PRD requires:** `||spoiler||` text renders hidden (e.g., as block characters) until user presses Enter on the selected message to reveal.
**Gap:** This is an application-level feature requiring per-message hidden/revealed state and keypress handling. clojure-tui doesn't prevent this but provides no specific support.
**Workaround:** Spoiler text renders as plain text (not hidden).
**To resolve:** Track revealed-spoiler state per message ID in app state. Render spoiler spans as `\u2588` block characters when hidden, original text when revealed. Toggle on Enter keypress when message is selected.
### Summary Table
| Feature | PRD Section | Status | Blocked By |
|---------|------------|--------|------------|
| Mouse support | 4.4 | Not implemented | clojure-tui: no mouse input |
| Inline images | 4.5 | Placeholder only | clojure-tui: no image rendering |
| Multiline input | 4.4 | Single-line only | clojure-tui: :input is single-line |
| Autocomplete | 4.4 | Not implemented | clojure-tui: no dropdown widget |
| SSE integration | 4.7 | Queue polling (100ms) | clojure-tui: no external event injection |
| Terminal bell | 4.8 | Not implemented | Trivial — just needs `\a` output |
| OSC 8 hyperlinks | 4.6 | Not implemented | clojure-tui: no OSC 8 support |
| Spoiler reveal | 4.6 | Plain text | Application-level (not blocked) |
+1
View File
@@ -1,5 +1,6 @@
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
org.clojure/tools.cli {:mvn/version "1.1.230"}
ajet/chat-shared {:local/root "../shared"}
ajet/clojure-tui {:local/root "../../clojure-tui"}}
:aliases
+269
View File
@@ -0,0 +1,269 @@
(ns ajet.chat.cli.auth
"Authentication commands for the CLI client.
Supports two login modes:
- Interactive OAuth: opens browser, starts temp HTTP callback server
- Token: saves an API token directly for scripting
Login flow:
1. Start a temporary local HTTP server on a random port
2. Open browser to auth gateway login with redirect to localhost callback
3. Capture session token from callback query params
4. Save to session.edn
5. Fetch /api/me to confirm and print user info
Fallback: if browser cannot open, print URL and prompt for manual token paste."
(:require [clojure.string :as str]
[ajet.chat.cli.config :as config]
[ajet.chat.cli.output :as output]
[ajet.chat.shared.api-client :as api])
(:import [java.net ServerSocket URLDecoder]
[java.io BufferedReader InputStreamReader PrintWriter]
[java.time Instant Duration]))
;;; ---------------------------------------------------------------------------
;;; Local callback server
;;; ---------------------------------------------------------------------------
(defn- parse-query-params
"Parse query string into a map."
[query-string]
(when (and query-string (not (str/blank? query-string)))
(into {}
(map (fn [pair]
(let [parts (str/split pair #"=" 2)]
[(URLDecoder/decode (first parts) "UTF-8")
(when (second parts)
(URLDecoder/decode (second parts) "UTF-8"))]))
(str/split query-string #"&")))))
(defn- extract-request-info
"Extract method, path, and query params from an HTTP request line."
[request-line]
(when request-line
(let [parts (str/split request-line #"\s+")
method (first parts)
full-path (second parts)
[path query] (str/split (or full-path "/") #"\?" 2)]
{:method method
:path path
:query (parse-query-params query)})))
(defn- send-http-response
"Send a simple HTTP response on a PrintWriter."
[^PrintWriter writer status-code body]
(.println writer (str "HTTP/1.1 " status-code " OK"))
(.println writer "Content-Type: text/html; charset=utf-8")
(.println writer "Connection: close")
(.println writer (str "Content-Length: " (count (.getBytes ^String body "UTF-8"))))
(.println writer "")
(.print writer body)
(.flush writer))
(def ^:private success-page
"<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;\">
<h1>Logged in!</h1>
<p>You can close this tab and return to your terminal.</p>
</body></html>")
(def ^:private error-page
"<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;\">
<h1>Login failed</h1>
<p>Something went wrong. Please try again.</p>
</body></html>")
(defn- start-callback-server
"Start a temporary HTTP server on a random port. Blocks until a callback
is received or timeout (120s). Returns the captured query params or nil."
[]
(let [server (ServerSocket. 0)
port (.getLocalPort server)
result (promise)]
(.setSoTimeout server 120000) ;; 2 minute timeout
(future
(try
(let [socket (.accept server)
reader (BufferedReader. (InputStreamReader. (.getInputStream socket)))
writer (PrintWriter. (.getOutputStream socket) true)
line (.readLine reader)
info (extract-request-info line)]
(if (and info (get-in info [:query "token"]))
(do
(send-http-response writer 200 success-page)
(deliver result (:query info)))
(do
(send-http-response writer 400 error-page)
(deliver result nil)))
(.close socket))
(catch java.net.SocketTimeoutException _
(deliver result nil))
(catch Exception _e
(deliver result nil))
(finally
(.close server))))
{:port port :result result}))
;;; ---------------------------------------------------------------------------
;;; Browser opening
;;; ---------------------------------------------------------------------------
(defn- open-browser
"Attempt to open a URL in the default browser. Returns true on success."
[url]
(try
(let [os-name (str/lower-case (System/getProperty "os.name"))]
(cond
(str/includes? os-name "linux")
(do (.start (ProcessBuilder. ["xdg-open" url])) true)
(str/includes? os-name "mac")
(do (.start (ProcessBuilder. ["open" url])) true)
(str/includes? os-name "windows")
(do (.start (ProcessBuilder. ["cmd" "/c" "start" url])) true)
:else false))
(catch Exception _
false)))
;;; ---------------------------------------------------------------------------
;;; Login commands
;;; ---------------------------------------------------------------------------
(defn login-interactive
"Interactive OAuth login flow.
1. Starts a temporary local HTTP server on a random port
2. Opens browser to auth gateway login URL
3. Waits for callback with session token
4. Saves session and fetches user info"
[& [{:keys [json?]}]]
(let [server-url (config/get-server-url)
{:keys [port result]} (start-callback-server)
callback-url (str "http://localhost:" port "/callback")
login-url (str server-url "/auth/login?redirect=" callback-url "&cli=true")
browser-ok? (open-browser login-url)]
(if browser-ok?
(output/print-info "Opening browser for login...")
(output/print-info (str "Open this URL in your browser:\n " login-url)))
(output/print-info "\nIf your browser didn't open, visit:")
(output/print-info (str " " login-url))
(output/print-info "\nOr paste your session token below:")
(output/print-info "(Waiting for callback...)")
;; Start a parallel thread to accept manual token paste
(let [manual-token (promise)]
(future
(try
(when-let [line (read-line)]
(when-not (str/blank? line)
(deliver manual-token (str/trim line))))
(catch Exception _ nil)))
;; Wait for either callback or manual paste
(let [callback-result (deref result 120000 nil)
token (or (get callback-result "token")
(deref manual-token 100 nil))]
(if-not token
(do
(output/print-error "Login timed out"
"No callback received within 2 minutes."
"Try 'ajet login --token <token>' instead")
3)
;; Got a token - save session and verify
(let [expires-at (or (get callback-result "expires_at")
(str (.plus (Instant/now) (Duration/ofDays 30))))
user-id (get callback-result "user_id")
username (get callback-result "username")]
;; Save initial session
(config/save-session! {:token token
:user-id user-id
:username username
:expires-at expires-at})
;; Try to verify by fetching /api/me
(try
(let [ctx (config/make-ctx)
me (api/get-me ctx)]
(config/save-session! {:token token
:user-id (or (:id me) user-id)
:username (or (:username me) username)
:expires-at expires-at})
(if json?
(output/print-json me)
(output/print-success
(str "Logged in as "
(or (:display-name me) (:username me))
" (" (:username me) ")")))
0)
(catch Exception _
;; Token saved but couldn't verify - that's OK
(if json?
(output/print-json {:token token :username username})
(output/print-success
(str "Logged in" (when username (str " as " username)))))
0))))))))
(defn login-token
"Login with an API token directly (for scripting).
Saves the token and verifies it by calling /api/me."
[token & [{:keys [json?]}]]
(config/save-session! {:token token
:user-id nil
:username nil
:expires-at (str (.plus (Instant/now) (Duration/ofDays 365)))})
(try
(let [ctx (config/make-ctx)
me (api/get-me ctx)]
(config/save-session! {:token token
:user-id (:id me)
:username (:username me)
:expires-at (str (.plus (Instant/now) (Duration/ofDays 365)))})
(if json?
(output/print-json me)
(output/print-success
(str "Logged in as "
(or (:display-name me) (:username me))
" (" (:username me) ")")))
0)
(catch Exception e
(let [data (ex-data e)]
(if (= :ajet.chat/api-error (:type data))
(do
(output/print-error "Invalid token"
"The provided token was rejected by the server."
"Check the token and try again")
(config/clear-session!)
3)
(do
(output/print-error "Could not verify token"
(.getMessage e)
"Token saved, but server may be unreachable")
0))))))
(defn logout
"Clear the current session."
[& [{:keys [json?]}]]
(config/clear-session!)
(if json?
(output/print-json {:status "logged_out"})
(output/print-success "Logged out"))
0)
(defn whoami
"Fetch and display current user info."
[& [{:keys [json?]}]]
(let [ctx (config/make-ctx)
me (api/get-me ctx)]
(if json?
(output/print-json me)
(do
(output/print-info (str "Username: " (:username me)))
(output/print-info (str "Display Name: " (or (:display-name me) "-")))
(output/print-info (str "Email: " (or (:email me) "-")))
(output/print-info (str "User ID: " (:id me)))
(when (:status-text me)
(output/print-info (str "Status: " (:status-text me))))))
0))
+589
View File
@@ -0,0 +1,589 @@
(ns ajet.chat.cli.commands
"All CLI command implementations.
Every public function in this namespace follows the pattern:
(fn [args opts] ...) -> exit-code (int)
args: parsed positional arguments (vector of strings)
opts: parsed options map (includes :json? flag)
All API calls go through ajet.chat.shared.api-client.
All output goes through ajet.chat.cli.output."
(:require [clojure.string :as str]
[ajet.chat.cli.config :as config]
[ajet.chat.cli.output :as output]
[ajet.chat.shared.api-client :as api])
(:import [java.io BufferedReader InputStreamReader]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- resolve-community-id
"Resolve a community slug or UUID to a community ID.
If community-slug is nil, uses the default community from config/state.
Returns the community map or throws."
[ctx community-slug]
(let [communities (:communities (api/get-communities ctx))
slug (or community-slug (config/get-default-community))]
(when-not slug
(throw (ex-info "No community specified"
{:type :ajet.chat/usage-error
:hint "Use --community <slug> or set a default with 'ajet config set default-community <slug>'"})))
(let [match (first (filter #(or (= (:slug %) slug)
(= (str (:id %)) slug))
communities))]
(when-not match
(throw (ex-info (str "Community not found: " slug)
{:type :ajet.chat/not-found
:hint "Run 'ajet communities' to see available communities"})))
match)))
(defn- resolve-channel
"Resolve a channel name to a channel map within a community.
Returns the channel map or throws."
[ctx community-id channel-name]
(let [channels (:channels (api/get-channels ctx community-id))
match (first (filter #(or (= (:name %) channel-name)
(= (str (:id %)) channel-name))
channels))]
(when-not match
(throw (ex-info (str "Channel not found: #" channel-name)
{:type :ajet.chat/not-found
:hint "Run 'ajet channels' to see available channels"})))
match))
(defn- read-stdin
"Read all of stdin and return as a string."
[]
(let [reader (BufferedReader. (InputStreamReader. System/in))
sb (StringBuilder.)]
(loop []
(let [line (.readLine reader)]
(when line
(when (pos? (.length sb))
(.append sb "\n"))
(.append sb line)
(recur))))
(str sb)))
(defn- save-last-community!
"Save the last-used community to state."
[community-id]
(let [state (config/load-state)]
(config/save-state! (assoc state :last-community (str community-id)))))
(defn- save-last-channel!
"Save the last-used channel for a community to state."
[community-id channel-id]
(let [state (config/load-state)]
(config/save-state!
(assoc-in state [:last-channels (str community-id)] (str channel-id)))))
;;; ---------------------------------------------------------------------------
;;; Communities
;;; ---------------------------------------------------------------------------
(defn communities
"List communities the current user belongs to."
[_args {:keys [json?]}]
(let [ctx (config/make-ctx)
result (api/get-communities ctx)
comms (or (:communities result) result)]
(if json?
(output/print-json result)
(if (empty? comms)
(output/print-info "You are not a member of any communities.")
(output/print-table
[["Name" :name] ["Slug" :slug] ["Role" :role] ["Members" :member-count]]
comms)))
0))
;;; ---------------------------------------------------------------------------
;;; Channels
;;; ---------------------------------------------------------------------------
(defn channels
"List, join, or leave channels in a community."
[_args {:keys [json? community join leave]}]
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)]
(save-last-community! comm-id)
(cond
join
(let [ch (resolve-channel ctx comm-id join)]
(api/join-channel ctx (:id ch))
(if json?
(output/print-json {:joined (:name ch)})
(output/print-success (str "Joined #" (:name ch))))
0)
leave
(let [ch (resolve-channel ctx comm-id leave)]
(api/leave-channel ctx (:id ch))
(if json?
(output/print-json {:left (:name ch)})
(output/print-success (str "Left #" (:name ch))))
0)
:else
(let [result (api/get-channels ctx comm-id)
chs (or (:channels result) result)]
(if json?
(output/print-json result)
(if (empty? chs)
(output/print-info "No channels found.")
(output/print-table
[["Name" :name] ["Type" :type] ["Topic" :topic] ["Members" :member-count]]
(map (fn [ch]
(-> ch
(update :name #(str "#" %))
(update :type #(or (some-> % name) "text"))
(update :topic #(or % ""))))
chs))))
0))))
;;; ---------------------------------------------------------------------------
;;; Messages
;;; ---------------------------------------------------------------------------
(defn read-messages
"Read messages from a channel."
[args {:keys [json? community limit before thread]}]
(let [channel-name (first args)]
(when-not channel-name
(throw (ex-info "Channel name required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet read <channel> [--limit N] [--before ID] [--thread ID]"})))
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
ch (resolve-channel ctx comm-id channel-name)
ch-id (:id ch)]
(save-last-community! comm-id)
(save-last-channel! comm-id ch-id)
(if thread
;; Read thread replies
(let [result (api/get-thread ctx thread)]
(if json?
(output/print-json result)
(let [messages (or (:messages result) result)]
(output/print-info (str "Thread in #" (:name ch)))
(output/print-info "")
(output/print-messages messages))))
;; Read channel messages
(let [opts (cond-> {}
limit (assoc :limit (parse-long (str limit)))
before (assoc :before before))
result (api/get-messages ctx ch-id opts)]
(if json?
(output/print-json result)
(let [messages (or (:messages result) result)]
(output/print-messages messages
{:channel-name (:name ch)
:channel-topic (:topic ch)})))))
0)))
(defn send-message
"Send a message to a channel."
[args {:keys [json? community stdin image]}]
(let [channel-name (first args)
message-text (if stdin
(read-stdin)
(str/join " " (rest args)))]
(when-not channel-name
(throw (ex-info "Channel name required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet send <channel> <message> [--stdin] [--image <path>]"})))
(when (and (not stdin) (str/blank? message-text))
(throw (ex-info "Message text required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet send <channel> <message> or echo 'msg' | ajet send <channel> --stdin"})))
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
ch (resolve-channel ctx comm-id channel-name)
ch-id (:id ch)]
(save-last-community! comm-id)
(save-last-channel! comm-id ch-id)
;; Upload image first if specified
(when image
(let [file (java.io.File. ^String image)]
(when-not (.exists file)
(throw (ex-info (str "File not found: " image)
{:type :ajet.chat/usage-error})))
(let [ext (str/lower-case (or (last (str/split image #"\.")) ""))
ct (case ext
"png" "image/png"
"jpg" "image/jpeg"
"jpeg" "image/jpeg"
"gif" "image/gif"
"webp" "image/webp"
(throw (ex-info (str "Unsupported image format: " ext)
{:type :ajet.chat/usage-error
:hint "Supported: png, jpg, jpeg, gif, webp"})))]
(api/upload-file ctx ch-id image ct))))
(let [body {:body-md message-text}
result (api/send-message ctx ch-id body)]
(if json?
(output/print-json result)
(output/print-success (str "Message sent to #" (:name ch))))
0))))
(defn edit-message
"Edit a message by ID."
[args {:keys [json?]}]
(let [msg-id (first args)
new-text (str/join " " (rest args))]
(when-not msg-id
(throw (ex-info "Message ID required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet edit <message-id> <new-text>"})))
(when (str/blank? new-text)
(throw (ex-info "New message text required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet edit <message-id> <new-text>"})))
(let [ctx (config/make-ctx)
result (api/edit-message ctx msg-id {:body-md new-text})]
(if json?
(output/print-json result)
(output/print-success "Message edited"))
0)))
(defn delete-message
"Delete a message by ID (with confirmation)."
[args {:keys [json? force]}]
(let [msg-id (first args)]
(when-not msg-id
(throw (ex-info "Message ID required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet delete <message-id>"})))
(when (and (not json?) (not force))
(when-not (output/confirm? "Are you sure you want to delete this message?")
(output/print-info "Cancelled.")
(throw (ex-info "Cancelled" {:type :ajet.chat/cancelled}))))
(let [ctx (config/make-ctx)
result (api/delete-message ctx msg-id)]
(if json?
(output/print-json (or result {:deleted msg-id}))
(output/print-success "Message deleted"))
0)))
;;; ---------------------------------------------------------------------------
;;; DMs
;;; ---------------------------------------------------------------------------
(defn list-dms
"List DM channels."
[_args {:keys [json?]}]
(let [ctx (config/make-ctx)
result (api/get-dms ctx)
dms (or (:dms result) (:channels result) result)]
(if json?
(output/print-json result)
(if (empty? dms)
(output/print-info "No DM conversations.")
(output/print-table
[["User" :display-name] ["Username" :username] ["Last Message" :last-message-preview] ["Time" :last-message-at]]
(map (fn [dm]
(-> dm
(update :display-name #(or % (:username dm) ""))
(update :username #(or % ""))
(update :last-message-preview #(or % ""))
(update :last-message-at #(if % (output/relative-time %) ""))))
dms))))
0))
(defn send-dm
"Send a DM to a user by username."
[args {:keys [json? read]}]
(let [username (first args)]
(when-not username
(throw (ex-info "Username required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet dm <username> <message> or ajet dm <username> --read"})))
(let [ctx (config/make-ctx)]
(if read
;; Read DM conversation
(let [dm-result (api/create-dm ctx {:username username})
ch-id (or (:id dm-result) (:channel-id dm-result))
messages (api/get-messages ctx ch-id {:limit 50})]
(if json?
(output/print-json messages)
(let [msgs (or (:messages messages) messages)]
(output/print-info (str "DM with @" username))
(output/print-info "")
(output/print-messages msgs)))
0)
;; Send DM
(let [message-text (str/join " " (rest args))]
(when (str/blank? message-text)
(throw (ex-info "Message text required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet dm <username> <message> or ajet dm <username> --read"})))
(let [dm-result (api/create-dm ctx {:username username})
ch-id (or (:id dm-result) (:channel-id dm-result))
result (api/send-message ctx ch-id {:body-md message-text})]
(if json?
(output/print-json result)
(output/print-success (str "DM sent to @" username)))
0))))))
;;; ---------------------------------------------------------------------------
;;; Notifications
;;; ---------------------------------------------------------------------------
(defn notifications
"List or manage notifications."
[_args {:keys [json? all mark-read]}]
(let [ctx (config/make-ctx)]
(if mark-read
;; Mark all as read
(let [result (api/mark-notifications-read ctx {:all true})]
(if json?
(output/print-json (or result {:marked-read true}))
(output/print-success "All notifications marked as read"))
0)
;; List notifications
(let [opts (if all {} {:unread true})
result (api/get-notifications ctx opts)
notifs (or (:notifications result) result)]
(if json?
(output/print-json result)
(if (empty? notifs)
(output/print-info (if all "No notifications." "No unread notifications."))
(doseq [n notifs]
(let [type-str (case (keyword (name (or (:type n) "")))
:mention "@mention"
:dm "DM"
:thread-reply "thread reply"
:invite "invite"
(str (:type n)))
read? (:read n)
marker (if read? " " "* ")
time-str (output/relative-time (:created-at n))]
(output/print-info
(str marker type-str " - " (or (:preview n) (:source-id n))
" " time-str))))))
0))))
;;; ---------------------------------------------------------------------------
;;; Search
;;; ---------------------------------------------------------------------------
(defn search
"Search messages and channels with filters."
[args {:keys [json? community channel from type]}]
(let [query (str/join " " args)]
(when (str/blank? query)
(throw (ex-info "Search query required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet search <query> [--channel <ch>] [--from <user>] [--type messages|channels]"})))
(let [ctx (config/make-ctx)
comm (when (or community channel)
(resolve-community-id ctx community))
comm-id (when comm (:id comm))
ch (when (and channel comm-id)
(resolve-channel ctx comm-id channel))
opts (cond-> {:q query}
comm-id (assoc :community-id comm-id)
ch (assoc :channel-id (:id ch))
from (assoc :from from)
type (assoc :type (keyword type)))
result (api/search ctx opts)]
(if json?
(output/print-json result)
(let [results (or (:results result) (:messages result) result)]
(if (empty? results)
(output/print-info "No results found.")
(do
(output/print-info (str "Search results for: " query))
(output/print-info "")
(output/print-messages results {:show-channel true})))))
0)))
;;; ---------------------------------------------------------------------------
;;; Presence & Status
;;; ---------------------------------------------------------------------------
(defn set-status
"Set or show the user's status text."
[args {:keys [json?]}]
(let [ctx (config/make-ctx)
status-text (str/join " " args)]
(if (str/blank? status-text)
;; Show current status
(let [me (api/get-me ctx)]
(if json?
(output/print-json {:status-text (:status-text me)})
(output/print-info
(if (:status-text me)
(str "Status: " (:status-text me))
"No status set.")))
0)
;; Set status
(let [result (api/update-me ctx {:status-text status-text})]
(if json?
(output/print-json result)
(output/print-success (str "Status set to: " status-text)))
0))))
(defn who-online
"Show online users in the current community."
[_args {:keys [json? community]}]
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
result (api/get-presence ctx comm-id)
users (or (:users result) (:online result) result)]
(save-last-community! comm-id)
(if json?
(output/print-json result)
(if (empty? users)
(output/print-info "No users online.")
(do
(output/print-info (str "Online in " (:name comm) ":"))
(output/print-info "")
(output/print-table
[["User" :display-name] ["Username" :username] ["Status" :status-text]]
(map (fn [u]
(-> u
(update :display-name #(or % (:username u) ""))
(update :username #(or % ""))
(update :status-text #(or % ""))))
users)))))
0))
;;; ---------------------------------------------------------------------------
;;; Invites
;;; ---------------------------------------------------------------------------
(defn invite
"Create, list, or revoke invites."
[args {:keys [json? community max-uses]}]
(let [subcommand (first args)]
(case subcommand
"create"
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
body (cond-> {}
max-uses (assoc :max-uses (parse-long (str max-uses))))
result (api/create-invite ctx comm-id body)]
(save-last-community! comm-id)
(if json?
(output/print-json result)
(let [code (or (:code result) (:id result))
server-url (config/get-server-url)]
(output/print-success "Invite created")
(output/print-info (str " Link: " server-url "/invite/" code))
(output/print-info (str " Code: " code))
(when max-uses
(output/print-info (str " Max uses: " max-uses)))))
0)
"list"
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
result (api/get-invites ctx comm-id)
invites (or (:invites result) result)]
(save-last-community! comm-id)
(if json?
(output/print-json result)
(if (empty? invites)
(output/print-info "No active invites.")
(output/print-table
[["Code" :code] ["Uses" :uses] ["Max" :max-uses] ["Created" :created-at] ["Expires" :expires-at]]
(map (fn [inv]
(-> inv
(update :uses #(or (str %) "0"))
(update :max-uses #(if % (str %) "unlimited"))
(update :created-at #(if % (output/relative-time %) ""))
(update :expires-at #(if % (output/relative-time %) "never"))))
invites))))
0)
"revoke"
(let [invite-id (second args)]
(when-not invite-id
(throw (ex-info "Invite ID required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet invite revoke <invite-id>"})))
(let [ctx (config/make-ctx)
result (api/revoke-invite ctx invite-id)]
(if json?
(output/print-json (or result {:revoked invite-id}))
(output/print-success (str "Invite revoked: " invite-id)))
0))
;; Default: unknown subcommand
(throw (ex-info (str "Unknown invite subcommand: " (or subcommand ""))
{:type :ajet.chat/usage-error
:hint "Usage: ajet invite <create|list|revoke>"})))))
;;; ---------------------------------------------------------------------------
;;; Config
;;; ---------------------------------------------------------------------------
(defn config-cmd
"Show or set configuration values."
[args {:keys [json?]}]
(let [subcommand (first args)]
(case subcommand
"set"
(let [key-name (second args)
value (str/join " " (drop 2 args))]
(when-not key-name
(throw (ex-info "Config key required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet config set <key> <value>"})))
(when (str/blank? value)
(throw (ex-info "Config value required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet config set <key> <value>"})))
(let [cfg (config/load-config)
kw-key (keyword key-name)
;; Coerce value
coerced (cond
(= "true" value) true
(= "false" value) false
(re-matches #"\d+" value) (parse-long value)
:else value)
new-cfg (assoc cfg kw-key coerced)]
(config/save-config! new-cfg)
(if json?
(output/print-json {kw-key coerced})
(output/print-success (str "Set " key-name " = " (pr-str coerced))))
0))
"server"
(let [url (second args)]
(when-not url
(throw (ex-info "Server URL required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet config server <url>"})))
(let [cfg (config/load-config)
new-cfg (assoc cfg :server-url url)]
(config/save-config! new-cfg)
(if json?
(output/print-json {:server-url url})
(output/print-success (str "Server URL set to: " url)))
0))
;; Default: show config
(let [cfg (config/load-config)]
(if json?
(output/print-json cfg)
(do
(output/print-info "Current configuration:")
(output/print-info "")
(doseq [[k v] (sort-by key cfg)]
(if (map? v)
(do
(output/print-info (str " " (name k) ":"))
(doseq [[k2 v2] (sort-by key v)]
(output/print-info (str " " (name k2) ": " (pr-str v2)))))
(output/print-info (str " " (name k) ": " (pr-str v)))))))
0))))
+182
View File
@@ -0,0 +1,182 @@
(ns ajet.chat.cli.config
"Configuration management for the CLI client.
Config directory: ~/.config/ajet-chat/
Files:
config.edn — server URL, default community, preferences
session.edn — session token + user info
state.edn — last community, last channel per community"
(:require [clojure.edn :as edn]
[clojure.java.io :as io]
[clojure.string :as str])
(:import [java.time Instant]))
;;; ---------------------------------------------------------------------------
;;; Paths
;;; ---------------------------------------------------------------------------
(def ^:private config-dir-path
"Path to the config directory."
(str (System/getProperty "user.home") "/.config/ajet-chat"))
(defn- config-file ^java.io.File [filename]
(io/file config-dir-path filename))
;;; ---------------------------------------------------------------------------
;;; Directory management
;;; ---------------------------------------------------------------------------
(defn ensure-config-dir!
"Create the config directory if it does not exist. Returns the path."
[]
(let [dir (io/file config-dir-path)]
(when-not (.exists dir)
(.mkdirs dir))
config-dir-path))
;;; ---------------------------------------------------------------------------
;;; Generic EDN read/write helpers
;;; ---------------------------------------------------------------------------
(defn- read-edn-file
"Read an EDN file. Returns nil if the file does not exist or is empty."
[filename]
(let [f (config-file filename)]
(when (.exists f)
(let [content (slurp f)]
(when-not (str/blank? content)
(edn/read-string content))))))
(defn- write-edn-file!
"Write data as EDN to a file. Creates the config directory if needed."
[filename data]
(ensure-config-dir!)
(spit (config-file filename) (pr-str data)))
(defn- delete-file!
"Delete a file if it exists."
[filename]
(let [f (config-file filename)]
(when (.exists f)
(.delete f))))
;;; ---------------------------------------------------------------------------
;;; Session management
;;; ---------------------------------------------------------------------------
(defn load-session
"Load session.edn. Returns nil if missing or expired.
Session shape:
{:token \"base64url-session-token\"
:user-id \"uuid\"
:username \"alice\"
:expires-at \"2026-03-19T...\"}"
[]
(when-let [session (read-edn-file "session.edn")]
(let [expires-at (:expires-at session)]
(if (and expires-at
(try
(.isBefore (Instant/parse expires-at) (Instant/now))
(catch Exception _ false)))
nil
session))))
(defn save-session!
"Write session data to session.edn.
data should contain :token, :user-id, :username, and optionally :expires-at."
[data]
(write-edn-file! "session.edn" data))
(defn clear-session!
"Delete session.edn, effectively logging out."
[]
(delete-file! "session.edn"))
;;; ---------------------------------------------------------------------------
;;; Config management
;;; ---------------------------------------------------------------------------
(def ^:private default-config
{:server-url "http://localhost:3000"
:default-community nil
:tui {:theme :dark
:image-viewer :timg
:mouse true
:timestamps :relative
:notifications :bell}})
(defn load-config
"Load config.edn, merged with defaults. Returns defaults if file missing."
[]
(let [file-config (read-edn-file "config.edn")]
(if file-config
(merge-with (fn [a b]
(if (and (map? a) (map? b))
(merge a b)
b))
default-config file-config)
default-config)))
(defn save-config!
"Write config data to config.edn."
[data]
(write-edn-file! "config.edn" data))
;;; ---------------------------------------------------------------------------
;;; State management
;;; ---------------------------------------------------------------------------
(defn load-state
"Load state.edn (last community, last channel per community).
State shape:
{:last-community \"uuid\"
:last-channels {\"community-uuid\" \"channel-uuid\"}}"
[]
(or (read-edn-file "state.edn")
{:last-community nil
:last-channels {}}))
(defn save-state!
"Write state data to state.edn."
[data]
(write-edn-file! "state.edn" data))
;;; ---------------------------------------------------------------------------
;;; Context builder
;;; ---------------------------------------------------------------------------
(defn make-ctx
"Build an API client context map from session + config.
Returns a ctx suitable for ajet.chat.shared.api-client functions:
{:base-url \"http://localhost:3000\"
:auth-token \"base64url-token\"
:user-id \"uuid\"
:trace-id \"uuid\"}
Throws ex-info with :type :ajet.chat/auth-error if no valid session."
[]
(let [config (load-config)
session (load-session)]
(when-not session
(throw (ex-info "Not logged in"
{:type :ajet.chat/auth-error
:hint "Run 'ajet login' to sign in"})))
{:base-url (:server-url config)
:auth-token (:token session)
:user-id (:user-id session)
:trace-id (str (java.util.UUID/randomUUID))}))
(defn get-server-url
"Get the configured server URL."
[]
(:server-url (load-config)))
(defn get-default-community
"Get the default community slug from config, or the last-used community from state."
[]
(or (:default-community (load-config))
(:last-community (load-state))))
+398 -3
View File
@@ -1,5 +1,400 @@
(ns ajet.chat.cli.core
"CLI client using clojure-tui.")
"Main entry point and command dispatcher for the ajet CLI client.
(defn -main [& _args]
(println "ajet-chat CLI starting..."))
Parses args with tools.cli, dispatches to subcommands:
login, logout, whoami, communities, channels, read, send, edit, delete,
dms, dm, notifications, search, status, who, invite, config, tui
Global options:
--json Output JSON (for scripting)
--help Show usage help
Exit codes:
0 = success
1 = general error
2 = usage error
3 = auth error
4 = permission error
5 = not found
130 = SIGINT"
(:require [clojure.tools.cli :as cli]
[ajet.chat.cli.auth :as auth]
[ajet.chat.cli.commands :as commands]
[ajet.chat.cli.config :as config]
[ajet.chat.cli.output :as output]
[ajet.chat.cli.tui :as tui])
(:gen-class))
;;; ---------------------------------------------------------------------------
;;; CLI option specs
;;; ---------------------------------------------------------------------------
(def ^:private global-options
[["-j" "--json" "Output JSON (for scripting)"]
["-h" "--help" "Show help"]])
(def ^:private login-options
[["-t" "--token TOKEN" "Login with an API token directly"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private channels-options
[["-c" "--community SLUG" "Community slug"]
[nil "--join CHANNEL" "Join a channel by name"]
[nil "--leave CHANNEL" "Leave a channel by name"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private read-options
[["-c" "--community SLUG" "Community slug"]
["-l" "--limit N" "Number of messages to fetch" :default 50 :parse-fn parse-long]
["-b" "--before ID" "Fetch messages before this message ID"]
["-t" "--thread ID" "Read thread replies for a message"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private send-options
[["-c" "--community SLUG" "Community slug"]
["-s" "--stdin" "Read message from stdin"]
["-i" "--image PATH" "Attach an image file"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private edit-options
[["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private delete-options
[["-f" "--force" "Skip confirmation"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private dm-options
[["-r" "--read" "Read DM conversation instead of sending"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private notifications-options
[["-a" "--all" "Show all notifications (not just unread)"]
["-m" "--mark-read" "Mark all notifications as read"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private search-options
[["-c" "--community SLUG" "Community slug"]
[nil "--channel CHANNEL" "Search in specific channel"]
[nil "--from USER" "Search by author"]
[nil "--type TYPE" "Filter by type (messages, channels)"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private status-options
[["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private who-options
[["-c" "--community SLUG" "Community slug"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private invite-options
[["-c" "--community SLUG" "Community slug"]
[nil "--max-uses N" "Maximum number of invite uses" :parse-fn parse-long]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private config-options
[["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private tui-options
[["-c" "--community SLUG" "Open to specific community"]
[nil "--channel CHANNEL" "Open to specific channel"]
["-h" "--help" "Show help"]])
;;; ---------------------------------------------------------------------------
;;; Usage / help text
;;; ---------------------------------------------------------------------------
(def ^:private program-name "ajet")
(def ^:private usage-header
(str program-name " - ajet chat CLI client\n"
"\n"
"Usage: " program-name " <command> [options] [args]\n"
"\n"
"Commands:\n"
" login Login via OAuth or API token\n"
" logout Clear session\n"
" whoami Show current user info\n"
" communities List communities\n"
" channels List/join/leave channels\n"
" read <channel> Read messages in a channel\n"
" send <channel> Send a message\n"
" edit <id> <text> Edit a message\n"
" delete <id> Delete a message\n"
" dms List DM conversations\n"
" dm <user> <text> Send a DM\n"
" notifications Manage notifications\n"
" search <query> Search messages\n"
" status [text] Show/set status\n"
" who Show online users\n"
" invite Manage invites (create/list/revoke)\n"
" config Show/set configuration\n"
" tui Launch interactive TUI\n"
"\n"
"Global options:\n"
" -j, --json Output JSON (for scripting)\n"
" -h, --help Show help\n"
"\n"
"Run '" program-name " <command> --help' for command-specific options."))
(defn- command-usage
"Generate usage text for a specific command."
[command options-spec description]
(let [{:keys [summary]} (cli/parse-opts [] options-spec)]
(str "Usage: " program-name " " command " " description "\n"
"\n"
"Options:\n"
summary)))
;;; ---------------------------------------------------------------------------
;;; Error handling
;;; ---------------------------------------------------------------------------
(defn- api-error->exit-code
"Map an API error status to an exit code."
[status]
(cond
(= status 401) 3
(= status 403) 4
(= status 404) 5
(>= status 500) 1
:else 1))
(defn- api-error->hint
"Generate a hint for common API error statuses."
[status]
(case status
401 "Run 'ajet login' to sign in"
403 "You don't have permission for this action"
404 nil
429 "Too many requests. Wait a moment and try again"
nil))
(defn- handle-error
"Handle an exception and return an exit code."
[e]
(let [data (ex-data e)]
(case (:type data)
:ajet.chat/api-error
(let [status (:status data)
body (:body data)
msg (or (:message body) (:error body) (.getMessage e))
detail (or (:detail body) (:details body))
hint (or (:hint data) (api-error->hint status))]
(output/print-error msg detail hint)
(api-error->exit-code status))
:ajet.chat/auth-error
(do
(output/print-error (.getMessage e)
"No session token found. You need to authenticate first."
(or (:hint data) "Run 'ajet login' to sign in"))
3)
:ajet.chat/usage-error
(do
(output/print-error (.getMessage e) nil (:hint data))
2)
:ajet.chat/not-found
(do
(output/print-error (.getMessage e) nil (:hint data))
5)
:ajet.chat/validation-error
(do
(output/print-error (.getMessage e) (:explain data) nil)
2)
:ajet.chat/cancelled
0
;; Unknown ex-info
(if data
(do
(output/print-error (.getMessage e) (pr-str data) nil)
1)
;; Regular exception
(do
(output/print-error (.getMessage e)
nil
"If this persists, check your server connection with 'ajet config'")
1)))))
;;; ---------------------------------------------------------------------------
;;; Command dispatch
;;; ---------------------------------------------------------------------------
(defn- parse-and-dispatch
"Parse command-specific options and dispatch to the handler."
[handler-fn raw-args option-spec]
(let [{:keys [options arguments errors summary]} (cli/parse-opts raw-args option-spec)]
(cond
(:help options)
(do (println summary) 0)
errors
(do
(doseq [err errors]
(output/print-error err))
2)
:else
(handler-fn arguments (assoc options :json? (:json options))))))
(defn- dispatch
"Dispatch to the appropriate command handler."
[command raw-args]
(case command
"login"
(let [{:keys [options errors]} (cli/parse-opts raw-args login-options)]
(cond
errors (do (doseq [e errors] (output/print-error e)) 2)
(:help options) (do (println (command-usage "login" login-options "[options]")) 0)
(:token options) (auth/login-token (:token options) {:json? (:json options)})
:else (auth/login-interactive {:json? (:json options)})))
"logout"
(let [{:keys [options]} (cli/parse-opts raw-args global-options)]
(if (:help options)
(do (println (command-usage "logout" global-options "")) 0)
(auth/logout {:json? (:json options)})))
"whoami"
(let [{:keys [options]} (cli/parse-opts raw-args global-options)]
(if (:help options)
(do (println (command-usage "whoami" global-options "")) 0)
(auth/whoami {:json? (:json options)})))
"communities"
(parse-and-dispatch commands/communities raw-args global-options)
"channels"
(parse-and-dispatch commands/channels raw-args channels-options)
"read"
(parse-and-dispatch commands/read-messages raw-args read-options)
"send"
(parse-and-dispatch commands/send-message raw-args send-options)
"edit"
(parse-and-dispatch commands/edit-message raw-args edit-options)
"delete"
(parse-and-dispatch commands/delete-message raw-args delete-options)
"dms"
(parse-and-dispatch commands/list-dms raw-args global-options)
"dm"
(parse-and-dispatch commands/send-dm raw-args dm-options)
"notifications"
(parse-and-dispatch commands/notifications raw-args notifications-options)
"search"
(parse-and-dispatch commands/search raw-args search-options)
"status"
(parse-and-dispatch commands/set-status raw-args status-options)
"who"
(parse-and-dispatch commands/who-online raw-args who-options)
"invite"
(parse-and-dispatch commands/invite raw-args invite-options)
"config"
(parse-and-dispatch commands/config-cmd raw-args config-options)
"tui"
(let [{:keys [options errors]} (cli/parse-opts raw-args tui-options)]
(cond
errors (do (doseq [e errors] (output/print-error e)) 2)
(:help options) (do (println (command-usage "tui" tui-options "[options]")) 0)
:else (tui/launch! {:community (:community options)
:channel (:channel options)})))
;; Unknown command
(do
(output/print-error (str "Unknown command: " command)
nil
(str "Run '" program-name " --help' to see available commands"))
2)))
;;; ---------------------------------------------------------------------------
;;; Main entry point
;;; ---------------------------------------------------------------------------
(defn -main
"Main entry point. Parses arguments and dispatches to commands."
[& args]
;; Register SIGINT handler
(let [original-handler (Thread/getDefaultUncaughtExceptionHandler)]
(.addShutdownHook (Runtime/getRuntime)
(Thread. ^Runnable (fn []
;; Restore terminal on shutdown (in case TUI was running)
(print "\033[?25h\033[?1049l")
(flush)))))
(let [args (vec args)]
(if (empty? args)
;; No arguments: show help
(do
(println usage-header)
(System/exit 0))
;; Parse global options first to check for --help
(let [{:keys [options arguments]} (cli/parse-opts args global-options :in-order true)]
(cond
(:help options)
(do
(println usage-header)
(System/exit 0))
(empty? arguments)
(do
(println usage-header)
(System/exit 0))
:else
(let [command (first arguments)
cmd-args (vec (rest arguments))
;; Merge global --json flag into remaining args if present
all-args (if (:json options)
(into ["--json"] cmd-args)
cmd-args)
exit-code (try
(dispatch command all-args)
(catch clojure.lang.ExceptionInfo e
(handle-error e))
(catch java.net.ConnectException _e
(output/print-error
(str "Cannot connect to server at " (config/get-server-url))
"The server may be down or the URL may be incorrect."
"Check your config with 'ajet config'")
1)
(catch java.net.SocketTimeoutException _e
(output/print-error
"Request timed out"
"The server took too long to respond."
"Try again, or check your connection")
1)
(catch InterruptedException _e
130)
(catch Exception e
(handle-error e)))]
(System/exit (or exit-code 0))))))))
+296
View File
@@ -0,0 +1,296 @@
(ns ajet.chat.cli.output
"Output formatting for the CLI client.
All terminal output goes through this module for consistent formatting.
Supports human-readable and JSON output modes."
(:require [clojure.data.json :as json]
[clojure.string :as str]
[ajet.chat.shared.markdown :as markdown]
[ajet.chat.shared.mentions :as mentions])
(:import [java.time Instant Duration ZoneId]
[java.time.format DateTimeFormatter]))
;;; ---------------------------------------------------------------------------
;;; ANSI color helpers
;;; ---------------------------------------------------------------------------
(def ^:private ansi
{:reset "\033[0m"
:bold "\033[1m"
:dim "\033[2m"
:italic "\033[3m"
:underline "\033[4m"
:cyan "\033[36m"
:green "\033[32m"
:yellow "\033[33m"
:red "\033[31m"
:magenta "\033[35m"
:blue "\033[34m"
:gray "\033[90m"
:white "\033[37m"
:bg-red "\033[41m"})
(defn- colorize [color text]
(str (get ansi color "") text (:reset ansi)))
;;; ---------------------------------------------------------------------------
;;; Timestamp formatting
;;; ---------------------------------------------------------------------------
(def ^:private local-zone (ZoneId/systemDefault))
(def ^:private time-fmt
(DateTimeFormatter/ofPattern "h:mm a"))
(def ^:private date-fmt
(DateTimeFormatter/ofPattern "MMM d, yyyy"))
(def ^:private datetime-fmt
(DateTimeFormatter/ofPattern "MMM d, yyyy h:mm a"))
(defn- parse-timestamp
"Parse a timestamp string to an Instant. Handles ISO-8601 strings."
[ts]
(when ts
(try
(if (instance? Instant ts)
ts
(Instant/parse (str ts)))
(catch Exception _ nil))))
(defn relative-time
"Format a timestamp as a relative time string.
Returns: \"just now\", \"5m ago\", \"2h ago\", \"yesterday\", or a date."
[ts]
(if-let [instant (parse-timestamp ts)]
(let [now (Instant/now)
duration (Duration/between instant now)
seconds (.toSeconds duration)
minutes (.toMinutes duration)
hours (.toHours duration)
days (.toDays duration)
zdt (.atZone instant local-zone)]
(cond
(< seconds 60) "just now"
(< minutes 60) (str minutes "m ago")
(< hours 24) (str hours "h ago")
(= days 1) "yesterday"
(< days 7) (str days "d ago")
:else (.format zdt date-fmt)))
""))
(defn- format-time
"Format a timestamp as a local time (e.g., '10:30 AM')."
[ts]
(if-let [instant (parse-timestamp ts)]
(let [zdt (.atZone instant local-zone)]
(.format zdt time-fmt))
""))
(defn- format-datetime
"Format a timestamp as a local date and time."
[ts]
(if-let [instant (parse-timestamp ts)]
(let [zdt (.atZone instant local-zone)]
(.format zdt datetime-fmt))
""))
;;; ---------------------------------------------------------------------------
;;; Mention rendering
;;; ---------------------------------------------------------------------------
(defn- default-mention-lookup
"Default lookup function for mentions. Returns the ID as a fallback."
[_type id]
id)
;;; ---------------------------------------------------------------------------
;;; Message formatting
;;; ---------------------------------------------------------------------------
(defn- render-body
"Render a message body with markdown and mentions for terminal display."
[body-md & [mention-lookup]]
(let [lookup (or mention-lookup default-mention-lookup)
with-mentions (mentions/render body-md lookup)]
(markdown/->ansi with-mentions)))
(defn- format-attachments
"Format attachment list for display."
[attachments]
(when (seq attachments)
(str/join "\n"
(map (fn [att]
(str " " (colorize :cyan (str "[" (:content-type att "file") ": " (:filename att) "]"))))
attachments))))
(defn- format-reactions
"Format reactions for display."
[reactions]
(when (seq reactions)
(let [grouped (group-by :emoji reactions)]
(str " "
(str/join " "
(map (fn [[emoji reacts]]
(str emoji " " (count reacts)))
grouped))))))
(defn print-message
"Format and print a single message for terminal display.
Message shape:
{:id, :user-id, :username, :display-name, :body-md, :created-at,
:edited-at, :attachments, :reactions, :thread-count, :parent-id}"
[msg & [{:keys [mention-lookup show-channel]}]]
(let [author (or (:display-name msg) (:username msg) "unknown")
time-str (relative-time (:created-at msg))
edited? (:edited-at msg)
channel (when show-channel
(str (colorize :cyan (str "#" (:channel-name msg))) " "))
header (str " " (colorize :bold author) " "
(colorize :gray time-str)
(when edited? (colorize :gray " (edited)")))
body (render-body (:body-md msg) mention-lookup)
indented (str/join "\n" (map #(str " " %) (str/split-lines body)))
atts (format-attachments (:attachments msg))
reacts (format-reactions (:reactions msg))
thread-ct (when (and (:thread-count msg) (pos? (:thread-count msg)))
(str " " (colorize :blue (str (:thread-count msg) " replies"))))]
(println (str (when channel (str channel "\n")) header))
(println indented)
(when atts (println atts))
(when reacts (println reacts))
(when thread-ct (println thread-ct))))
(defn print-messages
"Format and print a list of messages with grouping.
Messages from the same user within 5 minutes are grouped together
(only the first gets the full header)."
[messages & [{:keys [mention-lookup channel-name channel-topic] :as opts}]]
(when channel-name
(println (str (colorize :bold (str "#" channel-name))
(when channel-topic
(str (colorize :gray (str " -- " channel-topic))))))
(println))
(loop [msgs messages
prev-author nil
prev-time nil]
(when (seq msgs)
(let [msg (first msgs)
author (or (:username msg) (:user-id msg))
msg-time (parse-timestamp (:created-at msg))
same-group? (and (= author prev-author)
prev-time
msg-time
(< (.toMinutes (Duration/between prev-time msg-time)) 5))
display-name (or (:display-name msg) (:username msg) "unknown")
time-str (relative-time (:created-at msg))
edited? (:edited-at msg)]
(if same-group?
;; Grouped: just the body, indented
(let [body (render-body (:body-md msg) mention-lookup)
indented (str/join "\n" (map #(str " " %) (str/split-lines body)))]
(println indented)
(when-let [atts (format-attachments (:attachments msg))]
(println atts))
(when-let [reacts (format-reactions (:reactions msg))]
(println reacts)))
;; New group: full header
(do
(when prev-author (println)) ;; blank line between groups
(print-message msg opts)))
(recur (rest msgs) author (or msg-time prev-time))))))
;;; ---------------------------------------------------------------------------
;;; Table formatting
;;; ---------------------------------------------------------------------------
(defn print-table
"Print a simple table from a list of maps.
columns is a vector of [header-string key-keyword] pairs.
Example: [[\"Name\" :name] [\"Slug\" :slug] [\"Members\" :member-count]]"
[columns rows]
(when (seq rows)
(let [headers (mapv first columns)
keys (mapv second columns)
str-rows (mapv (fn [row]
(mapv (fn [k] (str (get row k ""))) keys))
rows)
all-rows (cons headers str-rows)
col-widths (reduce (fn [widths row]
(mapv (fn [w cell] (max w (count cell))) widths row))
(mapv (constantly 0) headers)
all-rows)
fmt-row (fn [row]
(str/join " "
(map-indexed (fn [i cell]
(let [w (nth col-widths i)]
(format (str "%-" w "s") cell)))
row)))]
;; Header
(println (colorize :bold (fmt-row headers)))
;; Separator
(println (str/join " " (map #(apply str (repeat % "-")) col-widths)))
;; Data rows
(doseq [row str-rows]
(println (fmt-row row))))))
;;; ---------------------------------------------------------------------------
;;; Error formatting
;;; ---------------------------------------------------------------------------
(defn print-error
"Format and print an error message with optional hint.
Format:
Error: <short description>
<details>
Hint: <actionable next step>"
([message]
(print-error message nil nil))
([message details]
(print-error message details nil))
([message details hint]
(binding [*out* *err*]
(println (str (colorize :red "Error: ") message))
(when details
(println)
(println (str " " details)))
(when hint
(println)
(println (str (colorize :yellow "Hint: ") hint))))))
;;; ---------------------------------------------------------------------------
;;; JSON output
;;; ---------------------------------------------------------------------------
(defn print-json
"Print data as formatted JSON. Used with --json flag."
[data]
(println (json/write-str data)))
;;; ---------------------------------------------------------------------------
;;; Miscellaneous
;;; ---------------------------------------------------------------------------
(defn print-success
"Print a success message."
[message]
(println (str (colorize :green "OK") " " message)))
(defn print-info
"Print an informational message."
[message]
(println message))
(defn confirm?
"Ask for confirmation. Returns true if user types 'y' or 'yes'."
[prompt]
(print (str prompt " [y/N] "))
(flush)
(let [input (str/trim (or (read-line) ""))]
(contains? #{"y" "yes" "Y" "Yes" "YES"} input)))
+748
View File
@@ -0,0 +1,748 @@
(ns ajet.chat.cli.tui
"TUI mode — full interactive terminal application using clojure-tui Elm architecture.
Launched with `ajet tui`. Connects to the TUI session manager via SSE
for real-time events, and uses the API client for data operations.
Architecture: clojure-tui's Elm model (init/update/view) manages all state.
SSE events arrive via a shared LinkedBlockingQueue polled every 100ms.
API calls are dispatched as futures that write results back to the queue.
Layout (4 panes via :col/:row):
Header — app name, community name, connection status, username
Sidebar — communities, channels, DMs with unread counts
Content — scrollable message list + typing indicator
Input — message composition line
Status — keybindings and focus indicator
TODO: clojure-tui gaps — features that cannot be implemented with
the current library (see cli/README.md for full details):
1. Mouse support (PRD 4.4) — no mouse tracking/parsing in clojure-tui
2. Inline image rendering (PRD 4.5) — no timg/sixel/kitty support
3. Multiline text input (PRD 4.4) — :input widget is single-line only
4. Autocomplete dropdowns (PRD 4.4) — no @mention/#channel//cmd popups
5. SSE client integration (PRD 4.7) — workaround: external queue + polling
6. Terminal bell (PRD 4.8) — trivial but outside render model
7. OSC 8 hyperlinks (PRD 4.6) — no OSC 8 in tui.ansi
8. Spoiler text reveal (PRD 4.6) — needs per-message hidden state"
(:require [tui.core :as tui]
[tui.events :as ev]
[tui.ansi :as ansi]
[babashka.http-client :as http]
[clojure.data.json :as json]
[clojure.string :as str]
[ajet.chat.cli.config :as config]
[ajet.chat.shared.api-client :as api]
[ajet.chat.shared.markdown :as markdown]
[ajet.chat.shared.mentions :as mentions])
(:import [java.io BufferedReader InputStreamReader]
[java.net HttpURLConnection URL]
[java.util ArrayList]
[java.util.concurrent LinkedBlockingQueue]))
;;; ---------------------------------------------------------------------------
;;; SSE parsing (pure)
;;; ---------------------------------------------------------------------------
(defn- parse-sse-event
"Parse accumulated SSE lines into {:event type :data parsed-json}."
[lines]
(when (seq lines)
(let [result (reduce
(fn [acc line]
(cond
(str/starts-with? line "event:")
(assoc acc :event (str/trim (subs line 6)))
(str/starts-with? line "data:")
(update acc :data-lines conj (str/trim (subs line 5)))
(str/starts-with? line "id:")
(assoc acc :id (str/trim (subs line 3)))
:else acc))
{:event "message" :data-lines [] :id nil}
lines)
data-str (str/join "\n" (:data-lines result))]
(when-not (str/blank? data-str)
{:event (:event result)
:data (try (json/read-str data-str :key-fn keyword)
(catch Exception _ {:raw data-str}))
:id (:id result)}))))
;;; ---------------------------------------------------------------------------
;;; SSE background thread (writes to shared queue)
;;; ---------------------------------------------------------------------------
(defn- connect-sse
"Open SSE connection. Returns {:connection :reader} or nil."
[base-url token community-id]
(let [url-str (str base-url "/tui/sse/events"
(when community-id
(str "?community_id=" community-id)))]
(try
(let [url (URL. url-str)
conn ^HttpURLConnection (.openConnection url)]
(.setRequestMethod conn "GET")
(.setRequestProperty conn "Accept" "text/event-stream")
(.setRequestProperty conn "Authorization" (str "Bearer " token))
(.setRequestProperty conn "Cache-Control" "no-cache")
(.setConnectTimeout conn 10000)
(.setReadTimeout conn 0)
(.connect conn)
(when (= 200 (.getResponseCode conn))
{:connection conn
:reader (BufferedReader.
(InputStreamReader. (.getInputStream conn)))}))
(catch Exception _ nil))))
(defn- sse-read-loop!
"Read SSE stream, put parsed events on queue. Blocks until disconnect."
[^BufferedReader reader ^LinkedBlockingQueue queue running?]
(try
(loop [lines []]
(when @running?
(let [line (.readLine reader)]
(if (nil? line)
(.put queue {:type :sse :event "disconnected" :data {}})
(if (str/blank? line)
(do
(when-let [event (parse-sse-event lines)]
(.put queue {:type :sse
:event (:event event)
:data (:data event)}))
(recur []))
(recur (conj lines line)))))))
(catch Exception _
(.put queue {:type :sse :event "disconnected" :data {}}))))
(defn- start-sse-thread!
"Start background SSE connection manager with auto-reconnect."
[base-url token ^LinkedBlockingQueue queue running?]
(future
(loop [backoff 1000]
(when @running?
(let [conn-info (connect-sse base-url token nil)]
(if conn-info
(do
(.put queue {:type :sse-status :connected true :reconnecting false})
(sse-read-loop! (:reader conn-info) queue running?)
(try (.close ^java.io.Closeable (:reader conn-info))
(catch Exception _ nil))
(when @running?
(.put queue {:type :sse-status :connected false :reconnecting true})
(Thread/sleep 1000)
(recur 1000)))
(do
(.put queue {:type :sse-status :connected false :reconnecting true})
(Thread/sleep (min backoff 30000))
(recur (min (* backoff 2) 30000)))))))))
;;; ---------------------------------------------------------------------------
;;; Side-effect dispatchers (futures that write results to queue)
;;; ---------------------------------------------------------------------------
(defn- fire-load-data!
"Load communities, channels, messages in background."
[ctx ^LinkedBlockingQueue queue {:keys [community channel]}]
(future
(try
(let [saved (config/load-state)
result (api/get-communities ctx)
comms (vec (or (:communities result) result))
;; Resolve community
comm-id (or (when community
(some #(when (or (= (:slug %) community)
(= (str (:id %)) community))
(str (:id %)))
comms))
(:last-community saved)
(when (seq comms) (str (:id (first comms)))))]
(if comm-id
(let [ch-result (api/get-channels ctx comm-id)
chs (vec (or (:channels ch-result) ch-result))
;; Resolve channel
ch-id (or (when channel
(some #(when (= (:name %) channel)
(str (:id %)))
chs))
(get-in saved [:last-channels comm-id])
(when (seq chs) (str (:id (first chs)))))
msgs (when ch-id
(let [r (api/get-messages ctx ch-id {:limit 50})]
(vec (or (:messages r) r))))]
(.put queue {:type :data-loaded
:communities comms
:channels chs
:active-community comm-id
:active-channel ch-id
:messages (or msgs [])}))
(.put queue {:type :data-loaded
:communities comms
:channels []
:active-community nil
:active-channel nil
:messages []})))
(catch Exception e
(.put queue {:type :data-error :error (.getMessage e)})))))
(defn- fire-switch-channel!
"Load messages for a channel + notify TUI SM."
[ctx ch-id ^LinkedBlockingQueue queue]
(future
(try
(let [result (api/get-messages ctx ch-id {:limit 50})
msgs (vec (or (:messages result) result))]
(.put queue {:type :channel-messages :channel-id ch-id :messages msgs}))
(catch Exception _ nil))
(try
(http/request
{:method :post
:uri (str (:base-url ctx) "/tui/navigate")
:headers {"Authorization" (str "Bearer " (:auth-token ctx))
"Content-Type" "application/json"}
:body (json/write-str {:channel-id ch-id})
:throw false})
(catch Exception _ nil))))
(defn- fire-send-message!
[ctx channel-id text ^LinkedBlockingQueue queue]
(future
(try
(api/send-message ctx channel-id {:body-md text})
(catch Exception e
(.put queue {:type :send-error :error (.getMessage e)})))))
(defn- fire-load-older!
[ctx channel-id before-id ^LinkedBlockingQueue queue]
(future
(try
(let [result (api/get-messages ctx channel-id {:limit 50 :before before-id})
msgs (vec (or (:messages result) result))]
(when (seq msgs)
(.put queue {:type :older-messages :messages msgs})))
(catch Exception _ nil))))
;;; ---------------------------------------------------------------------------
;;; Queue draining
;;; ---------------------------------------------------------------------------
(defn- drain-queue
"Drain all available events from queue into a vector."
[^LinkedBlockingQueue queue]
(let [buf (ArrayList.)]
(.drainTo queue buf)
(vec buf)))
;;; ---------------------------------------------------------------------------
;;; Model
;;; ---------------------------------------------------------------------------
(defn- initial-model
[ctx session ^LinkedBlockingQueue queue overrides]
{:ctx ctx
:session session
:queue queue
:overrides overrides ;; {:community ... :channel ...} for initial load
:connected false
:reconnecting false
;; Data
:communities []
:channels []
:messages []
:typing-users #{}
:unread-counts {}
;; Navigation
:active-community nil
:active-channel nil
;; Input — TODO: Multiline input (PRD 4.4) — :input is single-line only
:input-text ""
;; Focus: :input | :messages | :sidebar
:focus :input
;; Layout
:sidebar-width 22
:scroll-offset 0
;; User
:username (:username session)
:user-id (:user-id session)
;; Errors
:error-message nil
:loading false})
;;; ---------------------------------------------------------------------------
;;; Pure model helpers
;;; ---------------------------------------------------------------------------
(defn- community-name [model]
(or (some #(when (= (str (:id %)) (str (:active-community model)))
(:name %))
(:communities model))
"ajet chat"))
(defn- channel-name [model]
(or (some #(when (= (str (:id %)) (str (:active-channel model)))
(:name %))
(:channels model))
"?"))
(defn- channel-ids [model]
(mapv #(str (:id %)) (:channels model)))
(defn- text-channels [model]
(filterv #(or (= (:type %) "text") (= (:type %) :text) (nil? (:type %)))
(:channels model)))
(defn- dm-channels [model]
(filterv #(or (= (:type %) "dm") (= (:type %) :dm)
(= (:type %) "group-dm") (= (:type %) :group-dm))
(:channels model)))
(defn- adjacent-channel [model direction]
(let [ids (channel-ids model)
idx (.indexOf ^java.util.List ids (str (:active-channel model)))]
(when (seq ids)
(nth ids (case direction
:next (if (< idx (dec (count ids))) (inc idx) 0)
:prev (if (pos? idx) (dec idx) (dec (count ids))))))))
;;; ---------------------------------------------------------------------------
;;; SSE event → model (pure)
;;; ---------------------------------------------------------------------------
(defn- apply-sse-event
"Apply a single SSE event to the model. Returns {:model m :events []}."
[model sse-event]
(let [event-name (:event sse-event)
data (:data sse-event)]
(case event-name
"init"
{:model (merge model
{:communities (or (:communities data) (:communities model))
:channels (or (:channels data) (:channels model))
:active-community (or (:active-community data) (:active-community model))
:active-channel (or (:active-channel data) (:active-channel model))
:unread-counts (or (:unread-counts data) (:unread-counts model))})
:events []}
"message"
(let [ch-id (or (:channel-id data) (:channel_id data))]
;; TODO: Terminal bell (PRD 4.8) — emit \a on @mention or DM
{:model (if (= ch-id (:active-channel model))
(update model :messages conj data)
(update-in model [:unread-counts (str ch-id)] (fnil inc 0)))
:events []})
"message.update"
(let [msg-id (or (:id data) (:message-id data))]
{:model (update model :messages
(fn [msgs] (mapv #(if (= (:id %) msg-id) (merge % data) %) msgs)))
:events []})
"message.delete"
(let [msg-id (or (:id data) (:message-id data))]
{:model (update model :messages
(fn [msgs] (filterv #(not= (:id %) msg-id) msgs)))
:events []})
"typing"
(let [username (or (:username data) (:user data))]
(if (and username (not= username (:username model)))
{:model (update model :typing-users conj username)
:events [(ev/delayed-event 5000 {:type :clear-typing :username username})]}
{:model model :events []}))
"channel.update"
{:model (update model :channels
(fn [chs] (mapv #(if (= (:id %) (:id data)) (merge % data) %) chs)))
:events []}
;; Unknown / disconnected — ignore
{:model model :events []})))
;;; ---------------------------------------------------------------------------
;;; Queue event → model (pure, except for typing events)
;;; ---------------------------------------------------------------------------
(defn- process-queue-event
"Process a single queue event. Returns {:model m :events []}."
[model event]
(case (:type event)
:sse-status
{:model (assoc model :connected (:connected event) :reconnecting (:reconnecting event))
:events []}
:sse
(apply-sse-event model event)
:data-loaded
{:model (merge model (select-keys event [:communities :channels :active-community
:active-channel :messages])
{:loading false})
:events []}
:data-error
{:model (assoc model :error-message (:error event) :loading false)
:events []}
:channel-messages
{:model (if (= (:channel-id event) (:active-channel model))
(assoc model :messages (:messages event))
model)
:events []}
:older-messages
{:model (update model :messages #(vec (concat (:messages event) %)))
:events []}
:send-error
{:model (assoc model :error-message (:error event))
:events []}
;; Unknown
{:model model :events []}))
;;; ---------------------------------------------------------------------------
;;; Update function (Elm architecture)
;;; ---------------------------------------------------------------------------
(defn- tui-update
[{:keys [model event]}]
(let [queue ^LinkedBlockingQueue (:queue model)
ctx (:ctx model)]
(cond
;; ── Poll SSE queue ──────────────────────────────────────────────
(= (:type event) :sse-poll)
(let [q-events (drain-queue queue)
{:keys [m extra-events]}
(reduce (fn [{:keys [m extra-events]} e]
(let [result (process-queue-event m e)]
{:m (:model result)
:extra-events (into extra-events (:events result))}))
{:m model :extra-events []}
q-events)]
{:model m
:events (into [(ev/delayed-event 100 {:type :sse-poll})]
extra-events)})
;; ── Load initial data ───────────────────────────────────────────
(= (:type event) :load-initial-data)
(do
(fire-load-data! ctx queue (:overrides model))
{:model (assoc model :loading true)})
;; ── Clear typing indicator ──────────────────────────────────────
(= (:type event) :clear-typing)
{:model (update model :typing-users disj (:username event))}
;; ── Ctrl+Q / Ctrl+C → quit ─────────────────────────────────────
(or (ev/key= event \q #{:ctrl})
(ev/key= event \c #{:ctrl}))
{:model model :events [(ev/quit)]}
;; ── Ctrl+K → search (placeholder) ──────────────────────────────
(ev/key= event \k #{:ctrl})
{:model model}
;; ── Ctrl+N → next channel ──────────────────────────────────────
(ev/key= event \n #{:ctrl})
(if-let [ch (adjacent-channel model :next)]
(do
(fire-switch-channel! ctx ch queue)
{:model (assoc model
:active-channel ch
:messages []
:scroll-offset 0
:typing-users #{})})
{:model model})
;; ── Ctrl+P → previous channel ──────────────────────────────────
(ev/key= event \p #{:ctrl})
(if-let [ch (adjacent-channel model :prev)]
(do
(fire-switch-channel! ctx ch queue)
{:model (assoc model
:active-channel ch
:messages []
:scroll-offset 0
:typing-users #{})})
{:model model})
;; ── Ctrl+E → edit (placeholder) ────────────────────────────────
(ev/key= event \e #{:ctrl})
{:model model}
;; ── Ctrl+D → delete (placeholder) ──────────────────────────────
(ev/key= event \d #{:ctrl})
{:model model}
;; ── Ctrl+R → react (placeholder) ───────────────────────────────
(ev/key= event \r #{:ctrl})
{:model model}
;; ── Ctrl+T → thread (placeholder) ──────────────────────────────
(ev/key= event \t #{:ctrl})
{:model model}
;; ── Tab → cycle focus ──────────────────────────────────────────
(ev/key= event :tab)
{:model (update model :focus
{:input :messages, :messages :sidebar, :sidebar :input})}
;; ── Enter → send message ───────────────────────────────────────
(ev/key= event :enter)
(if (and (= (:focus model) :input)
(not (str/blank? (:input-text model))))
(do
(fire-send-message! ctx (:active-channel model)
(:input-text model) queue)
{:model (assoc model :input-text "" :error-message nil)})
{:model model})
;; ── Backspace ──────────────────────────────────────────────────
(ev/key= event :backspace)
(if (and (= (:focus model) :input)
(pos? (count (:input-text model))))
{:model (update model :input-text #(subs % 0 (dec (count %))))}
{:model model})
;; ── Arrow up ───────────────────────────────────────────────────
(ev/key= event :up)
{:model (if (= (:focus model) :messages)
(update model :scroll-offset
#(min (inc %) (count (:messages model))))
model)}
;; ── Arrow down ─────────────────────────────────────────────────
(ev/key= event :down)
{:model (if (= (:focus model) :messages)
(update model :scroll-offset #(max 0 (dec %)))
model)}
;; ── Page Up ────────────────────────────────────────────────────
(ev/key= event :page-up)
(let [new-model (update model :scroll-offset
#(min (+ % 10) (count (:messages model))))]
(when (and (>= (:scroll-offset new-model) (count (:messages new-model)))
(:active-channel new-model)
(seq (:messages new-model)))
(fire-load-older! ctx (:active-channel new-model)
(:id (first (:messages new-model))) queue))
{:model new-model})
;; ── Page Down ──────────────────────────────────────────────────
(ev/key= event :page-down)
{:model (update model :scroll-offset #(max 0 (- % 10)))}
;; ── Escape → focus input ───────────────────────────────────────
(ev/key= event :escape)
{:model (assoc model :focus :input)}
;; ── j/k vim navigation in messages ─────────────────────────────
(and (= (:focus model) :messages) (ev/key= event \j))
{:model (update model :scroll-offset #(max 0 (dec %)))}
(and (= (:focus model) :messages) (ev/key= event \k))
{:model (update model :scroll-offset
#(min (inc %) (count (:messages model))))}
;; ── Regular character → input ──────────────────────────────────
(and (= (:type event) :key)
(= (:focus model) :input)
(char? (:key event))
(<= 32 (int (:key event)) 126))
{:model (update model :input-text str (:key event))}
;; ── Default → ignore ───────────────────────────────────────────
:else
{:model model})))
;;; ---------------------------------------------------------------------------
;;; View functions (pure hiccup → clojure-tui render primitives)
;;; ---------------------------------------------------------------------------
(defn- view-header
"Header bar: app name | community name ... username status"
[model]
(let [comm (community-name model)
user (:username model)
[status-color status-text]
(cond
(:reconnecting model) [:yellow "reconnecting..."]
(:connected model) [:green "connected"]
:else [:red "disconnected"])]
[:row {:widths [:flex :flex]}
[:text {:bg :blue :fg :white :bold true}
(str " ajet chat | " comm " ")]
[:text {:bg :blue :fg :white}
(str user " ")
[:text {:fg status-color} status-text]
" "]]))
(defn- view-sidebar-entry
"Single channel/DM entry in sidebar."
[ch active-channel unread-counts sw]
(let [ch-id (str (:id ch))
active? (= ch-id (str active-channel))
unread (get unread-counts ch-id 0)
is-dm? (or (= (:type ch) "dm") (= (:type ch) :dm)
(= (:type ch) "group-dm") (= (:type ch) :group-dm))
prefix (if is-dm? "" "#")
raw-name (str prefix (or (:name ch) (:display-name ch) "?"))
name-str (ansi/truncate raw-name (- sw 6))]
(if active?
[:text {:bold true :fg :cyan}
"> " name-str
(when (pos? unread) [:text {:fg :yellow} (str " " unread)])]
[:text {}
" " name-str
(when (pos? unread) [:text {:fg :yellow} (str " " unread)])])))
(defn- view-sidebar [model]
(let [{:keys [active-channel unread-counts sidebar-width]} model
tchs (text-channels model)
dchs (dm-channels model)]
(into [:col {}
[:text {:bold true :fg :white} "CHANNELS"]]
(concat
(for [ch tchs]
(view-sidebar-entry ch active-channel unread-counts sidebar-width))
(when (seq dchs)
(cons [:text {} ""]
(cons [:text {:bold true :fg :white} "DMs"]
(for [ch dchs]
(view-sidebar-entry ch active-channel
unread-counts sidebar-width)))))))))
(defn- format-relative-time
"Simple relative time formatter."
[ts]
(if (nil? ts) ""
(try
(let [s (str ts)]
(if (> (count s) 16) (subs s 11 16) s))
(catch Exception _ ""))))
(defn- view-message
"Single message: author + timestamp, then body lines."
[msg]
;; TODO: Inline images (PRD 4.5) — show [image: file.png] placeholder only
;; TODO: Spoiler reveal (PRD 4.6) — ||spoiler|| not hidden yet
;; TODO: OSC 8 hyperlinks (PRD 4.6) — URLs not clickable
(let [author (or (:display-name msg) (:username msg) (:user-id msg) "?")
time-str (format-relative-time (or (:created-at msg) (:timestamp msg)))
body (-> (or (:body-md msg) (:body msg) "")
(mentions/render (fn [_type id] id))
markdown/->ansi)
lines (str/split-lines body)]
(into [:col {}
[:text {}
[:text {:bold true} author]
" "
[:text {:fg :bright-black} time-str]
(when (:edited-at msg)
[:text {:fg :bright-black} " (edited)"])]]
(for [line lines]
[:text {} (str " " line)]))))
(defn- view-messages
"Message list with scroll offset."
[model]
(let [{:keys [messages scroll-offset]} model
n (count messages)
visible (if (and (pos? scroll-offset) (> n 0))
(let [end (max 0 (- n scroll-offset))
start (max 0 (- end 50))]
(subvec (vec messages) start end))
messages)]
(if (seq visible)
(into [:col {}]
(for [msg visible]
(view-message msg)))
[:text {:fg :bright-black :italic true} " No messages yet"])))
(defn- view-typing [model]
(let [{:keys [typing-users]} model]
(if (seq typing-users)
[:text {:fg :bright-black :italic true}
(str " " (str/join ", " typing-users) " "
(if (= 1 (count typing-users)) "is" "are")
" typing...")]
[:text {} ""])))
;; TODO: Autocomplete dropdowns (PRD 4.4) — no @mention/#channel//cmd popups
;; TODO: Multiline input (PRD 4.4) — :input is single-line, no Shift+Enter
(defn- view-input [model]
(let [ch (channel-name model)
active? (= (:focus model) :input)]
[:row {:widths [nil :flex]}
[:text (if active? {:fg :cyan} {:fg :bright-black})
(str (if active? "> " " ") "#" ch " ")]
[:input {:value (:input-text model)
:placeholder (when active? "Type a message...")}]]))
(defn- view-status-bar [model]
(let [focus-str (str "[" (name (:focus model)) "]")
keys-str "Ctrl+Q:quit Ctrl+K:search Ctrl+N/P:next/prev Tab:focus Enter:send"
err (:error-message model)]
[:text {:bg :bright-black :fg :white}
(str " " focus-str " " keys-str
(when err (str " | Error: " err)))]))
(defn- tui-view
"Root view: header / (sidebar | content) / input / status"
[model]
[:col {:heights [1 :flex 1 1]}
(view-header model)
[:row {:widths [(:sidebar-width model) :flex] :gap 1}
(view-sidebar model)
[:col {:heights [:flex 1]}
(view-messages model)
(view-typing model)]]
(view-input model)
(view-status-bar model)])
;;; ---------------------------------------------------------------------------
;;; Launch
;;; ---------------------------------------------------------------------------
(defn launch!
"Launch the TUI using clojure-tui's Elm architecture.
Options:
:community - community slug to open to
:channel - channel name to open to
Returns exit code."
[& [{:keys [community channel]}]]
(let [session (config/load-session)]
(when-not session
(throw (ex-info "Not logged in"
{:type :ajet.chat/auth-error
:hint "Run 'ajet login' to sign in"})))
(let [ctx (config/make-ctx)
queue (LinkedBlockingQueue.)
sse-running (atom true)
_ (start-sse-thread! (:base-url ctx)
(:auth-token ctx)
queue sse-running)
init-model (initial-model ctx session queue
{:community community :channel channel})
final-model (tui/run
{:init init-model
:update tui-update
:view tui-view
:init-events [{:type :load-initial-data}
(ev/delayed-event 100 {:type :sse-poll})]})]
;; Cleanup
(reset! sse-running false)
(config/save-state!
{:last-community (str (:active-community final-model))
:last-channels {(str (:active-community final-model))
(str (:active-channel final-model))}})
0)))