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
3 changes: 2 additions & 1 deletion android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Native companion app for the desktop's **Connect Phone** (Remote Access) feature
- **Settings:** theme and looks, keep the screen on, widget background transparency and card color, connection status, wait for VPN (skipped on your home Wi-Fi, which needs location access to read the network name, and "Allow all the time" for agent notifications in the background), and forget this computer.
- **Swipe between tasks:** with a task open, swipe sideways to the previous or next one in the list; the header shows its position ("2 of 5"). When another task needs you, **Next task →** at the bottom of the terminal jumps straight to it.
- **Terminal:** an agent's terminal in the colors of the look you picked, matching the desktop. Once paired: a reply box and keys a phone keyboard lacks (Enter, Esc, Tab, arrows, Ctrl+C). As on the desktop, typing `!` into an empty reply switches to the agent's shell mode; tap the `!` to leave it. With "Fit the terminal to this phone" on (Settings, off by default), the terminal takes the phone's size while open so full-screen agents such as Claude Code fill it; the computer's own terminal shifts meanwhile and gets its size back when you leave.
- **Terminal space and zoom:** pinch with two fingers to magnify the whole terminal up to 400%, and pan with two fingers (or sideways with one) to read a part of it; one finger still scrolls the history. Swiping between tasks pauses while zoomed; double-tap or the zoom pill resets it. The expand icon in the title bar hides the title, tabs, and quick keys while keeping the message field and Send/Stop available; the restore icon or Android Back returns to the normal layout without losing your draft. Terminal and chat replies use full-width multiline fields, with action buttons below.
- **Terminal space and zoom:** pinch with two fingers to magnify the whole terminal up to 400%, and pan with two fingers (or sideways with one) to read a part of it; one finger still scrolls the history. A full-screen agent such as Claude Code's fullscreen mode keeps its history to itself, so once paired, one finger scrolls the agent's own transcript instead, as the mouse wheel does on the computer (the computer's view scrolls with it). Swiping between tasks pauses while zoomed; double-tap or the zoom pill resets it. The expand icon in the title bar hides the title, tabs, and quick keys while keeping the message field and Send/Stop available; the restore icon or Android Back returns to the normal layout without losing your draft. Terminal and chat replies use full-width multiline fields, with action buttons below.
- **History:** opening a terminal loads the desktop's history (up to 10,000 lines), and the phone keeps up to 20,000 lines while the terminal stays open.
- **Changes:** the task's diff against its base branch, file by file with added and removed lines.
- **Quick replies and voice:** saved replies above the built-in chat's reply box (edit them in Settings; the terminal leaves them out to make room) and a mic button that dictates with Android's speech recognizer.
Expand Down Expand Up @@ -93,6 +93,7 @@ See `electron/remote/server.ts` and `electron/remote/protocol.ts`.
| Connect | WebSocket `/ws`; first message `{ type: "auth", token }`. The paired token is used when present |
| Watch | `subscribe` / `unsubscribe`; the server sends `scrollback`, then `output` (base64 PTY bytes) |
| View size | `view-size` with `{ cols, rows }` (paired) while a terminal is open; without them, or on disconnect, the desktop size returns |
| Scroll | `scroll` with `{ lines, col, row }` (paired) over a full-screen agent; the desktop sends it mouse-wheel turns |
| Projects | `GET /api/mobile/projects` (paired) |
| Agents | `GET /api/mobile/agents` (paired) → `[{ id, name, isDefault, models }]`; `403` on older desktops |
| New task | `POST /api/mobile/tasks` with `{ projectId, name, prompt, agentId?, model? }` (paired); returns `{ taskId }` |
Expand Down
21 changes: 21 additions & 0 deletions android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,25 @@ class RemoteClient(
terminalBuffers[agentId]?.resize(cols, rows)
}

/**
* Turn the mouse wheel over an agent's full-screen TUI, as scrolling over the desktop pane
* does: the TUI keeps its transcript out of the terminal history. Negative [lines] scroll up.
* Needs pairing; the server closes a view-only socket that tries. Agents scroll their
* transcript wherever the wheel turns, so it turns over the middle of the screen.
*/
fun scrollTerminal(agentId: String, lines: Int) {
val screen = terminalBuffers[agentId]?.screen ?: return
if (lines == 0 || !_state.value.canControl) return
send(
JSONObject()
.put("type", "scroll")
.put("agentId", agentId)
.put("lines", lines.coerceIn(-MAX_SCROLL_LINES, MAX_SCROLL_LINES))
.put("col", screen.cols / 2 + 1)
.put("row", screen.rows / 2 + 1),
)
}

/** Stream an agent's terminal. Retained for backwards compatibility. */
fun watchTerminal(agentId: String, listener: TerminalListener): () -> Unit {
getTerminalBuffer(agentId)
Expand Down Expand Up @@ -883,6 +902,8 @@ class RemoteClient(
// The server drops a socket message past 64 KiB; leave room for the envelope.
const val MAX_CHAT_MESSAGE_LENGTH = 60_000
const val MAX_INPUT_LENGTH = 4096
// The server's MAX_SCROLL_LINES: wheel turns in one scroll message.
const val MAX_SCROLL_LINES = 100
const val PREFIX_KEY_DELAY_MS = 50L
}
}
45 changes: 45 additions & 0 deletions android/app/src/main/java/com/parallelcode/phone/Screens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,17 @@ import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
Expand Down Expand Up @@ -144,6 +148,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.animation.core.Animatable
Expand Down Expand Up @@ -965,6 +970,7 @@ fun AgentScreen(
val version by buffer.version.collectAsState()
// Recomposition is batched per frame, so a burst of output renders the text once.
val lines = remember(version) { buffer.screen.styledLines() }
val fullScreen = remember(version) { buffer.screen.alternateScreen }
var tab by rememberSaveable { mutableStateOf(AgentTab.TERMINAL) }
var closing by remember { mutableStateOf(false) }
var merging by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -1160,6 +1166,11 @@ fun AgentScreen(
fitToView = sizeTerminal,
onViewSize = { cols, rows -> viewSize = cols to rows },
onZoomedChange = onZoomedChange,
onWheel = if (fullScreen && state.canControl) {
{ wheelLines: Int -> client.scrollTerminal(agentId, wheelLines) }
} else {
null
},
// Keeps an agent's prompt clear of the Next task pill below it.
bottomInset = if (nextNeedingYou != null) 44.dp else 0.dp,
)
Expand Down Expand Up @@ -1340,6 +1351,8 @@ private fun TerminalText(
fitToView: Boolean,
onViewSize: (cols: Int, rows: Int) -> Unit,
onZoomedChange: (Boolean) -> Unit = {},
/** Takes the drag the terminal cannot follow, as mouse-wheel lines for a full-screen agent. */
onWheel: ((lines: Int) -> Unit)? = null,
/** Room kept under the newest line for a pill floating over the terminal's bottom. */
bottomInset: Dp = 0.dp,
) {
Expand All @@ -1362,6 +1375,8 @@ private fun TerminalText(
val currentOnZoomedChange by rememberUpdatedState(onZoomedChange)
LaunchedEffect(zoomed) { currentOnZoomedChange(zoomed) }
DisposableEffect(Unit) { onDispose { currentOnZoomedChange(false) } }
val wheel = remember { AgentWheel() }
SideEffect { wheel.onWheel = onWheel }

// Follow output only while parked at the bottom, decided where each scroll ends. Deciding at
// the start instead kept follow on for a scroll that began at the bottom (the usual case), so
Expand Down Expand Up @@ -1389,6 +1404,7 @@ private fun TerminalText(
.fillMaxSize()
.background(Color(palette.background))
.clipToBounds()
.nestedScroll(wheel)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
Expand Down Expand Up @@ -1437,6 +1453,7 @@ private fun TerminalText(
val autoFont = (10f * textWidthPx / (cols.coerceAtLeast(1) * charPxAt10)).coerceIn(8f, 14f).sp
val phoneFont = TERMINAL_FONT
val fontSize = if (fitToView) phoneFont else autoFont
SideEffect { wheel.linePx = with(density) { (fontSize * 1.27f).toPx() } }

val viewPaddingPx = with(density) { 16.dp.roundToPx() }
val viewHeightPx = constraints.maxHeight
Expand Down Expand Up @@ -1558,6 +1575,34 @@ private fun TerminalText(
}
}

/**
* Turns the drag a full-screen agent's terminal cannot follow into mouse-wheel lines for the agent,
* as scrolling over the desktop pane does: the agent keeps its transcript out of the history.
*/
internal class AgentWheel : NestedScrollConnection {
var onWheel: ((lines: Int) -> Unit)? = null
var linePx = 0f
private var pending = 0f

override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
val turn = onWheel
if (turn == null || source != NestedScrollSource.UserInput || linePx <= 0f) return Offset.Zero
// Dragging down reveals what came before, so the wheel turns up (negative lines).
pending -= available.y
val lines = (pending / linePx).toInt()
if (lines != 0) {
pending -= lines * linePx
turn(lines)
}
return Offset(0f, available.y)
}

override suspend fun onPreFling(available: Velocity): Velocity {
pending = 0f
return Velocity.Zero
}
}

private val TERMINAL_PADDING_H = 12.dp
private val TERMINAL_FONT = 11.sp
private const val MAX_TERMINAL_ZOOM = 4f
Expand Down
12 changes: 10 additions & 2 deletions android/app/src/main/java/com/parallelcode/phone/TerminalScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ class TerminalScreen(cols: Int = 80, rows: Int = 24) {
var bracketedPaste = false
private set

/**
* A full-screen program (Claude Code's fullscreen mode, vim) holds the alternate screen. It
* keeps its history to itself, so the phone scrolls it by sending it the mouse wheel.
*/
val alternateScreen: Boolean
get() = savedMainGrid != null

private class Line(cols: Int, style: Long) {
val chars = CharArray(cols) { ' ' }
val styles = LongArray(cols) { style }
Expand Down Expand Up @@ -129,10 +136,11 @@ class TerminalScreen(cols: Int = 80, rows: Int = 24) {

/**
* History then screen, each line split into style runs, without trailing blank lines. Built
* for every output frame, so the history is shared from a snapshot rather than copied.
* for every output frame, so the history is shared from a snapshot rather than copied. The
* alternate screen shows alone, as in xterm: the history above it is the shell's, not the program's.
*/
fun styledLines(): List<List<StyledSpan>> {
val past = history.snapshot()
val past = if (alternateScreen) emptyList() else history.snapshot()
val screen = grid.map(::spans)
var count = past.size + screen.size
fun line(i: Int) = if (i < past.size) past[i] else screen[i - past.size]
Expand Down
36 changes: 36 additions & 0 deletions android/app/src/test/java/com/parallelcode/phone/AgentWheelTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.parallelcode.phone

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import org.junit.Assert.assertEquals
import org.junit.Test

class AgentWheelTest {
private fun wheel(turns: MutableList<Int>) = AgentWheel().apply {
linePx = 10f
onWheel = { turns += it }
}

@Test
fun turnsTheWheelUpForADragDownTheTerminalCannotFollow() {
val turns = mutableListOf<Int>()
val wheel = wheel(turns)
val left = wheel.onPostScroll(Offset.Zero, Offset(0f, 25f), NestedScrollSource.UserInput)
assertEquals(listOf(-2), turns)
assertEquals(Offset(0f, 25f), left)
// The half line left over carries into the next drag step.
wheel.onPostScroll(Offset.Zero, Offset(0f, 5f), NestedScrollSource.UserInput)
wheel.onPostScroll(Offset.Zero, Offset(0f, -40f), NestedScrollSource.UserInput)
assertEquals(listOf(-2, -1, 4), turns)
}

@Test
fun leavesFlingsAndOrdinaryTerminalsAlone() {
val turns = mutableListOf<Int>()
val wheel = wheel(turns)
assertEquals(Offset.Zero, wheel.onPostScroll(Offset.Zero, Offset(0f, 50f), NestedScrollSource.SideEffect))
wheel.onWheel = null
assertEquals(Offset.Zero, wheel.onPostScroll(Offset.Zero, Offset(0f, 50f), NestedScrollSource.UserInput))
assertEquals(emptyList<Int>(), turns)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.parallelcode.phone

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

Expand Down Expand Up @@ -71,6 +72,16 @@ class TerminalScreenTest {
assertEquals("shell", s.text())
}

@Test
fun showsAFullScreenProgramWithoutTheShellHistoryBehindIt() {
val s = screen(rows = 2, data = "1\r\n2\r\n3\u001b[?1049h\u001b[Hclaude")
assertTrue(s.alternateScreen)
assertEquals("claude", s.text())
s.feed("\u001b[?1049l".toByteArray())
assertFalse(s.alternateScreen)
assertEquals("1\n2\n3", s.text())
}

@Test
fun decodesUtf8SplitAcrossChunks() {
val s = screen()
Expand Down
21 changes: 21 additions & 0 deletions electron/ipc/pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
setAgentRemoteSize,
setRemoteHistory,
resolveProjectDockerfile,
scrollAgent,
spawnAgent,
setAgentHookRuntime,
subscribeToAgent,
Expand Down Expand Up @@ -1257,6 +1258,26 @@ describe('peer prompt delivery', () => {
},
);

it('scrolls a TUI that reads the wheel without holding back peer prompts', async () => {
const { args, proc } = await launch();
scrollAgent(args.agentId, -2, 10, 5);
expect(proc.write).not.toHaveBeenCalled();
proc.emitData('\x1b[?1049h\x1b[?1000;1006h');
await vi.waitFor(() => expect(getAgentPromptSnapshot(args.agentId)).not.toBeNull());
scrollAgent(args.agentId, -2, 10, 5);
expect(proc.write.mock.calls).toEqual([['\x1b[<64;10;5M\x1b[<64;10;5M']]);
const delivery = writeAgentPrompt(args.agentId, 'peer', () => {});
// A report inside the peer's paste would be typed into the composer.
scrollAgent(args.agentId, 1, 10, 5);
await vi.advanceTimersByTimeAsync(50);
expect(await delivery).toBe(true);
expect(proc.write.mock.calls.slice(1)).toEqual([
['\x1b[I'],
['\x1b[200~peer\x1b[201~'],
['\r'],
]);
});

it('defers to recent raw input for five seconds', async () => {
const { args, proc } = await launch();
writeToAgent(args.agentId, 'user submission\r');
Expand Down
14 changes: 14 additions & 0 deletions electron/ipc/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,20 @@ export function writeToAgent(agentId: string, data: string): void {
writeSessionInput(session, data);
}

/**
* Turn the mouse wheel over the agent's screen, as the desktop pane does. A
* full-screen TUI keeps its transcript out of the terminal's history, so this
* is the only way a phone can scroll it. Does nothing unless the TUI reads the wheel.
*/
export function scrollAgent(agentId: string, lines: number, col: number, row: number): void {
const session = sessions.get(agentId);
// Inside a peer prompt's paste, a report would be typed into the composer.
if (!session || session.peerInputQueue || handingOff.has(agentId)) return;
const report = session.queries.wheelReport(lines, col, row);
// Not through writeSessionInput: a scroll is not a draft that holds back peer prompts.
if (report) session.proc.write(report);
}

function writeSessionInput(session: PtySession, data: string): void {
const userActivity = hasTerminalUserActivity(data);
if (userActivity) session.lastInputAt = Date.now();
Expand Down
28 changes: 28 additions & 0 deletions electron/ipc/terminal-query-responder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,34 @@ describe('createTerminalQueryResponder', () => {
responder.dispose();
});

it('reports wheel turns only while the program reads them as SGR reports', async () => {
const { responder } = setup();
expect(responder.wheelReport(-1, 10, 5)).toBeNull();
// Serializing waits for everything fed so far to be parsed.
responder.feed('\x1b[?1049h\x1b[?1000h');
await responder.serialize();
// Default-encoded reports go out as binary events the desktop pane never forwards.
expect(responder.wheelReport(-1, 10, 5)).toBeNull();
responder.feed('\x1b[?1006h');
await responder.serialize();
expect(responder.wheelReport(-2, 10, 5)).toBe('\x1b[<64;10;5M\x1b[<64;10;5M');
expect(responder.wheelReport(1, 500, 0)).toBe('\x1b[<65;80;1M');
responder.feed('\x1b[?1016h');
await responder.serialize();
expect(responder.wheelReport(1, 10, 5)).toBeNull();
responder.feed('\x1bc\x1b[?1002;1006h');
await responder.serialize();
expect(responder.wheelReport(-1, 1, 1)).toBe('\x1b[<64;1;1M');
responder.feed('\x1bc\x1b[?1002h');
await responder.serialize();
expect(responder.wheelReport(-1, 1, 1)).toBeNull();
responder.feed('\x1b[?1006h\x1b[?1002l\x1b[?9h');
await responder.serialize();
expect(responder.wheelReport(-1, 1, 1)).toBeNull();
responder.dispose();
expect(responder.wheelReport(-1, 1, 1)).toBeNull();
});

it('stops answering once disposed', async () => {
const { responder, replies } = setup();
responder.dispose();
Expand Down
Loading
Loading