updates

ober

cd1da5704687a8a9948157588b381266063dc561

diff --git a/Makefile b/Makefile
index e7c2b09..9525f7b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,10 @@
 # jerbuild is the self-contained build tool: it bundles Chez Scheme + the
 # jerboa stdlib under ~/.cache/jerbuild/<sha>/, so the host dev loop and the
 # native `binary` need NO mutable dependency checkout and NO separately-built Chez.
-JERBOA_VERSION ?= v0.2.3
+JERBOA_VERSION ?= v0.2.8
 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin
 JERBUILD ?= $(shell if [ -x ./jerbuild ]; then echo ./jerbuild; \
 	elif [ -x "$(JERBOA_TOOL_DIR)/jerbuild" ]; then echo "$(JERBOA_TOOL_DIR)/jerbuild"; \
-	elif [ -x ../jerboa/dist/jerbuild ]; then echo ../jerboa/dist/jerbuild; \
-	elif [ -x ../jerboa/jerbuild ]; then echo ../jerboa/jerbuild; \
 	elif command -v jerbuild >/dev/null 2>&1; then command -v jerbuild; \
 	else echo "$(JERBOA_TOOL_DIR)/jerbuild"; fi)
 
@@ -104,10 +102,6 @@ ensure-jerboa-tools:
 	  echo "=== Using project-local ./jerbuild ==="; \
 	elif [ -x "$(JERBOA_TOOL_DIR)/jerbuild" ]; then \
 	  echo "=== Using downloaded Jerboa toolchain: $(JERBOA_TOOL_DIR) ==="; \
-	elif [ -x ../jerboa/dist/jerbuild ]; then \
-	  echo "=== Using sibling Jerboa build: ../jerboa/dist/jerbuild ==="; \
-	elif [ -x ../jerboa/jerbuild ]; then \
-	  echo "=== Using sibling Jerboa build: ../jerboa/jerbuild ==="; \
 	elif command -v jerbuild >/dev/null 2>&1; then \
 	  echo "=== Using Jerboa toolchain from PATH: $$(command -v jerbuild) ==="; \
 	else \
@@ -391,7 +385,7 @@ install: binary
 # CROSS-compile from this host to another platform using the host Chez
 # ($(SCHEME)) + a cross-built Chez kernel in $(JERBOA_HOME) + a musl/clang
 # cross toolchain. These vars are used ONLY by the cross targets.
-JERBOA_HOME      ?= $(HOME)/mine/jerboa
+JERBOA_HOME      ?= $(JH)
 SCHEME           ?= $(JERBOA_HOME)/.chez/bin/scheme
 FREEBSD_AMD64_CC ?= $(JERBOA_HOME)/support/cross-cc-freebsd-amd64
 XC_LIBDIRS        = ./lib:$(JSQLITE_LIBDIR):$(WEBSEARCH_OVERLAY)/src:vendor/jerboa-websearch/src:$(JH)/lib:$(JERBOA_HOME)/lib
diff --git a/README.md b/README.md
index e5de79f..8fb9da4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # jerboa-code
 
-**A portable AI coding agent written in [Jerboa](https://git.sr.ht/~lisp/jerboa) Scheme.**
+**A portable AI coding agent written in [Jerboa](https://git.jerboa.sh/ober/jerboa) Scheme.**
 
 `jcode` is a terminal coding agent — think opencode / aider / Claude Code — that
 compiles to a standalone binary without a Node or Python runtime. It talks to 14
@@ -50,7 +50,7 @@ release bundle into `.jerboa/bin`. See
 **[docs/getting-started.md](docs/getting-started.md)** for the full setup.
 
 ```bash
-git clone https://git.sr.ht/~lisp/jerboa-code && cd jerboa-code
+git clone https://git.jerboa.sh/ober/jerboa-code && cd jerboa-code
 
 make build          # compile src/ → lib/
 make binary         # produce the standalone ./jcode
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 81be4cb..222ae34 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
@@ -10,16 +10,16 @@ import java.io.BufferedReader
 import java.io.BufferedWriter
 import java.io.InputStreamReader
 import java.io.OutputStreamWriter
+import java.net.InetSocketAddress
 import java.net.Socket
 import java.util.concurrent.LinkedBlockingQueue
 import java.util.concurrent.atomic.AtomicBoolean
 
 /**
- * TCP socket client for connecting to `jcode serve --port PORT` in Termux.
+ * TCP socket client for connecting to `jcode serve --port PORT`.
  *
  * Replaces JcodeProcess (which spawned an embedded binary). The jcode server
- * runs in Termux with full environment access; this client connects over
- * localhost and communicates via the same JSONL protocol.
+ * runs on the configured host and communicates via the JSONL protocol.
  *
  * Auth handshake: on connect, sends {"type":"auth","token":"<token>"} and
  * waits for {"type":"auth_ok"}. On failure, reports to listener and closes.
@@ -67,6 +67,7 @@ class JcodeClient(private val context: Context) {
     private val main = Handler(Looper.getMainLooper())
     private val outbound = LinkedBlockingQueue<String>()
     private val connected = AtomicBoolean(false)
+    private val connecting = AtomicBoolean(false)
     private val shouldReconnect = AtomicBoolean(false)
 
     private var socket: Socket? = null
@@ -84,7 +85,7 @@ class JcodeClient(private val context: Context) {
     private fun prefs(): SharedPreferences =
         context.getSharedPreferences("jcode_settings", Context.MODE_PRIVATE)
 
-    fun getHost(): String = prefs().getString("host", "127.0.0.1") ?: "127.0.0.1"
+    fun getHost(): String = prefs().getString("host", DEFAULT_HOST) ?: DEFAULT_HOST
     fun getPort(): Int = prefs().getInt("port", 8321)
     fun getToken(): String = prefs().getString("token", "") ?: ""
 
@@ -93,82 +94,94 @@ class JcodeClient(private val context: Context) {
      * thread. Calls listener on the main thread.
      */
     fun connect() {
-        disconnect()
+        if (connected.get() || !connecting.compareAndSet(false, true)) {
+            Log.i(TAG, "connect ignored; already connected or connecting")
+            return
+        }
         shouldReconnect.set(true)
+        outbound.clear()
 
         val host = getHost()
         val port = getPort()
         val token = getToken()
 
         if (token.isEmpty()) {
+            connecting.set(false)
             main.post { listener?.onAuthFailed("No auth token configured. Open Settings and paste the token from Termux.") }
             return
         }
 
         readerThread = Thread {
+            var lastFailure: Exception? = null
             try {
-                Log.i(TAG, "connecting to $host:$port")
-                val s = Socket(host, port)
-                socket = s
-                connected.set(true)
-
-                val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
-                val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
-
-                // Send auth handshake
-                val authMsg = JSONObject()
-                    .put("type", "auth")
-                    .put("token", token)
-                writer.write(authMsg.toString() + "\n")
-                writer.flush()
+                try {
+                    Log.i(TAG, "connecting to $host:$port")
+                    val s = Socket()
+                    socket = s
+                    s.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS)
+                    s.soTimeout = AUTH_TIMEOUT_MS
+
+                    val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
+                    val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
+
+                    val authMsg = JSONObject()
+                        .put("type", "auth")
+                        .put("token", token)
+                    writer.write(authMsg.toString() + "\n")
+                    writer.flush()
+
+                    val responseLine = reader.readLine()
+                        ?: throw IllegalStateException("server closed connection during auth")
+
+                    val response = JSONObject(responseLine)
+                    val type = response.optString("type", "")
+
+                    if (type == "error") {
+                        val msg = response.optString("message", "auth failed")
+                        s.close()
+                        connecting.set(false)
+                        main.post { listener?.onAuthFailed(msg) }
+                        return@Thread
+                    }
 
-                // Wait for auth response
-                val responseLine = reader.readLine()
-                if (responseLine == null) {
-                    connected.set(false)
-                    main.post { listener?.onDisconnected("Server closed connection during auth") }
-                    s.close()
-                    return@Thread
-                }
+                    if (type != "auth_ok") {
+                        s.close()
+                        connecting.set(false)
+                        main.post { listener?.onAuthFailed("Unexpected response: $type") }
+                        return@Thread
+                    }
 
-                val response = JSONObject(responseLine)
-                val type = response.optString("type", "")
+                    s.soTimeout = 0
+                    connected.set(true)
+                    connecting.set(false)
+                    Log.i(TAG, "authenticated to $host:$port")
+                    main.post { listener?.onConnected() }
 
-                if (type == "error") {
-                    connected.set(false)
-                    val msg = response.optString("message", "auth failed")
-                    main.post { listener?.onAuthFailed(msg) }
-                    s.close()
-                    return@Thread
-                }
+                    writerThread = Thread { writerLoop(writer) }.apply {
+                        name = "jcode-writer"
+                        isDaemon = true
+                        start()
+                    }
 
-                if (type != "auth_ok") {
-                    connected.set(false)
-                    main.post { listener?.onAuthFailed("Unexpected response: $type") }
-                    s.close()
+                    readerLoop(reader)
                     return@Thread
+                } catch (e: Exception) {
+                    Log.w(TAG, "connect failed to $host:$port", e)
+                    lastFailure = e
+                    connected.set(false)
+                    socket?.let {
+                        try { it.close() } catch (_: Exception) {}
+                    }
+                    socket = null
                 }
 
-                // Auth succeeded
-                Log.i(TAG, "authenticated to $host:$port")
-                main.post { listener?.onConnected() }
-
-                // Start writer thread
-                writerThread = Thread { writerLoop(writer) }.apply {
-                    name = "jcode-writer"
-                    isDaemon = true
-                    start()
-                }
-
-                // Reader loop runs on this thread
-                readerLoop(reader)
-
             } catch (e: Exception) {
-                Log.w(TAG, "connect failed", e)
-                connected.set(false)
-                main.post { listener?.onDisconnected("Connection failed: ${e.message}") }
-                scheduleReconnect()
+                lastFailure = e
             }
+            connecting.set(false)
+            connected.set(false)
+            main.post { listener?.onDisconnected("Connection failed: ${lastFailure?.message ?: "unknown error"}") }
+            scheduleReconnect()
         }.apply {
             name = "jcode-connect"
             isDaemon = true
@@ -182,6 +195,7 @@ class JcodeClient(private val context: Context) {
     fun disconnect() {
         shouldReconnect.set(false)
         connected.set(false)
+        connecting.set(false)
         outbound.clear()
         reconnectThread?.interrupt()
         reconnectThread = null
@@ -418,5 +432,8 @@ class JcodeClient(private val context: Context) {
 
     companion object {
         private const val TAG = "JcodeClient"
+        private const val DEFAULT_HOST = "10.66.60.2"
+        private const val CONNECT_TIMEOUT_MS = 10_000
+        private const val AUTH_TIMEOUT_MS = 15_000
     }
 }
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 f8be348..a5428f4 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
@@ -14,8 +14,8 @@ import android.widget.Button
 import android.widget.EditText
 import android.widget.ImageButton
 import android.widget.LinearLayout
+import android.widget.PopupMenu
 import android.widget.ScrollView
-import android.widget.Switch
 import android.widget.TextView
 import android.widget.Toast
 import android.app.AlertDialog
@@ -35,10 +35,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
 
     private lateinit var titleText: TextView
     private lateinit var cwdText: TextView
-    private lateinit var modeLabel: TextView
-    private lateinit var modeSwitch: Switch
-    private lateinit var btnNewSession: ImageButton
-    private lateinit var btnSettings: ImageButton
+    private lateinit var btnMenu: ImageButton
     private lateinit var chatScroll: ScrollView
     private lateinit var chatContainer: LinearLayout
     private lateinit var toolsScroll: ScrollView
@@ -73,6 +70,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
 
     private var sending = false
     private var activePage = Page.CHAT
+    private var buildMode = true
 
     /** Count of tool events since last tab switch — shown as badge on Tools tab. */
     private var toolCount = 0
@@ -86,17 +84,24 @@ class MainActivity : Activity(), JcodeClient.Listener {
 
     // ── Scroll throttle: at most one scroll per 150ms ───────────────────
     private var scrollPending = false
+    private var scrollTarget = Page.CHAT
     private val scrollRunnable = Runnable {
         scrollPending = false
-        val sv = when (activePage) {
+        val sv = when (scrollTarget) {
             Page.CHAT -> chatScroll
             Page.TOOLS -> toolsScroll
             Page.MCP -> mcpScroll
         }
-        sv.fullScroll(View.FOCUS_DOWN)
+        sv.post {
+            val child = sv.getChildAt(0) ?: return@post
+            val viewportHeight = sv.height - sv.paddingTop - sv.paddingBottom
+            val bottom = (child.height - viewportHeight).coerceAtLeast(0)
+            sv.scrollTo(0, bottom)
+        }
     }
 
-    private fun throttledScroll() {
+    private fun throttledScroll(page: Page = activePage) {
+        scrollTarget = page
         if (!scrollPending) {
             scrollPending = true
             handler.postDelayed(scrollRunnable, 150)
@@ -109,7 +114,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         tokenDirty = false
         val tv = currentAssistantText ?: return@Runnable
         tv.text = currentAssistantBuf.toString()
-        throttledScroll()
+        throttledScroll(Page.CHAT)
     }
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -118,10 +123,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
 
         titleText = findViewById(R.id.title_text)
         cwdText = findViewById(R.id.cwd_text)
-        modeLabel = findViewById(R.id.mode_label)
-        modeSwitch = findViewById(R.id.mode_switch)
-        btnNewSession = findViewById(R.id.btn_new_session)
-        btnSettings = findViewById(R.id.btn_settings)
+        btnMenu = findViewById(R.id.btn_menu)
         chatScroll = findViewById(R.id.chat_scroll)
         chatContainer = findViewById(R.id.chat_container)
         toolsScroll = findViewById(R.id.tools_scroll)
@@ -144,32 +146,12 @@ class MainActivity : Activity(), JcodeClient.Listener {
         infoConnections = findViewById(R.id.info_connections)
         infoUsage = findViewById(R.id.info_usage)
 
-        modeSwitch.setOnCheckedChangeListener { _, checked -> updateModeLabel(checked) }
-        updateModeLabel(modeSwitch.isChecked)
-
         // Tab switching
         tabChat.setOnClickListener { switchTab(Page.CHAT) }
         tabTools.setOnClickListener { switchTab(Page.TOOLS) }
         tabMcp.setOnClickListener { switchTab(Page.MCP) }
 
-        btnSettings.setOnClickListener {
-            startActivity(Intent(this, SettingsActivity::class.java))
-        }
-
-        btnNewSession.setOnClickListener {
-            if (!client.isConnected()) return@setOnClickListener
-            chatContainer.removeAllViews()
-            toolsContainer.removeAllViews()
-            mcpContainer.removeAllViews()
-            toolCount = 0
-            mcpCount = 0
-            updateTabLabels()
-            currentAssistantText = null
-            currentAssistantMeta = null
-            currentAssistantBuf.setLength(0)
-            client.sendNewSession()
-            setStatus(getString(R.string.status_ready))
-        }
+        btnMenu.setOnClickListener { showMainMenu() }
 
         btnProvider.setOnClickListener { showProviderPicker() }
         btnModel.setOnClickListener { showModelPicker() }
@@ -258,15 +240,59 @@ class MainActivity : Activity(), JcodeClient.Listener {
 
     // ── UI helpers ──────────────────────────────────────────────────────
 
-    private fun updateModeLabel(build: Boolean) {
-        modeLabel.setText(if (build) R.string.mode_build else R.string.mode_plan)
-        val color = resources.getColor(
-            if (build) R.color.mode_build else R.color.mode_plan, theme
-        )
-        modeLabel.setTextColor(color)
+    private fun currentMode(): String = if (buildMode) "build" else "plan"
+
+    private fun resetSession() {
+        if (!client.isConnected()) return
+        chatContainer.removeAllViews()
+        toolsContainer.removeAllViews()
+        mcpContainer.removeAllViews()
+        toolCount = 0
+        mcpCount = 0
+        updateTabLabels()
+        currentAssistantText = null
+        currentAssistantMeta = null
+        currentAssistantBuf.setLength(0)
+        client.sendNewSession()
+        switchTab(Page.CHAT)
+        setStatus(getString(R.string.status_ready))
+    }
+
+    private fun showMainMenu() {
+        val popup = PopupMenu(this, btnMenu)
+        val menu = popup.menu
+        menu.add(0, MENU_CHAT, 0, "Chat")
+        menu.add(0, MENU_TOOLS, 1, if (toolCount > 0) "Tools ($toolCount)" else "Tools")
+        menu.add(0, MENU_MCP, 2, if (mcpCount > 0) "MCP ($mcpCount)" else "MCP")
+        menu.add(0, MENU_PROVIDER, 3,
+            if (currentProvider.isNotEmpty()) "Provider: $currentProvider" else "Provider")
+        menu.add(0, MENU_MODEL, 4,
+            if (currentModel.isNotEmpty()) "Model: ${shortMenuValue(currentModel)}" else "Model")
+        menu.add(0, MENU_MODE, 5,
+            if (buildMode) "Mode: Build" else "Mode: Plan")
+        menu.add(0, MENU_NEW_SESSION, 6, "New session")
+        menu.add(0, MENU_SETTINGS, 7, "Settings")
+        popup.setOnMenuItemClickListener { item ->
+            when (item.itemId) {
+                MENU_CHAT -> switchTab(Page.CHAT)
+                MENU_TOOLS -> switchTab(Page.TOOLS)
+                MENU_MCP -> switchTab(Page.MCP)
+                MENU_PROVIDER -> showProviderPicker()
+                MENU_MODEL -> showModelPicker()
+                MENU_MODE -> {
+                    buildMode = !buildMode
+                    setStatus(if (buildMode) "Mode: Build" else "Mode: Plan")
+                }
+                MENU_NEW_SESSION -> resetSession()
+                MENU_SETTINGS -> startActivity(Intent(this, SettingsActivity::class.java))
+            }
+            true
+        }
+        popup.show()
     }
 
-    private fun currentMode(): String = if (modeSwitch.isChecked) "build" else "plan"
+    private fun shortMenuValue(value: String): String =
+        if (value.length > 28) value.take(28) + "\u2026" else value
 
     private fun setStatus(text: String) {
         statusLine.text = text
@@ -308,7 +334,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         val view = layoutInflater.inflate(R.layout.item_chat_user, chatContainer, false)
         view.findViewById<TextView>(R.id.message_text).text = text
         chatContainer.addView(view)
-        throttledScroll()
+        throttledScroll(Page.CHAT)
     }
 
     private fun beginAssistant() {
@@ -321,7 +347,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         currentAssistantText = tv
         currentAssistantMeta = meta
         currentAssistantBuf.setLength(0)
-        throttledScroll()
+        throttledScroll(Page.CHAT)
     }
 
     private fun appendAssistantToken(token: String) {
@@ -342,7 +368,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
             tokenDirty = false
             val tv = currentAssistantText ?: return
             tv.text = currentAssistantBuf.toString()
-            throttledScroll()
+            throttledScroll(Page.CHAT)
         }
     }
 
@@ -368,7 +394,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         updateTabLabels()
         // Auto-scroll tools pane if it's visible
         if (activePage == Page.TOOLS) {
-            throttledScroll()
+            throttledScroll(Page.TOOLS)
         }
     }
 
@@ -403,7 +429,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         mcpCount++
         updateTabLabels()
         if (activePage == Page.MCP) {
-            throttledScroll()
+            throttledScroll(Page.MCP)
         }
     }
 
@@ -411,7 +437,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         val view = layoutInflater.inflate(R.layout.item_chat_error, chatContainer, false)
         view.findViewById<TextView>(R.id.error_text).text = msg
         chatContainer.addView(view)
-        throttledScroll()
+        throttledScroll(Page.CHAT)
     }
 
     // ── provider/model pickers ────────────────────────────────────────
@@ -501,8 +527,8 @@ class MainActivity : Activity(), JcodeClient.Listener {
     override fun onConnected() {
         hideBanner()
         setInputEnabled(true)
-        providerRow.visibility = View.VISIBLE
-        infoRow.visibility = View.VISIBLE
+        providerRow.visibility = View.GONE
+        infoRow.visibility = View.GONE
         setStatus("Connected")
     }
 
@@ -535,7 +561,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
         view.findViewById<TextView>(R.id.message_text).text = content
         view.findViewById<TextView>(R.id.reply_meta).visibility = View.GONE
         chatContainer.addView(view)
-        throttledScroll()
+        throttledScroll(Page.CHAT)
     }
 
     override fun onHistoryTool(name: String, argsJson: String) {
@@ -543,7 +569,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
     }
 
     override fun onHistoryEnd() {
-        throttledScroll()
+        throttledScroll(Page.CHAT)
     }
 
     override fun onToken(text: String) {
@@ -629,7 +655,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
     }
 
     override fun onMcpCallsEnd() {
-        if (activePage == Page.MCP) throttledScroll()
+        if (activePage == Page.MCP) throttledScroll(Page.MCP)
     }
 
     private fun formatCount(n: Long): String = when {
@@ -676,5 +702,13 @@ class MainActivity : Activity(), JcodeClient.Listener {
 
     companion object {
         private const val TAG = "jcode.Main"
+        private const val MENU_CHAT = 1
+        private const val MENU_TOOLS = 2
+        private const val MENU_MCP = 3
+        private const val MENU_PROVIDER = 4
+        private const val MENU_MODEL = 5
+        private const val MENU_MODE = 6
+        private const val MENU_NEW_SESSION = 7
+        private const val MENU_SETTINGS = 8
     }
 }
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index adb4b37..e311842 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -5,15 +5,15 @@
     android:orientation="vertical"
     android:background="@color/bg_dark">
 
-    <!-- Header: title, mode switch, new session, settings -->
+    <!-- Compact header: title, cwd, overflow menu -->
     <LinearLayout
         android:layout_width="match_parent"
-        android:layout_height="56dp"
+        android:layout_height="44dp"
         android:background="@color/bg_card"
         android:gravity="center_vertical"
         android:orientation="horizontal"
-        android:paddingStart="16dp"
-        android:paddingEnd="8dp">
+        android:paddingStart="12dp"
+        android:paddingEnd="4dp">
 
         <TextView
             android:id="@+id/title_text"
@@ -22,7 +22,7 @@
             android:layout_weight="1"
             android:text="@string/app_name"
             android:textColor="@color/text_primary"
-            android:textSize="18sp"
+            android:textSize="16sp"
             android:textStyle="bold" />
 
         <TextView
@@ -39,38 +39,13 @@
             android:textColor="@color/text_muted"
             android:textSize="11sp" />
 
-        <TextView
-            android:id="@+id/mode_label"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginEnd="4dp"
-            android:text="@string/mode_build"
-            android:textColor="@color/mode_build"
-            android:textSize="12sp"
-            android:textStyle="bold" />
-
-        <Switch
-            android:id="@+id/mode_switch"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginEnd="4dp"
-            android:checked="true" />
-
-        <ImageButton
-            android:id="@+id/btn_new_session"
-            android:layout_width="40dp"
-            android:layout_height="40dp"
-            android:background="?android:attr/selectableItemBackgroundBorderless"
-            android:contentDescription="@string/new_session"
-            android:src="@android:drawable/ic_menu_add" />
-
         <ImageButton
-            android:id="@+id/btn_settings"
-            android:layout_width="40dp"
-            android:layout_height="40dp"
+            android:id="@+id/btn_menu"
+            android:layout_width="44dp"
+            android:layout_height="44dp"
             android:background="?android:attr/selectableItemBackgroundBorderless"
-            android:contentDescription="@string/settings"
-            android:src="@android:drawable/ic_menu_preferences" />
+            android:contentDescription="@string/menu"
+            android:src="@android:drawable/ic_menu_more" />
     </LinearLayout>
 
     <!-- Connection banner (shown when disconnected) -->
@@ -182,12 +157,14 @@
             android:textSize="10sp" />
     </LinearLayout>
 
-    <!-- Tab bar: Chat / Tools / MCP -->
+    <!-- Hidden tab controls; switching is exposed through the header menu. -->
     <LinearLayout
+        android:id="@+id/tab_bar"
         android:layout_width="match_parent"
         android:layout_height="40dp"
         android:background="@color/bg_card"
-        android:orientation="horizontal">
+        android:orientation="horizontal"
+        android:visibility="gone">
 
         <TextView
             android:id="@+id/tab_chat"
diff --git a/android/app/src/main/res/layout/item_chat_assistant.xml b/android/app/src/main/res/layout/item_chat_assistant.xml
index cde46a8..c881473 100644
--- a/android/app/src/main/res/layout/item_chat_assistant.xml
+++ b/android/app/src/main/res/layout/item_chat_assistant.xml
@@ -3,8 +3,8 @@
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
     android:orientation="vertical"
-    android:paddingStart="12dp"
-    android:paddingEnd="48dp"
+    android:paddingStart="8dp"
+    android:paddingEnd="8dp"
     android:paddingTop="4dp"
     android:paddingBottom="4dp">
 
@@ -14,10 +14,10 @@
         android:layout_height="wrap_content"
         android:background="@color/bubble_assistant"
         android:fontFamily="monospace"
-        android:padding="12dp"
+        android:padding="10dp"
         android:textColor="@color/text_primary"
         android:textIsSelectable="true"
-        android:textSize="13sp" />
+        android:textSize="15sp" />
 
     <TextView
         android:id="@+id/reply_meta"
diff --git a/android/app/src/main/res/layout/item_chat_user.xml b/android/app/src/main/res/layout/item_chat_user.xml
index e5a5500..4542674 100644
--- a/android/app/src/main/res/layout/item_chat_user.xml
+++ b/android/app/src/main/res/layout/item_chat_user.xml
@@ -3,8 +3,8 @@
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
     android:orientation="horizontal"
-    android:paddingStart="48dp"
-    android:paddingEnd="12dp"
+    android:paddingStart="32dp"
+    android:paddingEnd="8dp"
     android:paddingTop="4dp"
     android:paddingBottom="4dp">
 
@@ -14,7 +14,7 @@
         android:layout_height="wrap_content"
         android:layout_gravity="end"
         android:background="@color/bubble_user"
-        android:padding="12dp"
+        android:padding="10dp"
         android:textColor="@color/white"
-        android:textSize="14sp" />
+        android:textSize="15sp" />
 </LinearLayout>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 1987e9a..0644c20 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -9,6 +9,7 @@
     <string name="mode_build">Build</string>
     <string name="hint_message">Message jcode\u2026</string>
     <string name="new_session">New session</string>
+    <string name="menu">Menu</string>
     <string name="error">Error</string>
     <string name="status_ready">Ready</string>
     <string name="status_thinking">Thinking\u2026</string>
diff --git a/docs/README.md b/docs/README.md
index a925d74..2291f14 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -58,6 +58,9 @@ elevator pitch.
 
 - **[Architecture](architecture.md)** — the agent loop, the module map, and how a
   single turn flows from prompt to tool calls to final answer.
+- **[Prompt caching](prompt-caching.md)** — handoff for implementing server-side
+  prefix caching with local ds4: verification evidence, the harness's existing
+  cache plumbing, gap analysis, and the phased implementation plan.
 
 ## Contributing
 
diff --git a/docs/cli.md b/docs/cli.md
index ec40e4f..c15c367 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -178,7 +178,9 @@ Available in the REPL and TUI.
 | `/tool off <name>` | Disable a tool for the current session. Hidden from the model and rejected at execute time. |
 | `/tool on <name>` | Re-enable a previously disabled tool. |
 | `/agents` | List named sub-agent roles for the `task` tool (built-ins + `jcode.json` variants). |
-| `/mcp` | Toggle MCP tools on/off. |
+| `/mcp` | In the TUI, open an MCP server/tool popup; use arrows to move and Space or Enter to toggle the selected row. In the line-mode REPL, show MCP status and usage. |
+| `/mcp status` | List active MCP servers, enabled state, and tool counts. |
+| `/mcp on\|off [name]` | Enable or disable all MCP servers, or one named MCP server. |
 | `/plugins` | List loaded plugins. |
 | `/skills` | List built-in and user skills. |
 | `/<skill-name> [args]` | Run a skill (built-in or a user `SKILL.md`). |
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 400d697..523be58 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -8,9 +8,8 @@ prerequisites, building on each platform, configuration, and your first session.
 `jcode` builds with the Jerboa toolchain. The build needs three things:
 
 1. **A Jerboa toolchain.** The Makefile checks, in order:
-   project-local `./jerbuild` + `./jerboa`, `.jerboa/bin`, a sibling
-   `../jerboa/dist` build, `PATH`, then downloads the configured SourceHut
-   release artifact into `.jerboa/bin`.
+   project-local `./jerbuild`, `.jerboa/bin`, `PATH`, then downloads the
+   configured `git.jerboa.sh` release artifact into `.jerboa/bin`.
 
    ```bash
    make ensure-jerboa-tools
@@ -46,9 +45,9 @@ commit and verified by Git tree ID from `vendor-lock.env`.
 
 | Dependency | Source |
 |---|---|
-| `jsqlite` | `git.sr.ht/~lisp/jerboa-sqlite` — pure Scheme SQLite-compatible session storage |
+| `jsqlite` | `https://git.jerboa.sh/ober/jerboa-sqlite` — pure Scheme SQLite-compatible session storage |
 | `termbox2` | `github.com/termbox/termbox2` — the TUI's terminal backend |
-| `jerboa-websearch` | `git.sr.ht/~lisp/jerboa-websearch` — Websearch libraries, with the bounded worker overlay under `vendor-overrides/` |
+| `jerboa-websearch` | `https://git.jerboa.sh/ober/jerboa-websearch` — Websearch libraries, with the bounded worker overlay under `vendor-overrides/` |
 
 `make build` runs `make vendor-deps` for you. To verify already populated
 sources without changing them:
@@ -80,9 +79,59 @@ make linux-arm64      # on Linux arm64/aarch64 → jcode-linux-arm64
 make freebsd          # on FreeBSD amd64 → jcode-freebsd-amd64
 ```
 
-These targets are native host builds through `jerbuild`; they do not need a
-Jerboa source checkout or a separate Chez install. `make linux` aliases
-`make linux-amd64`.
+These targets create named platform outputs through `jerbuild`; they do not
+need a separate Chez install. `make linux` aliases `make linux-amd64`.
+
+### FreeBSD amd64: install Jerboa, then build jcode
+
+Install the authenticated `freebsd-amd64` Jerboa artifact first by following
+[Jerboa's FreeBSD artifact instructions](https://git.jerboa.sh/ober/jerboa/src/branch/main/docs/release-artifacts.md#freebsd-amd64).
+The installed multicall binary provides both `jerboa` and `jerbuild`.
+
+On FreeBSD, use GNU Make as `gmake`:
+
+```sh
+pkg install git gmake rust curl ncurses libiconv
+export PATH="$HOME/.local/bin:$PATH"
+
+git clone https://git.jerboa.sh/ober/jerboa-code
+cd jerboa-code
+gmake ensure-jerboa-tools
+gmake build
+gmake test
+gmake binary
+./jcode --version
+gmake install
+```
+
+`gmake ensure-jerboa-tools` uses the Jerboa commands on `PATH`; if they are not
+installed globally, it downloads the pinned `git.jerboa.sh` artifact into
+`.jerboa/bin`. To use a particular installed toolchain, pass its path
+explicitly, for example
+`gmake JERBUILD="$HOME/.local/bin/jerbuild" binary`.
+
+`gmake binary` is the native FreeBSD build and produces `./jcode`. The
+`gmake freebsd` target is for cross-compiling `jcode-freebsd-amd64` with an
+already configured FreeBSD sysroot; it is not needed when building on FreeBSD.
+
+### FreeBSD amd64: install a prebuilt jcode binary
+
+If you already have an authenticated `jcode-freebsd-amd64` binary, Jerboa is
+not required just to run it. Install it under the normal command name:
+
+```sh
+mkdir -p "$HOME/.local/bin"
+install -m 0755 ./jcode-freebsd-amd64 "$HOME/.local/bin/jcode"
+export PATH="$HOME/.local/bin:$PATH"
+jcode --version
+```
+
+Authenticate the binary against the publisher's signed checksum or release
+manifest before executing it. The FreeBSD binary is dynamically linked to
+FreeBSD system libraries; use `ldd "$HOME/.local/bin/jcode"` to identify any
+missing runtime package. A binary copied from a local `gmake binary` build may
+also need the generated native/TUI shared libraries beside it; `gmake install`
+copies those files and is the preferred installation route for local builds.
 
 ### Android
 
diff --git a/docs/prompt-caching.md b/docs/prompt-caching.md
new file mode 100644
index 0000000..4fddcf3
--- /dev/null
+++ b/docs/prompt-caching.md
@@ -0,0 +1,449 @@
+# Prompt Caching with Local ds4 — Implementation Handoff
+
+**Status:** handoff / not yet implemented
+**Verified:** 2026-07-27, against live `ds4` provider (`http://10.66.60.4:8000/v1`, model `deepseek-v4-pro`)
+**Audience:** whoever implements efficient prompt caching for local ds4 (and other local OpenAI-compatible endpoints)
+
+---
+
+## 1. TL;DR verdict
+
+**Does the harness do prompt caching with local ds4 today? Effectively NO.**
+
+- The **client side (jcode, this repo) is already wired**: it sends a
+  `prompt_cache_key` on every request to the `ds4` provider, and it parses
+  `usage.prompt_tokens_details.cached_tokens` / `cache_write_tokens` into the
+  usage accounting and TUI. Nothing more is required of a well-behaved client.
+- The **server side is broken/absent**: live probes show the ds4 server
+  **never returns a single cached token** — `cached_tokens` is `0` on every
+  request, even for byte-identical 2807-token prompts repeated back-to-back
+  with a stable `prompt_cache_key`. `cache_write_tokens` always equals
+  `prompt_tokens` exactly, i.e. the server writes cache-accounting fields but
+  never hits (stub accounting, or prefix caching disabled/unimplemented).
+
+So: the harness "does" prompt caching in the sense of asking for it and
+measuring it; **no cache hit has ever been observed**, and the TUI's
+`cache N%` indicator has never had a reason to appear for ds4.
+
+The fix is primarily **server-side** (Phase 0), plus a set of **harness-side
+hardening changes** so that (a) a cold cache is loudly detected instead of
+silent, and (b) jcode's request construction maximizes prefix reuse once the
+server cooperates (Phases 1–4).
+
+---
+
+## 2. What "prompt caching" means here
+
+Server-side **prefix/KV caching**: the inference server keeps the KV blocks of
+previously processed prompt prefixes and reuses them when the next request
+shares a long enough identical prefix. An agent loop is the best-case workload
+for this:
+
+```
+turn 1: [system][tools][user]                    → prefill everything
+turn 2: [system][tools][user][asst][tool-result] → prefill only the suffix
+turn 3: [...same prefix...][more]                → prefill only the suffix
+```
+
+Each turn reuses the entire prior conversation's KV; only the new tail pays
+prefill cost. On local hardware prefill dominates turn latency, so with a
+30k-token session the difference is roughly "30k tokens of prefill per turn"
+vs "~1–2k per turn". This is the single biggest latency lever available for
+local ds4 agent sessions.
+
+Explicitly **not** in scope: response caching, semantic caches, the
+deterministic eval harness in `eval/` (scripted, no live model), Anthropic's
+explicit `cache_control` breakpoints (already implemented for the anthropic
+provider; see §4.4), Gemini `cachedContent` (already implemented).
+
+---
+
+## 3. Verification evidence (2026-07-27)
+
+All probes against the **live configured** ds4 endpoint from
+`~/.jcode/config.json` (`providers.ds4`, `wire: "openai"`,
+`base_url: http://10.66.60.4:8000/v1`).
+
+### 3.1 Server identity
+
+`GET /v1/models` returns `deepseek-v4-flash` and `deepseek-v4-pro`,
+`owned_by: "ds4.c"`, `context_length: 100000`, `supported_parameters:
+[tools, tool_choice, max_tokens, temperature, top_p, top_k, min_p, stop,
+seed, stream, reasoning_effort]`. No `/version`, `/metrics`, `/health`,
+`/stats` endpoints (all 404).
+
+### 3.2 Cache probes
+
+| # | Prompt | Hint sent | `cached_tokens` | `cache_write_tokens` |
+|---|--------|-----------|-----------------|----------------------|
+| 1 | 6 tokens | `prompt_cache_key: jcode-test-123` | 0 | 6 |
+| 2 | 6 tokens, repeat of #1 | same key | 0 | 6 |
+| 3 | 6 tokens, repeat | none | 0 | 6 |
+| 4 | 6 tokens, repeat | different key | 0 | 6 |
+| 5 | 2807 tokens | `prompt_cache_key: jcode-bigcache-test-1` | 0 | 2807 |
+| 6 | 2807 tokens, exact repeat ×2 | same key | 0 / 0 | 2807 / 2807 |
+| 7 | 2807 tokens ×2 | `cache_salt: jcode-salt-1` (vLLM-style) | 0 / 0 | 2807 / 2807 |
+| 8 | 2807 tokens | both hints | 0 | 2807 |
+
+**Findings:**
+
+1. The server **accepts** `prompt_cache_key` and `cache_salt` without a 4xx
+   (they are ignored, not rejected).
+2. It **reports** `prompt_tokens_details.{cached_tokens, cache_write_tokens}`
+   — the OpenAI/vLLM telemetry shape jcode already parses.
+3. It **never hits**: `cached_tokens` is always `0`, and
+   `cache_write_tokens == prompt_tokens` on every request. A healthy
+   prefix-caching server shows `cached_tokens ≈ prompt_tokens` from the second
+   identical request onward.
+4. Conclusion: prefix caching is disabled, unimplemented, or stubbed in the
+   `ds4.c` server build. `cache_write_tokens` as emitted today is cosmetic.
+
+### 3.3 Harness-side confirmation
+
+- `ds4` resolves to provider-kind `"openai"` via `wire: "openai"`
+  (`src/jcode/core/models.ss:107-118` — confirmed by the existing test at
+  `test/run.ss:12519`).
+- `prompt-cache-key-provider?` allows kinds `openai`, `openrouter`, `mlx`
+  (`src/jcode/provider/openai.ss:770-771`), so **`prompt_cache_key` is sent
+  on every ds4 request already** (`apply-prompt-cache-controls!`,
+  `openai.ss:781-787`, called from `openai-request-body`).
+- Usage parsing already extracts both cache fields
+  (`openai-cached-tokens`, `openai.ss` / `provider.ss:1798-1823`) →
+  `cache-read` / `cache-creation` → `usage.ss` snapshot → TUI status bar
+  (`tui-status.ss:35-41`).
+
+No harness change would have made the probes above hit; the miss is in the
+server.
+
+---
+
+## 4. Architecture map — what exists today
+
+### 4.1 Request path (OpenAI-wire providers, incl. ds4)
+
+```
+agent loop (src/jcode/core/agent.ss)
+  └─ refresh-system-prompt            agent.ss:375   rebuilds system msg each turn
+  └─ provider-stream-chat-with-stats  provider.ss
+       └─ openai-request-body         provider.ss:1731 / openai.ss
+            ├─ apply-prompt-cache-controls!   openai.ss:781   adds prompt_cache_key
+            ├─ apply-provider-options!        openai.ss:803   config passthrough
+            └─ messages = [system, ...history], tools = "tools" field
+```
+
+### 4.2 Cache controls sent
+
+| Field | When | Code |
+|---|---|---|
+| `prompt_cache_key: "jcode-<real-time>"` | provider kind ∈ {openai, openrouter, mlx} | `openai.ss:768,781-784` |
+| `prompt_cache_retention: "24h"` | provider name = `openai` **and** model starts `gpt-5`/`gpt-4.1` | `openai.ss:773-787` |
+
+The key is **per-process**: `(real-time)` is evaluated once at module load.
+Within one jcode process it is stable; a new process (including a resumed
+session) gets a new key. See gap **G3**.
+
+### 4.3 Telemetry path
+
+```
+usage.prompt_tokens_details.cached_tokens  → openai-cached-tokens → 'cache-read
+usage.prompt_tokens_details.cache_write_*  → 'cache-creation
+  → usage.ss accumulate (cache_read_tokens / cache_creation_tokens snapshot)
+  → TUI status bar "cache N%" (hidden while cache-read = 0)  tui-status.ss:35-41
+  → streaming accumulation provider.ss:2736, 2886
+```
+
+The telemetry chain is complete and works for any OpenAI-compatible server
+that reports the fields honestly (vLLM does; the current ds4 server reports
+the fields but always zero).
+
+### 4.4 Reference pattern: Anthropic explicit breakpoints
+
+`provider.ss:1887-1970` shows the *explicit* style: `cache_control`
+breakpoints on the last tool block and the last two messages, with a comment
+documenting the prefix-order invariant ("Tools render first in the cache
+prefix and change rarely"). The OpenAI-wire style relies on the server's
+automatic prefix matching instead — no breakpoints needed — but the same
+invariant applies: **the bytes at the front of the request must be stable.**
+
+### 4.5 Prefix-stability properties today (mostly good, mostly accidental)
+
+| Component | Stable within process? | Stable across processes? | Notes |
+|---|---|---|---|
+| System prompt text | ✅ (rebuilt per turn but deterministic — no timestamps) | ✅ | `system-prompt`, `agent.ss:267-313` — keep it that way; see G6 |
+| History | ✅ append-only | n/a | except compaction, which rewrites the prefix (G7) |
+| Tool schema list | ✅ (cached, `registry.ss:192-228`) | ❌ order from `hash-keys` | `list-tools`, `registry.ss:230-232` (G4) |
+| `prompt_cache_key` | ✅ | ❌ new per process | G3 |
+
+---
+
+## 5. Gap analysis
+
+### Server gaps (the actual blocker)
+
+- **G1 — ds4 server does not prefix-cache.** Evidence in §3.2. Whether it is a
+  launch flag, a cache-size-0 config, or simply unimplemented in the `ds4.c`
+  server, nothing the client sends changes it. **This is the critical path.**
+- **G2 — no server introspection.** No `/metrics`, no cache stats endpoint.
+  After enabling caching we need *some* way to confirm hit rates server-side
+  (even if only the per-request `cached_tokens` fields).
+
+### Harness gaps (latent — each one silently wastes the cache once G1 is fixed)
+
+- **G3 — per-process `prompt_cache_key`.** If the server namespaces its cache
+  by this key (OpenAI routing semantics; vLLM `cache_salt` semantics), then
+  every jcode restart / session resume is a guaranteed cold cache. The key
+  should be stable per *session* and persist across restarts.