feat(ssh): integrate SSH terminal into settings navigation

- Add SshTerminal to SettingsRoute enum
- Add SSH entry in settings list (ShellScreen -> SettingsShellScreen)
- Create SshTerminalSettingsScreen hosting the SSH terminal UI
- Copy SSH source files to app/src/main/java source root
- Wire up SettingsDetailScreen dispatch for SshTerminal route
This commit is contained in:
茂之钳
2026-05-23 07:33:03 +00:00
parent 5f03ffc305
commit a537ae9ef1
6 changed files with 673 additions and 4 deletions
@@ -24,6 +24,9 @@ import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.tergent.SshTerminalManager
import ai.openclaw.app.ui.tergent.SshConnectDialog
import ai.openclaw.app.ui.tergent.SshTerminalScreen
import android.Manifest
import android.content.Context
import android.content.Intent
@@ -45,6 +48,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@@ -54,6 +58,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ScreenShare
@@ -77,12 +82,13 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -111,6 +117,7 @@ internal enum class SettingsRoute {
Appearance,
Health,
About,
SshTerminal,
}
@Composable
@@ -138,6 +145,7 @@ internal fun SettingsDetailScreen(
SettingsRoute.Appearance -> AppearanceSettingsScreen(onBack = onBack)
SettingsRoute.Health -> HealthLogsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.About -> AboutSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.SshTerminal -> SshTerminalSettingsScreen(viewModel = viewModel, onBack = onBack)
}
}
@@ -1144,3 +1152,58 @@ private fun openNotificationListenerSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
@Composable
internal fun SshTerminalSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val manager = remember { SshTerminalManager() }
var showConnectDialog by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
onDispose {
manager.disconnect()
}
}
if (showConnectDialog) {
ai.openclaw.app.ui.tergent.SshConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { config ->
showConnectDialog = false
manager.connect(config)
}
)
}
Box(modifier = Modifier.fillMaxSize()) {
ai.openclaw.app.ui.tergent.SshTerminalScreen(
manager = manager,
onDisconnect = {
manager.disconnect()
onBack()
},
onShowConnectDialog = { showConnectDialog = true },
modifier = Modifier.fillMaxSize(),
)
// Back button overlay
IconButton(
onClick = {
manager.disconnect()
onBack()
},
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.size(36.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color(0xFF9CA3AF),
)
}
}
}
@@ -38,6 +38,7 @@ import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Notifications
@@ -724,6 +725,7 @@ private fun SettingsShellScreen(
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Appearance", "Dark", Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("SSH 终端", "Remote shell", Icons.Default.Computer, route = SettingsRoute.SshTerminal),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
),
@@ -24,6 +24,9 @@ import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.tergent.SshTerminalManager
import ai.openclaw.app.ui.tergent.SshConnectDialog
import ai.openclaw.app.ui.tergent.SshTerminalScreen
import android.Manifest
import android.content.Context
import android.content.Intent
@@ -45,6 +48,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@@ -54,6 +58,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ScreenShare
@@ -77,12 +82,13 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -111,6 +117,7 @@ internal enum class SettingsRoute {
Appearance,
Health,
About,
SshTerminal,
}
@Composable
@@ -138,6 +145,7 @@ internal fun SettingsDetailScreen(
SettingsRoute.Appearance -> AppearanceSettingsScreen(onBack = onBack)
SettingsRoute.Health -> HealthLogsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.About -> AboutSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.SshTerminal -> SshTerminalSettingsScreen(viewModel = viewModel, onBack = onBack)
}
}
@@ -1144,3 +1152,58 @@ private fun openNotificationListenerSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
@Composable
internal fun SshTerminalSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val manager = remember { SshTerminalManager() }
var showConnectDialog by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
onDispose {
manager.disconnect()
}
}
if (showConnectDialog) {
ai.openclaw.app.ui.tergent.SshConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { config ->
showConnectDialog = false
manager.connect(config)
}
)
}
Box(modifier = Modifier.fillMaxSize()) {
ai.openclaw.app.ui.tergent.SshTerminalScreen(
manager = manager,
onDisconnect = {
manager.disconnect()
onBack()
},
onShowConnectDialog = { showConnectDialog = true },
modifier = Modifier.fillMaxSize(),
)
// Back button overlay
IconButton(
onClick = {
manager.disconnect()
onBack()
},
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.size(36.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color(0xFF9CA3AF),
)
}
}
}
@@ -38,6 +38,7 @@ import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Notifications
@@ -724,6 +725,7 @@ private fun SettingsShellScreen(
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Appearance", "Dark", Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("SSH 终端", "Remote shell", Icons.Default.Computer, route = SettingsRoute.SshTerminal),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
),
@@ -0,0 +1,163 @@
package ai.openclaw.app.ui.tergent
import com.jcraft.jsch.ChannelShell
import com.jcraft.jsch.JSch
import com.jcraft.jsch.Session
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.atomic.AtomicBoolean
data class SshConfig(
val host: String = "",
val port: Int = 22,
val username: String = "",
val password: String = "",
val displayName: String = ""
)
data class SshConnectionState(
val connected: Boolean = false,
val host: String = "",
val displayName: String = "",
val lines: List<String> = emptyList(),
val error: String? = null
)
class SshTerminalManager {
private val jsch = JSch()
private var session: Session? = null
private var channel: ChannelShell? = null
private var inputStream: InputStream? = null
private var outputStream: OutputStream? = null
private val _connectionState = MutableStateFlow(SshConnectionState())
val connectionState: StateFlow<SshConnectionState> = _connectionState.asStateFlow()
private val running = AtomicBoolean(false)
private var lineBuffer = mutableListOf<String>()
private var currentLine = StringBuilder()
suspend fun connect(config: SshConfig): Boolean = withContext(Dispatchers.IO) {
try {
_connectionState.value = _connectionState.value.copy(error = null)
session = jsch.getSession(config.username, config.host, config.port).apply {
setPassword(config.password)
setConfig("StrictHostKeyChecking", "no")
connect(10000) // 10s timeout
}
channel = session?.openChannel("shell") as? ChannelShell
channel?.setPtyType("xterm-256color")
channel?.setPtySize(120, 40, 0, 0)
inputStream = channel?.inputStream
outputStream = channel?.outputStream
channel?.connect(5000)
lineBuffer.clear()
currentLine.clear()
running.set(true)
_connectionState.value = SshConnectionState(
connected = true,
host = config.host,
displayName = config.displayName.ifEmpty { config.host }
)
// Start reading output
withContext(Dispatchers.IO) {
readOutput()
}
true
} catch (e: Exception) {
_connectionState.value = SshConnectionState(
connected = false,
error = "连接失败: ${e.message ?: "未知错误"}"
)
disconnect()
false
}
}
private fun readOutput() {
val buffer = ByteArray(4096)
while (running.get()) {
try {
val len = inputStream?.read(buffer) ?: break
if (len < 0) break
val text = String(buffer, 0, len)
// Parse ANSI and add lines
for (ch in text) {
when (ch) {
'\n' -> {
if (currentLine.isNotEmpty()) {
lineBuffer.add(currentLine.toString())
currentLine.clear()
}
}
'\r' -> {
// carriage return: some terminals use \r\n
// if next char is \n it will be handled above
}
else -> currentLine.append(ch)
}
}
// Keep line buffer manageable
if (lineBuffer.size > 2000) {
lineBuffer = lineBuffer.takeLast(1500).toMutableList()
}
_connectionState.value = _connectionState.value.copy(
lines = if (currentLine.isNotEmpty())
lineBuffer + currentLine.toString()
else
lineBuffer.toList()
)
} catch (e: Exception) {
if (running.get()) {
_connectionState.value = _connectionState.value.copy(
error = "读取错误: ${e.message}"
)
}
break
}
}
}
suspend fun sendCommand(command: String): Boolean = withContext(Dispatchers.IO) {
try {
outputStream?.write((command + "\n").toByteArray())
outputStream?.flush()
true
} catch (e: Exception) {
_connectionState.value = _connectionState.value.copy(
error = "发送失败: ${e.message}"
)
false
}
}
fun disconnect() {
running.set(false)
try { channel?.disconnect() } catch (_: Exception) {}
try { session?.disconnect() } catch (_: Exception) {}
channel = null
session = null
inputStream = null
outputStream = null
_connectionState.value = SshConnectionState()
}
fun isConnected(): Boolean = _connectionState.value.connected
}
@@ -0,0 +1,376 @@
package ai.openclaw.app.ui.tergent
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
// Tergent colors inline for now
private val bg = Color(0xFF111827)
private val surface = Color(0xFF1F2937)
private val border = Color(0xFF374151)
private val textPrimary = Color(0xFFF9FAFB)
private val textSecondary = Color(0xFF9CA3AF)
private val textTertiary = Color(0xFF6B7280)
private val primary = Color(0xFF5B8CFF)
private val success = Color(0xFF34D399)
private val danger = Color(0xFFF87171)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SshConnectDialog(
onDismiss: () -> Unit,
onConnect: (SshConfig) -> Unit,
savedConfigs: List<SshConfig> = emptyList()
) {
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("22") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = surface,
titleContentColor = textPrimary,
textContentColor = textSecondary,
shape = RoundedCornerShape(16.dp),
title = {
Text("SSH 连接", color = textPrimary, style = MaterialTheme.typography.titleMedium)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("主机地址") },
placeholder = { Text("192.168.1.100", color = textTertiary) },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = primary,
unfocusedBorderColor = border,
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = primary,
focusedLabelColor = primary,
unfocusedLabelColor = textTertiary
)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = port,
onValueChange = { port = it.filter { c -> c.isDigit() }.take(5) },
label = { Text("端口") },
placeholder = { Text("22", color = textTertiary) },
singleLine = true,
modifier = Modifier.width(100.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = primary,
unfocusedBorderColor = border,
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = primary,
focusedLabelColor = primary,
unfocusedLabelColor = textTertiary
)
)
Spacer(Modifier.width(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
placeholder = { Text("root", color = textTertiary) },
singleLine = true,
modifier = Modifier.weight(1f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = primary,
unfocusedBorderColor = border,
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = primary,
focusedLabelColor = primary,
unfocusedLabelColor = textTertiary
)
)
}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
placeholder = { Text("••••••••", color = textTertiary) },
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = primary,
unfocusedBorderColor = border,
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = primary,
focusedLabelColor = primary,
unfocusedLabelColor = textTertiary
)
)
}
},
confirmButton = {
Button(
onClick = {
onConnect(SshConfig(
host = host.trim(),
port = port.toIntOrNull() ?: 22,
username = username.trim(),
password = password,
displayName = displayName.trim().ifEmpty { host.trim() }
))
},
enabled = host.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = primary)
) {
Text("连接", color = Color.White)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = textSecondary)
}
}
)
}
@Composable
fun SshTerminalScreen(
manager: SshTerminalManager,
onDisconnect: () -> Unit,
onShowConnectDialog: () -> Unit,
modifier: Modifier = Modifier
) {
val state by manager.connectionState.collectAsState()
var input by remember { mutableStateOf(TextFieldValue("")) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
// Auto-scroll to bottom
LaunchedEffect(state.lines.size) {
if (state.lines.isNotEmpty()) {
listState.animateScrollToItem(state.lines.size - 1)
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(bg)
) {
Column(modifier = Modifier.fillMaxSize()) {
// Terminal header
Surface(
modifier = Modifier.fillMaxWidth(),
color = surface,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (state.connected) Icons.Default.CheckCircle else Icons.Default.Close,
contentDescription = null,
tint = if (state.connected) success else danger,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (state.connected) state.displayName else "SSH 终端",
color = textPrimary,
fontSize = 14.sp
)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (state.connected) {
IconButton(
onClick = { coroutineScope.launch { manager.sendCommand("exit") }; onDisconnect() },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.PowerSettingsNew,
contentDescription = "断开",
tint = danger,
modifier = Modifier.size(18.dp)
)
}
} else {
IconButton(
onClick = onShowConnectDialog,
modifier = Modifier.size(28.dp)
) {
Text("+", color = primary, fontSize = 18.sp)
}
}
}
}
}
// Error message
if (state.error != null) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
color = Color(0xFF3B1A1A),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = state.error ?: "",
color = danger,
fontSize = 12.sp,
modifier = Modifier.padding(12.dp)
)
}
}
// Terminal output area
if (state.connected && state.lines.isNotEmpty()) {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
state = listState,
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
items(state.lines) { line ->
Text(
text = line,
color = textPrimary,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 1.dp)
)
}
}
} else if (!state.connected) {
// Empty placeholder
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "terminal@tergent:~$",
color = textTertiary,
fontFamily = FontFamily.Monospace,
fontSize = 16.sp
)
Spacer(Modifier.height(8.dp))
Text(
text = "点击 + 开始 SSH 连接",
color = textTertiary,
fontSize = 13.sp
)
}
}
} else {
// Connected but no output yet
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "等待输出...",
color = textTertiary,
fontSize = 12.sp
)
}
}
// Bottom input bar
if (state.connected) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color(0xFF0D1117),
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$ ",
color = success,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp
)
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = input,
onValueChange = { input = it },
textStyle = TextStyle(
color = textPrimary,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
),
cursorBrush = SolidColor(primary),
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
if (input.text.isEmpty()) {
Text(
text = "输入命令...",
color = textTertiary,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
innerTextField()
}
)
}
IconButton(
onClick = {
if (input.text.isNotBlank()) {
coroutineScope.launch {
manager.sendCommand(input.text)
input = TextFieldValue("")
}
}
},
modifier = Modifier.size(36.dp)
) {
Text("", color = primary, fontSize = 16.sp)
}
}
}
}
}
}
}