Compare commits

..
17 Commits
Author SHA1 Message Date
Codex e077fa5d14 Add tmux terminal shortcut controls
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m0s
2026-07-06 03:47:56 +00:00
Codex d655fd7e09 Clarify app download and update paths
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m42s
2026-07-05 19:06:31 +00:00
Codex 4c5cd385b7 Use headless JDK in Gitea Android workflow
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m51s
2026-07-05 17:42:04 +00:00
Codex 3174b77923 Use Android SDK container for Gitea build
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:38:21 +00:00
Codex 226f2e781b Use clean Gitea Android build workflow
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:37:12 +00:00
Codex f35595706e Install JDK in Gitea build bootstrap
Gitea Smoke / smoke (push) Waiting to run
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / tools (push) Failing after 0s
Gitea Android APK / lsremote (push) Successful in 3s
Gitea Android APK / secret (push) Successful in 1s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:31:51 +00:00
Codex 5d821de7f7 Add Gitea git access diagnostics
Gitea Android APK / probe (push) Successful in 2s
Gitea Android APK / tools (push) Failing after 0s
Gitea Android APK / lsremote (push) Successful in 0s
Gitea Android APK / secret (push) Successful in 0s
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / checkout (push) Successful in 4s
2026-07-05 17:30:10 +00:00
Codex eb646cc0d9 Diagnose Gitea clone secret and auth format
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / secret (push) Successful in 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / probe (push) Successful in 2s
2026-07-05 17:29:11 +00:00
Codex 46b94ebdad Use Gitea clone token secret in workflow
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:27:45 +00:00
Codex 72780eb98f Add Gitea workflow diagnostic jobs
Gitea Android APK / probe (push) Successful in 0s
Gitea Android APK / checkout (push) Failing after 0s
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:25:56 +00:00
Codex e9615cb8fe Avoid unsupported Gitea workflow token syntax
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 4s
2026-07-05 17:24:53 +00:00
Codex 9e3e6d99cc Install Gitea workflow tools before checkout
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-05 17:23:57 +00:00
Codex fc673a0df9 Use Gitea job token for workflow clone
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:20:51 +00:00
Codex e144c1d3fb Fix Gitea workflow checkout directory
Gitea Android APK / build (push) Failing after 2s
Gitea Smoke / smoke (push) Successful in 1s
2026-07-05 17:18:45 +00:00
Codex 90db1dc04c Make Gitea Android workflow self contained
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 3s
2026-07-05 17:17:33 +00:00
Codex e47c8c15d4 Simplify Gitea Android workflow
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-05 17:10:54 +00:00
Codex 85469dae4b Support Gitea release update source
Gitea Smoke / smoke (push) Successful in 2s
2026-07-05 16:45:40 +00:00
6 changed files with 192 additions and 163 deletions
+53 -146
View File
@@ -1,160 +1,67 @@
name: Android APK
name: Gitea Android APK
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
workflow_dispatch:
inputs:
publish_release:
description: "Create or update a GitHub Release"
required: true
default: "false"
type: choice
options:
- "false"
- "true"
permissions:
contents: write
on: [push]
jobs:
build:
runs-on: ubuntu-latest
env:
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || inputs.publish_release == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set version
shell: bash
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION_NAME="${GITHUB_REF_NAME#v}"
else
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER}"
fi
VERSION_CODE=$((1000 + GITHUB_RUN_NUMBER))
echo "VERSION_NAME=${VERSION_NAME}" >> "${GITHUB_ENV}"
echo "VERSION_CODE=${VERSION_CODE}" >> "${GITHUB_ENV}"
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android packages
shell: bash
run: sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "8.10.2"
- name: Configure signing
id: signing
shell: bash
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
if [[ -z "${ANDROID_KEYSTORE_BASE64}" ]]; then
echo "signed=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
echo "${ANDROID_KEYSTORE_BASE64}" | base64 -d > release.jks
{
echo "storeFile=release.jks"
echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}"
echo "keyAlias=${ANDROID_KEY_ALIAS}"
echo "keyPassword=${ANDROID_KEY_PASSWORD}"
} > signing.properties
echo "signed=true" >> "${GITHUB_OUTPUT}"
- name: Build APK
shell: bash
env:
CLONE_TOKEN: ${{ secrets.TMUX_GITEA_TOKEN }}
run: |
if [[ "${{ steps.signing.outputs.signed }}" == "true" ]]; then
BUILD_TASK=":app:assembleRelease"
else
BUILD_TASK=":app:assembleDebug"
set -eu
if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
apt-get update
apt-get install -y git curl unzip
fi
gradle "${BUILD_TASK}" \
if ! command -v java >/dev/null 2>&1; then
apt-get update
apt-get install -y openjdk-17-jdk-headless
fi
java -version
WORKDIR="${PWD}/source"
rm -rf "${WORKDIR}"
SERVER_URL="${GITHUB_SERVER_URL:-https://gitea.neatcn.com}"
REPOSITORY="${GITHUB_REPOSITORY:-tmux/tmux-browser-android}"
AUTH_SERVER_URL="$(printf '%s' "${SERVER_URL}" | sed "s#https://#https://gouki:${CLONE_TOKEN}@#")"
git clone "${AUTH_SERVER_URL}/${REPOSITORY}.git" "${WORKDIR}"
cd "${WORKDIR}"
git checkout "${GITHUB_SHA:-main}"
export GRADLE_HOME="${PWD}/.ci/gradle-8.10.2"
mkdir -p .ci
if [ ! -x "${GRADLE_HOME}/bin/gradle" ]; then
curl -fsSL -o .ci/gradle.zip https://services.gradle.org/distributions/gradle-8.10.2-bin.zip
unzip -q .ci/gradle.zip -d .ci
fi
export PATH="${GRADLE_HOME}/bin:${PATH}"
export ANDROID_HOME="${PWD}/.ci/android-sdk"
export ANDROID_SDK_ROOT="${ANDROID_HOME}"
export CMDLINE_TOOLS="${ANDROID_HOME}/cmdline-tools/latest"
if [ ! -x "${CMDLINE_TOOLS}/bin/sdkmanager" ]; then
mkdir -p "${ANDROID_HOME}/cmdline-tools"
curl -fsSL -o .ci/android-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q .ci/android-tools.zip -d "${ANDROID_HOME}/cmdline-tools"
mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${CMDLINE_TOOLS}"
fi
export PATH="${CMDLINE_TOOLS}/bin:${ANDROID_HOME}/platform-tools:${PATH}"
yes | sdkmanager --licenses >/dev/null || true
sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
VERSION_NAME="0.1.${GITHUB_RUN_NUMBER:-0}"
VERSION_CODE=$((2000 + ${GITHUB_RUN_NUMBER:-0}))
gradle :app:assembleDebug \
-PversionCode="${VERSION_CODE}" \
-PversionName="${VERSION_NAME}" \
-PrepoSlug="${GITHUB_REPOSITORY}"
-PrepoSlug="neatstudio/tmux-browser-android"
- name: Prepare artifacts
shell: bash
run: |
mkdir -p release
APK_PATH="$(find app/build/outputs/apk -name '*.apk' | sort | tail -n 1)"
cp "${APK_PATH}" "release/tmux-android.apk"
cp "${APK_PATH}" "release/tmux-android-${VERSION_NAME}.apk"
SHA256="$(sha256sum release/tmux-android.apk | awk '{print $1}')"
APK_URL="https://github.com/${GITHUB_REPOSITORY}/releases/latest/download/tmux-android.apk"
RELEASE_PAGE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/latest"
cat > release/latest.json << JSON
{
"versionCode": ${VERSION_CODE},
"versionName": "${VERSION_NAME}",
"apkUrl": "${APK_URL}",
"sha256": "${SHA256}",
"releasePageUrl": "${RELEASE_PAGE_URL}",
"minSdk": 26
}
JSON
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: tmux-android-${{ env.VERSION_NAME }}
path: |
release/tmux-android.apk
release/tmux-android-${{ env.VERSION_NAME }}.apk
release/latest.json
- name: Require signing for release publishing
if: env.PUBLISH_RELEASE == 'true' && steps.signing.outputs.signed != 'true'
shell: bash
run: |
echo "Release publishing requires ANDROID_KEYSTORE_BASE64 and signing secrets." >&2
exit 1
- name: Publish GitHub Release
if: env.PUBLISH_RELEASE == 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="v${VERSION_NAME}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -f "${TAG}"
git push -f origin "${TAG}"
fi
if gh release view "${TAG}" >/dev/null 2>&1; then
gh release upload "${TAG}" release/tmux-android.apk release/latest.json --clobber
else
gh release create "${TAG}" release/tmux-android.apk release/latest.json \
--latest \
--title "tmux Android ${VERSION_NAME}" \
--notes "Android APK for tmux-ui remote testing."
fi
cp "${APK_PATH}" release/tmux-android-gitea.apk
ls -lh release/tmux-android-gitea.apk
sha256sum release/tmux-android-gitea.apk
+28 -7
View File
@@ -18,7 +18,8 @@ APIs directly:
- native API action center for health, server status, timeline, preferences,
kanban projects, group messages, hook events, image file/URL upload, image
preview info, and native image preview display
- mobile soft-key row for tmux-oriented input
- mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
new window, previous/next window, Ctrl keys, arrows, page keys, and paste
- automatic update checks against a GitHub Release manifest
- APK download, SHA-256 verification, and installer handoff
@@ -77,10 +78,13 @@ Publish a test build by pushing a `v*` tag. That creates a GitHub Release with:
```text
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/tmux-android.apk
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json
```
Those GitHub links are the stable public install/update channel. Gitea releases
are mirrored for internal tracking, but the current Gitea org/repo visibility
keeps anonymous release downloads behind login, so phones should use GitHub for
no-login install and in-app updates.
Plain branch builds only create Actions artifacts; they are useful for CI
verification, but releases are the stable download/update channel.
@@ -98,6 +102,10 @@ escape filtering. It is enough for shell-oriented remote testing, but it is not
yet a complete xterm-compatible renderer for full-screen TUIs such as `vim` or
`top`.
The terminal toolbar and shortcut row include tmux prefix helpers. The app sends
the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`,
`Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`.
All app features are native Android controls. Complex server objects such as
kanban projects, preferences, timeline events, group messages, and image metadata
currently use native forms plus native JSON detail dialogs; image preview uses a
@@ -126,13 +134,26 @@ The default update manifest is:
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
```
The Gitea mirror is:
The public manual APK download is:
```text
https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
```
The app tries the selected update source first, then falls back to the GitHub
and Gitea mirrors.
manifest and the Gitea release API. GitHub is the reliable no-login source today.
The Gitea API endpoint is:
The workflow uploads both the APK and `latest.json` to each release.
```text
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
```
In the app:
- Tap `Update` on the main screen to check `latest.json`, download the APK,
verify SHA-256, and open Android's installer.
- Open `More` -> `Open APK download` to download the current public APK in a
browser.
- Open `More` -> `Permissions / update status` to see the installed version,
update manifest, APK URL, install permission state, and a `Check update`
button.
+8 -2
View File
@@ -5,13 +5,17 @@ plugins {
}
val repoSlug = providers.gradleProperty("repoSlug")
.orElse("neatstudio/tmux-android")
.orElse("neatstudio/tmux-browser-android")
val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
.orElse("http://100.89.0.116:3000")
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
.orElse("https://gitea.neatcn.com/tmux/tmux-browser-android/releases/latest/download/latest.json")
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
val defaultApkUrl = providers.gradleProperty("defaultApkUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/tmux-android.apk")
val defaultReleasePageUrl = providers.gradleProperty("defaultReleasePageUrl")
.orElse("https://github.com/${repoSlug.get()}/releases/latest")
val signingProps = Properties()
val signingFile = rootProject.file("signing.properties")
@@ -33,6 +37,8 @@ android {
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"")
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
}
buildFeatures {
@@ -3,6 +3,7 @@ package com.neatstudio.tmuxandroid;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
@@ -463,6 +464,7 @@ public final class MainActivity extends Activity {
"Upload image URL",
"Image preview info",
"Open image preview",
"Open APK download",
"Update source",
"Permissions / update status"
};
@@ -525,9 +527,12 @@ public final class MainActivity extends Activity {
promptOpenImagePreview();
break;
case 18:
showUpdateSourcePicker();
openExternalUrl(BuildConfig.DEFAULT_APK_URL);
break;
case 19:
showUpdateSourcePicker();
break;
case 20:
showPermissionsAndUpdateStatus();
break;
default:
@@ -615,7 +620,12 @@ public final class MainActivity extends Activity {
"Page up",
"Page down",
"Session status",
"Send command"
"Send command",
"Tmux prefix",
"Detach tmux client",
"New tmux window",
"Next tmux window",
"Previous tmux window"
};
new AlertDialog.Builder(this)
.setTitle(sessionName)
@@ -650,6 +660,21 @@ public final class MainActivity extends Activity {
case 6:
promptSendCommand(sessionName);
break;
case 7:
sendTerminalInput("\u0002");
break;
case 8:
sendTerminalInput("\u0002d");
break;
case 9:
sendTerminalInput("\u0002c");
break;
case 10:
sendTerminalInput("\u0002n");
break;
case 11:
sendTerminalInput("\u0002p");
break;
default:
break;
}
@@ -914,12 +939,28 @@ public final class MainActivity extends Activity {
private void showPermissionsAndUpdateStatus() {
StringBuilder text = new StringBuilder();
text.append("Installed app: ")
.append(BuildConfig.VERSION_NAME)
.append(" (")
.append(BuildConfig.VERSION_CODE)
.append(")\n");
text.append("Server: ").append(getServerUrl()).append('\n');
text.append("Selected update manifest: ")
text.append('\n');
text.append("Manual APK download:\n")
.append(BuildConfig.DEFAULT_APK_URL)
.append('\n');
text.append("Release page:\n")
.append(BuildConfig.DEFAULT_RELEASE_PAGE_URL)
.append('\n');
text.append('\n');
text.append("In-app update:\n");
text.append("Tap Update. The app reads latest.json, downloads the APK, verifies SHA-256, then opens Android's installer.\n");
text.append("Selected manifest:\n")
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
.append('\n');
text.append("GitHub mirror: ").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
text.append("Gitea mirror: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append("GitHub manifest:\n").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
text.append("Gitea release API:\n").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
text.append('\n');
text.append("Network: manifest permission, no runtime grant required\n");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
text.append("Install unknown apps: ")
@@ -942,10 +983,18 @@ public final class MainActivity extends Activity {
.setMessage(text.toString())
.setNegativeButton("Close", null)
.setNeutralButton("Install permission", (dialog, which) -> openInstallPermissionSettings())
.setPositiveButton("Notify permission", (dialog, which) -> requestNotificationPermission())
.setPositiveButton("Check update", (dialog, which) -> updateManager.check(true))
.show();
}
private void openExternalUrl(String url) {
try {
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
} catch (ActivityNotFoundException error) {
showMessage("No app can open URL");
}
}
private void showUpdateSourcePicker() {
String[] items = {
"GitHub: " + BuildConfig.DEFAULT_UPDATE_URL,
@@ -1140,6 +1189,13 @@ public final class MainActivity extends Activity {
addSoftKey(row, "^A", "\u0001");
addSoftKey(row, "^E", "\u0005");
addSoftKey(row, "^V", "\u0016");
addSoftKey(row, "^Z", "\u001a");
addSoftKey(row, "^\\", "\u001c");
addSoftKey(row, "Tmux", "\u0002");
addSoftKey(row, "Detach", "\u0002d");
addSoftKey(row, "NewWin", "\u0002c");
addSoftKey(row, "NextWin", "\u0002n");
addSoftKey(row, "PrevWin", "\u0002p");
addSoftKey(row, "Left", "\u001b[D");
addSoftKey(row, "Down", "\u001b[B");
addSoftKey(row, "Up", "\u001b[A");
@@ -10,6 +10,7 @@ import android.os.Build;
import android.provider.Settings;
import android.widget.Toast;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedInputStream;
@@ -103,6 +104,9 @@ final class UpdateManager {
private ReleaseInfo fetchReleaseInfo(String manifestUrl) throws Exception {
String json = readText(manifestUrl);
JSONObject root = new JSONObject(json);
if (root.has("assets") && root.has("tag_name")) {
return fetchReleaseInfoFromReleaseApi(root);
}
return new ReleaseInfo(
root.getInt("versionCode"),
root.optString("versionName", ""),
@@ -112,6 +116,40 @@ final class UpdateManager {
);
}
private ReleaseInfo fetchReleaseInfoFromReleaseApi(JSONObject release) throws Exception {
JSONArray assets = release.getJSONArray("assets");
String manifestUrl = findAssetUrl(assets, "latest.json");
String apkUrl = findAssetUrl(assets, "tmux-android.apk");
if (manifestUrl.isEmpty()) {
throw new IllegalStateException("Release has no latest.json");
}
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
if (apkUrl.isEmpty()) {
return info;
}
return new ReleaseInfo(
info.versionCode,
info.versionName,
apkUrl,
info.sha256,
release.optString("html_url", info.releasePageUrl)
);
}
private String findAssetUrl(JSONArray assets, String name) {
for (int index = 0; index < assets.length(); index++) {
JSONObject asset = assets.optJSONObject(index);
if (asset == null || !name.equals(asset.optString("name"))) {
continue;
}
String url = asset.optString("browser_download_url", "");
if (!url.isEmpty()) {
return url;
}
}
return "";
}
private String readText(String url) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(12000);
+3 -2
View File
@@ -68,14 +68,15 @@ not kill a tmux session.
Implemented now:
- configurable base URL, defaulting to `http://127.0.0.1:3000`
- configurable base URL, defaulting to `http://100.89.0.116:3000`
- support for Tailscale URLs such as `http://100.x.y.z:3000`
- native session list from `GET /api/sessions`
- create, rename, command send, split, pane select, pane kill, pin, mute, and
kill session through documented session/preference endpoints
- basic live terminal through `/ws/terminal`
- native event stream through `/ws/events`
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, arrows, page keys, and paste
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys,
tmux prefix actions, and paste
- shortcut delivery through the terminal WebSocket `input` message
- native action center for health, server status, timeline, preferences, kanban
projects, group messages, hook events, image file/URL upload, image preview