Add provider/model selector to Android app and serve protocol
ober
1ae58bedd4ba5f5e78c97909c6c31d151a5b847c
--- 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") } --- 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" } --- 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" --- 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 --- 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