init codebase
This commit is contained in:
+126
@@ -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,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
|
||||
|
||||
@@ -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))
|
||||
@@ -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))))
|
||||
@@ -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))))
|
||||
@@ -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))))))))
|
||||
|
||||
@@ -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)))
|
||||
@@ -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)))
|
||||
Reference in New Issue
Block a user