Add provider/model selector to Android app and serve protocol

ober

1ae58bedd4ba5f5e78c97909c6c31d151a5b847c

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 caa5ab3..b6e6bf1 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
@@ -36,6 +36,9 @@ class JcodeClient(private val context: Context) {
         fun onError(message: String)
         fun onDisconnected(reason: String)
         fun onAuthFailed(message: String)
+        fun onConfig(provider: String, model: String, providers: List<String>)
+        fun onModelItem(id: String, name: String)
+        fun onModelsEnd()
     }
 
     private val main = Handler(Looper.getMainLooper())
@@ -194,6 +197,24 @@ class JcodeClient(private val context: Context) {
         send(JSONObject().put("type", "ping"))
     }
 
+    fun sendGetConfig() {
+        send(JSONObject().put("type", "get_config"))
+    }
+
+    fun sendListModels(provider: String? = null) {
+        val ev = JSONObject().put("type", "list_models")
+        if (provider != null) ev.put("provider", provider)
+        send(ev)
+    }
+
+    fun sendSetProvider(name: String) {
+        send(JSONObject().put("type", "set_provider").put("name", name))
+    }
+
+    fun sendSetModel(name: String) {
+        send(JSONObject().put("type", "set_model").put("name", name))
+    }
+
     // ── internal ────────────────────────────────────────────────────────
 
     private fun writerLoop(writer: BufferedWriter) {
@@ -249,6 +270,25 @@ class JcodeClient(private val context: Context) {
                 "tool_end" -> l.onToolEnd(obj.optString("name", "?"))
                 "turn_end" -> l.onTurnEnd(obj.optString("session_id", ""))
                 "error" -> l.onError(obj.optString("message", "unknown error"))
+                "config" -> {
+                    val providers = mutableListOf<String>()
+                    val arr = obj.optJSONArray("providers")
+                    if (arr != null) {
+                        for (i in 0 until arr.length()) {
+                            providers.add(arr.getString(i))
+                        }
+                    }
+                    l.onConfig(
+                        obj.optString("provider", ""),
+                        obj.optString("model", ""),
+                        providers
+                    )
+                }
+                "model_item" -> l.onModelItem(
+                    obj.optString("id", ""),
+                    obj.optString("name", "")
+                )
+                "models_end" -> l.onModelsEnd()
                 "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 b002354..defd88d 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
@@ -18,6 +18,7 @@ import android.widget.ScrollView
 import android.widget.Switch
 import android.widget.TextView
 import android.widget.Toast
+import android.app.AlertDialog
 
 /**
  * Chat UI for jcode. Connects to `jcode serve --port PORT` running in Termux
@@ -46,6 +47,9 @@ class MainActivity : Activity(), JcodeClient.Listener {
     private lateinit var connectionBanner: LinearLayout
     private lateinit var bannerText: TextView
     private lateinit var btnReconnect: Button
+    private lateinit var providerRow: LinearLayout
+    private lateinit var btnProvider: TextView
+    private lateinit var btnModel: TextView
 
     private lateinit var client: JcodeClient
     private val handler = Handler(Looper.getMainLooper())
@@ -62,6 +66,12 @@ class MainActivity : Activity(), JcodeClient.Listener {
     /** Count of tool events since last tab switch — shown as badge on Tools tab. */
     private var toolCount = 0
 
+    /** Provider/model state from server */
+    private var currentProvider = ""
+    private var currentModel = ""
+    private var availableProviders = listOf<String>()
+    private val pendingModels = mutableListOf<Pair<String, String>>() // id, name
+
     // ── Scroll throttle: at most one scroll per 150ms ───────────────────
     private var scrollPending = false
     private val scrollRunnable = Runnable {
@@ -107,6 +117,9 @@ class MainActivity : Activity(), JcodeClient.Listener {
         connectionBanner = findViewById(R.id.connection_banner)
         bannerText = findViewById(R.id.banner_text)
         btnReconnect = findViewById(R.id.btn_reconnect)
+        providerRow = findViewById(R.id.provider_row)
+        btnProvider = findViewById(R.id.btn_provider)
+        btnModel = findViewById(R.id.btn_model)
 
         modeSwitch.setOnCheckedChangeListener { _, checked -> updateModeLabel(checked) }
         updateModeLabel(modeSwitch.isChecked)
@@ -131,6 +144,9 @@ class MainActivity : Activity(), JcodeClient.Listener {
             setStatus(getString(R.string.status_ready))
         }
 
+        btnProvider.setOnClickListener { showProviderPicker() }
+        btnModel.setOnClickListener { showModelPicker() }
+
         btnSend.setOnClickListener { trySend() }
         inputText.setOnEditorActionListener { _, actionId, _ ->
             if (actionId == EditorInfo.IME_ACTION_SEND) {
@@ -303,6 +319,57 @@ class MainActivity : Activity(), JcodeClient.Listener {
         throttledScroll()
     }
 
+    // ── provider/model pickers ────────────────────────────────────────
+
+    private fun showProviderPicker() {
+        if (availableProviders.isEmpty()) return
+        val items = availableProviders.toTypedArray()
+        AlertDialog.Builder(this)
+            .setTitle("Switch Provider")
+            .setItems(items) { _, which ->
+                val chosen = items[which]
+                if (chosen != currentProvider) {
+                    client.sendSetProvider(chosen)
+                    setStatus("Switching to $chosen...")
+                }
+            }
+            .show()
+    }
+
+    private fun showModelPicker() {
+        if (!client.isConnected()) return
+        pendingModels.clear()
+        client.sendListModels(currentProvider)
+        setStatus("Loading models...")
+    }
+
+    private fun showModelDialog() {
+        if (pendingModels.isEmpty()) {
+            setStatus("No models available")
+            return
+        }
+        val names = pendingModels.map { it.second }.toTypedArray()
+        val ids = pendingModels.map { it.first }
+        AlertDialog.Builder(this)
+            .setTitle("Switch Model")
+            .setItems(names) { _, which ->
+                val chosenId = ids[which]
+                if (chosenId != currentModel) {
+                    client.sendSetModel(chosenId)
+                    setStatus("Switching to ${names[which]}...")
+                }
+            }
+            .show()
+    }
+
+    private fun updateProviderModelButtons() {
+        btnProvider.text = if (currentProvider.isNotEmpty()) currentProvider else "Provider"
+        val shortModel = currentModel.let {
+            if (it.length > 24) it.take(24) + "\u2026" else it
+        }
+        btnModel.text = if (shortModel.isNotEmpty()) shortModel else "Model"
+    }
+
     // ── send ────────────────────────────────────────────────────────────
 
     private fun trySend() {
@@ -331,6 +398,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
     override fun onConnected() {
         hideBanner()
         setInputEnabled(true)
+        providerRow.visibility = View.VISIBLE
         setStatus("Connected")
     }
 
@@ -370,6 +438,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
     override fun onDisconnected(reason: String) {
         showBanner("Disconnected \u2014 $reason")
         setInputEnabled(false)
+        providerRow.visibility = View.GONE
         finalizeAssistant()
         hideStatus()
         sending = false
@@ -381,6 +450,23 @@ class MainActivity : Activity(), JcodeClient.Listener {
         appendError("Authentication failed: $message")
     }
 
+    override fun onConfig(provider: String, model: String, providers: List<String>) {
+        currentProvider = provider
+        currentModel = model
+        availableProviders = providers
+        updateProviderModelButtons()
+        hideStatus()
+    }
+
+    override fun onModelItem(id: String, name: String) {
+        pendingModels.add(Pair(id, name))
+    }
+
+    override fun onModelsEnd() {
+        hideStatus()
+        showModelDialog()
+    }
+
     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 8ecb7c5..821b4ad 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -93,6 +93,49 @@
             android:textSize="11sp" />
     </LinearLayout>
 
+    <!-- Provider / Model selector row -->
+    <LinearLayout
+        android:id="@+id/provider_row"
+        android:layout_width="match_parent"
+        android:layout_height="36dp"
+        android:background="@color/bg_dark"
+        android:gravity="center_vertical"
+        android:orientation="horizontal"
+        android:paddingStart="12dp"
+        android:paddingEnd="12dp"
+        android:visibility="gone">
+
+        <TextView
+            android:id="@+id/btn_provider"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:gravity="center"
+            android:text="Provider"
+            android:textColor="@color/accent"
+            android:textSize="12sp"
+            android:textStyle="bold"
+            android:background="?android:attr/selectableItemBackground" />
+
+        <View
+            android:layout_width="1dp"
+            android:layout_height="match_parent"
+            android:layout_marginTop="6dp"
+            android:layout_marginBottom="6dp"
+            android:background="@color/bg_input" />
+
+        <TextView
+            android:id="@+id/btn_model"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="2"
+            android:gravity="center"
+            android:text="Model"
+            android:textColor="@color/text_secondary"
+            android:textSize="12sp"
+            android:background="?android:attr/selectableItemBackground" />
+    </LinearLayout>
+
     <!-- Tab bar: Chat / Tools -->
     <LinearLayout
         android:layout_width="match_parent"
diff --git a/lib/jcode/ui/serve.sls b/lib/jcode/ui/serve.sls
index 00e4503..583e9a9 100644
--- a/lib/jcode/ui/serve.sls
+++ b/lib/jcode/ui/serve.sls
@@ -11,8 +11,9 @@
       with-output-to-string)
     (std text json) (std misc string) (std misc ports)
     (std os path) (std net tcp) (jcode core log)
-    (jcode core session) (jcode core agent)
-    (jcode tool registry) (jerboa core) (jerboa runtime))
+    (jcode core config) (jcode core models) (jcode core session)
+    (jcode core agent) (jcode tool registry) (jerboa core)
+    (jerboa runtime))
   (def logger (make-logger "serve"))
   (def *mutating-tools*
        '("write" "edit" "multi-edit" "patch" "bash" "git_commit"))
@@ -92,6 +93,47 @@
                 ("created" . ,(session-created s)))))
          (session-list))
        (emit-event "sessions_end" '()))
+  (def (handle-get-config)
+       "Send current provider, model, and list of available providers."
+       (let* ([provider (or (current-provider-override)
+                            (config-provider))]
+              [model (or (current-model-override)
+                         (config-ref "model")
+                         (config-default-model provider))]
+              [avail (filter
+                       (lambda (p) (config-get-provider-key p))
+                       (all-providers))])
+         (emit-event
+           "config"
+           `(("provider" . ,provider)
+              ("model" . ,model)
+              ("providers" . ,avail)))))
+  (def (handle-list-models provider)
+       "Send model list for a given provider."
+       (let ([models (provider-models
+                       (or provider
+                           (current-provider-override)
+                           (config-provider)))])
+         (for-each
+           (lambda (m)
+             (emit-event
+               "model_item"
+               `(("id" . ,(car m)) ("name" . ,(cdr m)))))
+           models)
+         (emit-event "models_end" '())))
+  (def (handle-set-provider name)
+       (let ([key (config-get-provider-key name)])
+         (cond
+           [(not key)
+            (emit-error
+              (format "no API key configured for provider: ~a" name))]
+           [else
+            (current-provider-override name)
+            (current-model-override (config-default-model name))
+            (handle-get-config)])))
+  (def (handle-set-model name)
+       (current-model-override name)
+       (handle-get-config))
   (def (dispatch-event event)
        (let ([type (hash-get event "type")])
          (cond
@@ -109,6 +151,19 @@
                 [(not id) (emit-error "switch_session event missing 'id'")]
                 [else (handle-switch-session id)]))]
            [(equal? type "list_sessions") (handle-list-sessions)]
+           [(equal? type "get_config") (handle-get-config)]
+           [(equal? type "list_models")
+            (handle-list-models (hash-get event "provider"))]
+           [(equal? type "set_provider")
+            (let ([name (hash-get event "name")])
+              (if name
+                  (handle-set-provider name)
+                  (emit-error "set_provider missing 'name'")))]
+           [(equal? type "set_model")
+            (let ([name (hash-get event "name")])
+              (if name
+                  (handle-set-model name)
+                  (emit-error "set_model missing 'name'")))]
            [(equal? type "ping") (emit-event "pong" '())]
            [else
             (emit-error (format "unknown event type: ~a" type))])))
@@ -117,7 +172,8 @@
          (emit-event
            "ready"
            `(("session_id" . ,sid)
-              ("session_title" . "Serve session"))))
+              ("session_title" . "Serve session")))
+         (handle-get-config))
        (let loop ()
          (let ([line (get-line (*serve-in*))])
            (cond
diff --git a/src/jcode/ui/serve.ss b/src/jcode/ui/serve.ss
index efb50d7..aabba13 100644
--- a/src/jcode/ui/serve.ss
+++ b/src/jcode/ui/serve.ss
@@ -36,6 +36,8 @@
         :std/os/path
         :std/net/tcp
         :jcode/core/log
+        :jcode/core/config
+        :jcode/core/models
         :jcode/core/session
         :jcode/core/agent
         :jcode/tool/registry)
@@ -147,6 +149,51 @@
     (session-list))
   (emit-event "sessions_end" '()))
 
+;; ── config / provider / model handlers ─────────────────────────────────
+
+(def (handle-get-config)
+  "Send current provider, model, and list of available providers."
+  (let* ((provider (or (current-provider-override) (config-provider)))
+         (model (or (current-model-override)
+                    (config-ref "model")
+                    (config-default-model provider)))
+         (avail (filter
+                  (lambda (p) (config-get-provider-key p))
+                  (all-providers))))
+    (emit-event "config"
+      `(("provider" . ,provider)
+        ("model" . ,model)
+        ("providers" . ,avail)))))
+
+(def (handle-list-models provider)
+  "Send model list for a given provider."
+  (let ((models (provider-models (or provider
+                                     (current-provider-override)
+                                     (config-provider)))))
+    (for-each
+      (lambda (m)
+        (emit-event "model_item"
+          `(("id" . ,(car m))
+            ("name" . ,(cdr m)))))
+      models)
+    (emit-event "models_end" '())))
+
+(def (handle-set-provider name)
+  (let ((key (config-get-provider-key name)))
+    (cond
+      ((not key)
+       (emit-error (format "no API key configured for provider: ~a" name)))
+      (else
+       (current-provider-override name)
+       (current-model-override (config-default-model name))
+       (handle-get-config)))))
+
+(def (handle-set-model name)
+  (current-model-override name)
+  (handle-get-config))
+
+;; ── event dispatch ────────────────────────────────────────────────────
+
 (def (dispatch-event event)
   (let ((type (hash-get event "type")))
     (cond
@@ -165,6 +212,18 @@
            (else (handle-switch-session id)))))
       ((equal? type "list_sessions")
        (handle-list-sessions))
+      ((equal? type "get_config")
+       (handle-get-config))
+      ((equal? type "list_models")
+       (handle-list-models (hash-get event "provider")))
+      ((equal? type "set_provider")
+       (let ((name (hash-get event "name")))
+         (if name (handle-set-provider name)
+           (emit-error "set_provider missing 'name'"))))
+      ((equal? type "set_model")
+       (let ((name (hash-get event "name")))
+         (if name (handle-set-model name)
+           (emit-error "set_model missing 'name'"))))
       ((equal? type "ping")
        (emit-event "pong" '()))
       (else
@@ -176,7 +235,8 @@
   (let ((sid (ensure-session!)))
     (emit-event "ready"
       `(("session_id" . ,sid)
-        ("session_title" . "Serve session"))))
+        ("session_title" . "Serve session")))
+    (handle-get-config))
   (let loop ()
     (let ((line (get-line (*serve-in*))))
       (cond