Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ On-device agents run inside a Linux environment via PRoot. OpenCode, Claude Code
- **Device files** — Grant all-files access and the whole phone (`/sdcard`, SD cards, USB drives) becomes browsable in the folder picker and reachable by the agent, opened in place instead of copied into the app
- **Git support** — Browse status and review diffs from the GUI; use the embedded runtime terminal for other Git operations
- **Diff viewer** — Review code changes inline before accepting
- **Embedded terminal** — An interactive shell (real PTY) inside the on-device runtime: streamed output, stdin, Ctrl+C, colours, and it opens in the workspace you came from. Full-screen programs such as `vim` are not supported yet
- **Embedded terminal** — An interactive shell (real PTY) inside the on-device runtime: streamed output, stdin, Ctrl+C, colours, and it opens in the workspace you came from. Dev servers started in it (Vite, `python -m http.server`, …) show up as chips that open the page in the in-app browser. Full-screen programs such as `vim` are not supported yet
- **Pull request badges** — Pull requests opened in a chat stay pinned above the composer with their diff size and state (draft, open, conflict, merged, closed); tap to open them on GitHub
- **Tool approvals** — Approve or reject dangerous tool operations
- **Session management** — Create, resume, rename, and delete sessions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertHasNoClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
Expand All @@ -17,6 +18,7 @@ import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
Expand All @@ -26,6 +28,8 @@ import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.yugahashimoto.andcode.R
import com.yugahashimoto.andcode.feature.terminal.DevServer
import com.yugahashimoto.andcode.feature.terminal.DevServerStatus
import com.yugahashimoto.andcode.feature.terminal.StyledLine
import com.yugahashimoto.andcode.feature.terminal.TerminalKey
import org.junit.Assert.assertEquals
Expand All @@ -52,6 +56,7 @@ class TerminalScreenInstrumentedTest {
private var submits = 0
private var restarts = 0
private var clears = 0
private val openedServers = CopyOnWriteArrayList<String>()

private var state by mutableStateOf(TerminalUiState(status = TerminalStatus.RUNNING))

Expand All @@ -74,6 +79,7 @@ class TerminalScreenInstrumentedTest {
onKey = { keys += it },
onRestart = { restarts++ },
onClear = { clears++ },
onOpenServer = { openedServers += it },
)
}
}
Expand Down Expand Up @@ -115,6 +121,52 @@ class TerminalScreenInstrumentedTest {
composeRule.onNodeWithText("server listening on :3000").assertIsDisplayed()
}

@Test
fun aRunningServerIsOfferedAndOpensItsAddress() {
show(TerminalUiState(status = TerminalStatus.RUNNING, servers = listOf(DevServer(5173, DevServerStatus.RUNNING))))

composeRule.onNodeWithContentDescription(composeRule.activity.getString(R.string.cd_terminal_server_open, 5173)).apply {
assertIsDisplayed()
performClick()
}

assertEquals(listOf("http://localhost:5173/"), openedServers.toList())
}

@Test
fun aStoppedServerIsShownAsStoppedAndHasNothingToOpen() {
show(TerminalUiState(status = TerminalStatus.RUNNING, servers = listOf(DevServer(5173, DevServerStatus.STOPPED))))

composeRule.onNodeWithContentDescription(composeRule.activity.getString(R.string.cd_terminal_server_stopped, 5173)).apply {
assertIsDisplayed()
assertHasNoClickAction()
}
composeRule.onNodeWithText(string(R.string.terminal_server_stopped)).assertIsDisplayed()
assertTrue(openedServers.isEmpty())
}

@Test
fun severalServersAreEachOffered() {
show(
TerminalUiState(
status = TerminalStatus.RUNNING,
servers = listOf(DevServer(3000, DevServerStatus.RUNNING), DevServer(5173, DevServerStatus.RUNNING)),
),
)

composeRule.onNodeWithTag("terminal-server-5173").performClick()
composeRule.onNodeWithTag("terminal-server-3000").performClick()

assertEquals(listOf("http://localhost:5173/", "http://localhost:3000/"), openedServers.toList())
}

@Test
fun noServersMeansNoServerRow() {
show(TerminalUiState(status = TerminalStatus.RUNNING))

composeRule.onNodeWithTag("terminal-server-5173").assertDoesNotExist()
}

@Test
fun theOffscreenKeysSendTheirSequences() {
show(TerminalUiState(status = TerminalStatus.RUNNING))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.yugahashimoto.andcode.feature.terminal

/**
* Finds the ports of servers a program says it is serving on this device, from what the terminal
* shows. Dev servers announce themselves in a few common ways ("Local: http://localhost:5173/",
* "Serving HTTP on 0.0.0.0 port 8000", "listening on :3000"); that is all this recognises.
*
* What it finds is a *claim* made by program output, or by a command that was typed and echoed - so
* a port found here is only worth showing once it has answered a probe (see [DevServerTracker]).
* Only the port number is taken from the text; the host is never reused, so output cannot point
* the in-app browser anywhere but this device.
*/
object DevServerDetector {
private const val LOCAL_HOST = """(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])"""

// A sixth digit, or a dotted quad such as 192.168.1.20, must not be read as a shorter port.
private const val PORT = """(\d{1,5})(?!\d|\.\d|:\d)"""

private val URL = Regex("""https?://$LOCAL_HOST:$PORT""", RegexOption.IGNORE_CASE)
private val LISTENING =
Regex("""\blisten(?:ing|s)?\s+(?:on|at)\s+(?:port\s+)?(?:(?:$LOCAL_HOST|\*)?:)?$PORT""", RegexOption.IGNORE_CASE)

private val VALID_PORTS = 1..65535

/** Every distinct port mentioned in [lines], in the order it first appears. */
fun portsIn(lines: List<String>): List<Int> {
val found = LinkedHashSet<Int>()
for (line in lines) {
if (!mightMentionAServer(line)) continue
(URL.findAll(line) + LISTENING.findAll(line))
.sortedBy { it.range.first }
.mapNotNull { it.groupValues[1].toIntOrNull() }
.filter { it in VALID_PORTS }
.forEach(found::add)
}
return found.toList()
}

// Output is scanned after every burst of it, so skip the two regexes for the lines that cannot match.
private fun mightMentionAServer(line: String): Boolean = line.contains("://") || line.contains("isten", ignoreCase = true)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.yugahashimoto.andcode.feature.terminal

enum class DevServerStatus {
RUNNING,

/** It answered once and has stopped answering. */
STOPPED,
}

/** A server listening on [port] of this device, as far as the last probe could tell. */
data class DevServer(
val port: Int,
val status: DevServerStatus,
) {
/** Where the in-app browser opens it. `localhost` covers a server bound to IPv4 or to IPv6 only. */
val url: String get() = "http://localhost:$port/"
}

/**
* Which of the ports a terminal mentioned really have a server behind them, and whether they still do.
*
* Immutable: every step returns the next tracker. A port starts as a candidate that is probed but not
* shown - a command echoed back such as `curl http://localhost:8080` names a port nothing listens on -
* and becomes a [DevServer] the first time it answers. Probing is the caller's job; this only decides
* what the answers mean.
*/
class DevServerTracker private constructor(
private val entries: List<Entry>,
) {
constructor() : this(emptyList())

private data class Entry(
val port: Int,
val seenUp: Boolean,
/** Probes in a row that got no answer. */
val misses: Int,
)

/** Every port worth probing now: the shown servers and the candidates that have not answered yet. */
val ports: List<Int> get() = entries.map { it.port }

/** The servers to show, in the order they were first mentioned. */
val servers: List<DevServer>
get() =
entries
.filter { it.seenUp }
.map { DevServer(it.port, if (it.misses >= STOPPED_AFTER_MISSES) DevServerStatus.STOPPED else DevServerStatus.RUNNING) }

/**
* Starts watching ports the terminal mentioned. Programs can print any number of them, so when the
* slots are full a new port takes the place of the oldest one that never answered - that is where
* echoed commands and cut-off fragments end up. A port that has answered is never pushed out; if
* every slot holds one, the new port is ignored.
*/
fun observe(mentioned: Collection<Int>): DevServerTracker {
var next = entries
for (port in mentioned) {
if (next.any { it.port == port }) continue
if (next.size >= MAX_TRACKED) {
val evicted = next.indexOfFirst { !it.seenUp }
if (evicted < 0) continue
next = next.filterIndexed { index, _ -> index != evicted }
}
next = next + Entry(port, seenUp = false, misses = 0)
}
return if (next === entries) this else DevServerTracker(next)
}

/** Applies one round of probe answers: `true` if the port accepted a connection. */
fun applyProbe(answers: Map<Int, Boolean>): DevServerTracker =
DevServerTracker(
entries.mapNotNull { entry ->
val answered = answers[entry.port] ?: return@mapNotNull entry
val updated = if (answered) entry.copy(seenUp = true, misses = 0) else entry.copy(misses = entry.misses + 1)
updated.takeIf { it.misses < DROP_AFTER_MISSES }
},
)

companion object {
const val MAX_TRACKED = 8

/** A slow request can make one probe miss; two in a row is a server that is not there. */
const val STOPPED_AFTER_MISSES = 2

/** At the probe interval this is about half a minute: long enough to see "stopped", short enough to free the slot. */
const val DROP_AFTER_MISSES = 15
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.yugahashimoto.andcode.feature.terminal

/**
* Puts back together the lines the terminal wrapped.
*
* A program prints one line; the emulator breaks it at the screen width into several rows. An address
* that crosses the break is whole in none of them, so searching row by row misses it (or reads half of
* a port number as a port). A row that fills the width is taken to continue on the next row.
*
* That is an inference from the row's length, not a record of what the emulator did: it cannot tell a
* line that wrapped from one that happened to end exactly at the last column and was followed by a
* newline, so the latter is joined to the row after it as well. The cost is small - a port at the very
* end of such a line can be read together with digits that start the next line, and the stray
* candidate is dropped when nothing answers on it. Marking wrapped rows in the emulator would make it
* exact.
*/
object WrappedLines {
/**
* The lines of [rows] that contain at least one row for which [include] is true, each as one string.
* [columns] is the width the rows were wrapped at; with no usable width every row is its own line.
*
* A line whose last row is full may still be growing in output that has not arrived, so its trailing
* digits are left out: they could be the front of a longer number. Once the line ends - the next row
* is not full, even if only because it is empty - they are included.
*/
fun join(
rows: List<StyledLine>,
columns: Int,
include: (StyledLine) -> Boolean,
): List<String> {
if (columns <= 0) return rows.filter(include).map { it.text }
val lines = ArrayList<String>()
var start = 0
while (start < rows.size) {
var end = start
while (end + 1 < rows.size && rows[end].text.length >= columns) end++
val group = rows.subList(start, end + 1)
if (group.any(include)) {
val text = group.joinToString(separator = "") { it.text }
lines += if (rows[end].text.length >= columns) text.trimEnd { it.isDigit() } else text
}
start = end + 1
}
return lines
}
}
Loading
Loading