Add Chat/Tools tabs and fix streaming flicker

ober

292c8cf0b917656b8b7c53acecbd610192657e4f

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 de6aaf9..b002354 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
@@ -4,6 +4,8 @@ import android.app.Activity
 import android.content.Context
 import android.content.Intent
 import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
 import android.text.Editable
 import android.util.Log
 import android.view.View
@@ -21,6 +23,9 @@ import android.widget.Toast
  * Chat UI for jcode. Connects to `jcode serve --port PORT` running in Termux
  * over a localhost TCP socket, wires JSONL events to dynamically-appended
  * bubble views in a ScrollView.
+ *
+ * Two tabs: Chat (user + assistant messages) and Tools (tool invocations).
+ * Token updates are batched and scroll is throttled to reduce flicker.
  */
 class MainActivity : Activity(), JcodeClient.Listener {
 
@@ -31,6 +36,10 @@ class MainActivity : Activity(), JcodeClient.Listener {
     private lateinit var btnSettings: ImageButton
     private lateinit var chatScroll: ScrollView
     private lateinit var chatContainer: LinearLayout
+    private lateinit var toolsScroll: ScrollView
+    private lateinit var toolsContainer: LinearLayout
+    private lateinit var tabChat: TextView
+    private lateinit var tabTools: TextView
     private lateinit var statusLine: TextView
     private lateinit var inputText: EditText
     private lateinit var btnSend: Button
@@ -39,6 +48,7 @@ class MainActivity : Activity(), JcodeClient.Listener {
     private lateinit var btnReconnect: Button
 
     private lateinit var client: JcodeClient
+    private val handler = Handler(Looper.getMainLooper())
 
     /** TextView of the in-progress assistant bubble, or null when idle. */
     private var currentAssistantText: TextView? = null
@@ -47,6 +57,34 @@ class MainActivity : Activity(), JcodeClient.Listener {
     private val currentAssistantBuf = StringBuilder()
 
     private var sending = false
+    private var chatTabActive = true
+
+    /** Count of tool events since last tab switch — shown as badge on Tools tab. */
+    private var toolCount = 0
+
+    // ── Scroll throttle: at most one scroll per 150ms ───────────────────
+    private var scrollPending = false
+    private val scrollRunnable = Runnable {
+        scrollPending = false
+        val sv = if (chatTabActive) chatScroll else toolsScroll
+        sv.fullScroll(View.FOCUS_DOWN)
+    }
+
+    private fun throttledScroll() {
+        if (!scrollPending) {
+            scrollPending = true
+            handler.postDelayed(scrollRunnable, 150)
+        }
+    }
+
+    // ── Token batching: flush accumulated text every 80ms ───────────────
+    private var tokenDirty = false
+    private val tokenFlushRunnable = Runnable {
+        tokenDirty = false
+        val tv = currentAssistantText ?: return@Runnable
+        tv.text = currentAssistantBuf.toString()
+        throttledScroll()
+    }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -59,6 +97,10 @@ class MainActivity : Activity(), JcodeClient.Listener {
         btnSettings = findViewById(R.id.btn_settings)
         chatScroll = findViewById(R.id.chat_scroll)
         chatContainer = findViewById(R.id.chat_container)
+        toolsScroll = findViewById(R.id.tools_scroll)
+        toolsContainer = findViewById(R.id.tools_container)
+        tabChat = findViewById(R.id.tab_chat)
+        tabTools = findViewById(R.id.tab_tools)
         statusLine = findViewById(R.id.status_line)
         inputText = findViewById(R.id.input_text)
         btnSend = findViewById(R.id.btn_send)
@@ -69,6 +111,10 @@ class MainActivity : Activity(), JcodeClient.Listener {
         modeSwitch.setOnCheckedChangeListener { _, checked -> updateModeLabel(checked) }
         updateModeLabel(modeSwitch.isChecked)
 
+        // Tab switching
+        tabChat.setOnClickListener { switchTab(chat = true) }
+        tabTools.setOnClickListener { switchTab(chat = false) }
+
         btnSettings.setOnClickListener {
             startActivity(Intent(this, SettingsActivity::class.java))
         }
@@ -76,6 +122,9 @@ class MainActivity : Activity(), JcodeClient.Listener {
         btnNewSession.setOnClickListener {
             if (!client.isConnected()) return@setOnClickListener
             chatContainer.removeAllViews()
+            toolsContainer.removeAllViews()
+            toolCount = 0
+            updateTabLabels()
             currentAssistantText = null
             currentAssistantBuf.setLength(0)
             client.sendNewSession()
@@ -121,9 +170,39 @@ class MainActivity : Activity(), JcodeClient.Listener {
 
     override fun onDestroy() {
         super.onDestroy()
+        handler.removeCallbacks(scrollRunnable)
+        handler.removeCallbacks(tokenFlushRunnable)
         client.disconnect()
     }
 
+    // ── Tab switching ───────────────────────────────────────────────────
+
+    private fun switchTab(chat: Boolean) {
+        chatTabActive = chat
+        if (chat) {
+            chatScroll.visibility = View.VISIBLE
+            toolsScroll.visibility = View.GONE
+        } else {
+            chatScroll.visibility = View.GONE
+            toolsScroll.visibility = View.VISIBLE
+            // Clear badge when viewing tools
+            toolCount = 0
+        }
+        updateTabLabels()
+    }
+
+    private fun updateTabLabels() {
+        tabChat.setTextColor(resources.getColor(
+            if (chatTabActive) R.color.accent else R.color.text_muted, theme))
+        tabChat.setTypeface(null, if (chatTabActive) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL)
+
+        val toolsLabel = if (toolCount > 0 && chatTabActive) "Tools ($toolCount)" else "Tools"
+        tabTools.text = toolsLabel
+        tabTools.setTextColor(resources.getColor(
+            if (!chatTabActive) R.color.accent else R.color.text_muted, theme))
+        tabTools.setTypeface(null, if (!chatTabActive) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL)
+    }
+
     // ── UI helpers ──────────────────────────────────────────────────────
 
     private fun updateModeLabel(build: Boolean) {
@@ -159,15 +238,11 @@ class MainActivity : Activity(), JcodeClient.Listener {
         btnSend.isEnabled = enabled && !sending
     }
 
-    private fun scrollToBottom() {
-        chatScroll.post { chatScroll.fullScroll(View.FOCUS_DOWN) }
-    }
-
     private fun appendUser(text: String) {
         val view = layoutInflater.inflate(R.layout.item_chat_user, chatContainer, false)
         view.findViewById<TextView>(R.id.message_text).text = text
         chatContainer.addView(view)
-        scrollToBottom()
+        throttledScroll()
     }
 
     private fun beginAssistant() {
@@ -177,37 +252,55 @@ class MainActivity : Activity(), JcodeClient.Listener {
         chatContainer.addView(view)
         currentAssistantText = tv
         currentAssistantBuf.setLength(0)
-        scrollToBottom()
+        throttledScroll()
     }
 
     private fun appendAssistantToken(token: String) {
-        val tv = currentAssistantText ?: run {
+        if (currentAssistantText == null) {
             beginAssistant()
-            currentAssistantText!!
         }
         currentAssistantBuf.append(token)
-        tv.text = currentAssistantBuf.toString()
-        scrollToBottom()
+        // Batch: schedule a flush if not already pending
+        if (!tokenDirty) {
+            tokenDirty = true
+            handler.postDelayed(tokenFlushRunnable, 80)
+        }
+    }
+
+    private fun flushTokens() {
+        if (tokenDirty) {
+            handler.removeCallbacks(tokenFlushRunnable)
+            tokenDirty = false
+            val tv = currentAssistantText ?: return
+            tv.text = currentAssistantBuf.toString()
+            throttledScroll()
+        }
     }
 
     private fun finalizeAssistant() {
+        flushTokens()
         currentAssistantText = null
         currentAssistantBuf.setLength(0)
     }
 
     private fun appendTool(name: String, argsJson: String) {
-        val view = layoutInflater.inflate(R.layout.item_chat_tool, chatContainer, false)
+        val view = layoutInflater.inflate(R.layout.item_chat_tool, toolsContainer, false)
         view.findViewById<TextView>(R.id.tool_header).text = "\u2699 $name"
         view.findViewById<TextView>(R.id.tool_args).text = argsJson
-        chatContainer.addView(view)
-        scrollToBottom()
+        toolsContainer.addView(view)
+        toolCount++
+        updateTabLabels()
+        // Auto-scroll tools pane if it's visible
+        if (!chatTabActive) {
+            throttledScroll()
+        }
     }
 
     private fun appendError(msg: String) {
         val view = layoutInflater.inflate(R.layout.item_chat_error, chatContainer, false)
         view.findViewById<TextView>(R.id.error_text).text = msg
         chatContainer.addView(view)
-        scrollToBottom()
+        throttledScroll()
     }
 
     // ── send ────────────────────────────────────────────────────────────
@@ -224,6 +317,9 @@ class MainActivity : Activity(), JcodeClient.Listener {
         sending = true
         btnSend.isEnabled = false
         appendUser(text)
+        // Reset tool count for new turn
+        toolCount = 0
+        updateTabLabels()
         beginAssistant()
         setStatus(getString(R.string.status_thinking))
         client.sendUserMessage(text, currentMode())
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index cde6d6e..8ecb7c5 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -93,7 +93,43 @@
             android:textSize="11sp" />
     </LinearLayout>
 
-    <!-- Chat transcript -->
+    <!-- Tab bar: Chat / Tools -->
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="40dp"
+        android:background="@color/bg_card"
+        android:orientation="horizontal">
+
+        <TextView
+            android:id="@+id/tab_chat"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:gravity="center"
+            android:text="Chat"
+            android:textColor="@color/accent"
+            android:textSize="14sp"
+            android:textStyle="bold"
+            android:background="?android:attr/selectableItemBackground" />
+
+        <View
+            android:layout_width="1dp"
+            android:layout_height="match_parent"
+            android:background="@color/bg_input" />
+
+        <TextView
+            android:id="@+id/tab_tools"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:gravity="center"
+            android:text="Tools"
+            android:textColor="@color/text_muted"
+            android:textSize="14sp"
+            android:background="?android:attr/selectableItemBackground" />
+    </LinearLayout>
+
+    <!-- Chat transcript (visible when Chat tab selected) -->
     <ScrollView
         android:id="@+id/chat_scroll"
         android:layout_width="match_parent"
@@ -111,6 +147,25 @@
             android:orientation="vertical" />
     </ScrollView>
 
+    <!-- Tools log (visible when Tools tab selected) -->
+    <ScrollView
+        android:id="@+id/tools_scroll"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:fillViewport="true"
+        android:paddingTop="8dp"
+        android:paddingBottom="8dp"
+        android:scrollbars="vertical"
+        android:visibility="gone">
+
+        <LinearLayout
+            android:id="@+id/tools_container"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical" />
+    </ScrollView>
+
     <!-- Status line (shown during tool calls / errors) -->
     <TextView
         android:id="@+id/status_line"