Files
tty7/mobile
l0ng-ai 3990a05795 Mobile: an iOS pass from a user's seat (#1075)
* fix(mobile): draw a prompt's icons

Prompts built with Powerline and Nerd Font glyphs (branch, folder, git
status) showed as empty boxes: a phone has no such font to fall back on.
Ship the symbols-only Nerd Font, one cell wide, behind Hack.

* fix(gateway): send a phone the screen the desktop shows

A phone opening a pane was sent its raw output, ring segment by segment,
and read it with xterm.js. Across the pane's resizes that emulator wraps
and reflows unlike the desktop's, so shells' SIGWINCH redraws landed on
the wrong rows: prompts twice, old output under new prompts. Leaving a
full-screen program left stale lines behind the same way.

The gateway now reads the pane with the desktop's emulator and, on
opening, after a resize, and when the main screen comes back, sends the
phone that screen drawn afresh: scrollback, colours, cursor and modes.
Output in between still goes straight through.

* fix(mobile): the message box rides the keyboard on iOS

On iOS 26 the WebView, run edge to edge, does not say how much the
keyboard covers, so the message box and the dock stayed under it. The
app now hears the keyboard's frame natively and lays out above its top
edge, on iOS as on Android.

- WebKit's previous/next/Done bar over the keyboard is gone; a tap on
  nothing in particular, or on the pane, puts the keyboard away.
- Return sends however the keyboard delivers it.
- A shell's message box no longer capitalises or corrects: ls stays ls.
  An agent's keeps both, as a chat would.

* fix(gateway): a tab opened from the phone joins its repository's group

The desktop files a tab under its repository as its sidebar draws it, so
a tab opened from the phone while that window was out of sight stayed in
Ungrouped. The gateway now reads, for a tab the desktop has not filed,
which repository its directory is in, the way the desktop would.

* feat(mobile): close a tab from the phone

Swipe a tab's row left for Close, or use Close tab in a pane's menu. The
tab goes where the desktop puts a closed tab, onto the workspace's
recently-closed list, so tty7 on the computer can reopen it. A tab whose
agent is at work asks first.

A new CloseTab stream carries it; a machine that keeps no closed tabs
closes it for good and its panes are ended, as tty7 tab close does.

* feat(mobile): a machine's list reads at a glance, and the app opens where it was left

- The app reopens the machine it was last on; leaving for the list of
  machines is what makes it start there.
- A tab named after its directory goes by the directory's own name, its
  parent underneath, rather than a path cut off at the end that matters.
  An agent's row says where it works too.
- New tab asks which folder, from those the workspace has tabs in, the
  tab in front first; Workspace is asked only when there is a choice.
- The back button names where it goes in full until the title folds in.
- Search fields have a clear button; a few machines need no search.
- Rows swipe left to close their tab, and a pane's menu has Close tab.

* fix(mobile): agents' marks draw, and Return sends a sentence

- Claude Code's ⏺ and ⎿, ⏵⏵, ⚙, ⏰, ✔, braille spinners and the like
  showed as empty boxes: the phone's fonts do not reach a canvas for
  them. Noto's symbols and outline emoji ship, cut down to those blocks
  (about 80 KB), behind Hack and the prompt icons.
- The phone's keyboard reports Shift with Return whenever it has armed
  a capital — at the start, after a full stop — so a message ending in a
  sentence got a new line instead of being sent. Shift-Return starts a
  line only on a keyboard with a real Shift; Return delivered as typed
  text sends as well.

* fix(mobile): an agent's choices become buttons wherever they are on screen

The answers were looked for in the bottom 24 rows of the terminal, which
can be taller than the pane: Claude Code's question sat higher up and no
buttons came. The whole screen is read now.

* fix(mobile): a pane on its side keeps room for the pane

In landscape the bar, the key row, the page dots and the message box
left three lines of terminal. The bar slims and the keys and the message
box share a row, for eight. A message box's hint stays on one line, and
a row's hidden Close names its tab to VoiceOver.

* fix(mobile): Settings names the ⋯ menu with its own mark, and asks before clearing history

The phone's text font has no ⋯, so the note showed an empty box; the
menu button's icon stands in. Clearing the message history, which cannot
be undone, now asks first.

* fix(mobile): Changes shows the changed files, not just the untracked ones

The files' blocks shrank to nothing under a long list of untracked ones:
flex items that clip their overflow give up their height. They keep it
now, and the sheet scrolls. The untracked list sits flush and compact,
the top line totals the change, and lock files start folded.

* feat(mobile): pull a sheet down to put it away

Sheets showed a grabber but only closed from their × or the dimmed screen
around them. Pulled down — from the top, or from anywhere while their
content is scrolled to the start — they go; let go short of the way,
they settle back.

* fix(mobile): a machine that went away says so within seconds

The connection kept QUIC's default 30-second idle timeout, so a laptop
gone to sleep stayed "Direct · 1 ms" on the phone for half a minute, and
typing into it went nowhere without a word. The phone now gives up on a
silent machine after 12 seconds, a few keep-alives missed, says it
cannot reach it, and reconnects on its own once it is back.

* feat(mobile): keys and answers are felt

The key row, an agent's answer buttons and Send give the light tap the
phone's own keyboard does, and a row swiped far enough to open ticks as
it passes the point. Through the Tauri haptics plugin; nothing is felt
where there is no engine for it.

* feat(mobile): pair by pointing the phone's camera at the desktop's code

The pairing QR code reads tty7pair:…, and the app now answers to that
scheme: the phone's own Camera offers to open it in tty7, which pairs
straight away, as its Scan button does. No hunting for the button first.

* fix(settings): the pairing code can be read by the phone's camera

The phone app now opens from its tty7pair: link, so Show code says to
point the phone's camera at it first.

* fix(mobile): Android opens tty7pair: links too

The deep-link plugin's intent filter, written into the manifest by the
Android build.

* fix(mobile): pairing links without the deep-link plugin, cold launches included

The deep-link plugin's build script rewrites the iOS entitlements while
Xcode builds, which Xcode refuses ("modified during the build"), and a
link that launched the app was lost on the way in any case: UIKit hands
it to the first scene as it connects, and the event loop only passes on
links that come later.

- The tty7pair scheme is declared in Info.ios.plist and the Android
  manifest, and links are heard as Tauri's own Opened event.
- The scene delegate's connect is wrapped, before UIKit starts, to keep
  a launching link; the page asks for it once it listens, so neither
  order of arrival loses it.

* feat(mobile): another agent waiting or done says so over the pane you are in

Inside one pane, nothing told you another agent on the machine had
stopped for an answer or finished its turn; you had to back out and
look. The pane now watches the machine's tree too, and such a change
drops a card over the top of the pane, felt as it comes — the agent,
what it says or which tab — that opens that pane when tapped. What was
already so when the pane opened is not news.

* fix(mobile): with the lock on, the app switcher shows no panes

The phone keeps a picture of an app as it leaves, for its app switcher.
With the lock on, that picture showed whatever pane was open. The app is
now covered as it goes, and uncovered — or locked — as it comes back.

* fix(mobile): turning the lock off takes Face ID too

Anyone holding the phone, unlocked, could switch the app lock off from
Settings. Off now asks for Face ID or the passcode, as on does.

* fix(mobile): a pane that is not running says so, with a way on

After the machine's server restarted, panes no window had opened since
were gone, and the phone tried to watch them forever: "Daemon refused
Observe: no such pane 9. Reconnecting…". The gateway now marks panes
its server is not running; the list shows them as Not running, without
their last agent status, and opening one says what is going on and
offers a new tab in the same folder.

* feat(mobile): a workspace not on screen shows when an agent in it needs you

The switcher's chip for another workspace carries the status dot a
folded group does, so an agent waiting there is not hidden behind the
one you are looking at.

* fix(mobile): a new tab's agent starts, instead of waiting at the prompt

Opening a tab for Claude Code or Codex typed the command as soon as the
pane was live, while the shell was still printing its greeting; the
Enter was eaten and the command sat at the prompt unrun. It is typed once
the shell's output has gone quiet now, or after six seconds whatever.

Rows also say where a tab is the same way whatever it is named.

* fix(mobile): the first screen says how to pair in one step

With no machine paired yet, it now says where the code is on the
computer and that the phone's camera reads it.

* fix(mobile): a tap that closes a pane's menu does only that

Closing the ⋯ menu by tapping the pane also brought the keyboard up for
typing into it. The tap that puts a menu away is the menu's now.

* fix(mobile): second pass from a phone user's seat

- Chinese, Japanese and Korean text rendered as boxes in the terminal:
  the glyph atlas does not fall back past the web fonts, so the system's
  CJK fonts are named in the stack.
- A tab opened from the phone was drawn at the desktop's width first and
  then squeezed: the gateway now holds it at the phone's size from before
  the desktop hears of it, until the phone's own view takes over.
- A tab opened with no folder started wherever the server did; it starts
  at home, and the new-tab sheet offers Home.
- Going back to a machine showed a skeleton every time; its last tree is
  drawn at once and refreshed when the watch reports.
- Errors said the transport's chain, repeated: each part is said once, and
  an unreachable machine is explained in words.
- Wide panes say once that they can run at the phone's size; a pane taken
  over is taken over again on return.
- Back in a pane's history, a button jumps to the latest output.
- Find hides the key bar and message box and keeps the match clear of the
  bar; Copy keeps the pane's lines and offers Copy all.
- The message box pans a wide pane back to the cursor; Changes outside a
  repository says so plainly; machine rows count waiting agents; the back
  pill folds to a chevron with the title; filled reds use the system red.

* feat(mobile): attachments wait as chips beside the message box

A sent file used to drop its full temporary path into the box, five lines
of /var/folders/... underlined by the spell checker. It now waits as a
chip with its picture and name, can be taken back before sending, and its
path goes after the message when it is sent.

* fix(mobile): pairing says what went wrong, and leaves the code alone

The code field offered word suggestions over the keyboard; it is a code.
A wrong, expired or unreachable code is explained with what to do next
rather than the gateway's lower-case reason.

* feat(mobile): hold a tab's row for what can be done with it

A long press used to open the pane like a tap. It now brings up the
row's actions: open, open at the phone's size, Changes, a new tab in the
same folder, copy the folder's path, and close.

* fix(mobile): a pane on its way shows that it is coming

Opening a pane over a slow link left an empty screen until its replay
arrived. After a quarter second without it, three dots say it is coming.

* fix(mobile): agent menus, stopped panes and new tabs, as used

- An agent's arrow-key menu with no numbers (Claude Code's 'trust this
  folder?') now gets answer buttons too; picking one moves to it and
  presses Enter.
- A pane that is not running greys out its keys and message box instead
  of taking typing that goes nowhere.
- Suggestions match where a word starts, so 'ls' no longer offers every
  message that mentions tools.
- A new tab's starting size counted neither the status bar nor the home
  indicator, so its last rows sat under the key bar.
- A tab opened on the phone keeps the phone's size on later visits, so it
  follows the keyboard instead of hiding behind it.

* fix(mobile): another agent waiting is not missed, and dismissing is only that

- The card for another agent that needs you stays until it is answered or
  put away (it went after 15 seconds); a finished one still goes on its
  own. While any other agent waits, the back button carries a dot.
- The card's dismiss, a banner's action and Jump to latest took themselves
  away under the finger, and the same tap then reached the pane and
  brought up its keyboard; a clear cover takes that tap now.
- A row's Current tag no longer gets cut to 'C...' by a long name.

* fix(mobile): Changes lists a new folder once, by name

The untracked list walked into every new directory — a generated folder
filled the sheet with thirty truncated paths. New directories come as one
entry, as git status shows them, and each entry reads as a name with the
end of its folder beside it.

* fix(mobile): bar buttons answer a 44pt square

Back, More, Close and the pane's round buttons are drawn at 38pt and took
taps only there, under the 44pt a finger is owed. Each now answers a 44pt
square around the same drawing.
2026-10-03 20:28:29 +08:00
..

tty7 mobile

Watch and drive your desktop's tty7 panes and agents from a phone.

phone (Tauri: WebView + Rust)  ──iroh──▶  tty7-gateway  ──local sockets──▶  tty7 server
   xterm.js ◀─ raw bytes ─ tty7-mobile-client          (crates/tty7-gateway)
  • Transport: iroh. The phone dials the desktop by public key. Connections hole-punch to a direct path when the network allows and fall back to a relay when it doesn't. Either way they're end-to-end encrypted. No port forwarding, no VPN.
  • Protocol: crates/tty7-mobile-proto. The phone never speaks the daemon's own protocols. The gateway exposes a small vocabulary: pair, a live tree of workspaces, tabs, panes and agent status, and one stream per open pane.
  • Panes are observed, not attached. A phone never takes a pane away from the desktop window showing it. Keystrokes go in beside the observer (SendInput). The terminal keeps the desktop's size, and the app shrinks the font to fit the width.
  • Take over. The phone button runs the pane at the phone's grid instead: a size lease the daemon holds for the observer (ClientMsg::Lease, feature size-lease). The desktop window keeps its grid, shows "In use on " with Take Back, and a resize there is remembered for when the lease ends. It ends when the phone lets go, when the desktop takes it back, or when the phone's stream closes — a phone that just drops off gives the pane back once the connection times out.
  • Auth: pairing with a one-time code, valid 10 minutes, single use. After that the gateway admits only the phone keys on its device list (<config dir>/mobile/devices.json).

Run it

On the desktop: Settings → Mobile → Allow phone access. The local daemon runs the gateway from then on, with every window closed too. Show code there gives a QR code and a tty7pair: code, and the phones it paired are listed below it, each with an Unpair button.

Whichever daemon is running starts the gateway as its own child process and stops it with the switch. tty7-app --daemon runs itself as tty7-app --mobile-gateway. The lean tty7-server runs a tty7-gateway from beside it or on PATH, and without one it says so in Settings. The gateway exits when its daemon does, so an update never leaves an old one running.

Without the GUI, the same gateway runs from the command line. It shares the state in <config dir>/mobile/, so the two never run at once:

cargo run -p tty7-gateway -- serve     # keep this running
cargo run -p tty7-gateway -- pair      # prints a QR code + a tty7pair:… code
cargo run -p tty7-gateway -- devices   # paired phones; `revoke <name>` removes one

The app on the desktop (fastest loop)

Tauri builds the same app for macOS, which is the quickest way to work on the UI:

cd mobile
npm install
npm run tauri dev

Paste the tty7pair: code and tap Pair.

iOS

Needs the full Xcode, not just the Command Line Tools, plus an Apple developer account to run on a device.

rustup target add aarch64-apple-ios aarch64-apple-ios-sim
cd mobile
npm run tauri ios init       # once: generates src-tauri/gen/apple
npm run tauri ios dev        # simulator, or pick a connected device

Releases go to TestFlight from CI (see Releasing). To send one from this machine instead, signed in to Xcode with an account on the team, or with an App Store Connect API key in ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH:

scripts/testflight.sh              # archive, sign for the App Store, upload
scripts/testflight.sh --no-upload  # a signed .ipa in build/testflight/ instead

The build is <version>.<n>; n defaults to the time, so each upload is higher than the last. --build-number 7 picks it.

Android

Needs the Android SDK and NDK, with ANDROID_HOME and NDK_HOME set, and a JDK 17 to 21 as JAVA_HOME.

rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
cd mobile
npm run tauri android dev                                        # emulator or a connected phone
npm run tauri android build -- --debug --apk --target aarch64   # an installable .apk

src-tauri/gen/android is kept in the repo, unlike gen/apple: its MainActivity hands the keyboard's height to the page, which the WebView does not report edge to edge. Don't re-run android init over it.

The release key is in the ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD and ANDROID_KEY_ALIAS secrets, with a copy kept outside GitHub. A local release build signs with it when src-tauri/gen/android/keystore.properties (ignored by git) names it:

storeFile=/path/to/tty7-release.jks
storePassword=…
keyAlias=tty7
keyPassword=…

Releasing

Both platforms ship at one version, apart from the desktop's:

  1. Raise version in src-tauri/tauri.conf.json and merge it. Each version must be higher than the last: Android installs over a build only when its versionCode, which Tauri derives from the version, is higher.
  2. Tag that commit mobile-v<version> and push the tag.

.github/workflows/mobile.yml then:

  • Android: builds a signed arm64 APK and attaches it to a draft release, which you publish. The release is never marked latest, because the desktop updater reads /releases/latest.
  • iOS: uploads a build to TestFlight. It reaches testers once App Store Connect has processed it, and, for the external group, once Beta App Review passes.
  • Checks: a tag that doesn't match tauri.conf.json fails the run.

Running the workflow by hand builds both platforms but uploads nothing.

Secrets:

Secret What
ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS the Android release key
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 an App Store Connect API key with the Admin role, which signs and uploads iOS builds (App Manager keys may not sign in the cloud)

Keep both keys outside GitHub as well; secrets can't be read back. A phone feature that needs a newer desktop says so when the desktop is older, so the release notes should name the desktop version a release needs.

Without a phone

crates/tty7-mobile-client/examples/probe.rs is a phone in a shell. It pairs, prints the tree, and times keystroke echo on a pane:

cargo run -p tty7-mobile-client --example probe -- pair '<code>'
cargo run -p tty7-mobile-client --example probe -- tree
cargo run -p tty7-mobile-client --example probe -- type 1 'echo hi'

Finding the computer again

A pairing code carries the gateway's addresses at the time it was made. Those stay good across restarts: serve listens on the same UDP port every time (<config dir>/mobile/port, a fresh one only if it is taken). When they go stale anyway — a new DHCP lease, a new IPv6 prefix — the phone looks the gateway up by key: through n0's relay and DNS where those are reachable, and by mDNS (_tty7._udp) on the same local network where they are not.

mDNS needs the OS's local-network permission on both ends. On macOS that belongs to whatever launched the gateway (your terminal), and it answers "Allow … to find devices on local networks" once. On iOS the app declares it in src-tauri/Info.ios.plist. Without it, known addresses and the relay still work.

Not done yet

  • QR scanning. The app takes a pasted code for now. Next step is tauri-plugin-barcode-scanner on mobile.
  • Push notifications when an agent starts waiting. The gateway sees the status change, but APNs/FCM delivery needs a small native plugin and a push relay.
  • Keychain / Keystore for the phone's key. It lives in the app's private data directory today.
  • A self-hosted iroh relay for production, instead of n0's public ones.