diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index 8838a61073b..990dd4bbe9c 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -25,3 +25,10 @@ The Android terminal renderer vendors upstream `libghostty-vt` shared libraries Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when updating `Vendor/libghostty-vt`. + +## MesloLGS NF (Android terminal font) + +- Files: `android/src/main/assets/fonts/MesloLGS-NF-{Regular,Bold}.ttf` +- Source: https://github.com/romkatv/powerlevel10k-media (Meslo LG patched with Nerd Fonts glyphs) +- Upstream: Meslo LG by André Berg (customization of Apple's Menlo), Nerd Fonts patcher +- License: Apache License 2.0 diff --git a/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf new file mode 100644 index 00000000000..e0e39544b02 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf new file mode 100644 index 00000000000..88b81490cd7 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp index 3faf5e230fb..95760e8ead9 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp @@ -171,6 +171,16 @@ std::vector DrainResponses(Session* session) { return responses; } +bool ViewportGridRef(Session* session, jint x, jint y, GhosttyGridRef* out) { + *out = GhosttyGridRef{}; + out->size = sizeof(*out); + GhosttyPoint point{}; + point.tag = GHOSTTY_POINT_TAG_VIEWPORT; + point.value.coordinate.x = static_cast(std::max(x, 0)); + point.value.coordinate.y = static_cast(std::max(y, 0)); + return ghostty_terminal_grid_ref(session->terminal, point, out) == GHOSTTY_SUCCESS; +} + uint16_t StyleFlags(const GhosttyStyle& style, bool selected) { uint16_t flags = 0; if (style.bold) flags |= kBold; @@ -272,6 +282,96 @@ Java_expo_modules_t3terminal_GhosttyBridge_nativeSetTheme( ApplyTheme(session, foreground, background, cursor, env, palette); } +extern "C" JNIEXPORT jboolean JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectWordAt(JNIEnv*, jclass, + jlong handle, jint x, + jint y) { + auto* session = FromHandle(handle); + if (session == nullptr) return JNI_FALSE; + std::lock_guard lock(session->mutex); + GhosttyTerminalSelectWordOptions options{}; + options.size = sizeof(options); + if (!ViewportGridRef(session, x, y, &options.ref)) return JNI_FALSE; + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (ghostty_terminal_select_word(session->terminal, &options, &selection) != + GHOSTTY_SUCCESS) { + return JNI_FALSE; + } + return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, + &selection) == GHOSTTY_SUCCESS + ? JNI_TRUE + : JNI_FALSE; +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeExtendSelection( + JNIEnv*, jclass, jlong handle, jint anchor_x, jint anchor_y, jint x, jint y) { + auto* session = FromHandle(handle); + if (session == nullptr) return; + std::lock_guard lock(session->mutex); + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (!ViewportGridRef(session, anchor_x, anchor_y, &selection.start)) return; + if (!ViewportGridRef(session, x, y, &selection.end)) return; + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectAll(JNIEnv*, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return JNI_FALSE; + std::lock_guard lock(session->mutex); + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (ghostty_terminal_select_all(session->terminal, &selection) != GHOSTTY_SUCCESS) { + return JNI_FALSE; + } + return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, + &selection) == GHOSTTY_SUCCESS + ? JNI_TRUE + : JNI_FALSE; +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeClearSelection(JNIEnv*, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return; + std::lock_guard lock(session->mutex); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, nullptr); +} + +// Returns the active selection as UTF-8 bytes (soft-wrapped lines unwrapped, +// trailing whitespace trimmed), or null when there is no selection. +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeGetSelectionText(JNIEnv* env, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return nullptr; + std::lock_guard lock(session->mutex); + GhosttyTerminalSelectionFormatOptions options{}; + options.size = sizeof(options); + options.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + options.unwrap = true; + options.trim = true; + uint8_t* bytes = nullptr; + size_t len = 0; + if (ghostty_terminal_selection_format_alloc(session->terminal, nullptr, options, + &bytes, &len) != GHOSTTY_SUCCESS || + bytes == nullptr) { + return nullptr; + } + auto result = env->NewByteArray(static_cast(len)); + if (result != nullptr && len > 0) { + env->SetByteArrayRegion(result, 0, static_cast(len), + reinterpret_cast(bytes)); + } + ghostty_free(nullptr, bytes, len); + return result; +} + extern "C" JNIEXPORT jbyteArray JNICALL Java_expo_modules_t3terminal_GhosttyBridge_nativeSnapshot(JNIEnv* env, jclass, jlong handle) { diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt index ead52f30263..06c13a2824a 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt @@ -44,4 +44,21 @@ internal object GhosttyBridge { ) @JvmStatic external fun nativeSnapshot(handle: Long): ByteArray + + @JvmStatic external fun nativeSelectWordAt(handle: Long, col: Int, row: Int): Boolean + + @JvmStatic + external fun nativeExtendSelection( + handle: Long, + anchorCol: Int, + anchorRow: Int, + col: Int, + row: Int + ) + + @JvmStatic external fun nativeSelectAll(handle: Long): Boolean + + @JvmStatic external fun nativeClearSelection(handle: Long) + + @JvmStatic external fun nativeGetSelectionText(handle: Long): ByteArray? } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 398895f1cf8..fcc6092dbee 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -106,6 +106,40 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } terminalCanvas.onCellMetricsChanged = { emitResize() } + terminalCanvas.selectionDelegate = object : TerminalSelectionDelegate { + override fun selectWordAt(col: Int, row: Int): Boolean { + if (terminalHandle == 0L) return false + val selected = GhosttyBridge.nativeSelectWordAt(terminalHandle, col, row) + if (selected) renderSnapshot() + return selected + } + + override fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int) { + if (terminalHandle == 0L) return + GhosttyBridge.nativeExtendSelection(terminalHandle, anchorCol, anchorRow, col, row) + renderSnapshot() + } + + override fun selectAll(): Boolean { + if (terminalHandle == 0L) return false + val selected = GhosttyBridge.nativeSelectAll(terminalHandle) + if (selected) renderSnapshot() + return selected + } + + override fun clearSelection() { + if (terminalHandle == 0L) return + GhosttyBridge.nativeClearSelection(terminalHandle) + renderSnapshot() + } + + override fun selectionText(): String? = + if (terminalHandle == 0L) { + null + } else { + GhosttyBridge.nativeGetSelectionText(terminalHandle)?.let { String(it, Charsets.UTF_8) } + } + } configureInputView() container.addView( @@ -147,6 +181,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalCanvas.onScrollRows = null terminalCanvas.onRequestKeyboard = null terminalCanvas.onCellMetricsChanged = null + terminalCanvas.selectionDelegate = null destroyTerminal() } @@ -174,7 +209,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex event.action == KeyEvent.ACTION_DOWN val isEnter = isImeSend || isHardwareEnter if (isEnter) { - onInput(mapOf("data" to "\n")) + // Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline). + onInput(mapOf("data" to "\r")) true } else { false @@ -288,6 +324,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex GhosttyBridge.nativeDestroy(terminalHandle) terminalHandle = 0L fedBuffer = "" + terminalCanvas.resetSelectionState() } private fun feedPendingBuffer() { @@ -299,6 +336,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex val suffix = initialBuffer.substring(fedBuffer.length) if (suffix.isNotEmpty()) { emitResponse(GhosttyBridge.nativeFeed(terminalHandle, suffix.toByteArray(Charsets.UTF_8))) + // New output invalidates an active selection (matches the web drawer); + // otherwise the copy toolbar drifts out of sync with the grid. + if (terminalCanvas.hasActiveSelection()) { + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } } fedBuffer = initialBuffer renderSnapshot() diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt index 835ecab2df9..f713bdb4ff0 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt @@ -1,15 +1,65 @@ package expo.modules.t3terminal +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.Rect import android.graphics.Typeface +import android.util.Log +import android.view.ActionMode import android.view.GestureDetector +import android.view.HapticFeedbackConstants +import android.view.Menu +import android.view.MenuItem import android.view.MotionEvent import android.view.View +import android.widget.OverScroller import kotlin.math.ceil import kotlin.math.max +import kotlin.math.min + +/** + * Bundled terminal font with Nerd Font glyphs (powerline, file icons). + * MesloLGS NF is the powerlevel10k-tuned Meslo Nerd Font patch. + */ +internal object TerminalTypefaces { + private var loaded = false + var regular: Typeface = Typeface.MONOSPACE + private set + var bold: Typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + private set + + @Suppress("TooGenericExceptionCaught") // Typeface.createFromAsset exposes RuntimeException. + fun ensureLoaded(context: Context) { + if (loaded) return + loaded = true + try { + regular = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Regular.ttf") + bold = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Bold.ttf") + } catch (error: RuntimeException) { + Log.w("TerminalCanvasView", "bundled terminal font unavailable, using monospace", error) + } + } +} + +/** + * Selection operations backed by the native terminal. The terminal owns the + * selection state; the canvas only drives gestures and renders the result. + */ +internal interface TerminalSelectionDelegate { + fun selectWordAt(col: Int, row: Int): Boolean + + fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int) + + fun selectAll(): Boolean + + fun clearSelection() + + fun selectionText(): String? +} internal class TerminalCanvasView(context: Context) : View(context) { companion object { @@ -20,19 +70,45 @@ internal class TerminalCanvasView(context: Context) : View(context) { const val FLAG_OVERLINE = 1 shl 6 const val FLAG_UNDERLINE = 1 shl 7 const val FLAG_SELECTED = 1 shl 8 + + private const val MENU_COPY = 1 + private const val MENU_SELECT_ALL = 2 + private const val HANDLE_COLOR = 0xFF7AA2F7.toInt() } private val density = resources.displayMetrics.density private val scaledDensity = density * resources.configuration.fontScale private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) - private val regularTypeface = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL) - private val boldTypeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) - private val italicTypeface = Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC) - private val boldItalicTypeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD_ITALIC) + + init { + TerminalTypefaces.ensureLoaded(context) + } + + private val regularTypeface = TerminalTypefaces.regular + private val boldTypeface = TerminalTypefaces.bold + private val italicTypeface = Typeface.create(TerminalTypefaces.regular, Typeface.ITALIC) + private val boldItalicTypeface = Typeface.create(TerminalTypefaces.bold, Typeface.ITALIC) private val gestureDetector = GestureDetector(context, TerminalGestureListener()) private val contentPadding = 8f * density private var frame: TerminalFrame? = null private var scrollRemainder = 0f + private val scroller = OverScroller(context) + private var flingLastY = 0 + private val flingRunnable = object : Runnable { + override fun run() { + if (!scroller.computeScrollOffset()) return + val currentY = scroller.currY + val deltaPx = (currentY - flingLastY).toFloat() + flingLastY = currentY + scrollRemainder += -deltaPx / cellHeightPx + val rows = scrollRemainder.toInt() + if (rows != 0) { + scrollRemainder -= rows + onScrollRows?.invoke(rows) + } + postOnAnimation(this) + } + } private var cursorOn = true private val cursorBlink = object : Runnable { override fun run() { @@ -47,6 +123,33 @@ internal class TerminalCanvasView(context: Context) : View(context) { var onScrollRows: ((Int) -> Unit)? = null var onRequestKeyboard: (() -> Unit)? = null var onCellMetricsChanged: (() -> Unit)? = null + var selectionDelegate: TerminalSelectionDelegate? = null + + private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private var selectionActive = false + private var dragSelecting = false + private var draggingHandle = false + private var anchorCol = 0 + private var anchorRow = 0 + private var extentCol = 0 + private var extentRow = 0 + + // Word-snapped span from the initial long-press; extending anchors to the + // far word edge so the word never shrinks mid-drag. + private var wordStartCol = 0 + private var wordStartRow = 0 + private var wordEndCol = 0 + private var wordEndRow = 0 + private var actionMode: ActionMode? = null + + // Actual selection endpoints in viewport cells, derived from the decoded + // frame (word-snap can extend past the pressed cell). Drive handle + // placement and hit testing. + private var selectionEndpointsValid = false + private var selectionStartCol = 0 + private var selectionStartRow = 0 + private var selectionEndCol = 0 + private var selectionEndRow = 0 var fontSizeSp: Float = 10f set(value) { @@ -72,11 +175,22 @@ internal class TerminalCanvasView(context: Context) : View(context) { fun setFrame(value: TerminalFrame) { frame = value cursorOn = true + updateSelectionEndpoints() removeCallbacks(cursorBlink) if (value.cursorBlinking && value.cursorVisible) postDelayed(cursorBlink, 500) invalidate() } + fun resetSelectionState() { + selectionActive = false + dragSelecting = false + draggingHandle = false + selectionEndpointsValid = false + actionMode?.finish() + } + + fun hasActiveSelection(): Boolean = selectionActive + fun usableWidth(): Float = max(width - contentPadding * 2f, 1f) fun usableHeight(): Float = max(height - contentPadding * 2f, 1f) @@ -134,21 +248,41 @@ internal class TerminalCanvasView(context: Context) : View(context) { drawCursor(canvas, currentFrame) } canvas.restore() + drawSelectionHandles(canvas) } override fun onTouchEvent(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_DOWN) { parent?.requestDisallowInterceptTouchEvent(true) + // Touch-down always stops momentum, even when the event is consumed by + // a selection-handle grab and never reaches the gesture detector. + scroller.forceFinished(true) + removeCallbacks(flingRunnable) } else if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL ) { parent?.requestDisallowInterceptTouchEvent(false) } - return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) + return when { + dragSelecting -> { + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> extendSelectionTo(event.x, event.y) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + dragSelecting = false + showSelectionActions() + } + } + true + } + event.actionMasked == MotionEvent.ACTION_DOWN && grabHandleAt(event.x, event.y) -> true + else -> gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) + } } override fun onDetachedFromWindow() { removeCallbacks(cursorBlink) + removeCallbacks(flingRunnable) + actionMode?.finish() super.onDetachedFromWindow() } @@ -209,6 +343,231 @@ internal class TerminalCanvasView(context: Context) : View(context) { } } + private fun columnAt(px: Float): Int { + val cols = frame?.cols ?: return 0 + return ((px - contentPadding) / cellWidthPx).toInt().coerceIn(0, max(cols - 1, 0)) + } + + private fun rowAt(py: Float): Int { + val rows = frame?.rows ?: return 0 + return ((py - contentPadding) / cellHeightPx).toInt().coerceIn(0, max(rows - 1, 0)) + } + + private fun startWordSelection(px: Float, py: Float) { + val delegate = selectionDelegate ?: return + val col = columnAt(px) + val row = rowAt(py) + // Set before selectWordAt: the delegate re-renders synchronously and + // updateSelectionEndpoints only scans while a selection is active. + selectionActive = true + if (!delegate.selectWordAt(col, row)) { + selectionActive = false + return + } + dragSelecting = true + draggingHandle = false + if (selectionEndpointsValid) { + wordStartCol = selectionStartCol + wordStartRow = selectionStartRow + wordEndCol = selectionEndCol + wordEndRow = selectionEndRow + } else { + wordStartCol = col + wordStartRow = row + wordEndCol = col + wordEndRow = row + } + anchorCol = wordStartCol + anchorRow = wordStartRow + extentCol = col + extentRow = row + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + + private fun extendSelectionTo(px: Float, py: Float) { + if (!selectionActive) return + val col = columnAt(px) + val row = rowAt(py) + if (col == extentCol && row == extentRow) return + extentCol = col + extentRow = row + if (!draggingHandle) { + val beforeWord = row < wordStartRow || (row == wordStartRow && col < wordStartCol) + if (beforeWord) { + anchorCol = wordEndCol + anchorRow = wordEndRow + } else { + anchorCol = wordStartCol + anchorRow = wordStartRow + } + } + selectionDelegate?.extendSelection(anchorCol, anchorRow, col, row) + } + + private fun clearSelection() { + if (!selectionActive) return + selectionActive = false + dragSelecting = false + draggingHandle = false + selectionEndpointsValid = false + actionMode?.finish() + selectionDelegate?.clearSelection() + } + + private fun copySelection() { + val text = selectionDelegate?.selectionText() ?: return + if (text.isEmpty()) return + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText("Terminal", text)) + } + + private fun handleCenterX(col: Int, leadingEdge: Boolean): Float = + contentPadding + (col + if (leadingEdge) 0 else 1) * cellWidthPx + + private fun handleCenterY(row: Int): Float = + contentPadding + (row + 1) * cellHeightPx + handleRadius() + + private fun handleRadius(): Float = max(cellHeightPx * 0.45f, 12f) + + /** + * Begin dragging when the touch lands on a selection handle. The opposite + * endpoint becomes the drag anchor so the grabbed end follows the finger. + */ + private fun grabHandleAt(px: Float, py: Float): Boolean { + if (!selectionActive || !selectionEndpointsValid) return false + val slop = max(handleRadius() * 2f, 24 * density) + + fun near(cx: Float, cy: Float): Boolean { + val dx = px - cx + val dy = py - cy + return dx * dx + dy * dy <= slop * slop + } + + val startGrabbed = + near(handleCenterX(selectionStartCol, true), handleCenterY(selectionStartRow)) + val endGrabbed = + !startGrabbed && near(handleCenterX(selectionEndCol, false), handleCenterY(selectionEndRow)) + val handleGrabbed = startGrabbed || endGrabbed + if (handleGrabbed) { + if (startGrabbed) { + anchorCol = selectionEndCol + anchorRow = selectionEndRow + extentCol = selectionStartCol + extentRow = selectionStartRow + } else { + anchorCol = selectionStartCol + anchorRow = selectionStartRow + extentCol = selectionEndCol + extentRow = selectionEndRow + } + dragSelecting = true + draggingHandle = true + actionMode?.finish() + } + return handleGrabbed + } + + /** Scan the decoded frame for the first/last selected cells. */ + private fun updateSelectionEndpoints() { + selectionEndpointsValid = false + val currentFrame = frame + if (!selectionActive || currentFrame == null) return + val totalCells = currentFrame.cols * currentFrame.rows + var first = -1 + var last = -1 + for (index in 0 until totalCells) { + if (currentFrame.cellFlags[index] and FLAG_SELECTED != 0) { + if (first < 0) first = index + last = index + } + } + if (first >= 0 && currentFrame.cols > 0) { + selectionStartCol = first % currentFrame.cols + selectionStartRow = first / currentFrame.cols + selectionEndCol = last % currentFrame.cols + selectionEndRow = last / currentFrame.cols + selectionEndpointsValid = true + } + } + + // Anchor the toolbar to the actual word-snapped endpoints when known; + // gesture cells can lag behind what the terminal selected. + private fun selectionBounds(): Rect { + val startCol = if (selectionEndpointsValid) selectionStartCol else min(anchorCol, extentCol) + val endCol = if (selectionEndpointsValid) selectionEndCol else max(anchorCol, extentCol) + val startRow = if (selectionEndpointsValid) selectionStartRow else min(anchorRow, extentRow) + val endRow = if (selectionEndpointsValid) selectionEndRow else max(anchorRow, extentRow) + val left = contentPadding + min(startCol, endCol) * cellWidthPx + val right = contentPadding + (max(startCol, endCol) + 1) * cellWidthPx + val top = contentPadding + startRow * cellHeightPx + val bottom = contentPadding + (endRow + 1) * cellHeightPx + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + } + + private fun showSelectionActions() { + if (actionMode != null || !selectionActive) return + actionMode = startActionMode( + object : ActionMode.Callback2() { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.add(Menu.NONE, MENU_COPY, 0, android.R.string.copy) + menu.add(Menu.NONE, MENU_SELECT_ALL, 1, android.R.string.selectAll) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = + when (item.itemId) { + MENU_COPY -> { + copySelection() + clearSelection() + true + } + MENU_SELECT_ALL -> { + val currentFrame = frame + if (currentFrame != null && selectionDelegate?.selectAll() == true) { + anchorCol = 0 + anchorRow = 0 + extentCol = max(currentFrame.cols - 1, 0) + extentRow = max(currentFrame.rows - 1, 0) + } + true + } + else -> false + } + + override fun onDestroyActionMode(mode: ActionMode) { + actionMode = null + // Dismissing the toolbar (e.g. Back) drops the selection too — + // except mid-drag, where grabHandleAt finishes the mode on purpose. + if (selectionActive && !dragSelecting) clearSelection() + } + + override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) { + outRect.set(selectionBounds()) + } + }, + ActionMode.TYPE_FLOATING, + ) + } + + private fun drawSelectionHandles(canvas: Canvas) { + if (!selectionActive || !selectionEndpointsValid) return + val radius = handleRadius() + handlePaint.color = HANDLE_COLOR + val stemWidth = max(radius / 4f, 2f) + + fun drawHandle(cx: Float, row: Int) { + val cornerY = contentPadding + (row + 1) * cellHeightPx + val cy = handleCenterY(row) + canvas.drawRect(cx - stemWidth / 2f, cornerY, cx + stemWidth / 2f, cy, handlePaint) + canvas.drawCircle(cx, cy, radius, handlePaint) + } + + drawHandle(handleCenterX(selectionStartCol, true), selectionStartRow) + drawHandle(handleCenterX(selectionEndCol, false), selectionEndRow) + } + private fun blend(foreground: Int, background: Int, amount: Float): Int { val inverseAmount = 1f - amount return Color.rgb( @@ -220,15 +579,25 @@ internal class TerminalCanvasView(context: Context) : View(context) { private inner class TerminalGestureListener : GestureDetector.SimpleOnGestureListener() { override fun onDown(event: MotionEvent): Boolean { + scroller.forceFinished(true) + removeCallbacks(flingRunnable) onRequestKeyboard?.invoke() return true } override fun onSingleTapUp(event: MotionEvent): Boolean { - performClick() + if (selectionActive) { + clearSelection() + } else { + performClick() + } return true } + override fun onLongPress(event: MotionEvent) { + startWordSelection(event.x, event.y) + } + override fun onScroll( first: MotionEvent?, current: MotionEvent, @@ -243,5 +612,17 @@ internal class TerminalCanvasView(context: Context) : View(context) { } return true } + + override fun onFling( + first: MotionEvent?, + current: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + flingLastY = 0 + scroller.fling(0, 0, 0, velocityY.toInt(), 0, 0, Int.MIN_VALUE / 2, Int.MAX_VALUE / 2) + postOnAnimation(flingRunnable) + return true + } } } diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 9c2c71d8c48..ac813bdbe0a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -10,6 +10,7 @@ import { IconArrowUpCircle, IconArrowUpRight, IconArrowUpRightCircle, + IconArrowsMaximize, IconBellRinging, IconBolt, IconCamera, @@ -83,6 +84,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.turn.left.up": IconArrowBackUp, "arrow.up": IconArrowUp, "arrow.up.circle": IconArrowUpCircle, + "arrow.up.left.and.arrow.down.right": IconArrowsMaximize, "arrow.up.right": IconArrowUpRight, "arrow.up.right.circle": IconArrowUpRightCircle, "arrow.uturn.backward": IconArrowBackUp, diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index ded6346ffd2..1c7adb6834d 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -32,6 +32,8 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( ) { const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); + const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const nativeTerminalAvailable = hasNativeTerminalSurface(); const terminalId = DEFAULT_TERMINAL_ID; const lastGridSizeRef = useRef({ @@ -63,6 +65,94 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; const isRunning = terminal.status === "running" || terminal.status === "starting"; + // Close the session and dismiss the panel when the process ends while + // attached (e.g. typing `exit`), mirroring the web drawer's + // onSessionExited flow. + const runningTerminalKeyRef = useRef(null); + const reopenedStaleTerminalKeyRef = useRef(null); + + // Attach subscriptions are cached with an idle TTL; reopening the panel + // after its session ended reuses the stale stream without a new attach + // RPC. Issue an explicit open so the server respawns the session and its + // snapshot flows into the live subscription. + useEffect(() => { + if (isRunning) { + reopenedStaleTerminalKeyRef.current = null; + return; + } + if ( + attachInput === null || + (terminal.status !== "closed" && terminal.status !== "exited") || + terminal.version === 0 || + runningTerminalKeyRef.current === terminalKey || + reopenedStaleTerminalKeyRef.current === terminalKey + ) { + return; + } + reopenedStaleTerminalKeyRef.current = terminalKey; + void openTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + cwd: props.cwd, + worktreePath: props.worktreePath, + cols: lastGridSizeRef.current.cols, + rows: lastGridSizeRef.current.rows, + }, + }).then((result) => { + // Release the guard on failure so a later render can retry the respawn. + if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { + reopenedStaleTerminalKeyRef.current = null; + } + }); + }, [ + attachInput, + isRunning, + openTerminal, + props.cwd, + props.environmentId, + props.threadId, + props.worktreePath, + terminal.status, + terminal.version, + terminalId, + terminalKey, + ]); + + useEffect(() => { + // Forget both markers while hidden: if the process ends while the panel + // is unobserved (or was just auto-closed), the next show must take the + // stale-reopen path instead of treating it as a live exit or skipping + // the respawn. + if (attachInput === null) { + runningTerminalKeyRef.current = null; + reopenedStaleTerminalKeyRef.current = null; + return; + } + if (isRunning) { + runningTerminalKeyRef.current = terminalKey; + return; + } + // The web drawer treats both exited and closed as session end. + const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; + if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { + return; + } + runningTerminalKeyRef.current = null; + // Mark this key handled so the stale-attach effect doesn't respawn the + // session the user just ended. + reopenedStaleTerminalKeyRef.current = terminalKey; + void closeTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + }, + }); + props.onClose(); + }, [attachInput, closeTerminal, isRunning, props, terminal.status, terminalId, terminalKey]); + const sendResize = useCallback( (size: TerminalGridSize) => { void resizeTerminal({ diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index b1690e4ac16..f927ca001fe 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,5 +1,6 @@ import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; +import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; @@ -12,11 +13,13 @@ import { useKeyboardState, } from "react-native-keyboard-controller"; +import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, } from "../../components/ComposerToolbarTrigger"; +import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; @@ -58,6 +61,7 @@ import { buildTerminalMenuSessions, getTerminalStatusLabel, nextOpenTerminalId, + previousLiveTerminalId, resolveTerminalSessionLabel, type TerminalMenuSession, } from "./terminalMenu"; @@ -158,6 +162,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); + const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const { state: workspaceState } = useWorkspaceState(); @@ -346,6 +352,64 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); const isRunning = terminal.status === "running" || terminal.status === "starting"; + // When the process ends while this screen is attached (e.g. typing `exit`), + // close the session and leave the screen, mirroring the web drawer's + // onSessionExited flow. Only react to a running -> exited transition + // observed on this screen so already-exited sessions can still be opened + // (they restart on attach). + const runningTerminalKeyRef = useRef(null); + const reopenedStaleTerminalKeyRef = useRef(null); + const pendingExitNavigationRef = useRef(null); + + // Attach subscriptions are cached with an idle TTL, so revisiting a + // terminal whose session ended while unobserved reuses the stale stream + // without a new attach RPC — the server never respawns anything. Detect + // that (dead status with processed events, never seen running here) and + // issue an explicit open; its snapshot flows into the live subscription. + useEffect(() => { + if (isRunning) { + reopenedStaleTerminalKeyRef.current = null; + return; + } + if ( + terminalAttachInput === null || + !selectedThread || + (terminal.status !== "closed" && terminal.status !== "exited") || + terminal.version === 0 || + runningTerminalKeyRef.current === terminalKey || + reopenedStaleTerminalKeyRef.current === terminalKey + ) { + return; + } + reopenedStaleTerminalKeyRef.current = terminalKey; + void openTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + cwd: terminalAttachInput.cwd, + worktreePath: terminalAttachInput.worktreePath, + cols: terminalAttachInput.cols, + rows: terminalAttachInput.rows, + ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}), + }, + }).then((result) => { + // Release the guard on failure so a later render can retry the respawn. + if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { + reopenedStaleTerminalKeyRef.current = null; + } + }); + }, [ + isRunning, + openTerminal, + selectedThread, + terminal.status, + terminal.version, + terminalAttachInput, + terminalId, + terminalKey, + ]); + useEffect(() => { terminalDebugLog("surface:props", { terminalKey, @@ -737,6 +801,105 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [navigation, selectedThread, terminalId], ); + const navigateAwayAfterExit = useCallback(() => { + // With other shells still live, fall through to the previous one instead + // of dropping the user back on the thread. + const fallbackTerminalId = previousLiveTerminalId({ + sessions: terminalMenuSessions, + exitedTerminalId: terminalId, + }); + if (fallbackTerminalId !== null && selectedThread) { + navigation.dispatch( + StackActions.replace("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: fallbackTerminalId, + }), + ); + return; + } + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + // Deep-linked/root mounts have nothing to pop; land on the thread + // instead of stranding the user on a dead terminal. + if (selectedThread) { + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + }), + ); + } + }, [navigation, selectedThread, terminalId, terminalMenuSessions]); + + useEffect(() => { + // Detached (hidden surface or environment drop): forget the running + // marker so a reattach takes the stale-reopen path instead of misreading + // the dead snapshot as an exit observed on this screen. A pending exit + // navigation stays armed — it only clears once the session runs again — + // so refocusing a dead screen still leaves it. + if (terminalAttachInput === null) { + runningTerminalKeyRef.current = null; + return; + } + if (isRunning) { + runningTerminalKeyRef.current = terminalKey; + // The session came back (e.g. respawned elsewhere) before the user + // returned; a stale pending exit must not eject a live terminal. + pendingExitNavigationRef.current = null; + return; + } + // The web drawer treats both exited and closed as session end. + const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; + if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { + return; + } + runningTerminalKeyRef.current = null; + // Mark this key handled so the stale-attach effect doesn't respawn the + // session the user just ended. + reopenedStaleTerminalKeyRef.current = terminalKey; + if (selectedThread) { + void closeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + }, + }); + } + if (navigation.isFocused()) { + navigateAwayAfterExit(); + return; + } + // An unfocused screen can't navigate; leave when the user returns so + // they never land on the dead session. + pendingExitNavigationRef.current = terminalKey; + }, [ + closeTerminal, + isRunning, + navigateAwayAfterExit, + navigation, + selectedThread, + terminal.status, + terminalAttachInput, + terminalId, + terminalKey, + ]); + + useEffect( + () => + navigation.addListener("focus", () => { + if (pendingExitNavigationRef.current !== terminalKey) { + return; + } + pendingExitNavigationRef.current = null; + navigateAwayAfterExit(); + }), + [navigateAwayAfterExit, navigation, terminalKey], + ); + const handleOpenNewTerminal = useCallback(() => { if (!selectedThread) { return; @@ -762,6 +925,69 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); }, [fontSize, setTerminalFontSize]); + // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text + // size, session switching, and "Open new terminal", rendered through the + // token-styled anchored menu (the native header items are iOS-only). + const androidTerminalMenuActions = useMemo( + () => [ + { + id: "text-size", + title: "Text size", + subactions: [ + { + id: "font-decrease", + title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, + attributes: fontSize <= MIN_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, + }, + { + id: "font-increase", + title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, + attributes: fontSize >= MAX_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, + }, + ], + }, + ...terminalMenuSessions.map( + (session): MenuAction => ({ + id: `terminal-session:${session.terminalId}`, + title: session.displayLabel, + subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] + .filter(Boolean) + .join(" · "), + state: session.terminalId === terminalId ? ("on" as const) : undefined, + }), + ), + { + id: "terminal-new", + title: "Open new terminal", + image: "plus", + subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, + }, + ], + [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], + ); + + const handleAndroidTerminalMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "font-decrease") { + handleDecreaseFontSize(); + return; + } + if (id === "font-increase") { + handleIncreaseFontSize(); + return; + } + if (id === "terminal-new") { + handleOpenNewTerminal(); + return; + } + if (id.startsWith("terminal-session:")) { + handleSelectTerminal(id.slice("terminal-session:".length)); + } + }, + [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal], + ); + const handleClearTerminal = useCallback(() => { if (!selectedThread) { return; @@ -858,12 +1084,53 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty // scrolls internally, nothing for glass to sample). Default title/subtitle // styling, like every other page. + // Android draws its own in-flow header (AndroidScreenHeader below); + // the native stack header stays iOS-only. + headerShown: Platform.OS !== "android", title: "Terminal", unstable_headerSubtitle: usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined, }} /> + {Platform.OS === "android" ? ( + navigation.goBack() : undefined} + trailing={ + <> + {layout.usesSplitView ? ( + + ) : null} + {isEnvironmentReady ? ( + + + + ) : null} + + } + /> + ) : null} + {layout.usesSplitView ? ( { }); }); +describe("previousLiveTerminalId", () => { + it("returns null when no other live session remains", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: "term-2", status: "exited" }), + makeMenuSession({ terminalId: "term-3", status: "closed" }), + ], + exitedTerminalId: "term-2", + }), + ).toBe(null); + }); + + it("prefers the nearest live session below the exited id", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "running" }), + makeMenuSession({ terminalId: "term-2", status: "running" }), + makeMenuSession({ terminalId: "term-3", status: "exited" }), + makeMenuSession({ terminalId: "term-4", status: "running" }), + ], + exitedTerminalId: "term-3", + }), + ).toBe("term-2"); + }); + + it("falls back to the nearest live session above when the exited id was lowest", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "exited" }), + makeMenuSession({ terminalId: "term-2", status: "starting" }), + makeMenuSession({ terminalId: "term-4", status: "running" }), + ], + exitedTerminalId: DEFAULT_TERMINAL_ID, + }), + ).toBe("term-2"); + }); + + it("ignores dead sessions when picking the fallback", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "running" }), + makeMenuSession({ terminalId: "term-2", status: "exited" }), + makeMenuSession({ terminalId: "term-3", status: "exited" }), + ], + exitedTerminalId: "term-3", + }), + ).toBe(DEFAULT_TERMINAL_ID); + }); +}); + describe("resolveProjectScriptTerminalId", () => { it("reuses the default shell when no terminal is running", () => { expect( diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 29374bdda6d..06cb74e9467 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -114,6 +114,35 @@ export function buildTerminalMenuSessions(input: { return Arr.sort(sessionsById.values(), terminalMenuSessionOrder); } +/** + * Picks the session to show after a terminal exits: the nearest live session + * below the exited id (terminal n-1), falling back to the nearest one above. + * Returns null when no other live session remains and the terminal UI should + * be dismissed instead. + */ +export function previousLiveTerminalId(input: { + readonly sessions: ReadonlyArray; + readonly exitedTerminalId: string; +}): string | null { + const live = Arr.sort( + input.sessions.filter( + (session) => + session.terminalId !== input.exitedTerminalId && + (session.status === "running" || session.status === "starting"), + ), + terminalMenuSessionOrder, + ); + if (live.length === 0) { + return null; + } + + const below = live.filter( + (session) => + session.terminalId.localeCompare(input.exitedTerminalId, undefined, { numeric: true }) < 0, + ); + return (below[below.length - 1] ?? live[0])?.terminalId ?? null; +} + export function resolveProjectScriptTerminalId(input: { readonly existingTerminalIds: ReadonlyArray; readonly hasRunningTerminal: boolean; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index c470eea23ef..dd8216dcecf 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -613,10 +613,9 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const isDarkMode = colorScheme === "dark"; - // Mirrors ThreadComposer: collapsed pill until focused, and typed content - // keeps it expanded after blur. - const hasContent = flow.prompt.trim().length > 0 || flow.attachments.length > 0; - const isExpanded = !isAndroid || isComposerFocused || hasContent; + // Android expansion follows native editor focus so relayout cannot race + // the touch gesture that opens the keyboard. + const isExpanded = !isAndroid || isComposerFocused; const canStart = Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && @@ -779,15 +778,17 @@ export function NewTaskDraftScreen(props: { ) : null} - - - {toolbarPills} - - {isExpanded ? startButton : null} - + {isExpanded ? ( + + + {toolbarPills} + + {startButton} + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c88efe1136a..e712664d30a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -68,7 +68,7 @@ import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerComm * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). * Exported so the parent can compute feed overlap / content insets. */ -export const COMPOSER_COLLAPSED_CHROME = Platform.OS === "android" ? 120 : 60; +export const COMPOSER_COLLAPSED_CHROME = 60; /** * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). @@ -261,24 +261,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); - // Android: expansion starts on touch-down. Waiting for the native focus - // event to round-trip through JS starts the 220ms morph after the IME is - // already animating in, so the two play serially and look jittery. - const [eagerExpand, setEagerExpand] = useState(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - const isExpanded = isFocused || eagerExpand || (Platform.OS === "android" && hasContent); + const isExpanded = isFocused; const canSend = hasContent; - useEffect(() => { - if (Platform.OS !== "android") return; - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); - const onPressImage = useCallback( (uri: string) => { wasExpandedBeforePreviewRef.current = isFocused; @@ -296,17 +287,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleFocus = useCallback(() => { setIsFocused(true); - if (Platform.OS !== "android") { - onExpandedChange?.(true); - } + onExpandedChange?.(true); }, [onExpandedChange]); const handleBlur = useCallback(() => { setIsFocused(false); - setEagerExpand(false); - if (Platform.OS !== "android") { - onExpandedChange?.(false); - } + onExpandedChange?.(false); }, [onExpandedChange]); const showStopAction = props.selectedThread.session?.status === "running" || @@ -778,12 +764,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - setEagerExpand(true) : undefined - } - > + - {Platform.OS === "android" && !isExpanded ? ( - // Collapsed Android toolbar shows exactly three controls, so skip - // the scroller and let the two selector pills flex to fill the row. - - void props.onPickDraftImages()} - showChevron={false} - /> - handleModelMenuAction(nativeEvent.event)} - > - } - label={currentModelOption?.label ?? currentModelSelection.model} - className="w-full max-w-full" - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - - - ) : isExpanded ? ( + {isExpanded ? ( // Toolbar row — matches draft page layout (expanded only)