Add MCP/LSP status and token usage tracking to Android app
ober
df8546986b9d8fc9d4f95685cd0f05b68d40ac54
--- 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") } --- 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" } --- 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" --- 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))) --- 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 --- 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) --- 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")))))