Add Chat/Tools tabs and fix streaming flicker
ober
292c8cf0b917656b8b7c53acecbd610192657e4f
--- 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()) --- 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"