Add MCP/LSP status and token usage tracking to Android app

ober

df8546986b9d8fc9d4f95685cd0f05b68d40ac54

diff --git a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
index b6e6bf1..1f06541 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
@@ -39,6 +39,8 @@ class JcodeClient(private val context: Context) {
         fun onConfig(provider: String, model: String, providers: List<String>)
         fun onModelItem(id: String, name: String)
         fun onModelsEnd()
+        fun onUsage(tokensIn: Long, tokensOut: Long, cost: Double)
+        fun onStatus(mcp: List<Pair<String, Int>>, lspActive: Boolean)
     }
 
     private val main = Handler(Looper.getMainLooper())
@@ -215,6 +217,10 @@ class JcodeClient(private val context: Context) {
         send(JSONObject().put("type", "set_model").put("name", name))
     }
 
+    fun sendGetStatus() {
+        send(JSONObject().put("type", "get_status"))
+    }
+
     // ── internal ────────────────────────────────────────────────────────
 
     private fun writerLoop(writer: BufferedWriter) {
@@ -289,6 +295,25 @@ class JcodeClient(private val context: Context) {
                     obj.optString("name", "")
                 )
                 "models_end" -> l.onModelsEnd()
+                "usage" -> l.onUsage(
+                    obj.optLong("tokens_in", 0),
+                    obj.optLong("tokens_out", 0),
+                    obj.optDouble("cost", 0.0)
+                )
+                "status" -> {
+                    val mcpList = mutableListOf<Pair<String, Int>>()
+                    val mcpArr = obj.optJSONArray("mcp")
+                    if (mcpArr != null) {
+                        for (i in 0 until mcpArr.length()) {
+                            val item = mcpArr.getJSONObject(i)
+                            mcpList.add(Pair(
+                                item.optString("name", "?"),
+                                item.optInt("tools", 0)
+                            ))
+                        }
+                    }
+                    l.onStatus(mcpList, obj.optBoolean("lsp", false))
+                }
                 "pong" -> {} // silent heartbeat ack
                 else -> Log.d(TAG, "unhandled event type: $type")
             }
diff --git a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
index defd88d..6a6a935 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
@@ -50,6 +50,9 @@ class MainActivity : Activity(), JcodeClient.Listener {
     private lateinit var providerRow: LinearLayout
     private lateinit var btnProvider: TextView
     private lateinit var btnModel: TextView
+    private lateinit var infoRow: LinearLayout
+    private lateinit var infoConnections: TextView
+    private lateinit var infoUsage: TextView
 
     private lateinit var client: JcodeClient
     private val handler = Handler(Looper.getMainLooper())
@@ -120,6 +123,9 @@ class MainActivity : Activity(), JcodeClient.Listener {
         providerRow = findViewById(R.id.provider_row)
         btnProvider = findViewById(R.id.btn_provider)
         btnModel = findViewById(R.id.btn_model)
+        infoRow = findViewById(R.id.info_row)
+        infoConnections = findViewById(R.id.info_connections)
+        infoUsage = findViewById(R.id.info_usage)
 
         modeSwitch.setOnCheckedChangeListener { _, checked -> updateModeLabel(checked) }
         updateModeLabel(modeSwitch.isChecked)
@@ -399,6 +405,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         hideBanner()
         setInputEnabled(true)
         providerRow.visibility = View.VISIBLE
+        infoRow.visibility = View.VISIBLE
         setStatus("Connected")
     }
 
@@ -439,6 +446,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         showBanner("Disconnected \u2014 $reason")
         setInputEnabled(false)
         providerRow.visibility = View.GONE
+        infoRow.visibility = View.GONE
         finalizeAssistant()
         hideStatus()
         sending = false
@@ -467,6 +475,32 @@ class MainActivity : Activity(), JcodeClient.Listener {
         showModelDialog()
     }
 
+    override fun onUsage(tokensIn: Long, tokensOut: Long, cost: Double) {
+        infoUsage.text = "${formatCount(tokensIn)}/${formatCount(tokensOut)} \u00b7 ${formatCost(cost)}"
+    }
+
+    override fun onStatus(mcp: List<Pair<String, Int>>, lspActive: Boolean) {
+        val parts = mutableListOf<String>()
+        if (mcp.isNotEmpty()) {
+            val mcpStr = mcp.joinToString(", ") { "${it.first}(${it.second})" }
+            parts.add("MCP: $mcpStr")
+        }
+        if (lspActive) parts.add("LSP")
+        infoConnections.text = if (parts.isEmpty()) "No MCP/LSP" else parts.joinToString(" \u00b7 ")
+    }
+
+    private fun formatCount(n: Long): String = when {
+        n >= 1_000_000 -> String.format("%.1fM", n / 1_000_000.0)
+        n >= 1_000 -> String.format("%.1fk", n / 1_000.0)
+        else -> n.toString()
+    }
+
+    private fun formatCost(c: Double): String = when {
+        c <= 0.0 -> "$0"
+        c < 0.01 -> String.format("$%.3f", c)
+        else -> String.format("$%.2f", c)
+    }
+
     companion object {
         private const val TAG = "jcode.Main"
     }
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index 821b4ad..14b19f7 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -136,6 +136,38 @@
             android:background="?android:attr/selectableItemBackground" />
     </LinearLayout>
 
+    <!-- Connection + usage info row -->
+    <LinearLayout
+        android:id="@+id/info_row"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/bg_dark"
+        android:gravity="center_vertical"
+        android:orientation="horizontal"
+        android:paddingStart="12dp"
+        android:paddingEnd="12dp"
+        android:paddingTop="2dp"
+        android:paddingBottom="4dp"
+        android:visibility="gone">
+
+        <TextView
+            android:id="@+id/info_connections"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text=""
+            android:textColor="@color/text_muted"
+            android:textSize="10sp" />
+
+        <TextView
+            android:id="@+id/info_usage"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text=""
+            android:textColor="@color/text_muted"
+            android:textSize="10sp" />
+    </LinearLayout>
+
     <!-- Tab bar: Chat / Tools -->
     <LinearLayout
         android:layout_width="match_parent"
diff --git a/lib/jcode/tool/lsp.sls b/lib/jcode/tool/lsp.sls
index b03958c..c181ebf 100644
--- a/lib/jcode/tool/lsp.sls
+++ b/lib/jcode/tool/lsp.sls
@@ -3,7 +3,7 @@
 ;;; Source: src/jcode/tool/lsp.ss
 
 (library (jcode tool lsp)
-  (export init-lsp-tools lsp-stop!)
+  (export init-lsp-tools lsp-stop! lsp-active?)
   (import
     (except (chezscheme) make-hash-table hash-table? iota \x31;+ \x31;-
       getenv path-extension path-absolute? thread? make-mutex
@@ -16,6 +16,9 @@
     lsp-conn
     (to-stdin from-stdout from-stderr pid next-id root-uri))
   (def *lsp-conn* #f)
+  (def (lsp-active?)
+       "Return #t if an LSP server is connected."
+       (and *lsp-conn* #t))
   (def (lsp-start command args root-path)
        "Start an LSP server subprocess."
        (log-info logger "starting" `((command . ,command)))
diff --git a/lib/jcode/ui/serve.sls b/lib/jcode/ui/serve.sls
index 583e9a9..0bcce25 100644
--- a/lib/jcode/ui/serve.sls
+++ b/lib/jcode/ui/serve.sls
@@ -12,8 +12,8 @@
     (std text json) (std misc string) (std misc ports)
     (std os path) (std net tcp) (jcode core log)
     (jcode core config) (jcode core models) (jcode core session)
-    (jcode core agent) (jcode tool registry) (jerboa core)
-    (jerboa runtime))
+    (jcode core agent) (jcode tool registry) (jcode mcp client)
+    (jcode tool lsp) (jerboa core) (jerboa runtime))
   (def logger (make-logger "serve"))
   (def *mutating-tools*
        '("write" "edit" "multi-edit" "patch" "bash" "git_commit"))
@@ -46,7 +46,27 @@
             `(("name" . ,name) ("args" . ,args)))]
          [(eq? phase 'end)
           (emit-event "tool_end" `(("name" . ,name)))]))
+  (def (serve-usage-cb usage)
+       (let ([tin 0] [tout 0] [cost 0.0])
+         (for-each
+           (lambda (pair)
+             (case (car pair)
+               [(tokens-in) (set! tin (cdr pair))]
+               [(tokens-out) (set! tout (cdr pair))]
+               [(cost) (set! cost (cdr pair))]))
+           usage)
+         (set! *session-tokens-in* (+ *session-tokens-in* tin))
+         (set! *session-tokens-out* (+ *session-tokens-out* tout))
+         (set! *session-cost* (+ *session-cost* cost))
+         (emit-event
+           "usage"
+           `(("tokens_in" . ,*session-tokens-in*)
+              ("tokens_out" . ,*session-tokens-out*)
+              ("cost" . ,*session-cost*)))))
   (def *current-session* #f)
+  (def *session-tokens-in* 0)
+  (def *session-tokens-out* 0)
+  (def *session-cost* 0.0)
   (def (ensure-session!)
        (unless *current-session*
          (let ([s (session-create "Serve session")])
@@ -58,7 +78,8 @@
                 mode
                 (lambda ()
                   (parameterize ([current-stream-cb serve-stream-cb]
-                                 [current-tool-cb serve-tool-cb])
+                                 [current-tool-cb serve-tool-cb]
+                                 [current-usage-cb serve-usage-cb])
                     (agent-run sid text))))
               (emit-event "turn_end" `(("session_id" . ,sid)))
               (catch
@@ -69,6 +90,9 @@
   (def (handle-new-session title)
        (let ([s (session-create (or title "New session"))])
          (set! *current-session* (session-id s))
+         (set! *session-tokens-in* 0)
+         (set! *session-tokens-out* 0)
+         (set! *session-cost* 0.0)
          (emit-event
            "ready"
            `(("session_id" . ,(session-id s))
@@ -134,6 +158,21 @@
   (def (handle-set-model name)
        (current-model-override name)
        (handle-get-config))
+  (def (handle-get-status)
+       "Send MCP/LSP connection status."
+       (let ([mcp (try (mcp-active-servers) (catch (e) '()))]
+             [lsp (try (lsp-active?) (catch (e) #f))])
+         (emit-event
+           "status"
+           `(("mcp"
+               .
+               ,(map (lambda (s)
+                       (let ([ht (make-hash-table)])
+                         (hash-put! ht "name" (car s))
+                         (hash-put! ht "tools" (cdr s))
+                         ht))
+                     mcp))
+              ("lsp" . ,(if lsp #t #f))))))
   (def (dispatch-event event)
        (let ([type (hash-get event "type")])
          (cond
@@ -164,6 +203,7 @@
               (if name
                   (handle-set-model name)
                   (emit-error "set_model missing 'name'")))]
+           [(equal? type "get_status") (handle-get-status)]
            [(equal? type "ping") (emit-event "pong" '())]
            [else
             (emit-error (format "unknown event type: ~a" type))])))
@@ -173,7 +213,8 @@
            "ready"
            `(("session_id" . ,sid)
               ("session_title" . "Serve session")))
-         (handle-get-config))
+         (handle-get-config)
+         (handle-get-status))
        (let loop ()
          (let ([line (get-line (*serve-in*))])
            (cond
@@ -282,6 +323,9 @@
                '((msg . "client connected")))
              (try (parameterize ([*serve-in* in] [*serve-out* out])
                     (set! *current-session* #f)
+                    (set! *session-tokens-in* 0)
+                    (set! *session-tokens-out* 0)
+                    (set! *session-cost* 0.0)
                     (if (validate-auth! token)
                         (serve-loop)
                         (log-warn
diff --git a/src/jcode/tool/lsp.ss b/src/jcode/tool/lsp.ss
index 8ed04ba..13e4bcf 100644
--- a/src/jcode/tool/lsp.ss
+++ b/src/jcode/tool/lsp.ss
@@ -3,7 +3,8 @@
 ;;; Registers definition, hover, and references as tools.
 
 (export init-lsp-tools
-        lsp-stop!)
+        lsp-stop!
+        lsp-active?)
 
 (import :std/text/json
         :std/misc/string
@@ -19,6 +20,10 @@
 
 (def *lsp-conn* #f)
 
+(def (lsp-active?)
+  "Return #t if an LSP server is connected."
+  (and *lsp-conn* #t))
+
 ;; --- subprocess management ---
 
 (def (lsp-start command args root-path)
diff --git a/src/jcode/ui/serve.ss b/src/jcode/ui/serve.ss
index aabba13..fb5acfc 100644
--- a/src/jcode/ui/serve.ss
+++ b/src/jcode/ui/serve.ss
@@ -40,7 +40,9 @@
         :jcode/core/models
         :jcode/core/session
         :jcode/core/agent
-        :jcode/tool/registry)
+        :jcode/tool/registry
+        :jcode/mcp/client
+        :jcode/tool/lsp)
 
 (def logger (make-logger "serve"))
 
@@ -95,9 +97,29 @@
     ((eq? phase 'end)
      (emit-event "tool_end" `(("name" . ,name))))))
 
+(def (serve-usage-cb usage)
+  (let ((tin 0) (tout 0) (cost 0.0))
+    (for-each
+      (lambda (pair)
+        (case (car pair)
+          ((tokens-in) (set! tin (cdr pair)))
+          ((tokens-out) (set! tout (cdr pair)))
+          ((cost) (set! cost (cdr pair)))))
+      usage)
+    (set! *session-tokens-in* (+ *session-tokens-in* tin))
+    (set! *session-tokens-out* (+ *session-tokens-out* tout))
+    (set! *session-cost* (+ *session-cost* cost))
+    (emit-event "usage"
+      `(("tokens_in" . ,*session-tokens-in*)
+        ("tokens_out" . ,*session-tokens-out*)
+        ("cost" . ,*session-cost*)))))
+
 ;; ── session state ──────────────────────────────────────────────────────
 
 (def *current-session* #f)
+(def *session-tokens-in* 0)
+(def *session-tokens-out* 0)
+(def *session-cost* 0.0)
 
 (def (ensure-session!)
   (unless *current-session*
@@ -113,7 +135,8 @@
       (with-mode mode
         (lambda ()
           (parameterize ((current-stream-cb serve-stream-cb)
-                         (current-tool-cb serve-tool-cb))
+                         (current-tool-cb serve-tool-cb)
+                         (current-usage-cb serve-usage-cb))
             (agent-run sid text))))
       (emit-event "turn_end" `(("session_id" . ,sid)))
       (catch (e)
@@ -124,6 +147,9 @@
 (def (handle-new-session title)
   (let ((s (session-create (or title "New session"))))
     (set! *current-session* (session-id s))
+    (set! *session-tokens-in* 0)
+    (set! *session-tokens-out* 0)
+    (set! *session-cost* 0.0)
     (emit-event "ready"
       `(("session_id" . ,(session-id s))
         ("session_title" . ,(session-title s))))))
@@ -192,6 +218,19 @@
   (current-model-override name)
   (handle-get-config))
 
+(def (handle-get-status)
+  "Send MCP/LSP connection status."
+  (let ((mcp (try (mcp-active-servers) (catch (e) '())))
+        (lsp (try (lsp-active?) (catch (e) #f))))
+    (emit-event "status"
+      `(("mcp" . ,(map (lambda (s)
+                         (let ((ht (make-hash-table)))
+                           (hash-put! ht "name" (car s))
+                           (hash-put! ht "tools" (cdr s))
+                           ht))
+                       mcp))
+        ("lsp" . ,(if lsp #t #f))))))
+
 ;; ── event dispatch ────────────────────────────────────────────────────
 
 (def (dispatch-event event)
@@ -224,6 +263,8 @@
        (let ((name (hash-get event "name")))
          (if name (handle-set-model name)
            (emit-error "set_model missing 'name'"))))
+      ((equal? type "get_status")
+       (handle-get-status))
       ((equal? type "ping")
        (emit-event "pong" '()))
       (else
@@ -236,7 +277,8 @@
     (emit-event "ready"
       `(("session_id" . ,sid)
         ("session_title" . "Serve session")))
-    (handle-get-config))
+    (handle-get-config)
+    (handle-get-status))
   (let loop ()
     (let ((line (get-line (*serve-in*))))
       (cond
@@ -343,6 +385,9 @@
                          (*serve-out* out))
             ;; Reset session for new client connection
             (set! *current-session* #f)
+            (set! *session-tokens-in* 0)
+            (set! *session-tokens-out* 0)
+            (set! *session-cost* 0.0)
             (if (validate-auth! token)
               (serve-loop)
               (log-warn logger "tcp-reject" '((msg . "auth failed, closing")))))